@deepseek-ai/dsh-repeat-tool-reminder 0.0.1-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, DeepSeek
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/guard/repeat-tool-reminder/README.md
5
+ README.md: cfece8150dce8e2fe45b4e942bf7188f46836551
6
+ README.zh.md: 439480d876ad07d8bc095b2534a5a2ce732bfc9b
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @deepseek-ai/dsh-repeat-tool-reminder
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ 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-reminder Agent Note](../../../.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.md).
6
+
7
+ ## Config
8
+
9
+ ```yaml
10
+ - id: repeat-tool-reminder
11
+ name: '@deepseek-ai/dsh-repeat-tool-reminder'
12
+ config:
13
+ thresholds: [3, 5, 8] # default; consecutive counts that trigger a reminder
14
+ include: [] # tool-name patterns to track; empty ⇒ all tools
15
+ exclude: [todo_write] # tool-name patterns transparent to the chain
16
+ argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder
17
+ ```
18
+
19
+ `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).
20
+
21
+ `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.
22
+
23
+ ## Chain semantics
24
+
25
+ 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.
26
+
27
+ - **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.
28
+ - **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.
29
+ - **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no live agent object to key on.
30
+ - **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap<Agent, Chain>` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/pre-step`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener.
31
+ - **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.
32
+
33
+ ## Reminder delivery
34
+
35
+ Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-reminder'}`), 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 an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — 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 prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata.
36
+
37
+ ## Model Experience
38
+
39
+ ### First-threshold context message
40
+
41
+ #### What the model sees
42
+
43
+ At the first configured consecutive-repeat threshold, that agent receives the reminder below. No tool schema or normal-call text is added.
44
+
45
+ ##### First-threshold reminder
46
+
47
+ ```markdown
48
+ 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.
49
+ ```
50
+
51
+ #### Token effect
52
+
53
+ Zero tokens before the threshold. The reminder is retained history for that agent.
54
+
55
+ #### KV Cache effect
56
+
57
+ Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
58
+
59
+ ### Later-threshold context message
60
+
61
+ #### What the model sees
62
+
63
+ A later threshold receives the detailed reminder template below. A capped argument preview ends exactly `… (+<omitted> more chars)`.
64
+
65
+ ##### Later-threshold reminder
66
+
67
+ ```markdown
68
+ Repeated tool call detected:
69
+ - tool: <toolName>
70
+ - consecutive_calls: <count>
71
+ - arguments: <canonicalArguments>
72
+ 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.
73
+ ```
74
+
75
+ #### Token effect
76
+
77
+ Each reminder is retained history; `argumentsPreviewChars` bounds its data-dependent argument text, while agents keep independent counters.
78
+
79
+ #### KV Cache effect
80
+
81
+ Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
82
+
83
+ ## Known Limitations and Deferred Work
84
+
85
+ - **Exact-match detection only** — canonicalization is a deep key-sort, so near-identical variants (a tweaked path, extra whitespace inside a value) evade the chain; fuzzy matching is rejected pending evidence of need.
86
+ - **Compaction does not reset chains** — a chain spanning a compaction checkpoint keeps counting.
87
+ - **Advisory only** — escalating to `block` at a high threshold is not implemented, though `PostToolDecision` already supports blocking.
88
+ - **No subagent chain-sharing** — chains stay isolated per agent; a parent and its subagent repeating the same call never combine.
89
+ - **Legitimate idempotent polling still draws nudges** past the thresholds — the pressure valves are `thresholds`/`exclude` config.
90
+ - **Past the highest threshold a chain goes silent** — reminders fire only at exact configured counts, never beyond them.
package/README.zh.md ADDED
@@ -0,0 +1,90 @@
1
+ # @deepseek-ai/dsh-repeat-tool-reminder
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 这是一个仅提供建议的循环中断器,而非面向模型的工具:它不会出现在工具列表中,不会否决或改写调用,只增加一种行为。它监视每个 agent(智能体)的工具调用流,统计以完全相同的规范化参数连续调用同一工具的次数;达到所配置的连续次数时,它会注入逐级增强的提示,要求模型停止重复、重新阅读上一次结果,并改用其他方案或结束任务。究竟是换一种方式重试、收集更多证据还是完成任务,仍完全由模型决定:合理的重复调用既不会延迟,也不会受阻。决策记录见 [repeat-tool-reminder Agent Note](../../../.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.md)。
6
+
7
+ ## 配置
8
+
9
+ ```yaml
10
+ - id: repeat-tool-reminder
11
+ name: '@deepseek-ai/dsh-repeat-tool-reminder'
12
+ config:
13
+ thresholds: [3, 5, 8] # default; consecutive counts that trigger a reminder
14
+ include: [] # tool-name patterns to track; empty ⇒ all tools
15
+ exclude: [todo_write] # tool-name patterns transparent to the chain
16
+ argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder
17
+ ```
18
+
19
+ 插件加载时,`thresholds` 会对错误配置快速失败:空列表、非整数、小于 2 的值或重复值都会抛出错误,绝不静默回退到默认值;`argumentsPreviewChars` 同样只接受大于等于 1 的整数。系统会将列表按升序规范化;第一个阈值只发送简短的通用提醒,后续每个阈值都会发送详细版本,列出工具、连续次数和规范参数。参数内容截取前 `argumentsPreviewChars` 个字符,并附带省略字符数标记,避免循环中的 `write`/`edit` 载荷无限制进入下一次请求(链键始终比较完整的规范字符串;此上限只约束提醒,不影响检测)。
20
+
21
+ `include`/`exclude` 条目支持 `*` 通配符,并针对调用时实际存在的工具执行谓词判断,而不是引用注册表条目。因此,与当前任何已注册工具都不匹配的模式并非错误(未加载 MCP 工具的部署中,`exclude: [mcp_*]` 仍然有效);这与 `toolOrder` 的引用目标检查不同。
22
+
23
+ ## 链语义
24
+
25
+ 链键为「`(tool name, canonical arguments)`」:规范化过程会对键进行深度排序,然后执行 `JSON.stringify`,因此仅属性顺序不同的参数对象会视为相同。若某次调用与上一条受跟踪调用相同,该 agent 的连续计数器递增;换成另一条受跟踪调用则重置为 1。
26
+
27
+ - **不受跟踪的调用对链透明。** 被 `include`/`exclude` 排除的调用既不递增计数器,也不重置计数器;因此,`grep X → todo_write → grep X` 仍算作连续两次 `grep X`,即使 `todo_write` 已被排除。这正是排除机制的价值:循环中穿插的记录类工具不能掩盖循环。
28
+ - **被拒绝的调用也计数。** 检测位于 `tools/post-execute`;即便调用被 `tools/pre-execute` 监听器拒绝,该事件也会运行。模型反复尝试被拒绝的调用,恰恰是需要打断的循环。
29
+ - **忽略没有 agent 的调用。** 直接调用 `ctx.tools.execute()` 的调用方没有需要提醒的模型,也没有可作为键的活跃 agent 对象。
30
+ - **按 agent 分键。** 工具注册表位于上下文层级,subagent 会交错通过同一个 waterfall(瀑布式事件),因此每条链使用 `WeakMap<Agent, Chain>`,以活跃 agent 对象为键。一个 agent 的重复调用绝不会触发另一个 agent 的提醒。用户提示词(`agent/pre-step`)会重置提交该提示词的 agent 链;对象生命周期会自然限制弱引用条目的寿命,无需 dispose(资源释放)监听器。
31
+ - **仅驻留内存。** 从持久化恢复的会话会从一条全新的链开始:guard 是启发式提醒,并非有日志记录的不变量;提醒会延后,这是可接受的代价。
32
+
33
+ ## 提醒传递
34
+
35
+ 提醒通过 post-execute 决策中的 `additionalContexts`(来源为 `{kind: 'plugin', plugin: 'repeat-tool-reminder'}`)传递,绝不替换 `content`;用于审计的 `tool/result` 事件仍保留工具自己的输出。循环会缓冲这段上下文,并在该步骤的工具结果之后将其作为注入的 `user/message` 追加;会话会将它渲染为普通的合成用户消息。因此,提醒对模型可见、带有来源归属,并且无需增加会话事件即可从会话日志重建。guard 始终通过 `next()` 委派,并将自己的提醒放在下游决策的上下文数组之前(两种结果都适用:被阻止的调用也会收到提醒);每个条目保留自己的来源和元数据。
36
+
37
+ ## 模型体验
38
+
39
+ ### 首个阈值的上下文消息
40
+
41
+ #### 模型看到的内容
42
+
43
+ 达到第一个配置的连续重复阈值时,对应 agent 会收到以下提醒。系统不会添加工具 schema 或正常调用文本。
44
+
45
+ ##### 首个阈值提醒
46
+
47
+ ```markdown
48
+ 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.
49
+ ```
50
+
51
+ #### Token 影响
52
+
53
+ 达到阈值前为零 token。提醒会作为该 agent 的历史记录保留。
54
+
55
+ #### KV Cache 影响
56
+
57
+ 仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
58
+
59
+ ### 后续阈值的上下文消息
60
+
61
+ #### 模型看到的内容
62
+
63
+ 达到后续阈值时,agent 会收到以下详细提醒模板。受上限约束的参数预览严格以 `… (+<omitted> more chars)` 结尾。
64
+
65
+ ##### 后续阈值提醒
66
+
67
+ ```markdown
68
+ Repeated tool call detected:
69
+ - tool: <toolName>
70
+ - consecutive_calls: <count>
71
+ - arguments: <canonicalArguments>
72
+ 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.
73
+ ```
74
+
75
+ #### Token 影响
76
+
77
+ 每条提醒都会作为历史记录保留;`argumentsPreviewChars` 会限制随数据变化的参数文本长度,而各 agent 仍使用独立计数器。
78
+
79
+ #### KV Cache 影响
80
+
81
+ 仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
82
+
83
+ ## 已知限制与暂缓事项
84
+
85
+ - **仅检测精确匹配**:规范化过程会对键进行深度排序,因此近似变体(稍作修改的路径、值内增加的空白)可以绕过链;在没有需求证据前,不采用模糊匹配。
86
+ - **压缩(compaction)不会重置链**:跨越压缩检查点的链会继续计数。
87
+ - **仅提供建议**:尚未实现达到较高阈值后升级为 `block`,但 `PostToolDecision` 已支持阻止调用。
88
+ - **subagent 之间不共享链**:链始终按 agent 隔离;即使父 agent 与其 subagent 重复相同调用,也不会合并计数。
89
+ - **合理的幂等轮询超过阈值后仍会收到提醒**:可通过 `thresholds`/`exclude` 配置释放压力。
90
+ - **超过最高阈值后链不再提醒**:提醒只在精确达到所配置的次数时触发,超过后不会继续发送。
package/lib/index.js ADDED
@@ -0,0 +1,323 @@
1
+ import { createRequire } from "node:module";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import "@deepseek-ai/cordis";
4
+ //#region ../../llm/llm/src/brand.ts
5
+ /**
6
+ * Brand a message identifier.
7
+ * @param id - the opaque message identifier.
8
+ * @returns the same string, branded; no validation is performed.
9
+ */
10
+ function MessageId(id) {
11
+ return id;
12
+ }
13
+ //#endregion
14
+ //#region ../../llm/llm/src/call-config.ts
15
+ /**
16
+ * Deep-freeze a value in place with an iterative traversal, guarding cycles,
17
+ * so later mutation throws without imposing a JavaScript call-stack depth cap.
18
+ * {@link AbortSignal} objects are deliberately skipped because they are the
19
+ * request's live cancellation channel and freezing them breaks abort.
20
+ * @param value - the value to freeze in place.
21
+ * @returns the same value, frozen.
22
+ */
23
+ function deepFreeze(value) {
24
+ const seen = /* @__PURE__ */ new WeakSet();
25
+ const pending = [{
26
+ kind: "visit",
27
+ node: value
28
+ }];
29
+ while (pending.length > 0) {
30
+ const task = pending.pop();
31
+ /* v8 ignore next -- the loop condition guarantees one pending task. */
32
+ if (task === void 0) continue;
33
+ if (task.kind === "property") {
34
+ pending.push({
35
+ kind: "visit",
36
+ node: task.source[task.key]
37
+ });
38
+ continue;
39
+ }
40
+ const node = task.node;
41
+ if (node === null || typeof node !== "object") continue;
42
+ if (node instanceof AbortSignal) continue;
43
+ if (seen.has(node)) continue;
44
+ seen.add(node);
45
+ Object.freeze(node);
46
+ const keys = Object.keys(node);
47
+ for (let index = keys.length - 1; index >= 0; index--) {
48
+ const key = keys[index];
49
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
50
+ if (key === void 0) continue;
51
+ pending.push({
52
+ kind: "property",
53
+ source: node,
54
+ key
55
+ });
56
+ }
57
+ }
58
+ return value;
59
+ }
60
+ //#endregion
61
+ //#region ../../llm/llm/src/message.ts
62
+ /** Message value types, identity, and immutable construction helpers. */
63
+ /**
64
+ * Detach and deep-freeze a message whose identity already exists.
65
+ * @param message - complete message, including its stable identity.
66
+ * @returns an immutable snapshot that preserves the identity.
67
+ */
68
+ function freezeMessage(message) {
69
+ return deepFreeze(structuredClone(message));
70
+ }
71
+ /**
72
+ * Create one identified message and freeze it before publication.
73
+ * @param input - complete role, content, and source for a new message.
74
+ * @returns an immutable message with a fresh stable identity.
75
+ */
76
+ function createMessage(input) {
77
+ return freezeMessage({
78
+ ...input,
79
+ id: MessageId(crypto.randomUUID())
80
+ });
81
+ }
82
+ /**
83
+ * Create one identified user-role message and freeze it before publication.
84
+ * @param input - complete content and source for a new user message.
85
+ * @returns an immutable user message with a fresh stable identity.
86
+ */
87
+ function createUserMessage(input) {
88
+ return createMessage({
89
+ ...input,
90
+ role: "user"
91
+ });
92
+ }
93
+ //#endregion
94
+ //#region ../../util/timeout/src/index.ts
95
+ /** Largest delay Node schedules without clamping it to one millisecond. */
96
+ const MAX_TIMER_DELAY_MS = 2147483647;
97
+ //#endregion
98
+ //#region ../../llm/llm/src/error.ts
99
+ /**
100
+ * Canonical provider-neutral code for a response that completed normally but
101
+ * carried no content blocks at all. Providers occasionally emit a degenerate
102
+ * completion (a terminal stop with zero output); adapters classify it as this
103
+ * failure instead of yielding an empty assistant message, because an empty
104
+ * message silently ends the turn with nothing for the user or the loop to act
105
+ * on. The attempt produced nothing durable, so retry policy treats it as safe
106
+ * to repeat.
107
+ */
108
+ const EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE";
109
+ new RegExp(String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, "i");
110
+ new RegExp(String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, "i");
111
+ new RegExp(String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, "i");
112
+ //#endregion
113
+ //#region ../../llm/llm/src/retry-policy.ts
114
+ /**
115
+ * Provider-owned request-retry policy configuration and resolution.
116
+ *
117
+ * Adapters expose one resolved policy per registered provider route; the
118
+ * optional dsh-llm-retry plugin executes it on the agent's failed-step extension point.
119
+ *
120
+ * @module @deepseek-ai/dsh-llm/retry-policy
121
+ */
122
+ const DEFAULT_MAX_RETRIES = 2;
123
+ const DEFAULT_INITIAL_DELAY_MS = 500;
124
+ const DEFAULT_MAX_DELAY_MS = 1e4;
125
+ const DEFAULT_JITTER_RATIO = .1;
126
+ const DEFAULT_RETRYABLE_CODES = Object.freeze([
127
+ EMPTY_RESPONSE_CODE,
128
+ "RATE_LIMIT",
129
+ "SERVER",
130
+ "TIMEOUT",
131
+ "TRANSPORT"
132
+ ]);
133
+ const backoffSchema = z.object({
134
+ initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
135
+ maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
136
+ jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO)
137
+ });
138
+ const normalPolicySchema = z.object({
139
+ mode: z.const("normal").required(),
140
+ maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
141
+ retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
142
+ backoff: backoffSchema
143
+ });
144
+ const alwaysPolicySchema = z.object({
145
+ mode: z.const("always").required(),
146
+ backoff: backoffSchema
147
+ });
148
+ z.union([normalPolicySchema, alwaysPolicySchema]);
149
+ //#endregion
150
+ //#region ../../llm/llm/src/attribution.ts
151
+ /**
152
+ * Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
153
+ * adapters from drifting. See
154
+ * `.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
155
+ *
156
+ * App-attribution vocabulary for provider requests.
157
+ * @module @deepseek-ai/dsh-llm/attribution
158
+ */
159
+ const { version } = createRequire(import.meta.url)("../package.json");
160
+ //#endregion
161
+ //#region lib/types/index.js
162
+ /**
163
+ * Advisory per-agent repeat-call detector. It enriches post-execute decisions
164
+ * with logged model context without vetoing or rewriting calls. Configuration
165
+ * and chain semantics live in the package README; rationale lives in the
166
+ * repeat-tool-reminder Agent Note.
167
+ * @module @deepseek-ai/dsh-repeat-tool-reminder
168
+ */
169
+ const name = "repeat-tool-reminder";
170
+ const Config = z.object({
171
+ thresholds: z.array(z.number()).default([
172
+ 3,
173
+ 5,
174
+ 8
175
+ ]),
176
+ include: z.array(z.string()).default([]),
177
+ exclude: z.array(z.string()).default([]),
178
+ argumentsPreviewChars: z.number().default(500)
179
+ });
180
+ /**
181
+ * The `{kind:'plugin'}` source stamped on every reminder this guard injects —
182
+ * the label is load-bearing (an unlabeled context would render as a user
183
+ * prompt in derived history).
184
+ */
185
+ const PLUGIN_SOURCE = {
186
+ kind: "plugin",
187
+ plugin: "repeat-tool-reminder"
188
+ };
189
+ /**
190
+ * The gentle first-threshold reminder. Keyed to `thresholds[0]`, not a literal
191
+ * count, so a custom first threshold keeps the gentle-then-detailed escalation.
192
+ */
193
+ 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.";
194
+ /** The detailed later-threshold reminder naming the tool, the run length, and the canonical arguments. */
195
+ function detailedReminder(toolName, count, canonicalArguments) {
196
+ return `Repeated tool call detected:
197
+ - tool: ${toolName}\n- consecutive_calls: ${count}\n- arguments: ${canonicalArguments}\nThe 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.`;
198
+ }
199
+ /**
200
+ * Deep key-sort of a parsed-JSON value so two argument objects that differ
201
+ * only in property order canonicalize identically. Arguments reach the guard
202
+ * as the loop's `JSON.parse` output (or its raw-string fallback for malformed
203
+ * argument JSON), so JSON's value domain is the whole input domain — no
204
+ * bigint, cycle, or `undefined` handling exists because no input path can
205
+ * produce them.
206
+ */
207
+ function sortJsonValue(value) {
208
+ if (Array.isArray(value)) return value.map(sortJsonValue);
209
+ if (value !== null && typeof value === "object") {
210
+ const record = value;
211
+ const sorted = {};
212
+ for (const key of Object.keys(record).sort()) sorted[key] = sortJsonValue(record[key]);
213
+ return sorted;
214
+ }
215
+ return value;
216
+ }
217
+ /** Canonical string form of a call's arguments: deep key-sort, then stringify. */
218
+ function canonicalize(argumentsValue) {
219
+ return JSON.stringify(sortJsonValue(argumentsValue));
220
+ }
221
+ /** Compile one `*`-wildcard pattern to an anchored RegExp (every other regex metacharacter is matched literally). */
222
+ function wildcardToRegExp(pattern) {
223
+ const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, String.raw`\$&`);
224
+ return new RegExp(`^${escaped.replaceAll("*", ".*")}$`);
225
+ }
226
+ /**
227
+ * Head-truncate the canonical arguments for quoting in the detailed reminder,
228
+ * marking how much was omitted. Bounds only the model-visible text — the
229
+ * chain key always uses the full canonical string.
230
+ */
231
+ function previewArguments(canonical, cap) {
232
+ if (canonical.length <= cap) return canonical;
233
+ return `${canonical.slice(0, cap)}… (+${canonical.length - cap} more chars)`;
234
+ }
235
+ /**
236
+ * Validate `thresholds` per the fail-loud contract and return them sorted
237
+ * ascending (the escalation rule reads `thresholds[0]` as the gentle tier, so
238
+ * order is normalized here, once).
239
+ */
240
+ function validateThresholds(values) {
241
+ if (values.length === 0) throw new Error("repeat-tool-reminder: `thresholds` must not be empty");
242
+ for (const value of values) if (!Number.isInteger(value) || value < 2) throw new Error(`repeat-tool-reminder: invalid threshold ${value} — every threshold must be an integer >= 2`);
243
+ if (new Set(values).size !== values.length) throw new Error("repeat-tool-reminder: `thresholds` must not contain duplicates");
244
+ return [...values].sort((a, b) => a - b);
245
+ }
246
+ /**
247
+ * Prepend the guard's reminder while preserving every downstream context's
248
+ * source and metadata.
249
+ */
250
+ function prependContext(ours, theirs) {
251
+ return [ours, ...theirs ?? []];
252
+ }
253
+ /**
254
+ * Install the guard's listeners.
255
+ * @param ctx - plugin context; listeners are scoped to it and disposed with it.
256
+ * @param config - validated {@link Config}; `thresholds` is re-checked fail-loud here.
257
+ */
258
+ function apply(ctx, config) {
259
+ const thresholds = validateThresholds(config.thresholds);
260
+ const thresholdSet = new Set(thresholds);
261
+ const includePatterns = config.include.map(wildcardToRegExp);
262
+ const excludePatterns = config.exclude.map(wildcardToRegExp);
263
+ const argumentsPreviewChars = config.argumentsPreviewChars;
264
+ if (!Number.isInteger(argumentsPreviewChars) || argumentsPreviewChars < 1) throw new Error(`repeat-tool-reminder: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`);
265
+ const chains = /* @__PURE__ */ new WeakMap();
266
+ /** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */
267
+ function tracked(toolName) {
268
+ if (includePatterns.length > 0 && !includePatterns.some((pattern) => pattern.test(toolName))) return false;
269
+ return !excludePatterns.some((pattern) => pattern.test(toolName));
270
+ }
271
+ /**
272
+ * Advance the calling agent's chain for one attempt and return the reminder
273
+ * to deliver, if this attempt's run length hits a configured threshold.
274
+ * Counting happens here — in post-execute — because denied calls also flow
275
+ * through this waterfall (`ToolRuntime.execute` routes a deny through the
276
+ * same pipeline), and a model hammering a denied call is exactly the loop
277
+ * worth breaking.
278
+ */
279
+ function observe(exec) {
280
+ if (!exec.agent) return void 0;
281
+ if (!tracked(exec.name)) return void 0;
282
+ const canonical = canonicalize(exec.arguments);
283
+ const key = JSON.stringify([exec.name, canonical]);
284
+ const chain = chains.get(exec.agent);
285
+ const count = chain !== void 0 && chain.key === key ? chain.count + 1 : 1;
286
+ chains.set(exec.agent, {
287
+ key,
288
+ count
289
+ });
290
+ if (!thresholdSet.has(count)) return void 0;
291
+ return createUserMessage({
292
+ content: [{
293
+ type: "text",
294
+ text: count === thresholds[0] ? GENTLE_REMINDER : detailedReminder(exec.name, count, previewArguments(canonical, argumentsPreviewChars))
295
+ }],
296
+ source: {
297
+ ...PLUGIN_SOURCE,
298
+ form: "notice",
299
+ summary: `${exec.name} × ${count}`
300
+ }
301
+ });
302
+ }
303
+ ctx.on("tools/post-execute", async (exec, _result, next) => {
304
+ const reminder = observe(exec);
305
+ const downstream = await next();
306
+ if (!reminder) return downstream;
307
+ if (downstream.kind === "block") return {
308
+ kind: "block",
309
+ feedback: downstream.feedback,
310
+ additionalContexts: prependContext(reminder, downstream.additionalContexts)
311
+ };
312
+ return {
313
+ ...downstream,
314
+ additionalContexts: prependContext(reminder, downstream.additionalContexts)
315
+ };
316
+ });
317
+ ctx.on("agent/pre-step", ({ agent, messages }, next) => {
318
+ if (messages.some((message) => message.source.kind === "user")) chains.delete(agent);
319
+ return next();
320
+ });
321
+ }
322
+ //#endregion
323
+ export { Config, apply, name };
@@ -0,0 +1,23 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@deepseek-ai/dsh-repeat-tool-reminder`.
4
+ * @module @deepseek-ai/dsh-repeat-tool-reminder/invariant
5
+ */
6
+ const PACKAGE_NAME = "@deepseek-ai/dsh-repeat-tool-reminder";
7
+ /** Cordis companion plugin name. */
8
+ const name = "repeat-tool-reminder-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: the repeat chain is private to one post-execute listener and exposes no
13
+ * package-owned event or snapshot that an independent companion can observe.
14
+ */
15
+ const install = () => {};
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ //#endregion
23
+ export { apply, inject, name };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Advisory per-agent repeat-call detector. It enriches post-execute decisions
3
+ * with logged model context without vetoing or rewriting calls. Configuration
4
+ * and chain semantics live in the package README; rationale lives in the
5
+ * repeat-tool-reminder Agent Note.
6
+ * @module @deepseek-ai/dsh-repeat-tool-reminder
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import z from '@deepseek-ai/schemastery';
10
+ export declare const name = "repeat-tool-reminder";
11
+ /**
12
+ * Plugin config, validated by the same-named schemastery schema plus the
13
+ * load-time checks in `apply` (misconfiguration fails loud: an empty
14
+ * `thresholds` list, a non-integer, a value below 2, or a duplicate throws at
15
+ * plugin load, never a silent fall-back). `include`/`exclude` entries are
16
+ * `*`-wildcard predicates over tool names at call time, not references to
17
+ * registry entries — a pattern matching no currently registered tool is valid
18
+ * (`exclude: [mcp_*]` must stay legal in a deployment that loads no MCP tools).
19
+ */
20
+ export interface Config {
21
+ /** Consecutive-repeat counts that trigger a reminder (default `[3, 5, 8]`). */
22
+ thresholds?: number[];
23
+ /** Tool-name patterns to track; empty means every tool is tracked. */
24
+ include?: string[];
25
+ /** Tool-name patterns transparent to the chain (neither count nor reset). */
26
+ exclude?: string[];
27
+ /**
28
+ * Maximum characters of canonical arguments quoted in the DETAILED reminder
29
+ * (default 500). Large payloads (a `write` body, a long command) would
30
+ * otherwise ride into the next request unbounded — precisely in a loop
31
+ * scenario; the cap bounds the reminder, never the detection (the chain key
32
+ * always compares the FULL canonical string).
33
+ */
34
+ argumentsPreviewChars?: number;
35
+ }
36
+ export declare const Config: z<Config>;
37
+ /**
38
+ * Install the guard's listeners.
39
+ * @param ctx - plugin context; listeners are scoped to it and disposed with it.
40
+ * @param config - validated {@link Config}; `thresholds` is re-checked fail-loud here.
41
+ */
42
+ export declare function apply(ctx: Context, config: Config): void;
43
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@deepseek-ai/dsh-repeat-tool-reminder`.
3
+ * @module @deepseek-ai/dsh-repeat-tool-reminder/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "repeat-tool-reminder-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@deepseek-ai/dsh-repeat-tool-reminder",
3
+ "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls",
4
+ "version": "0.0.1-rc.3",
5
+ "publishConfig": {
6
+ "access": "restricted"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/guard/repeat-tool-reminder"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib/index.js",
30
+ "lib/invariant.js",
31
+ "lib/types/**/*.d.ts"
32
+ ],
33
+ "license": "BSD-3-Clause",
34
+ "dependencies": {
35
+ "@deepseek-ai/schemastery": "^3.18.1-rc.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
39
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.3",
40
+ "@deepseek-ai/dsh-tools": "^0.0.1-rc.3",
41
+ "@deepseek-ai/cordis": "^4.0.1-rc.1"
42
+ },
43
+ "devDependencies": {
44
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.3",
45
+ "@deepseek-ai/dsh-agent-loop-testkit": "^0.0.1-rc.3",
46
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
47
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.3",
48
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.3",
49
+ "@deepseek-ai/dsh-agent-loop": "^0.0.1-rc.3",
50
+ "@deepseek-ai/dsh-tools": "^0.0.1-rc.3",
51
+ "@deepseek-ai/cordis": "^4.0.1-rc.1"
52
+ }
53
+ }