fix(agent): raise tool rounds and ease calendar creates
CI / checks (push) Has been cancelled

Local models were burning the 8-round budget on simple event adds.
Raise the limit, resolve calendars by name/default, and inject the
household clock so relative dates work.
This commit is contained in:
ginnoir
2026-07-08 21:40:44 -05:00
parent ea371b8085
commit d3e79edb9e
7 changed files with 240 additions and 10 deletions
+2
View File
@@ -55,3 +55,5 @@ LLM_PROVIDER=openai
LLM_BASE_URL= LLM_BASE_URL=
LLM_API_KEY= LLM_API_KEY=
LLM_MODEL=llama3.2 LLM_MODEL=llama3.2
# IANA timezone for assistant relative dates ("Thursday at 2"). Falls back to TZ, then America/Chicago.
HOUSEHOLD_TIMEZONE=America/Chicago
+1
View File
@@ -111,5 +111,6 @@ P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-m
- Repo: https://github.com/ginnoir/famapp (HTTPS remote on `origin`). - Repo: https://github.com/ginnoir/famapp (HTTPS remote on `origin`).
- Local dev tooling installed: Node 22+, pnpm 10.33.3. - Local dev tooling installed: Node 22+, pnpm 10.33.3.
- `.env` is **not** committed; copy `.env.example``.env` when needed. - `.env` is **not** committed; copy `.env.example``.env` when needed.
- Assistant relative dates use `HOUSEHOLD_TIMEZONE` (default `America/Chicago` if unset).
- VS Code recommended extensions in `.vscode/extensions.json`; copy `.vscode/settings.json.example``.vscode/settings.json` for the workspace defaults. - VS Code recommended extensions in `.vscode/extensions.json`; copy `.vscode/settings.json.example``.vscode/settings.json` for the workspace defaults.
- Memory files (cross-session, only seen by Claude): `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\`. - Memory files (cross-session, only seen by Claude): `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\`.
+3 -3
View File
@@ -1,13 +1,13 @@
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm"; import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
import { buildVisionContentParts, textFromMessageContent } from "@/lib/llm/content"; import { buildVisionContentParts, textFromMessageContent } from "@/lib/llm/content";
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools"; import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS, appendAgentRuntimeContext } from "../tools";
import type { ClientChatMessage } from "../messages"; import type { ClientChatMessage } from "../messages";
import { describeToolActivity } from "../tool-labels"; import { describeToolActivity } from "../tool-labels";
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor"; import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
import { resolveAssistantImageDataUrls } from "./resolve-images"; import { resolveAssistantImageDataUrls } from "./resolve-images";
import { thinkingLabel, type AgentProgressEvent } from "./progress"; import { thinkingLabel, type AgentProgressEvent } from "./progress";
const MAX_TOOL_ROUNDS = 8; const MAX_TOOL_ROUNDS = 24;
export type AgentToolCallSummary = { export type AgentToolCallSummary = {
name: string; name: string;
@@ -53,7 +53,7 @@ export async function runAgentChat(options: {
const llm = options.llm ?? createLlmClient({ model: options.model }); const llm = options.llm ?? createLlmClient({ model: options.model });
const executeTool = options.executeTool ?? createApiToolExecutor(options.request); const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
const onProgress = options.onProgress; const onProgress = options.onProgress;
const systemPrompt = options.systemPrompt ?? AGENT_SYSTEM_PROMPT; const systemPrompt = appendAgentRuntimeContext(options.systemPrompt ?? AGENT_SYSTEM_PROMPT);
const userMessages = await Promise.all( const userMessages = await Promise.all(
options.messages.map((message) => toLlmUserMessage(message)), options.messages.map((message) => toLlmUserMessage(message)),
+47 -1
View File
@@ -111,8 +111,11 @@ async function dispatchTool(
return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`); return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`);
} }
case "create_event": { case "create_event": {
const resolved = await resolveCalendarId(args, request, origin);
if ("error" in resolved) return resolved.error;
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
calendarId: requireString(args, "calendarId"), calendarId: resolved.calendarId,
title: requireString(args, "title"), title: requireString(args, "title"),
startAt: requireString(args, "startAt"), startAt: requireString(args, "startAt"),
endAt: requireString(args, "endAt"), endAt: requireString(args, "endAt"),
@@ -375,6 +378,49 @@ async function dispatchTool(
} }
} }
async function resolveCalendarId(
args: Record<string, unknown>,
request: Request,
origin: string,
): Promise<{ calendarId: string } | { error: ApiCallResult }> {
const calendarId = typeof args.calendarId === "string" ? args.calendarId.trim() : "";
if (calendarId) return { calendarId };
const calendarsResult = await callApi(request, origin, "GET", "/api/v1/calendars");
if (calendarsResult.status !== 200 || !Array.isArray(calendarsResult.body)) {
return { error: calendarsResult };
}
const calendars = calendarsResult.body as Array<{ id?: string; name?: string }>;
const calendarName =
typeof args.calendarName === "string" ? args.calendarName.trim().toLowerCase() : "";
if (calendarName) {
const match = calendars.find(
(calendar) =>
typeof calendar.name === "string" && calendar.name.toLowerCase() === calendarName,
);
if (!match?.id) {
return {
error: {
status: 404,
body: {
error: `No calendar named "${args.calendarName}"`,
calendars: calendars.map((calendar) => ({ id: calendar.id, name: calendar.name })),
},
},
};
}
return { calendarId: match.id };
}
const first = calendars[0];
if (!first?.id) {
return { error: { status: 404, body: { error: "No calendars found" } } };
}
return { calendarId: first.id };
}
function requireString(args: Record<string, unknown>, key: string): string { function requireString(args: Record<string, unknown>, key: string): string {
const value = args[key]; const value = args[key];
if (typeof value !== "string" || value.trim().length === 0) { if (typeof value !== "string" || value.trim().length === 0) {
+40 -6
View File
@@ -159,11 +159,16 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
type: "function", type: "function",
function: { function: {
name: "create_event", name: "create_event",
description: "Create a calendar event.", description:
"Create a calendar event. Provide calendarId, or calendarName to match by name, or omit both to use the first visible calendar.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
calendarId: { type: "string" }, calendarId: { type: "string", description: "UUID of the calendar" },
calendarName: {
type: "string",
description: "Calendar display name when calendarId is unknown",
},
title: { type: "string" }, title: { type: "string" },
startAt: { type: "string", description: "ISO 8601 start" }, startAt: { type: "string", description: "ISO 8601 start" },
endAt: { type: "string", description: "ISO 8601 end" }, endAt: { type: "string", description: "ISO 8601 end" },
@@ -175,7 +180,7 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
description: "Optional reminder N minutes before start", description: "Optional reminder N minutes before start",
}, },
}, },
required: ["calendarId", "title", "startAt", "endAt"], required: ["title", "startAt", "endAt"],
}, },
}, },
}, },
@@ -762,13 +767,14 @@ export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help
Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly. Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly.
When no dedicated tool fits, or you are unsure how to do something: Prefer a dedicated tool when one exists (create_event, add_list_item, create_note, etc.). When no dedicated tool fits, or you need an endpoint that is not wrapped yet:
1. Call get_api_docs with a relevant search term to find the right /api/v1/* endpoint. 1. Call get_api_docs with a relevant search term to find the right /api/v1/* endpoint.
2. Call call_api with the documented method, path, query, and body. 2. Call call_api with the documented method, path, query, and body.
If a tool call fails, read the error body and fix the arguments before trying a different approach.
When the user sends a photo, read dates, times, locations, and action items from it, then use tools to act. When the user sends a photo, read dates, times, locations, and action items from it, then use tools to act.
Lists: resolve list ids via list_lists. To complete items, list_list_items then update_list_item with done: true. Lists: resolve list ids via list_lists, or pass listType to add_list_item. To complete items, list_list_items then update_list_item with done: true.
Journal: per-user private entries. Valid mood ids: ${JOURNAL_MOOD_IDS}. stress is 1-10. pillsTaken is boolean. Journal: per-user private entries. Valid mood ids: ${JOURNAL_MOOD_IDS}. stress is 1-10. pillsTaken is boolean.
@@ -776,4 +782,32 @@ Garden: care types are free text (water, fertilize, prune, etc.). Use list_garde
Sharing: journal entries are not shareable. Shareable types: calendar, calendar.event, list, note, garden.plant, garden.container. Sharing: journal entries are not shareable. Shareable types: calendar, calendar.event, list, note, garden.plant, garden.container.
Calendar: use ISO 8601 datetimes. Bang dates use YYYY-MM-DD.`; Calendar: use ISO 8601 datetimes with the household timezone below. Pass calendarId, or calendarName, or omit both to use the first visible calendar. Bang dates use YYYY-MM-DD.`;
export function resolveHouseholdTimezone(): string {
return process.env.HOUSEHOLD_TIMEZONE?.trim() || process.env.TZ?.trim() || "America/Chicago";
}
export function appendAgentRuntimeContext(prompt: string, now: Date = new Date()): string {
const timeZone = resolveHouseholdTimezone();
let localNow: string;
try {
localNow = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
timeZoneName: "short",
}).format(now);
} catch {
localNow = now.toISOString();
}
return `${prompt.trim()}
Current time: ${localNow} (${timeZone}). ISO now: ${now.toISOString()}. Resolve relative dates from this clock.`;
}
+30
View File
@@ -83,6 +83,36 @@ describe("runAgentChat", () => {
assert.equal(result.message.role, "assistant"); assert.equal(result.message.role, "assistant");
assert.ok(result.message.content.length > 0); assert.ok(result.message.content.length > 0);
}); });
it("appends runtime clock context to the system prompt", async () => {
const original = process.env.HOUSEHOLD_TIMEZONE;
process.env.HOUSEHOLD_TIMEZONE = "America/Chicago";
let systemContent = "";
const result = await runAgentChat({
messages: [{ role: "user", content: "hello" }],
request: new Request("http://localhost:3000/api/agent/chat"),
systemPrompt: "You are a pirate.",
llm: {
async chatCompletion(request) {
const system = request.messages.find((message) => message.role === "system");
systemContent = typeof system?.content === "string" ? system.content : "";
return {
message: { role: "assistant", content: "Ahoy" },
finishReason: "stop",
};
},
},
});
assert.equal(result.message.content, "Ahoy");
assert.match(systemContent, /^You are a pirate\./);
assert.match(systemContent, /Current time:/);
assert.match(systemContent, /America\/Chicago/);
if (original === undefined) delete process.env.HOUSEHOLD_TIMEZONE;
else process.env.HOUSEHOLD_TIMEZONE = original;
});
}); });
it("passes a model override to the OpenAI-compatible client", async () => { it("passes a model override to the OpenAI-compatible client", async () => {
+117
View File
@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { appendAgentRuntimeContext, resolveHouseholdTimezone } from "../../src/modules/agent/tools";
import { createApiToolExecutor } from "../../src/modules/agent/tool-executor";
describe("appendAgentRuntimeContext", () => {
it("appends current time and timezone to the prompt", () => {
const original = process.env.HOUSEHOLD_TIMEZONE;
process.env.HOUSEHOLD_TIMEZONE = "America/Chicago";
const now = new Date("2026-07-09T14:30:00.000Z");
const result = appendAgentRuntimeContext("Be helpful.", now);
assert.match(result, /^Be helpful\./);
assert.match(result, /Current time:/);
assert.match(result, /America\/Chicago/);
assert.match(result, /2026-07-09T14:30:00\.000Z/);
assert.match(result, /Resolve relative dates from this clock/);
if (original === undefined) delete process.env.HOUSEHOLD_TIMEZONE;
else process.env.HOUSEHOLD_TIMEZONE = original;
});
});
describe("resolveHouseholdTimezone", () => {
it("prefers HOUSEHOLD_TIMEZONE over TZ", () => {
const originalHousehold = process.env.HOUSEHOLD_TIMEZONE;
const originalTz = process.env.TZ;
process.env.HOUSEHOLD_TIMEZONE = "America/New_York";
process.env.TZ = "UTC";
assert.equal(resolveHouseholdTimezone(), "America/New_York");
if (originalHousehold === undefined) delete process.env.HOUSEHOLD_TIMEZONE;
else process.env.HOUSEHOLD_TIMEZONE = originalHousehold;
if (originalTz === undefined) delete process.env.TZ;
else process.env.TZ = originalTz;
});
});
describe("create_event calendar resolution", () => {
it("uses the first calendar when calendarId and calendarName are omitted", async () => {
const originalFetch = globalThis.fetch;
const posts: Array<{ path: string; body: unknown }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/calendars") && (!init?.method || init.method === "GET")) {
return Response.json([
{ id: "cal-1", name: "Family" },
{ id: "cal-2", name: "Work" },
]);
}
if (url.endsWith("/api/v1/events") && init?.method === "POST") {
const body = JSON.parse(String(init.body));
posts.push({ path: url, body });
return Response.json({ id: "evt-1", ...body }, { status: 201 });
}
return new Response("not found", { status: 404 });
}) as typeof fetch;
const execute = createApiToolExecutor(new Request("http://localhost:3000/api/agent/chat"));
const result = JSON.parse(
await execute(
"create_event",
JSON.stringify({
title: "Dentist",
startAt: "2026-07-10T15:00:00.000Z",
endAt: "2026-07-10T16:00:00.000Z",
}),
),
) as { status: number; body: { calendarId?: string } };
assert.equal(result.status, 201);
assert.equal(result.body.calendarId, "cal-1");
assert.equal(posts.length, 1);
globalThis.fetch = originalFetch;
});
it("matches calendarName case-insensitively", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/calendars") && (!init?.method || init.method === "GET")) {
return Response.json([
{ id: "cal-1", name: "Family" },
{ id: "cal-2", name: "Work" },
]);
}
if (url.endsWith("/api/v1/events") && init?.method === "POST") {
const body = JSON.parse(String(init.body));
return Response.json({ id: "evt-1", ...body }, { status: 201 });
}
return new Response("not found", { status: 404 });
}) as typeof fetch;
const execute = createApiToolExecutor(new Request("http://localhost:3000/api/agent/chat"));
const result = JSON.parse(
await execute(
"create_event",
JSON.stringify({
calendarName: "work",
title: "Standup",
startAt: "2026-07-10T15:00:00.000Z",
endAt: "2026-07-10T15:30:00.000Z",
}),
),
) as { status: number; body: { calendarId?: string } };
assert.equal(result.status, 201);
assert.equal(result.body.calendarId, "cal-2");
globalThis.fetch = originalFetch;
});
});