Compare commits

...
6 Commits
Author SHA1 Message Date
ginnoir 7bf4a801b3 chore: release v0.6.5
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Successful in 7m19s
2026-07-09 00:38:50 -05:00
ginnoir 3e8fe2d06d fix(agent): call api v1 via loopback instead of public url
CI / checks (push) Has been cancelled
Tool self-fetches were hairpinning to fam.ginnoir.com and failing,
so list_calendars/create_event returned 500 and the model gave up.
2026-07-09 00:38:33 -05:00
ginnoir 60eb101015 chore: release v0.6.4
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Successful in 7m30s
2026-07-08 22:51:47 -05:00
ginnoir 72d123b9a0 fix(agent): stop tool thrash after writes and log rounds
CI / checks (push) Has been cancelled
Cap get_api_docs, break duplicate tool rounds, force a reply after
successful writes, and log each tool call so limit hits are diagnosable.
2026-07-08 22:51:31 -05:00
ginnoir 9bf8e508c7 chore: release v0.6.3
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Successful in 8m55s
2026-07-08 21:41:06 -05:00
ginnoir d3e79edb9e 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.
2026-07-08 21:40:44 -05:00
13 changed files with 763 additions and 32 deletions
+4
View File
@@ -55,3 +55,7 @@ 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
# Optional override for agent tool → /api/v1 self-calls (defaults to http://127.0.0.1:$PORT).
# INTERNAL_API_BASE_URL=http://127.0.0.1:3000
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [0.6.5](https://github.com/ginnoir/famapp/compare/v0.6.4...v0.6.5) (2026-07-09)
### Bug Fixes
- **agent:** call api v1 via loopback instead of public url ([3e8fe2d](https://github.com/ginnoir/famapp/commit/3e8fe2d06dc31d3a1058b4b8942c46b1220a6b4c))
## [0.6.4](https://github.com/ginnoir/famapp/compare/v0.6.3...v0.6.4) (2026-07-09)
### Bug Fixes
- **agent:** stop tool thrash after writes and log rounds ([72d123b](https://github.com/ginnoir/famapp/commit/72d123b9a0f67589d92614a8d2138912585b0d79))
## [0.6.3](https://github.com/ginnoir/famapp/compare/v0.6.2...v0.6.3) (2026-07-09)
### Bug Fixes
- **agent:** raise tool rounds and ease calendar creates ([d3e79ed](https://github.com/ginnoir/famapp/commit/d3e79edb9ef0b931fa9d6e6629dad2677d661741))
## [0.6.2](https://github.com/ginnoir/famapp/compare/v0.6.1...v0.6.2) (2026-07-09)
### Features
+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`).
- 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\`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "famapp",
"version": "0.6.2",
"version": "0.6.5",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
@@ -4,6 +4,7 @@ export type AssistantChatMessage = {
role: "user" | "assistant";
content: string;
imageUrl?: string;
toolCalls?: Array<{ name: string; status: number }>;
};
const STORAGE_VERSION = "v2";
@@ -19,6 +20,14 @@ function isValidMessage(value: unknown): value is AssistantChatMessage {
if (row.role !== "user" && row.role !== "assistant") return false;
if (typeof row.content !== "string" || row.content.trim().length === 0) return false;
if (row.imageUrl !== undefined && typeof row.imageUrl !== "string") return false;
if (row.toolCalls !== undefined) {
if (!Array.isArray(row.toolCalls)) return false;
for (const call of row.toolCalls) {
if (!call || typeof call !== "object") return false;
const entry = call as Record<string, unknown>;
if (typeof entry.name !== "string" || typeof entry.status !== "number") return false;
}
}
return true;
}
@@ -229,7 +229,11 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
setMessages((current) => [
...current,
{ role: "assistant", content: result.message.content },
{
role: "assistant",
content: result.message.content,
...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}),
},
]);
scrollToBottom();
} catch (err) {
@@ -347,6 +351,13 @@ export function AssistantPanel({ configured, userId, assistantName, assistantMod
/>
) : null}
{message.content}
{message.role === "assistant" &&
message.toolCalls &&
message.toolCalls.length > 0 ? (
<p className="mt-1 text-[10px] text-muted-foreground">
{message.toolCalls.map((call) => `${call.name}${call.status}`).join(" · ")}
</p>
) : null}
</div>
))}
+85
View File
@@ -0,0 +1,85 @@
const MUTATION_TOOLS = new Set([
"add_list_item",
"update_list_item",
"delete_list_item",
"create_list",
"update_list",
"delete_list",
"create_event",
"update_event",
"delete_event",
"create_calendar",
"create_note",
"update_note",
"delete_note",
"create_journal_entry",
"update_journal_entry",
"delete_journal_entry",
"create_garden_plant",
"update_garden_plant",
"delete_garden_plant",
"create_garden_container",
"update_garden_container",
"delete_garden_container",
"log_garden_care",
"create_garden_care_schedule",
"update_garden_care_schedule",
"delete_garden_care_schedule",
"schedule_garden_care_on_calendar",
"create_bang",
"update_bang",
"delete_bang",
"create_share_link",
"revoke_share_link",
]);
export function isMutationTool(name: string): boolean {
return MUTATION_TOOLS.has(name);
}
export function isSuccessfulWrite(name: string, status: number, argsJson = ""): boolean {
if (!isSuccessfulStatus(status)) return false;
if (isMutationTool(name)) return true;
if (name !== "call_api") return false;
try {
const args = JSON.parse(argsJson || "{}") as { method?: string };
const method = typeof args.method === "string" ? args.method.toUpperCase() : "";
return method === "POST" || method === "PATCH" || method === "DELETE";
} catch {
return false;
}
}
export function isSuccessfulStatus(status: number): boolean {
return status >= 200 && status < 300;
}
export function fingerprintToolCalls(calls: Array<{ name: string; arguments: string }>): string {
return calls.map((call) => `${call.name}:${normalizeArgs(call.arguments)}`).join("|");
}
export function truncateToolResult(result: string, maxChars = 6000): string {
if (result.length <= maxChars) return result;
return `${result.slice(0, maxChars)}\n…[truncated ${result.length - maxChars} chars]`;
}
export function summarizeToolTrace(toolCalls: Array<{ name: string; status: number }>): string {
if (toolCalls.length === 0) return "No tools were called.";
return toolCalls.map((call) => `${call.name}${call.status}`).join(", ");
}
function normalizeArgs(argsJson: string): string {
try {
const parsed: unknown = JSON.parse(argsJson || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return JSON.stringify(parsed);
}
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(parsed as Record<string, unknown>).sort()) {
sorted[key] = (parsed as Record<string, unknown>)[key];
}
return JSON.stringify(sorted);
} catch {
return argsJson.trim();
}
}
+131 -7
View File
@@ -1,13 +1,20 @@
import logger from "@/lib/logger";
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 {
fingerprintToolCalls,
isSuccessfulWrite,
summarizeToolTrace,
truncateToolResult,
} from "./loop-guards";
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;
@@ -41,6 +48,21 @@ async function toLlmUserMessage(message: ClientChatMessage): Promise<ChatMessage
};
}
function forceReplyAfterWrite(writeNames: string[]): ChatMessage {
return {
role: "user",
content: `The write already succeeded (${writeNames.join(", ")}). Stop calling tools and reply to the user in one short sentence confirming what you did.`,
};
}
function forceReplyAfterRepeat(): ChatMessage {
return {
role: "user",
content:
"You repeated the same tool call. Stop calling tools and reply with what you already know, or ask one clarifying question.",
};
}
export async function runAgentChat(options: {
messages: ClientChatMessage[];
request: Request;
@@ -53,7 +75,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)),
@@ -62,13 +84,16 @@ export async function runAgentChat(options: {
const transcript: ChatMessage[] = [{ role: "system", content: systemPrompt }, ...userMessages];
const toolCalls: AgentToolCallSummary[] = [];
const seenFingerprints = new Set<string>();
const successfulWriteNames: string[] = [];
let forceReply = false;
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
onProgress?.({ type: "thinking", label: thinkingLabel(round), round });
const completion = await llm.chatCompletion({
messages: transcript,
tools: AGENT_TOOLS,
tools: forceReply ? undefined : AGENT_TOOLS,
});
const assistantMessage = completion.message;
@@ -76,6 +101,15 @@ export async function runAgentChat(options: {
if (!assistantMessage.tool_calls?.length) {
onProgress?.({ type: "responding", label: "Writing a reply…" });
logger.info(
{
msg: "agent.chat.done",
rounds: round + 1,
toolCalls: toolCalls.map((call) => `${call.name}:${call.status}`),
forcedReply: forceReply,
},
"agent chat completed",
);
return {
message: {
role: "assistant",
@@ -87,6 +121,36 @@ export async function runAgentChat(options: {
};
}
if (forceReply) {
logger.warn(
{
msg: "agent.chat.forced_tools_ignored",
round,
names: assistantMessage.tool_calls.map((c) => c.function.name),
},
"model kept calling tools after force-reply",
);
break;
}
const fingerprint = fingerprintToolCalls(
assistantMessage.tool_calls.map((call) => ({
name: call.function.name,
arguments: call.function.arguments,
})),
);
if (seenFingerprints.has(fingerprint)) {
logger.warn(
{ msg: "agent.chat.duplicate_tools", round, fingerprint, toolCalls: fingerprint },
"duplicate tool round detected",
);
transcript.push(forceReplyAfterRepeat());
forceReply = true;
continue;
}
seenFingerprints.add(fingerprint);
for (const toolCall of assistantMessage.tool_calls) {
const label = describeToolActivity(toolCall.function.name, toolCall.function.arguments);
onProgress?.({ type: "tool", name: toolCall.function.name, label });
@@ -96,8 +160,20 @@ export async function runAgentChat(options: {
try {
result = await executeTool(toolCall.function.name, toolCall.function.arguments);
const parsed = JSON.parse(result) as { status?: number };
const parsed = JSON.parse(result) as { status?: number; body?: unknown };
status = typeof parsed.status === "number" ? parsed.status : 200;
if (status >= 400) {
logger.warn(
{
msg: "agent.chat.tool_error",
round,
name: toolCall.function.name,
status,
body: parsed.body,
},
"agent tool returned error",
);
}
} catch (err) {
status = 500;
result = JSON.stringify({
@@ -107,21 +183,69 @@ export async function runAgentChat(options: {
}
toolCalls.push({ name: toolCall.function.name, status });
logger.info(
{
msg: "agent.chat.tool",
round,
name: toolCall.function.name,
status,
args: toolCall.function.arguments.slice(0, 300),
},
"agent tool call",
);
if (isSuccessfulWrite(toolCall.function.name, status, toolCall.function.arguments)) {
successfulWriteNames.push(toolCall.function.name);
}
transcript.push({
role: "tool",
tool_call_id: toolCall.id,
name: toolCall.function.name,
content: result,
content: truncateToolResult(result),
});
}
if (successfulWriteNames.length > 0) {
transcript.push(forceReplyAfterWrite(successfulWriteNames));
forceReply = true;
}
}
onProgress?.({ type: "responding", label: "Wrapping up…" });
const trace = summarizeToolTrace(toolCalls);
if (successfulWriteNames.length > 0) {
logger.warn(
{
msg: "agent.chat.forced_summary",
rounds: MAX_TOOL_ROUNDS,
toolCalls: toolCalls.map((call) => `${call.name}:${call.status}`),
},
"agent summarizing after write without clean stop",
);
return {
message: {
role: "assistant",
content: `Done. Completed: ${[...new Set(successfulWriteNames)].join(", ")}.`,
},
toolCalls,
};
}
logger.warn(
{
msg: "agent.chat.tool_limit",
rounds: MAX_TOOL_ROUNDS,
toolCalls: toolCalls.map((call) => `${call.name}:${call.status}`),
forcedReply: forceReply,
},
"agent hit tool-call limit",
);
return {
message: {
role: "assistant",
content: "I hit the tool-call limit for this request. Please try a simpler question.",
content: `I hit the tool-call limit for this request. Tools used: ${trace}.`,
},
toolCalls,
};
+104 -14
View File
@@ -1,3 +1,5 @@
import logger from "@/lib/logger";
type ApiCallResult = {
status: number;
body: unknown;
@@ -5,8 +7,22 @@ type ApiCallResult = {
export type ToolExecutor = (name: string, argsJson: string) => Promise<string>;
/** Loopback base for in-process tool → /api/v1 calls. Avoids hairpinning to the public URL. */
export function resolveInternalApiBase(request: Request): string {
const configured = process.env.INTERNAL_API_BASE_URL?.trim();
if (configured) return configured.replace(/\/$/, "");
const port = process.env.PORT?.trim() || "3000";
const requestUrl = new URL(request.url);
if (requestUrl.hostname === "localhost" || requestUrl.hostname === "127.0.0.1") {
return requestUrl.origin;
}
return `http://127.0.0.1:${port}`;
}
export function createApiToolExecutor(request: Request): ToolExecutor {
const origin = new URL(request.url).origin;
const origin = resolveInternalApiBase(request);
return async (name: string, argsJson: string) => {
const args = parseArgs(argsJson);
@@ -111,8 +127,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<string, unknown> = {
calendarId: requireString(args, "calendarId"),
calendarId: resolved.calendarId,
title: requireString(args, "title"),
startAt: requireString(args, "startAt"),
endAt: requireString(args, "endAt"),
@@ -375,6 +394,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 {
const value = args[key];
if (typeof value !== "string" || value.trim().length === 0) {
@@ -405,13 +467,18 @@ async function getApiDocs(args: Record<string, unknown>): Promise<ApiCallResult>
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
const spec = await readFile(specPath, "utf8");
const search = typeof args.search === "string" ? args.search.trim().toLowerCase() : "";
const pathLines = spec
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("/api/v1/"));
if (!search) {
return {
status: 200,
body: {
spec,
hint: "Pass search to filter paths, or use call_api with a /api/v1/* path.",
paths: pathLines.slice(0, 80),
pathCount: pathLines.length,
hint: "Pass search (e.g. calendar, events, lists) to get matching lines. Do not request the full OpenAPI dump.",
},
};
}
@@ -423,8 +490,8 @@ async function getApiDocs(args: Record<string, unknown>): Promise<ApiCallResult>
body: {
search,
matchCount: matches.length,
matches: matches.slice(0, 100),
hint: "Use call_api with method and path from the matches above.",
matches: matches.slice(0, 40),
hint: "Use a dedicated tool when one exists; otherwise call_api with method and path from the matches above.",
},
};
}
@@ -471,14 +538,30 @@ async function callApi(
path: string,
body?: Record<string, unknown>,
): Promise<ApiCallResult> {
const response = await fetch(`${origin}${path}`, {
method,
headers: {
"Content-Type": "application/json",
cookie: request.headers.get("cookie") ?? "",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const url = `${origin}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const cookie = request.headers.get("cookie");
if (cookie) headers.cookie = cookie;
const authorization = request.headers.get("authorization");
if (authorization) headers.authorization = authorization;
let response: Response;
try {
response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (err) {
const message = err instanceof Error ? err.message : "fetch failed";
logger.error(
{ msg: "agent.tool.fetch_failed", method, url, error: message },
"agent tool internal fetch failed",
);
return { status: 502, body: { error: `Internal API unreachable: ${message}`, url } };
}
const text = await response.text();
let parsed: unknown = null;
@@ -490,5 +573,12 @@ async function callApi(
}
}
if (response.status >= 400) {
logger.warn(
{ msg: "agent.tool.api_error", method, url, status: response.status, body: parsed },
"agent tool API error",
);
}
return { status: response.status, body: parsed };
}
+45 -9
View File
@@ -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"],
},
},
},
@@ -713,15 +718,16 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
function: {
name: "get_api_docs",
description:
"Read famapp REST API documentation (OpenAPI). Use when unsure which endpoint to call or no dedicated tool exists. Pass search to filter relevant paths.",
"Search famapp REST API docs for /api/v1 paths. Always pass search. Returns matching lines only — not the full OpenAPI file.",
parameters: {
type: "object",
properties: {
search: {
type: "string",
description: "Optional keyword to filter paths (e.g. garden, share, journal)",
description: "Keyword to filter paths (e.g. garden, share, journal, events)",
},
},
required: ["search"],
},
},
},
@@ -762,13 +768,15 @@ 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:
1. Call get_api_docs with a relevant search term to find the right /api/v1/* endpoint.
Prefer a dedicated tool when one exists (create_event, add_list_item, create_note, etc.). After a successful write (2xx), stop calling tools and confirm in one short sentence — do not re-list or re-create.
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 (always pass search; never request the full spec).
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. Do not repeat the exact same tool call.
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 +784,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.`;
}
+30
View File
@@ -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 () => {
+152
View File
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
fingerprintToolCalls,
isSuccessfulWrite,
summarizeToolTrace,
truncateToolResult,
} from "../../src/modules/agent/server/loop-guards";
import { runAgentChat } from "../../src/modules/agent/server/run";
import type {
ChatCompletionRequest,
ChatCompletionResult,
LlmClient,
} from "../../src/lib/llm/types";
describe("loop-guards", () => {
it("fingerprints tool calls stably regardless of key order", () => {
const a = fingerprintToolCalls([
{ name: "create_event", arguments: '{"title":"Dentist","calendarName":"Family"}' },
]);
const b = fingerprintToolCalls([
{ name: "create_event", arguments: '{"calendarName":"Family","title":"Dentist"}' },
]);
assert.equal(a, b);
});
it("treats create_event 201 as a successful write", () => {
assert.equal(isSuccessfulWrite("create_event", 201), true);
assert.equal(isSuccessfulWrite("list_calendars", 200), false);
assert.equal(
isSuccessfulWrite("call_api", 201, '{"method":"POST","path":"/api/v1/events"}'),
true,
);
assert.equal(
isSuccessfulWrite("call_api", 200, '{"method":"GET","path":"/api/v1/events"}'),
false,
);
});
it("truncates oversized tool results", () => {
const result = truncateToolResult("x".repeat(7000), 100);
assert.ok(result.length < 200);
assert.match(result, /truncated/);
});
it("summarizes tool traces", () => {
assert.equal(
summarizeToolTrace([
{ name: "list_calendars", status: 200 },
{ name: "create_event", status: 201 },
]),
"list_calendars→200, create_event→201",
);
});
});
describe("runAgentChat loop guards", () => {
it("stops after a successful write instead of looping", async () => {
let calls = 0;
const llm: LlmClient = {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
calls += 1;
if (calls === 1) {
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "1",
type: "function",
function: {
name: "create_event",
arguments: JSON.stringify({
title: "Dentist",
startAt: "2026-07-10T15:00:00.000Z",
endAt: "2026-07-10T16:00:00.000Z",
}),
},
},
],
},
finishReason: "tool_calls",
};
}
assert.equal(request.tools, undefined);
return {
message: { role: "assistant", content: "Added Dentist to your calendar." },
finishReason: "stop",
};
},
};
const result = await runAgentChat({
messages: [{ role: "user", content: "add dentist tomorrow at 10" }],
request: new Request("http://localhost:3000/api/agent/chat"),
llm,
executeTool: async () =>
JSON.stringify({ status: 201, body: { id: "evt-1", title: "Dentist" } }),
});
assert.equal(calls, 2);
assert.equal(result.toolCalls.length, 1);
assert.equal(result.toolCalls[0]?.name, "create_event");
assert.match(result.message.content, /Dentist/);
});
it("breaks duplicate identical tool rounds", async () => {
let calls = 0;
const llm: LlmClient = {
async chatCompletion(): Promise<ChatCompletionResult> {
calls += 1;
if (calls <= 2) {
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: String(calls),
type: "function",
function: {
name: "list_calendars",
arguments: "{}",
},
},
],
},
finishReason: "tool_calls",
};
}
return {
message: { role: "assistant", content: "You have one Family calendar." },
finishReason: "stop",
};
},
};
const result = await runAgentChat({
messages: [{ role: "user", content: "what calendars do I have?" }],
request: new Request("http://localhost:3000/api/agent/chat"),
llm,
executeTool: async () =>
JSON.stringify({ status: 200, body: [{ id: "cal-1", name: "Family" }] }),
});
assert.equal(calls, 3);
assert.equal(result.toolCalls.length, 1);
assert.match(result.message.content, /Family/);
});
});
+171
View File
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { appendAgentRuntimeContext, resolveHouseholdTimezone } from "../../src/modules/agent/tools";
import {
createApiToolExecutor,
resolveInternalApiBase,
} 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("resolveInternalApiBase", () => {
it("uses loopback instead of the public request origin", () => {
const original = process.env.INTERNAL_API_BASE_URL;
const originalPort = process.env.PORT;
delete process.env.INTERNAL_API_BASE_URL;
process.env.PORT = "3000";
const base = resolveInternalApiBase(new Request("https://fam.ginnoir.com/api/agent/chat"));
assert.equal(base, "http://127.0.0.1:3000");
if (original === undefined) delete process.env.INTERNAL_API_BASE_URL;
else process.env.INTERNAL_API_BASE_URL = original;
if (originalPort === undefined) delete process.env.PORT;
else process.env.PORT = originalPort;
});
it("honors INTERNAL_API_BASE_URL when set", () => {
const original = process.env.INTERNAL_API_BASE_URL;
process.env.INTERNAL_API_BASE_URL = "http://127.0.0.1:3010/";
const base = resolveInternalApiBase(new Request("https://fam.ginnoir.com/api/agent/chat"));
assert.equal(base, "http://127.0.0.1:3010");
if (original === undefined) delete process.env.INTERNAL_API_BASE_URL;
else process.env.INTERNAL_API_BASE_URL = original;
});
});
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.includes("/api/v1/calendars") && (!init?.method || init.method === "GET")) {
return Response.json([
{ id: "cal-1", name: "Family" },
{ id: "cal-2", name: "Work" },
]);
}
if (url.includes("/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("https://fam.ginnoir.com/api/agent/chat", {
headers: { cookie: "authjs.session-token=test" },
}),
);
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);
assert.match(posts[0]!.path, /^http:\/\/127\.0\.0\.1:3000\/api\/v1\/events/);
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.includes("/api/v1/calendars") && (!init?.method || init.method === "GET")) {
return Response.json([
{ id: "cal-1", name: "Family" },
{ id: "cal-2", name: "Work" },
]);
}
if (url.includes("/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;
});
it("returns a clear error when the internal API is unreachable", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new TypeError("fetch failed");
}) as typeof fetch;
const execute = createApiToolExecutor(new Request("https://fam.ginnoir.com/api/agent/chat"));
const result = JSON.parse(await execute("list_calendars", "{}")) as {
status: number;
body: { error?: string };
};
assert.equal(result.status, 502);
assert.match(String(result.body.error), /unreachable/i);
globalThis.fetch = originalFetch;
});
});