Added a cross-turn tool-call loop guard that hashes canonical tool names and arguments, ignores intent metadata, and injects a hidden redirect when identical calls reach the configured threshold. Fixes #3971
108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
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<string, unknown>;
|
|
const output: Record<string, unknown> = {};
|
|
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<string>;
|
|
#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),
|
|
};
|
|
}
|
|
}
|