import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; import type { AssistantMessage, ToolCall, ToolResultMessage } from "../types"; const LEGACY_INTENT_FIELD = "__intent"; const RESULT_SUMMARY_LIMIT = 200; const ARGUMENT_SUMMARY_LIMIT = 400; /** Runtime settings for cross-turn tool-call repetition detection. */ export interface ToolCallLoopGuardOptions { readonly threshold: number; readonly exemptTools: readonly string[]; } /** A completed assistant turn plus the tool results it produced. */ export interface ToolCallLoopTurn { readonly message: AssistantMessage; readonly toolResults: readonly ToolResultMessage[]; } /** Details needed to steer the model away from a repeated tool call. */ export interface RepeatedToolCallDetection { readonly kind: "repeated_tool_call"; readonly toolName: string; readonly count: number; readonly resultSummary: string; readonly argumentsSummary: string; } function canonicalizeToolCallValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(item => canonicalizeToolCallValue(item)); } if (!value || typeof value !== "object") { return value; } const input = value as Record; const output: Record = {}; for (const key of Object.keys(input).sort()) { if (key === INTENT_FIELD || key === LEGACY_INTENT_FIELD) continue; output[key] = canonicalizeToolCallValue(input[key]); } return output; } function summarizeText(text: string, limit: number): string { let summary = text.replace(/\s+/g, " ").trim(); if (summary.length > limit) { summary = `${summary.slice(0, limit)}…`; } return summary; } function summarizeToolResult(toolResults: readonly ToolResultMessage[], toolCallId: string): string { const result = toolResults.find(candidate => candidate.toolCallId === toolCallId); if (!result) return ""; const textParts: string[] = []; for (const block of result.content) { if (block.type === "text") { textParts.push(block.text); } } return summarizeText(textParts.join("\n"), RESULT_SUMMARY_LIMIT); } /** Detects consecutive identical assistant tool calls across model turns. */ export class ToolCallLoopGuard { #threshold: number; #exemptTools: ReadonlySet; #lastHash: string | undefined; #count = 0; constructor(options: ToolCallLoopGuardOptions) { this.#threshold = Math.max(1, Math.trunc(options.threshold)); this.#exemptTools = new Set(options.exemptTools); } /** Records one completed turn and returns the threshold hit, if any. */ recordTurn(turn: ToolCallLoopTurn): RepeatedToolCallDetection | null { const toolCalls = turn.message.content.filter((part): part is ToolCall => part.type === "toolCall"); if (toolCalls.length !== 1 || this.#exemptTools.has(toolCalls[0]!.name)) { this.#lastHash = undefined; this.#count = 0; return null; } const toolCall = toolCalls[0]!; const canonicalArgs = JSON.stringify(canonicalizeToolCallValue(toolCall.arguments)); const hash = `${toolCall.name}:${canonicalArgs}`; if (hash === this.#lastHash) { this.#count++; } else { this.#lastHash = hash; this.#count = 1; } if (this.#count !== this.#threshold) return null; return { kind: "repeated_tool_call", toolName: toolCall.name, count: this.#count, resultSummary: summarizeToolResult(turn.toolResults, toolCall.id), argumentsSummary: summarizeText(canonicalArgs, ARGUMENT_SUMMARY_LIMIT), }; } }