// Grouping LLM tool-use loop. The dispatcher and parsers live in // candidate-search.ts / domain.ts; this file is just the orchestration. import type Anthropic from "@anthropic-ai/sdk"; import { inspectCandidate, listIncidentFacets, listIncidentTitles, searchCandidates, } from "./candidate-search.js"; import { type GroupingCandidateIncident, type GroupingNewIssue, type GroupingVerdict, parseDecisionToolInput, parseVerdictFromText, } from "./domain.js"; import { buildInitialUserMessage } from "./initial-message.js"; import { GROUPING_SYSTEM_PROMPT, GROUPING_TOOLS } from "./tools.js"; // Abstraction over the Anthropic SDK so tests can substitute canned // responses. The eval harness keeps using the real client via // `analyzeIssueGroupingWithClient` in the facade, which adapts the SDK // to this shape. export type GroupingLLMClient = { send(input: { model: string; system: string; tools: Anthropic.Messages.Tool[]; messages: Anthropic.Messages.MessageParam[]; maxTokens: number; temperature: number; }): Promise; }; export type GroupingTokenAccountant = { record(input: { model: string; usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; }; }): void | Promise; }; export type RunGroupingAgentInput = { projectName: string; newIssue: GroupingNewIssue; candidates: GroupingCandidateIncident[]; }; export type RunGroupingAgentDeps = { client: GroupingLLMClient; model: string; accountant: GroupingTokenAccountant; maxIterations: number; }; function extractText(message: Anthropic.Messages.Message): string { return message.content .map((block) => (block.type === "text" ? block.text : "")) .join("") .trim(); } export async function runGroupingAgent( input: RunGroupingAgentInput, deps: RunGroupingAgentDeps, ): Promise { const candidateIds = new Set(input.candidates.map((candidate) => candidate.id)); const conversation: Anthropic.Messages.MessageParam[] = [ { role: "user", content: buildInitialUserMessage(input) }, ]; const inspectedCandidateIds = new Set(); for (let iter = 0; iter < deps.maxIterations; iter++) { const message = await deps.client.send({ model: deps.model, system: GROUPING_SYSTEM_PROMPT, tools: GROUPING_TOOLS, messages: conversation, maxTokens: 700, temperature: 0, }); try { await deps.accountant.record({ model: deps.model, usage: { inputTokens: message.usage?.input_tokens ?? 0, outputTokens: message.usage?.output_tokens ?? 0, cacheReadTokens: message.usage?.cache_read_input_tokens ?? 0, cacheCreationTokens: message.usage?.cache_creation_input_tokens ?? 0, }, }); } catch { // Token accounting is best-effort; grouping decisions must not fail just // because the optional usage sink is down. } const toolUses = message.content.filter( (block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use", ); if (toolUses.length === 0) { const parsed = parseVerdictFromText(extractText(message), candidateIds); return parsed.decision === "join" ? parsed : { decision: "standalone", evidence: parsed.evidence ?? "Model did not call a grouping tool.", }; } conversation.push({ role: "assistant", content: message.content }); const toolResults: Anthropic.Messages.ToolResultBlockParam[] = []; let terminal: GroupingVerdict | null = null; for (const toolUse of toolUses) { const dispatch = dispatchTool(toolUse, input.candidates, candidateIds, inspectedCandidateIds); if (dispatch.kind === "terminal") { terminal = dispatch.verdict; break; } toolResults.push(dispatch.result); } if (terminal) return terminal; conversation.push({ role: "user", content: toolResults }); } return { decision: "standalone", evidence: "Grouping agent exhausted its tool-use budget without a valid decision.", }; } type DispatchResult = | { kind: "terminal"; verdict: GroupingVerdict } | { kind: "continue"; result: Anthropic.Messages.ToolResultBlockParam }; function dispatchTool( toolUse: Anthropic.Messages.ToolUseBlock, candidates: GroupingCandidateIncident[], candidateIds: ReadonlySet, inspectedCandidateIds: Set, ): DispatchResult { switch (toolUse.name) { case "decide_grouping": return dispatchDecide(toolUse, candidateIds, inspectedCandidateIds); case "search_incidents": return { kind: "continue", result: { type: "tool_result", tool_use_id: toolUse.id, content: JSON.stringify(searchCandidates(candidates, toolUse.input)), }, }; case "list_incident_titles": return { kind: "continue", result: { type: "tool_result", tool_use_id: toolUse.id, content: JSON.stringify(listIncidentTitles(candidates, toolUse.input)), }, }; case "list_incident_facets": return { kind: "continue", result: { type: "tool_result", tool_use_id: toolUse.id, content: JSON.stringify(listIncidentFacets(candidates)), }, }; case "inspect_incident": { const obj = toolUse.input && typeof toolUse.input === "object" ? (toolUse.input as Record) : {}; const incidentId = typeof obj.incident_id === "string" ? obj.incident_id : ""; if (candidateIds.has(incidentId)) inspectedCandidateIds.add(incidentId); return { kind: "continue", result: { type: "tool_result", tool_use_id: toolUse.id, content: JSON.stringify(inspectCandidate(candidates, toolUse.input)), }, }; } default: return { kind: "continue", result: { type: "tool_result", tool_use_id: toolUse.id, content: `Unknown tool: ${toolUse.name}`, is_error: true, }, }; } } function dispatchDecide( toolUse: Anthropic.Messages.ToolUseBlock, candidateIds: ReadonlySet, inspectedCandidateIds: ReadonlySet, ): DispatchResult { const obj = toolUse.input && typeof toolUse.input === "object" ? (toolUse.input as Record) : {}; const incidentId = typeof obj.incidentId === "string" ? obj.incidentId : ""; // Domain invariant: a join must follow an inspect_incident call for that // target. The model has access to the data via search_incidents, but // requiring an inspect makes it stop and *commit* to one candidate // before claiming a shared root cause. if (obj.decision === "join" && !inspectedCandidateIds.has(incidentId)) { return { kind: "continue", result: { type: "tool_result", tool_use_id: toolUse.id, content: "Invalid decide_grouping input. Before joining, call inspect_incident for that incidentId.", is_error: true, }, }; } const verdict = parseDecisionToolInput(toolUse.input, candidateIds); if (verdict) return { kind: "terminal", verdict }; return { kind: "continue", result: { type: "tool_result", tool_use_id: toolUse.id, content: "Invalid decide_grouping input. Required: decision='standalone' or decision='join' with a known incidentId and >=20 chars of evidence.", is_error: true, }, }; } // Adapter from the real Anthropic SDK to our injectable client shape. export function asGroupingLLMClient(client: Pick): GroupingLLMClient { return { async send(input) { return client.messages.create({ model: input.model, max_tokens: input.maxTokens, temperature: input.temperature, system: input.system, tools: input.tools, messages: input.messages, }); }, }; }