From db26ef479dfa91dc73d8254c860f57c4265cedfa Mon Sep 17 00:00:00 2001 From: kingwl Date: Wed, 8 Jul 2026 14:24:20 +0800 Subject: [PATCH] feat(guard): add the repeat-tool-guard plugin --- AGENTS.md | 5 +- docs/config-catalog.md | 24 ++ docs/event-producer-consumer.md | 6 +- docs/module-graph.md | 6 + packages/README.md | 1 + packages/guard/README.md | 9 + packages/guard/repeat-tool-guard/README.md | 36 ++ packages/guard/repeat-tool-guard/package.json | 41 ++ packages/guard/repeat-tool-guard/src/index.ts | 243 ++++++++++++ .../tests/repeat-tool-guard.spec.ts | 372 ++++++++++++++++++ .../guard/repeat-tool-guard/tsconfig.json | 30 ++ pnpm-lock.yaml | 28 ++ tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + 15 files changed, 799 insertions(+), 5 deletions(-) create mode 100644 packages/guard/README.md create mode 100644 packages/guard/repeat-tool-guard/README.md create mode 100644 packages/guard/repeat-tool-guard/package.json create mode 100644 packages/guard/repeat-tool-guard/src/index.ts create mode 100644 packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts create mode 100644 packages/guard/repeat-tool-guard/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 2cd72aff60..911b368a62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,11 +19,12 @@ packages/ Harness packages at packages///, 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) + 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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e268f63da6..18a1387baa 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c10d7caa52..1736981563 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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`. diff --git a/docs/module-graph.md b/docs/module-graph.md index c08b1fd171..b91d12234b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/packages/README.md b/packages/README.md index f07e0d5a36..5a291a6012 100644 --- a/packages/README.md +++ b/packages/README.md @@ -16,6 +16,7 @@ Packages are grouped by modular role at `packages///`. 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 | diff --git a/packages/guard/README.md b/packages/guard/README.md new file mode 100644 index 0000000000..9198698b20 --- /dev/null +++ b/packages/guard/README.md @@ -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. diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md new file mode 100644 index 0000000000..647426e382 --- /dev/null +++ b/packages/guard/repeat-tool-guard/README.md @@ -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. diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json new file mode 100644 index 0000000000..9b085bb015 --- /dev/null +++ b/packages/guard/repeat-tool-guard/package.json @@ -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" + } +} diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts new file mode 100644 index 0000000000..e32d9efd67 --- /dev/null +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -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 = 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 + 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('*', '.*')}$`) +} + +/** + * 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() + + /** 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 => { + 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 => { + 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) + }) +} diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts new file mode 100644 index 0000000000..df59eb1019 --- /dev/null +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -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 { + 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 { + 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 { + 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/) + }) +}) diff --git a/packages/guard/repeat-tool-guard/tsconfig.json b/packages/guard/repeat-tool-guard/tsconfig.json new file mode 100644 index 0000000000..66439bcd5f --- /dev/null +++ b/packages/guard/repeat-tool-guard/tsconfig.json @@ -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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9856e99e95..23de7eac3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/tsconfig.base.json b/tsconfig.base.json index 40e4dbe728..152a0064ac 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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", diff --git a/tsconfig.build.json b/tsconfig.build.json index 96d87c01e9..6bf35060a0 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -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" } diff --git a/tsconfig.json b/tsconfig.json index ff737baf04..5a0c19acb9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" }