feat(guard): add the repeat-tool-guard plugin

This commit is contained in:
kingwl
2026-07-08 14:24:20 +08:00
parent 2f162308fe
commit db26ef479d
15 changed files with 799 additions and 5 deletions
+3 -2
View File
@@ -19,11 +19,12 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
compact/ compaction seam + basic backend
subagent/ subagent seam + spawn/fork/ACP backends + delegation tool
todo/ the todo_write tool
guard/ loop-hygiene plugins
hooks/ Claude Code / Codex hook bridges + shared wire-protocol library
session-persistence/ persistence seam + JSONL/SQLite backends
ui/ ACP bridge + app-boot glue + the stdio/ACP app bins
support/ dev/test infrastructure: invariants, llm-replay, subagent-mock
util/ zero-dependency utilities (Branded<B>)
support/ dev/test infrastructure packages
util/ zero-dependency utilities
examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md)
docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md)
scripts/ repo gates and generators
+24
View File
@@ -353,6 +353,30 @@ export interface Config {
Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts)
## `@deepseek-ai/dsh-repeat-tool-guard`
```ts config-catalog
/**
* 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[]
}
```
Source: [`packages/guard/repeat-tool-guard/src/index.ts:55`](../packages/guard/repeat-tool-guard/src/index.ts)
## `@deepseek-ai/dsh-session-persistence-jsonl`
Requires: `sessions`
+3 -3
View File
@@ -11,11 +11,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:385`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:408`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
@@ -32,7 +32,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:92`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:92`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:76`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`.
+6
View File
@@ -79,6 +79,9 @@ flowchart TD
pkg_app_boot["app-boot"]
pkg_stdio_agent["stdio-agent"]
end
subgraph group_guard["packages/guard"]
pkg_repeat_tool_guard["repeat-tool-guard"]
end
pkg_llm --> pkg_brand
pkg_bash --> pkg_brand
pkg_llm_deepseek --> pkg_llm
@@ -157,6 +160,8 @@ flowchart TD
pkg_acp --> pkg_session
pkg_acp --> pkg_session_persistence
pkg_acp --> pkg_tools
pkg_repeat_tool_guard --> pkg_agent
pkg_repeat_tool_guard --> pkg_tools
pkg_agent_core --> pkg_agent
pkg_agent_core --> pkg_agent_loop
pkg_agent_core --> pkg_invariants
@@ -243,6 +248,7 @@ flowchart TD
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+1
View File
@@ -16,6 +16,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guard family: advisory plugins that nudge the model out of unproductive patterns (repeat-tool-guard) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface |
+9
View File
@@ -0,0 +1,9 @@
# guard/ — loop-hygiene guard family
Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability.
| Package | Role | ctx key |
|---|---|---|
| `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) |
Reminders travel as `additionalContext` on the `tools/post-execute` decision; the agent loop appends them as logged `context/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log.
@@ -0,0 +1,36 @@
# @deepseek-ai/dsh-repeat-tool-guard
An advisory loop-breaker, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The decision (retry differently, gather more evidence, or finish) stays entirely with the model: a legitimately repeated call is delayed by nothing and blocked by nothing. Decision record: [the repeat-tool-guard RFC](../../../docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md).
## Config
```yaml
- id: repeat-tool-guard
name: '@deepseek-ai/dsh-repeat-tool-guard'
config:
thresholds: [3, 5, 8] # default; 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
```
`thresholds` fails loud at plugin load: an empty list, a non-integer, a value below 2, or a duplicate throws, never a silent fall-back to defaults. The list is normalized to ascending order; the FIRST threshold delivers a short generic nudge, every later threshold delivers the detailed form naming the tool, the run length, and the canonical arguments.
`include`/`exclude` entries support `*` wildcards and are predicates over whatever tools exist at call time, not references to registry entries — a pattern matching no currently registered tool is NOT an error (`exclude: [mcp_*]` stays valid in a deployment that loads no MCP tools), unlike `toolOrder`'s referent check.
## Chain semantics
The chain key is `(tool name, canonical arguments)` — canonicalization is a deep key-sort plus `JSON.stringify`, so argument objects differing only in property order count as identical. A call identical to the previous tracked call increments the agent's consecutive counter; a different tracked call resets it to 1.
- **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it.
- **Denied calls count.** Detection sits on `tools/post-execute`, which also runs for calls a `tools/pre-execute` listener denied — a model hammering a denied call is exactly the loop worth breaking.
- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no `AgentId` to key on.
- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so chains are keyed by `AgentId`; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain; agent disposal drops its state.
- **In-memory only.** A session resumed from persistence starts with a fresh chain — the guard is a heuristic nudge, not a logged invariant, later reminders are the accepted cost.
## Reminder delivery
Reminders ride the post-execute decision's `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as the tagged synthetic-user envelope — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and folds its reminder onto the downstream decision (both variants — a blocked call still gets the nudge); when a downstream listener attached its own `additionalContext`, the fold concatenates content and carries the guard's `source` (a `HookContext` holds one `MessageSource`; `source.kind` is what framing depends on).
## Testing
Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as `context/message`s in the ACP transcript.
@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-repeat-tool-guard",
"description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
@@ -0,0 +1,243 @@
/**
* 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 per {@link AgentId} — 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 { AgentId, 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[]
}
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([]),
})
/**
* 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('*', '.*')}$`)
}
/**
* 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 arrays 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 chains = new Map<AgentId, 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.id)
const count = chain !== undefined && chain.key === key ? chain.count + 1 : 1
chains.set(exec.agent.id, { key, count })
if (!thresholdSet.has(count)) return undefined
const text = count === thresholds[0] ? GENTLE_REMINDER : detailedReminder(exec.name, count, canonical)
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.id)
return next()
})
// Drop state when an agent goes away, bounding the map over harness lifetime.
ctx.on('agent/status', (agent, status) => {
if (status === 'disposed') chains.delete(agent.id)
})
}
@@ -0,0 +1,372 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Behavior suite for the repeat-tool-call guard: chain semantics (identical /
* different-tracked / untracked-transparent / per-agent / resets), threshold
* escalation incl. the `thresholds[0]` gentle-text rule, canonicalization,
* fold-onto-downstream-decision, and fail-loud config validation — all driven
* through a real agent loop against a scripted mock adapter (no network).
*/
/** Boot the core spine + the guard; the caller registers adapters and extra listeners. */
async function harness(config: Config = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(RepeatToolGuard, config)
ctx.tools.register(defineTool({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineTool({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
/** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */
function reminders(agent: ReactLoopAgent): { text: string; source: unknown }[] {
return [...agent.session.events]
.filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message')
.map(e => ({
text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'),
source: e.data.source,
}))
}
const GUARD_SOURCE = { kind: 'plugin', plugin: 'repeat-tool-guard' }
describe('threshold escalation', () => {
it('reminds gently at the first default threshold (3) and in detail at the second (5)', async () => {
const ctx = await harness()
const adapter = new MockAdapter([
...Array.from({ length: 5 }, (_, i) => toolCallResponse(`c${i}`, 'probe', { q: 'same' })),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(2)
expect(found[0]!.text).toContain('repeating the exact same tool call')
expect(found[0]!.source).toEqual(GUARD_SOURCE)
expect(found[1]!.text).toContain('consecutive_calls: 5')
expect(found[1]!.text).toContain('- tool: probe')
expect(found[1]!.text).toContain('{"q":"same"}')
expect(found[1]!.source).toEqual(GUARD_SOURCE)
})
it('keys the gentle text to thresholds[0], not the literal 3', async () => {
const ctx = await harness({ thresholds: [4, 2] }) // unsorted on purpose: normalized ascending
const adapter = new MockAdapter([
...Array.from({ length: 4 }, (_, i) => toolCallResponse(`c${i}`, 'probe', {})),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(2)
expect(found[0]!.text).toContain('repeating the exact same tool call') // gentle at 2
expect(found[1]!.text).toContain('consecutive_calls: 4') // detailed at 4
})
})
describe('chain semantics', () => {
it('a different tracked call resets the chain', async () => {
const ctx = await harness()
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
toolCallResponse('c3', 'other', {}), // tracked, different → reset
toolCallResponse('c4', 'probe', { q: 1 }),
toolCallResponse('c5', 'probe', { q: 1 }),
toolCallResponse('c6', 'probe', { q: 1 }), // 3rd consecutive AFTER the reset
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1)
})
it('excluded calls are transparent: they neither count nor reset', async () => {
const ctx = await harness({ exclude: ['other'] })
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'other', {}), // excluded → invisible to the chain
toolCallResponse('c3', 'probe', { q: 1 }),
toolCallResponse('c4', 'other', {}),
toolCallResponse('c5', 'probe', { q: 1 }), // 3rd consecutive probe
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(1)
expect(found[0]!.text).toContain('repeating the exact same tool call')
})
it('include patterns track only matching tools (wildcard star)', async () => {
const ctx = await harness({ include: ['pro*'] })
const adapter = new MockAdapter([
toolCallResponse('c1', 'other', {}),
toolCallResponse('c2', 'other', {}),
toolCallResponse('c3', 'other', {}), // 3 identical, but untracked
toolCallResponse('c4', 'probe', {}),
toolCallResponse('c5', 'probe', {}),
toolCallResponse('c6', 'probe', {}), // 3 identical, tracked
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(1)
expect(found[0]!.text).toContain('repeating the exact same tool call')
})
it('escapes regex metacharacters in patterns (a dot matches only a literal dot)', async () => {
const ctx = await harness({ exclude: ['pr.be'] }) // would match 'probe' as a regex; must not as a wildcard
const adapter = new MockAdapter([
...Array.from({ length: 3 }, (_, i) => toolCallResponse(`c${i}`, 'probe', {})),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1) // probe was NOT excluded
})
it('canonicalization ignores property order, deeply', async () => {
const ctx = await harness()
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { a: 1, nested: { x: [1, 2], y: null } }),
toolCallResponse('c2', 'probe', { nested: { y: null, x: [1, 2] }, a: 1 }),
toolCallResponse('c3', 'probe', { a: 1, nested: { x: [1, 2], y: null } }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1) // all three canonicalize identically
})
it('keys chains per agent: one agent repeating never trips another', async () => {
const ctx = await harness()
ctx.llm.registerAdapter(['mock-a'], new MockAdapter([
toolCallResponse('a1', 'probe', { q: 1 }),
toolCallResponse('a2', 'probe', { q: 1 }),
textResponse('done'),
]))
ctx.llm.registerAdapter(['mock-b'], new MockAdapter([
toolCallResponse('b1', 'probe', { q: 1 }),
toolCallResponse('b2', 'probe', { q: 1 }),
toolCallResponse('b3', 'probe', { q: 1 }),
textResponse('done'),
]))
const agentA = ctx.agentLoop.create(AgentId('a'), { model: 'mock-a' })
const agentB = ctx.agentLoop.create(AgentId('b'), { model: 'mock-b' })
agentA.send([{ type: 'text', text: 'go' }])
agentB.send([{ type: 'text', text: 'go' }])
await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)])
expect(reminders(agentA)).toHaveLength(0) // 2 repeats < 3, despite B's 3 in the same registry
expect(reminders(agentB)).toHaveLength(1)
})
it('a new user prompt resets the chain', async () => {
const ctx = await harness()
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
textResponse('turn one done'),
toolCallResponse('c3', 'probe', { q: 1 }), // without the reset this would be the 3rd
textResponse('turn two done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'again' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(0)
})
it('drops an agent chain on disposal', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.llm.registerAdapter(['mock'], new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
textResponse('done'),
toolCallResponse('c2', 'probe', { q: 1 }), // same id, fresh agent: count 1, not 2
textResponse('done'),
]))
// Loop agents are torn down by disposing the scope that created them
// (the loop.spec pattern): a child plugin fiber owns `first`.
let first!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.agentLoop.create(AgentId('reused'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
first.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, first)
await fiber.dispose()
await first.done
const second = ctx.agentLoop.create(AgentId('reused'), { model: 'mock' })
second.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, second)
expect(reminders(second)).toHaveLength(0)
})
it('counts denied calls: hammering a denied tool still draws the reminder', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.on('tools/pre-execute', async () => ({ kind: 'deny' as const, reason: 'sealed' }))
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1)
})
it('ignores direct executes with no agent (they neither crash nor advance any chain)', async () => {
const ctx = await harness({ thresholds: [2] })
const direct = await ctx.tools.execute({ callId: CallId('d1'), name: 'probe', arguments: { q: 1 } })
expect(direct.isError).toBe(false)
ctx.llm.registerAdapter(['mock'], new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2
textResponse('done'),
]))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(0)
})
})
describe('fold onto the downstream decision', () => {
it('folds the reminder onto a downstream block and keeps its feedback', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.on('tools/post-execute', async () => ({
kind: 'block' as const,
feedback: [{ type: 'text' as const, text: 'nope' }],
additionalContext: { content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'plugin' as const, plugin: 'test' } },
}))
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(2)
// Call 1: below threshold — the downstream context passes through untouched.
expect(found[0]!.text).toBe('downstream-ctx')
expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
// Call 2: reminder folded in front, single merged context, the guard's source.
expect(found[1]!.text).toContain('repeating the exact same tool call')
expect(found[1]!.text).toContain('|downstream-ctx')
expect(found[1]!.source).toEqual(GUARD_SOURCE)
// The block's feedback reached the tool result unchanged.
const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
expect(results.every(r => r.data.isError)).toBe(true)
expect(results[1]!.data.content).toEqual([{ type: 'text', text: 'nope' }])
})
it('preserves a downstream accept content replacement while folding', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'replaced' }],
}))
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(1)
expect(found[0]!.text).toContain('repeating the exact same tool call')
const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
expect(results[1]!.data.content).toEqual([{ type: 'text', text: 'replaced' }])
})
})
describe('config validation fails loud', () => {
async function spine(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
return ctx
}
it('rejects an empty thresholds list', async () => {
const ctx = await spine()
await expect(ctx.plugin(RepeatToolGuard, { thresholds: [] })).rejects.toThrow(/must not be empty/)
})
it('rejects a threshold below 2', async () => {
const ctx = await spine()
await expect(ctx.plugin(RepeatToolGuard, { thresholds: [1, 3] })).rejects.toThrow(/integer >= 2/)
})
it('rejects a non-integer threshold', async () => {
const ctx = await spine()
await expect(ctx.plugin(RepeatToolGuard, { thresholds: [2.5] })).rejects.toThrow(/integer >= 2/)
})
it('rejects duplicate thresholds', async () => {
const ctx = await spine()
await expect(ctx.plugin(RepeatToolGuard, { thresholds: [3, 3] })).rejects.toThrow(/duplicates/)
})
})
@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
}
]
}
+28
View File
@@ -381,6 +381,34 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/guard/repeat-tool-guard:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/hooks/hook-protocol:
devDependencies:
'@deepseek-ai/dsh-bash':
+1
View File
@@ -45,6 +45,7 @@
"./packages/bash/*/src",
"./packages/fs/*/src",
"./packages/compact/*/src",
"./packages/guard/*/src",
"./packages/subagent/*/src",
"./packages/web/*/src",
"./packages/todo/*/src",
+1
View File
@@ -53,6 +53,7 @@
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/hooks/hook-protocol" },
{ "path": "./packages/hooks/hooks-claude" },
{ "path": "./packages/hooks/hooks-codex" }
+1
View File
@@ -64,6 +64,7 @@
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/hooks/hook-protocol" },
{ "path": "./packages/hooks/hooks-claude" },
{ "path": "./packages/hooks/hooks-codex" }