Download superlog/apps/worker/src/grouping/agent.ts from smlflg/Experimente: direct link, hf CLI and curl.
- Browser
- Download file 8.15 kB
-
https://huggingface.co/smlflg/Experimente/resolve/main/superlog/apps/worker/src/grouping/agent.ts
- Command line
-
hf download hf://smlflg/Experimente/superlog/apps/worker/src/grouping/agent.ts
-
curl -L -o agent.ts https://huggingface.co/smlflg/Experimente/resolve/main/superlog/apps/worker/src/grouping/agent.ts
8.15 kB
| // 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<Anthropic.Messages.Message>; | |
| }; | |
| export type GroupingTokenAccountant = { | |
| record(input: { | |
| model: string; | |
| usage: { | |
| inputTokens: number; | |
| outputTokens: number; | |
| cacheReadTokens: number; | |
| cacheCreationTokens: number; | |
| }; | |
| }): void | Promise<void>; | |
| }; | |
| 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<GroupingVerdict> { | |
| const candidateIds = new Set(input.candidates.map((candidate) => candidate.id)); | |
| const conversation: Anthropic.Messages.MessageParam[] = [ | |
| { role: "user", content: buildInitialUserMessage(input) }, | |
| ]; | |
| const inspectedCandidateIds = new Set<string>(); | |
| 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<string>, | |
| inspectedCandidateIds: Set<string>, | |
| ): 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<string, unknown>) | |
| : {}; | |
| 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<string>, | |
| inspectedCandidateIds: ReadonlySet<string>, | |
| ): DispatchResult { | |
| const obj = | |
| toolUse.input && typeof toolUse.input === "object" | |
| ? (toolUse.input as Record<string, unknown>) | |
| : {}; | |
| 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<Anthropic, "messages">): 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, | |
| }); | |
| }, | |
| }; | |
| } | |