feat(agent): add llm assistant chat with api tools (task 88)

OpenAI-compatible client with mock fallback, tool-calling loop, and /assistant UI.

Tools map to /api/v1/ endpoints per ADR 0006 direct-tools decision.
This commit is contained in:
ginnoir
2026-07-04 19:46:09 -05:00
parent 04ae809e07
commit 4a924a4107
19 changed files with 956 additions and 6 deletions
+96
View File
@@ -0,0 +1,96 @@
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools";
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
const MAX_TOOL_ROUNDS = 8;
export type ClientChatMessage = {
role: "user" | "assistant";
content: string;
};
export type AgentToolCallSummary = {
name: string;
status: number;
};
export type AgentChatResult = {
message: ClientChatMessage;
toolCalls: AgentToolCallSummary[];
};
export async function runAgentChat(options: {
messages: ClientChatMessage[];
request: Request;
llm?: LlmClient;
executeTool?: ToolExecutor;
}): Promise<AgentChatResult> {
const llm = options.llm ?? createLlmClient();
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
const transcript: ChatMessage[] = [
{ role: "system", content: AGENT_SYSTEM_PROMPT },
...options.messages.map(
(message): ChatMessage => ({
role: message.role,
content: message.content,
}),
),
];
const toolCalls: AgentToolCallSummary[] = [];
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const completion = await llm.chatCompletion({
messages: transcript,
tools: AGENT_TOOLS,
});
const assistantMessage = completion.message;
transcript.push(assistantMessage);
if (!assistantMessage.tool_calls?.length) {
return {
message: {
role: "assistant",
content: assistantMessage.content?.trim() || "I couldn't generate a response.",
},
toolCalls,
};
}
for (const toolCall of assistantMessage.tool_calls) {
let result: string;
let status: number;
try {
result = await executeTool(toolCall.function.name, toolCall.function.arguments);
const parsed = JSON.parse(result) as { status?: number };
status = typeof parsed.status === "number" ? parsed.status : 200;
} 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 });
transcript.push({
role: "tool",
tool_call_id: toolCall.id,
name: toolCall.function.name,
content: result,
});
}
}
return {
message: {
role: "assistant",
content: "I hit the tool-call limit for this request. Please try a simpler question.",
},
toolCalls,
};
}