/** * Advisory per-agent repeat-call detector. It enriches post-execute decisions * with logged model context without vetoing or rewriting calls. Configuration * and chain semantics live in the package README; rationale lives in the * repeat-tool-guard Agent Note. * @module @deepseek-ai/dsh-repeat-tool-guard */ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' export const name = 'repeat-tool-guard' /** * Plugin config, validated by the same-named schemastery schema plus the * load-time checks in `apply` (misconfiguration fails loud: an empty * `thresholds` list, a non-integer, a value below 2, or a duplicate throws at * plugin load, never a silent fall-back). `include`/`exclude` entries are * `*`-wildcard predicates over tool names at call time, not references to * registry entries — a pattern matching no currently registered tool is valid * (`exclude: [mcp_*]` must stay legal in a deployment that loads no MCP tools). */ export interface Config { /** Consecutive-repeat counts that trigger a reminder (default `[3, 5, 8]`). */ thresholds?: number[] /** Tool-name patterns to track; empty means every tool is tracked. */ include?: string[] /** Tool-name patterns transparent to the chain (neither count nor reset). */ exclude?: string[] /** * Maximum characters of canonical arguments quoted in the DETAILED reminder * (default 500). Large payloads (a `write` body, a long command) would * otherwise ride into the next request unbounded — precisely in a loop * scenario; the cap bounds the reminder, never the detection (the chain key * always compares the FULL canonical string). */ argumentsPreviewChars?: number } export const Config: z = z.object({ thresholds: z.array(z.number()).default([3, 5, 8]), include: z.array(z.string()).default([]), exclude: z.array(z.string()).default([]), argumentsPreviewChars: z.number().default(500), }) /** * The `{kind:'plugin'}` source stamped on every reminder this guard injects — * the label is load-bearing (an unlabeled context would render as a user * prompt in derived history). */ const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'repeat-tool-guard' } /** * The gentle first-threshold reminder. Keyed to `thresholds[0]`, not a literal * count, so a custom first threshold keeps the gentle-then-detailed escalation. */ const GENTLE_REMINDER = 'You are repeating the exact same tool call with identical arguments. ' + 'Carefully analyze the previous result before calling again: if the task is ' + 'not complete, try a different approach or different arguments instead of ' + 'repeating the call.' /** The detailed later-threshold reminder naming the tool, the run length, and the canonical arguments. */ function detailedReminder(toolName: string, count: number, canonicalArguments: string): string { return 'Repeated tool call detected:\n' + `- tool: ${toolName}\n` + `- consecutive_calls: ${count}\n` + `- arguments: ${canonicalArguments}\n` + 'The repeated calls are not making progress. Do not call this tool with ' + 'these exact arguments again. Inspect the latest result and choose a ' + 'different action, different arguments, or finish the task if enough ' + 'evidence has been gathered.' } /** * Deep key-sort of a parsed-JSON value so two argument objects that differ * only in property order canonicalize identically. Arguments reach the guard * as the loop's `JSON.parse` output (or its raw-string fallback for malformed * argument JSON), so JSON's value domain is the whole input domain — no * bigint, cycle, or `undefined` handling exists because no input path can * produce them. */ function sortJsonValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortJsonValue) if (value !== null && typeof value === 'object') { const record = value as Record const sorted: Record = {} for (const key of Object.keys(record).sort()) { sorted[key] = sortJsonValue(record[key]) } return sorted } return value } /** Canonical string form of a call's arguments: deep key-sort, then stringify. */ function canonicalize(argumentsValue: unknown): string { return JSON.stringify(sortJsonValue(argumentsValue)) } /** Compile one `*`-wildcard pattern to an anchored RegExp (every other regex metacharacter is matched literally). */ function wildcardToRegExp(pattern: string): RegExp { const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, String.raw`\$&`) return new RegExp(`^${escaped.replaceAll('*', '.*')}$`) } /** * Head-truncate the canonical arguments for quoting in the detailed reminder, * marking how much was omitted. Bounds only the model-visible text — the * chain key always uses the full canonical string. */ function previewArguments(canonical: string, cap: number): string { if (canonical.length <= cap) return canonical return `${canonical.slice(0, cap)}… (+${canonical.length - cap} more chars)` } /** * Validate `thresholds` per the fail-loud contract and return them sorted * ascending (the escalation rule reads `thresholds[0]` as the gentle tier, so * order is normalized here, once). */ function validateThresholds(values: number[]): number[] { if (values.length === 0) { throw new Error('repeat-tool-guard: `thresholds` must not be empty') } for (const value of values) { if (!Number.isInteger(value) || value < 2) { throw new Error(`repeat-tool-guard: invalid threshold ${value} — every threshold must be an integer >= 2`) } } if (new Set(values).size !== values.length) { throw new Error('repeat-tool-guard: `thresholds` must not contain duplicates') } return [...values].sort((a, b) => a - b) } /** * Prepend the guard's reminder while preserving every downstream context's * source and metadata. */ function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { return [ours, ...theirs ?? []] } /** One agent's consecutive-repeat chain: the last tracked call's identity key and its run length. */ interface Chain { key: string count: number } /** * Install the guard's listeners. * @param ctx - plugin context; listeners are scoped to it and disposed with it. * @param config - validated {@link Config}; `thresholds` is re-checked fail-loud here. */ export function apply(ctx: Context, config: Config): void { // schemastery's .default() guarantees the fields are set after validation. const thresholds = validateThresholds(config.thresholds as number[]) const thresholdSet = new Set(thresholds) const includePatterns = (config.include as string[]).map(wildcardToRegExp) const excludePatterns = (config.exclude as string[]).map(wildcardToRegExp) const argumentsPreviewChars = config.argumentsPreviewChars as number if (!Number.isInteger(argumentsPreviewChars) || argumentsPreviewChars < 1) { throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`) } const chains = new WeakMap() /** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */ function tracked(toolName: string): boolean { if (includePatterns.length > 0 && !includePatterns.some(pattern => pattern.test(toolName))) return false return !excludePatterns.some(pattern => pattern.test(toolName)) } /** * Advance the calling agent's chain for one attempt and return the reminder * to deliver, if this attempt's run length hits a configured threshold. * Counting happens here — in post-execute — because denied calls also flow * through this waterfall (`ToolRegistry.execute` routes a deny through the * same pipeline), and a model hammering a denied call is exactly the loop * worth breaking. */ function observe(exec: ToolExecution): HookContext | undefined { // A direct `ctx.tools.execute()` caller has no model to remind and no id // to key on; only agent-loop calls participate. if (!exec.agent) return undefined if (!tracked(exec.name)) return undefined const canonical = canonicalize(exec.arguments) const key = JSON.stringify([exec.name, canonical]) const chain = chains.get(exec.agent) const count = chain !== undefined && chain.key === key ? chain.count + 1 : 1 chains.set(exec.agent, { key, count }) if (!thresholdSet.has(count)) return undefined const text = count === thresholds[0] ? GENTLE_REMINDER : detailedReminder(exec.name, count, previewArguments(canonical, argumentsPreviewChars)) return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } } // Observe-and-enrich, never veto: count first (state advances regardless of // the downstream outcome), DELEGATE so a later listener can still block or // replace, then fold the reminder onto whatever came back — additionalContexts // rides both decision variants, so a blocked call still gets the nudge. ctx.on('tools/post-execute', async (exec, _result, next): Promise => { const reminder = observe(exec) const downstream = await next() if (!reminder) return downstream if (downstream.kind === 'block') { return { kind: 'block', feedback: downstream.feedback, additionalContexts: prependContext(reminder, downstream.additionalContexts) } } return { ...downstream, additionalContexts: prependContext(reminder, downstream.additionalContexts), } }) // A user interjection changes the context; repetition across it is not a // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next): Promise => { chains.delete(agent) return next() }) }