Files
famapp/src/modules/agent/server/run.ts
T
ginnoir 3e8fe2d06d
CI / checks (push) Has been cancelled
fix(agent): call api v1 via loopback instead of public url
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

253 lines
7.5 KiB
TypeScript

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, 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 = 24;
export type AgentToolCallSummary = {
name: string;
status: number;
};
export type AgentChatResult = {
message: ClientChatMessage;
toolCalls: AgentToolCallSummary[];
};
export type AgentProgressHandler = (event: AgentProgressEvent) => void;
async function toLlmUserMessage(message: ClientChatMessage): Promise<ChatMessage> {
if (message.role === "assistant") {
return { role: "assistant", content: message.content };
}
const imageUrls =
message.attachments?.filter((attachment) => attachment.type === "image").map((a) => a.url) ??
[];
if (imageUrls.length === 0) {
return { role: "user", content: message.content };
}
const dataUrls = await resolveAssistantImageDataUrls(imageUrls);
return {
role: "user",
content: buildVisionContentParts(message.content, dataUrls),
};
}
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;
systemPrompt?: string;
model?: string;
llm?: LlmClient;
executeTool?: ToolExecutor;
onProgress?: AgentProgressHandler;
}): Promise<AgentChatResult> {
const llm = options.llm ?? createLlmClient({ model: options.model });
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
const onProgress = options.onProgress;
const systemPrompt = appendAgentRuntimeContext(options.systemPrompt ?? AGENT_SYSTEM_PROMPT);
const userMessages = await Promise.all(
options.messages.map((message) => toLlmUserMessage(message)),
);
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: forceReply ? undefined : AGENT_TOOLS,
});
const assistantMessage = completion.message;
transcript.push(assistantMessage);
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",
content:
textFromMessageContent(assistantMessage.content).trim() ||
"I couldn't generate a response.",
},
toolCalls,
};
}
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 });
let result: string;
let status: number;
try {
result = await executeTool(toolCall.function.name, toolCall.function.arguments);
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({
status: 500,
body: { error: err instanceof Error ? err.message : "Tool execution failed" },
});
}
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: 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. Tools used: ${trace}.`,
},
toolCalls,
};
}