From e491759f308e88d3c82bbd67e5fa96a10bf35688 Mon Sep 17 00:00:00 2001 From: kingwl Date: Wed, 8 Jul 2026 14:40:08 +0800 Subject: [PATCH] fix review finding: cap the detailed reminder's argument payload --- docs/config-catalog.md | 8 +++++ .../feature/2026-07-08-repeat-tool-guard.md | 9 +++--- packages/guard/repeat-tool-guard/README.md | 9 +++--- packages/guard/repeat-tool-guard/src/index.ts | 29 +++++++++++++++++-- .../tests/repeat-tool-guard.spec.ts | 29 +++++++++++++++++++ 5 files changed, 74 insertions(+), 10 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18a1387baa..275c35ff85 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -372,6 +372,14 @@ export interface Config { include?: string[] /** Tool-name patterns transparent to the chain (neither count nor reset). */ exclude?: string[] + /** + * Maximum characters of canonical arguments quoted in the DETAILED reminder + * (default 500). Large payloads (a `write` body, a long command) would + * otherwise ride into the next request unbounded — precisely in a loop + * scenario; the cap bounds the reminder, never the detection (the chain key + * always compares the FULL canonical string). + */ + argumentsPreviewChars?: number } ``` diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index e0e9c28b5c..9d0446dcad 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -29,7 +29,7 @@ Two deliberate rules, both documented in [the package README](../../../../packag ### Reminder delivery -Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}` — the label is load-bearing per `HookContext`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit, and the loop appends buffered context as `context/message`(s) after the step's results, which the session renders as the tagged synthetic-user envelope and derived history replays. Thresholds escalate: the first configured threshold gets a short "you are repeating yourself, analyze the previous result" nudge; each later threshold gets the detailed form naming the tool, the repeat count, and the canonical arguments, and stating that the calls made no progress. The pi original hardcodes the gentle text to the literal count 3; the guard keys it to `thresholds[0]`, fixing that bug in the port. When the downstream decision already carries `additionalContext` (a hook bridge on the same call), the guard concatenates content under its own `source` — a `HookContext` holds one `MessageSource`, and `source.kind` is what framing depends on. +Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}` — the label is load-bearing per `HookContext`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit, and the loop appends buffered context as `context/message`(s) after the step's results, which the session renders as the tagged synthetic-user envelope and derived history replays. Thresholds escalate: the first configured threshold gets a short "you are repeating yourself, analyze the previous result" nudge; each later threshold gets the detailed form naming the tool, the repeat count, and the canonical arguments (head-truncated at `argumentsPreviewChars`, default 500 — a looping `write`-sized payload must not ride into the next request unbounded; the chain key always compares the full canonical string), and stating that the calls made no progress. The pi original hardcodes the gentle text to the literal count 3; the guard keys it to `thresholds[0]`, fixing that bug in the port. When the downstream decision already carries `additionalContext` (a hook bridge on the same call), the guard concatenates content under its own `source` — a `HookContext` holds one `MessageSource`, and `source.kind` is what framing depends on. ### Config @@ -37,9 +37,10 @@ Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-too - 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: [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 + argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder ``` `thresholds` is validated at load and throws on an empty list, a non-integer, a value below 2, or a duplicate — misconfiguration fails loud, replacing the pi original's silent fall-back to defaults. `include`/`exclude` entries support `*` wildcards. Patterns are predicates over whatever tools exist at call time, not references to a registry entry, so an entry matching no currently registered tool is NOT an error — unlike `toolOrder`'s referent check, `exclude: [mcp_*]` must stay valid in a deployment that loads no MCP tools. diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 647426e382..dc385bc033 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -8,12 +8,13 @@ An advisory loop-breaker, not a model-facing tool: it never appears in the tool - 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: [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 + argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder ``` -`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. +`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; `argumentsPreviewChars` equally rejects anything but an integer >= 1. 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 — head-truncated at `argumentsPreviewChars` with an omitted-count marker, so a looping `write`/`edit` payload cannot ride into the next request unbounded (the chain key always compares the FULL canonical string; the cap bounds the reminder, never the detection). `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. diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index e32d9efd67..919d0541ba 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -59,12 +59,21 @@ export interface Config { include?: string[] /** Tool-name patterns transparent to the chain (neither count nor reset). */ exclude?: string[] + /** + * Maximum characters of canonical arguments quoted in the DETAILED reminder + * (default 500). Large payloads (a `write` body, a long command) would + * otherwise ride into the next request unbounded — precisely in a loop + * scenario; the cap bounds the reminder, never the detection (the chain key + * always compares the FULL canonical string). + */ + argumentsPreviewChars?: number } export const Config: z = z.object({ thresholds: z.array(z.number()).default([3, 5, 8]), include: z.array(z.string()).default([]), exclude: z.array(z.string()).default([]), + argumentsPreviewChars: z.number().default(500), }) /** @@ -128,6 +137,16 @@ function wildcardToRegExp(pattern: string): RegExp { return new RegExp(`^${escaped.replaceAll('*', '.*')}$`) } +/** + * Head-truncate the canonical arguments for quoting in the detailed reminder, + * marking how much was omitted. Bounds only the model-visible text — the + * chain key always uses the full canonical string. + */ +function previewArguments(canonical: string, cap: number): string { + if (canonical.length <= cap) return canonical + return `${canonical.slice(0, cap)}… (+${canonical.length - cap} more chars)` +} + /** * Validate `thresholds` per the fail-loud contract and return them sorted * ascending (the escalation rule reads `thresholds[0]` as the gentle tier, so @@ -173,11 +192,15 @@ interface Chain { * @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. + // schemastery's .default() guarantees the fields are set after validation. const thresholds = validateThresholds(config.thresholds as number[]) const thresholdSet = new Set(thresholds) const includePatterns = (config.include as string[]).map(wildcardToRegExp) const excludePatterns = (config.exclude as string[]).map(wildcardToRegExp) + const argumentsPreviewChars = config.argumentsPreviewChars as number + if (!Number.isInteger(argumentsPreviewChars) || argumentsPreviewChars < 1) { + throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`) + } const chains = new Map() @@ -206,7 +229,9 @@ export function apply(ctx: Context, config: Config): void { 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) + const text = count === thresholds[0] + ? GENTLE_REMINDER + : detailedReminder(exec.name, count, previewArguments(canonical, argumentsPreviewChars)) return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } } 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 index df59eb1019..565f1076b5 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -90,6 +90,28 @@ describe('threshold escalation', () => { }) describe('chain semantics', () => { + it('caps the detailed reminder arguments at argumentsPreviewChars (detection still keys on the full string)', async () => { + const ctx = await harness({ thresholds: [2, 3], argumentsPreviewChars: 24 }) + const bigPayload = 'x'.repeat(400) + const adapter = new MockAdapter([ + toolCallResponse('c1', 'probe', { body: bigPayload }), + toolCallResponse('c2', 'probe', { body: bigPayload }), + toolCallResponse('c3', 'probe', { body: bigPayload }), + 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) // gentle at 2, detailed at 3 — full-key matching survived the cap + const detailed = found[1]!.text + expect(detailed).toContain('- arguments: {"body":"xxxxxxxxxxxxxx') // 24-char head + expect(detailed).toContain('… (+387 more chars)') + expect(detailed).not.toContain(bigPayload) + }) + it('a different tracked call resets the chain', async () => { const ctx = await harness() const adapter = new MockAdapter([ @@ -369,4 +391,11 @@ describe('config validation fails loud', () => { const ctx = await spine() await expect(ctx.plugin(RepeatToolGuard, { thresholds: [3, 3] })).rejects.toThrow(/duplicates/) }) + + it('rejects a non-positive or fractional argumentsPreviewChars', async () => { + const ctx = await spine() + await expect(ctx.plugin(RepeatToolGuard, { argumentsPreviewChars: 0 })).rejects.toThrow(/argumentsPreviewChars/) + const ctx2 = await spine() + await expect(ctx2.plugin(RepeatToolGuard, { argumentsPreviewChars: 12.5 })).rejects.toThrow(/argumentsPreviewChars/) + }) })