From d3e79edb9ef0b931fa9d6e6629dad2677d661741 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 8 Jul 2026 21:40:44 -0500 Subject: [PATCH] fix(agent): raise tool rounds and ease calendar creates 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. --- .env.example | 2 + STATUS.md | 1 + src/modules/agent/server/run.ts | 6 +- src/modules/agent/tool-executor.ts | 48 +++++++++- src/modules/agent/tools.ts | 46 ++++++++-- tests/unit/agent-chat.test.ts | 30 +++++++ tests/unit/agent-tools-runtime.test.ts | 117 +++++++++++++++++++++++++ 7 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 tests/unit/agent-tools-runtime.test.ts diff --git a/.env.example b/.env.example index 36a21bc..62618cc 100644 --- a/.env.example +++ b/.env.example @@ -55,3 +55,5 @@ LLM_PROVIDER=openai LLM_BASE_URL= LLM_API_KEY= LLM_MODEL=llama3.2 +# IANA timezone for assistant relative dates ("Thursday at 2"). Falls back to TZ, then America/Chicago. +HOUSEHOLD_TIMEZONE=America/Chicago diff --git a/STATUS.md b/STATUS.md index 8410ba1..45f78ae 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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`). - Local dev tooling installed: Node 22+, pnpm 10.33.3. - `.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. - Memory files (cross-session, only seen by Claude): `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\`. diff --git a/src/modules/agent/server/run.ts b/src/modules/agent/server/run.ts index 46d54f8..406a7dc 100644 --- a/src/modules/agent/server/run.ts +++ b/src/modules/agent/server/run.ts @@ -1,13 +1,13 @@ import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm"; 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 { describeToolActivity } from "../tool-labels"; import { createApiToolExecutor, type ToolExecutor } from "../tool-executor"; import { resolveAssistantImageDataUrls } from "./resolve-images"; import { thinkingLabel, type AgentProgressEvent } from "./progress"; -const MAX_TOOL_ROUNDS = 8; +const MAX_TOOL_ROUNDS = 24; export type AgentToolCallSummary = { name: string; @@ -53,7 +53,7 @@ export async function runAgentChat(options: { const llm = options.llm ?? createLlmClient({ model: options.model }); const executeTool = options.executeTool ?? createApiToolExecutor(options.request); const onProgress = options.onProgress; - const systemPrompt = options.systemPrompt ?? AGENT_SYSTEM_PROMPT; + const systemPrompt = appendAgentRuntimeContext(options.systemPrompt ?? AGENT_SYSTEM_PROMPT); const userMessages = await Promise.all( options.messages.map((message) => toLlmUserMessage(message)), diff --git a/src/modules/agent/tool-executor.ts b/src/modules/agent/tool-executor.ts index 606d558..5ff7f04 100644 --- a/src/modules/agent/tool-executor.ts +++ b/src/modules/agent/tool-executor.ts @@ -111,8 +111,11 @@ async function dispatchTool( return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`); } case "create_event": { + const resolved = await resolveCalendarId(args, request, origin); + if ("error" in resolved) return resolved.error; + const body: Record = { - calendarId: requireString(args, "calendarId"), + calendarId: resolved.calendarId, title: requireString(args, "title"), startAt: requireString(args, "startAt"), endAt: requireString(args, "endAt"), @@ -375,6 +378,49 @@ async function dispatchTool( } } +async function resolveCalendarId( + args: Record, + 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, key: string): string { const value = args[key]; if (typeof value !== "string" || value.trim().length === 0) { diff --git a/src/modules/agent/tools.ts b/src/modules/agent/tools.ts index 5678cf7..76274e7 100644 --- a/src/modules/agent/tools.ts +++ b/src/modules/agent/tools.ts @@ -159,11 +159,16 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [ type: "function", function: { 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: { type: "object", 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" }, startAt: { type: "string", description: "ISO 8601 start" }, endAt: { type: "string", description: "ISO 8601 end" }, @@ -175,7 +180,7 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [ 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. -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. 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. -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. @@ -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. -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.`; +} diff --git a/tests/unit/agent-chat.test.ts b/tests/unit/agent-chat.test.ts index 5e06efe..d614ca6 100644 --- a/tests/unit/agent-chat.test.ts +++ b/tests/unit/agent-chat.test.ts @@ -83,6 +83,36 @@ describe("runAgentChat", () => { assert.equal(result.message.role, "assistant"); 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 () => { diff --git a/tests/unit/agent-tools-runtime.test.ts b/tests/unit/agent-tools-runtime.test.ts new file mode 100644 index 0000000..82437b1 --- /dev/null +++ b/tests/unit/agent-tools-runtime.test.ts @@ -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; + }); +});