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 { 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 { 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(); 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, }; }