mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
/**
|
|
* Repeat-tool-call guard: advisory loop-breaker for agents stuck re-issuing
|
|
* the same tool call with identical arguments.
|
|
*
|
|
* Not a model-facing tool — it registers no tool, never vetoes or rewrites a
|
|
* call, and adds exactly one behavior: watch each agent's stream of tool calls
|
|
* through the `tools/post-execute` waterfall, count runs of consecutive calls
|
|
* to the same tool with identical canonicalized arguments, and at configured
|
|
* run lengths fold an escalating advisory reminder onto the decision's
|
|
* `additionalContext`. The loop appends that context as a logged
|
|
* `context/message` after the step's tool results, so the reminder is
|
|
* model-visible, source-attributed, and reconstructable from the session log
|
|
* with no new session event. Decision record:
|
|
* docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md.
|
|
*
|
|
* ```yaml
|
|
* - id: repeat-tool-guard
|
|
* name: '@deepseek-ai/dsh-repeat-tool-guard'
|
|
* config:
|
|
* thresholds: [3, 5, 8] # consecutive counts that trigger a reminder
|
|
* include: [] # tool-name patterns to track; empty = all tools
|
|
* exclude: [todo_write] # tool-name patterns transparent to the chain
|
|
* ```
|
|
*
|
|
* Chain state is keyed by the live agent object — the tool registry is a
|
|
* context-level singleton whose waterfalls interleave every agent's calls, so
|
|
* a shared counter would let one agent's repetition trip another's reminder.
|
|
* State is in-memory only: a session resumed from persistence starts with a
|
|
* fresh chain (the guard is a heuristic nudge, not a logged invariant).
|
|
*
|
|
* Plugin export shape: named exports, NO default. The cordis Loader's
|
|
* `unwrapExports` does `exports.default ?? exports`, so a stray default would
|
|
* collapse the module to the bare `apply` (see docs/postmortem/0001).
|
|
*
|
|
* @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<Config> = 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<string, unknown>
|
|
const sorted: Record<string, unknown> = {}
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Concatenate the guard's reminder context with a downstream listener's
|
|
* optional one so folding drops neither. The merged block carries the guard's
|
|
* `source` — a `HookContext` holds one `MessageSource` and the seam cannot
|
|
* represent mixed provenance; the rendered `context/message` only
|
|
* distinguishes by `source.kind`, so a downstream plugin's text is still
|
|
* correctly framed as plugin context.
|
|
*/
|
|
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
|
|
if (!theirs) return ours
|
|
return { content: [...ours.content, ...theirs.content], source: ours.source }
|
|
}
|
|
|
|
/** 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<Agent, Chain>()
|
|
|
|
/** 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 — additionalContext
|
|
// rides both decision variants, so a blocked call still gets the nudge.
|
|
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
|
|
const reminder = observe(exec)
|
|
const downstream = await next()
|
|
if (!reminder) return downstream
|
|
if (downstream.kind === 'block') {
|
|
return { kind: 'block', feedback: downstream.feedback, additionalContext: concatContext(reminder, downstream.additionalContext) }
|
|
}
|
|
return {
|
|
kind: 'accept',
|
|
...downstream.content !== undefined ? { content: downstream.content } : {},
|
|
additionalContext: concatContext(reminder, downstream.additionalContext),
|
|
}
|
|
})
|
|
|
|
// 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, next): Promise<PromptDecision> => {
|
|
chains.delete(agent)
|
|
return next()
|
|
})
|
|
}
|