dsh-dsml-rescue 0.0.0-stage → 0.1.0

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zlZayn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="icon.svg" alt="dsh-dsml-rescue" width="80" height="80">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">dsh-dsml-rescue</h1>
6
+
7
+ <div align="center">
8
+ <p><strong>Continue the turn a model stalled by writing tool-call markup into its reasoning</strong></p>
9
+ <p><em>把"思考里写了工具调用标记、于是什么都没发生"的那一回合续上</em></p>
10
+
11
+ <p>
12
+ <a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat&logo=deepseek&logoColor=white" alt="powered by dsh"></a>
13
+ </p>
14
+
15
+ <p>
16
+ <a href="https://github.com/dsh-plugin-lab/dsh-dsml-rescue/actions/workflows/ci.yml"><img src="https://github.com/dsh-plugin-lab/dsh-dsml-rescue/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
17
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat" alt="MIT License"></a>
18
+ </p>
19
+
20
+ <p>
21
+ Per-version changes → <a href="https://github.com/dsh-plugin-lab/dsh-dsml-rescue/releases">Releases</a>
22
+ </p>
23
+
24
+ <p>
25
+ <strong><a href="README.md">English</a></strong> · <a href="README_zh.md">简体中文</a>
26
+ </p>
27
+ </div>
28
+
29
+ ---
30
+
31
+ Some provider routes deliver a model's tool call as **reasoning text carrying the call's own markup**
32
+ instead of a structured call. The call never happens, the turn closes having produced nothing, and
33
+ the conversation waits for someone to type "continue". This plugin types it for you.
34
+
35
+ ## What it does
36
+
37
+ - Continues a turn that closed with leaked protocol markup and **nothing else**.
38
+ - Sends one neutral sentence: take the next step, or close out with a summary.
39
+ - Waits for you — if you are already typing, it stays out of the way.
40
+ - Stops after a few attempts instead of spending requests forever.
41
+ - Covers child agents too, so a delegated run is not reported finished while it is stuck.
42
+
43
+ ## Getting started
44
+
45
+ **Prerequisites**: DSH on its current RC line; Node `>= 20`. The only source for the compatible range
46
+ is `engines.dsh` in [package.json](package.json).
47
+
48
+ ```bash
49
+ dsh plugin --profile web add dsh-dsml-rescue
50
+ ```
51
+
52
+ Restart `dsh --profile web`. Nothing to configure: the defaults are the intended behaviour.
53
+
54
+ From source: `git clone`, then `pnpm install && pnpm run build`, then
55
+ `dsh plugin --profile web add "$PWD"`.
56
+
57
+ ## What it will not do
58
+
59
+ - **It does not fix the cause.** The leak happens below the model, so no prompt prevents it; this
60
+ plugin only shortens the pause that follows.
61
+ - **It does not rescue turns that ended empty for other reasons.** No markup, no rescue — a
62
+ cancellation, a failure, or a step cut off at its output ceiling all stay untouched.
63
+ - **It does not touch your conversation.** Nothing is rewritten or re-sent; it adds one message and
64
+ lets the host run.
65
+
66
+ ## Configuration
67
+
68
+ Three keys, all optional: `enabled`, `maxConsecutive`, `applyToSubagents`. Defaults, and what moving
69
+ each one changes → [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#configuration).
70
+
71
+ ## Under the hood
72
+
73
+ How a stall is recognized, why the continuation is queued where it is, and what must not change →
74
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
75
+ What each check establishes, and a repeatable end-to-end run → [docs/VERIFICATION.md](docs/VERIFICATION.md).
76
+
77
+ ## License
78
+
79
+ [MIT](LICENSE)
package/README_zh.md ADDED
@@ -0,0 +1,72 @@
1
+ <p align="center">
2
+ <img src="icon.svg" alt="dsh-dsml-rescue" width="80" height="80">
3
+ </p>
4
+
5
+ <h1 align="center">dsh-dsml-rescue</h1>
6
+
7
+ <div align="center">
8
+ <p><strong>把"思考里写了工具调用标记、于是什么都没发生"的那一回合续上</strong></p>
9
+ <p><em>Continue the turn a model stalled by writing tool-call markup into its reasoning</em></p>
10
+
11
+ <p>
12
+ <a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat&logo=deepseek&logoColor=white" alt="powered by dsh"></a>
13
+ </p>
14
+
15
+ <p>
16
+ <a href="https://github.com/dsh-plugin-lab/dsh-dsml-rescue/actions/workflows/ci.yml"><img src="https://github.com/dsh-plugin-lab/dsh-dsml-rescue/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
17
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat" alt="MIT License"></a>
18
+ </p>
19
+
20
+ <p>
21
+ 每个版本改了什么 → <a href="https://github.com/dsh-plugin-lab/dsh-dsml-rescue/releases">Releases</a>
22
+ </p>
23
+
24
+ <p>
25
+ <a href="README.md">English</a> · <strong><a href="README_zh.md">简体中文</a></strong>
26
+ </p>
27
+ </div>
28
+
29
+ ---
30
+
31
+ 有些 provider 路由会把模型的工具调用当成**普通思考文本**吐回来,里面夹着调用本身的协议标记。于是调用没有发生,回合以"什么都没产出"结束,会话就停在那儿等人敲一句"继续"。这个插件替你敲。
32
+
33
+ ## 它做什么
34
+
35
+ - 只续一种回合:以泄漏的协议标记结束、且**没有产出任何别的**内容的回合。
36
+ - 只发一句中性的话:要么执行下一步,要么收尾给结论。
37
+ - 让着你:你已经在打字时它不插话。
38
+ - 续几次就停,不会无限花请求。
39
+ - 子 agent 也管,被派出去的活儿不会"卡着却被告知已结束"。
40
+
41
+ ## 开始用
42
+
43
+ **前置**:DSH 当前 RC 线;Node `>= 20`。兼容区间的唯一来源是 [package.json](package.json) 里的 `engines.dsh`。
44
+
45
+ ```bash
46
+ dsh plugin --profile web add dsh-dsml-rescue
47
+ ```
48
+
49
+ 重启 `dsh --profile web` 即可。不需要配置,默认值就是预期行为。
50
+
51
+ 从源码装:`git clone` 后 `pnpm install && pnpm run build`,再 `dsh plugin --profile web add "$PWD"`。
52
+
53
+ ## 它不做什么
54
+
55
+ - **不修根因。** 泄漏发生在模型之下,任何提示词都规避不了;本插件只把随之而来的停顿缩短。
56
+ - **不救其他空转。** 没有标记就不救——用户主动取消、请求失败、以及输出被长度截断后停在原地的回合,都不碰。
57
+ - **不动你的对话。** 不改写、不重发;只多加一条消息,然后交给宿主继续跑。
58
+
59
+ ## 配置
60
+
61
+ 三个键,都可选:`enabled`、`maxConsecutive`、`applyToSubagents`。默认值以及改动各自的影响 →
62
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#configuration)。
63
+
64
+ ## 内部实现
65
+
66
+ 怎么认定一次空转、续跑消息为什么排在那个位置、哪些约束不能破 →
67
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
68
+ 每项检查能证明什么、以及可复现的端到端验法 → [docs/VERIFICATION.md](docs/VERIFICATION.md)。
69
+
70
+ ## 许可
71
+
72
+ [MIT](LICENSE)
@@ -0,0 +1,15 @@
1
+ # Bundle patch: inserts this plugin as one row in a dsh profile.
2
+ #
3
+ # The format follows the official DSH bundle patch: the top level is an array of loader patch entries,
4
+ # and `insert` appends plugin rows at the profile root.
5
+ #
6
+ # About `name`: it must be a module id the loader can resolve from the profile's node_modules, which is
7
+ # this package's own name.
8
+ #
9
+ # About `config`: **never put credentials here.** This is no longer a source of values, so writing one
10
+ # here has exactly two consequences — the startup migration moves it into the credential store, and the
11
+ # row cannot be deleted from the composed config, so every startup warns you to remove it by hand.
12
+ # Secrets belong in the plugin's configuration card, or in an environment variable.
13
+ - insert:
14
+ - id: 'dsh-dsml-rescue'
15
+ name: 'dsh-dsml-rescue'
package/icon.svg ADDED
@@ -0,0 +1,10 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="dsh-dsml-rescue">
2
+ <!--
3
+ Two chevrons advancing to the right: the turn continues. The second one is
4
+ lighter so the pair reads as motion rather than as a single direction sign.
5
+ Transparent canvas, no background plate, colours fixed because the host
6
+ paints this as an image.
7
+ -->
8
+ <path d="M11 10.5 L18 18 L11 25.5" stroke="#4D6BFE" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
9
+ <path d="M18.5 10.5 L25.5 18 L18.5 25.5" stroke="#93A5FF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
10
+ </svg>
package/lib/index.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { Context } from "@deepseek-ai/cordis";
3
+ /** Cordis plugin name, used by the loader for diagnostics. */
4
+ declare const name = "dsh-dsml-rescue";
5
+ /** The agent registry, needed to map a session back to its live agent. */
6
+ declare const inject: string[];
7
+ /** Validated configuration; the defaults are the documented behaviour. */
8
+ interface Config {
9
+ /** Whether the plugin acts at all. Turn it off without uninstalling. */
10
+ enabled?: boolean;
11
+ /**
12
+ * Consecutive rescues allowed for one agent before it is left alone. Each
13
+ * rescue costs one model request, so this is the only spend control here.
14
+ */
15
+ maxConsecutive?: number;
16
+ /**
17
+ * Whether child agents are rescued too. Their stalls otherwise settle
18
+ * silently and reach their parent only as "finished".
19
+ */
20
+ applyToSubagents?: boolean;
21
+ }
22
+ declare const Config: z<Config>;
23
+ /**
24
+ * Mount the plugin.
25
+ * @param ctx - host context.
26
+ * @param config - validated configuration.
27
+ */
28
+ declare function apply(ctx: Context, config: Config): void;
29
+ export { Config, apply, inject, name };
package/lib/index.js ADDED
@@ -0,0 +1,373 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
3
+ // src/protocol.ts
4
+ /**
5
+ * Protocol-mark detection over one reasoning stream.
6
+ *
7
+ * Some provider routes deliver the model's tool-call dialect as ordinary
8
+ * reasoning text instead of a structured tool call. The dialect's closing
9
+ * tokens then sit at the very end of the thinking stream, and the turn ends
10
+ * having done nothing. This module answers one question about that text: does
11
+ * it end with such a token? It knows nothing about turns, agents, or sessions.
12
+ *
13
+ * @module dsh-dsml-rescue/protocol
14
+ */
15
+ /**
16
+ * The marker that opens and closes every token of the dialect. Matched on
17
+ * NFKC-normalized text, so the full-width vertical bar the dialect actually
18
+ * uses arrives here as an ASCII pipe and needs no separate alternative.
19
+ */
20
+ const MARKER = /\|\|\s*dsml\s*\|\|/i;
21
+ /**
22
+ * How many trailing non-blank lines are considered. A leak puts its closing
23
+ * tokens at the end; scanning further back starts matching prose *about* the
24
+ * dialect, including examples a model quotes while explaining it.
25
+ */
26
+ const TAIL_LINES = 3;
27
+ /**
28
+ * A line that is protocol markup rather than a sentence.
29
+ *
30
+ * Requiring the line to open with `<` is what separates the two: a model that
31
+ * mentions the marker inside a sentence starts that line with ordinary text,
32
+ * while genuine leaked markup starts with its opening angle bracket.
33
+ * @param line - one NFKC-normalized line, trimmed.
34
+ * @returns whether the line opens as markup and carries the marker.
35
+ */
36
+ function isMarkupLine(line) {
37
+ return line.startsWith("<") && MARKER.test(line);
38
+ }
39
+ /**
40
+ * Whether a reasoning stream ends with leaked protocol markup.
41
+ *
42
+ * Empty or absent reasoning is not a leak: a turn that produced nothing at all
43
+ * is a different failure and stays outside this plugin's scope.
44
+ * @param reasoning - the complete reasoning text of one assistant message.
45
+ * @returns whether any of its last few non-blank lines is protocol markup.
46
+ */
47
+ function hasLeakedProtocolTail(reasoning) {
48
+ if (reasoning === "") return false;
49
+ const lines = reasoning.normalize("NFKC").split(/\r?\n/);
50
+ let scanned = 0;
51
+ for (let index = lines.length - 1; index >= 0 && scanned < TAIL_LINES; index -= 1) {
52
+ const line = (lines[index] ?? "").trim();
53
+ if (line === "") continue;
54
+ scanned += 1;
55
+ if (isMarkupLine(line)) return true;
56
+ }
57
+ return false;
58
+ }
59
+ // src/detect.ts
60
+ /**
61
+ * The one judgment this plugin makes: does a finished turn deserve a rescue?
62
+ *
63
+ * A turn deserves one when it ended normally yet produced nothing the loop or
64
+ * the user can act on, and its reasoning ends in leaked protocol markup. Both
65
+ * halves are required — a turn that produced nothing for another reason stays
66
+ * untouched, because rescuing it would paper over an unrelated failure.
67
+ *
68
+ * Pure: no host types, no events, no clock. The caller adapts real session
69
+ * events into {@link TurnObservation}.
70
+ *
71
+ * @module dsh-dsml-rescue/detect
72
+ */
73
+ /** Whether a text block carries anything other than whitespace. */
74
+ function carriesText(block) {
75
+ return block.type === "text" && block.text !== void 0 && /\S/.test(block.text);
76
+ }
77
+ /**
78
+ * Whether the turn produced anything a caller could act on.
79
+ *
80
+ * An unknown block type counts as actionable on purpose: a provider that adds
81
+ * a content kind this plugin has never seen is far more likely to have produced
82
+ * output than to have produced nothing, and a false rescue costs a request.
83
+ * @param blocks - the turn's closing content blocks.
84
+ * @returns whether any block is visible text, a tool call, or an unknown kind.
85
+ */
86
+ function hasActionableContent(blocks) {
87
+ return blocks.some((block) => block.type === "reasoning" ? false : block.type === "text" ? carriesText(block) : true);
88
+ }
89
+ /**
90
+ * Concatenate every reasoning block of a message.
91
+ * @param blocks - the turn's closing content blocks.
92
+ * @returns the joined reasoning text, or the empty string when there is none.
93
+ */
94
+ function joinReasoning(blocks) {
95
+ return blocks.flatMap((block) => block.type === "reasoning" && block.text !== void 0 ? [block.text] : []).join("\n");
96
+ }
97
+ /**
98
+ * Decide whether one finished turn is a leaking stall.
99
+ * @param observation - the turn's end reason and its closing message blocks.
100
+ * @returns true only when the turn completed, produced nothing actionable, and
101
+ * its reasoning ends in protocol markup.
102
+ */
103
+ function isRescueCandidate(observation) {
104
+ if (!observation.completed) return false;
105
+ if (hasActionableContent(observation.blocks)) return false;
106
+ return hasLeakedProtocolTail(joinReasoning(observation.blocks));
107
+ }
108
+ // src/policy.ts
109
+ /**
110
+ * Decide what one finished turn deserves.
111
+ *
112
+ * Order matters: a spent cap outranks a waiting human, because giving way would
113
+ * leave the cap unrecorded and the streak silently unbounded.
114
+ * @param input - detection verdict, current streak, cap, and human presence.
115
+ * @returns the decision and the streak to store.
116
+ */
117
+ function evaluate(input) {
118
+ if (!input.candidate) return {
119
+ decision: "ignore",
120
+ consecutive: 0
121
+ };
122
+ if (input.consecutive >= input.maxConsecutive) return {
123
+ decision: "give-up",
124
+ consecutive: 0
125
+ };
126
+ if (input.humanPending) return {
127
+ decision: "yield",
128
+ consecutive: input.consecutive
129
+ };
130
+ return {
131
+ decision: "rescue",
132
+ consecutive: input.consecutive + 1
133
+ };
134
+ }
135
+ // src/machine.ts
136
+ /**
137
+ * One agent's turn-to-turn decision, as a pure reducer.
138
+ *
139
+ * The runtime's only job is translation: Cordis events in, this reducer called,
140
+ * one side effect out. Keeping the sequencing here means the recorded sessions
141
+ * under `test/fixtures/` exercise the same code the host runs, instead of a
142
+ * second implementation written for the tests.
143
+ *
144
+ * @module dsh-dsml-rescue/machine
145
+ */
146
+ /** The state a machine starts in. */
147
+ const INITIAL_STATE = {
148
+ consecutive: 0,
149
+ turn: void 0,
150
+ blocks: []
151
+ };
152
+ /**
153
+ * Advance one agent's machine by one event.
154
+ *
155
+ * The streak clears on two facts only: a turn that ended abnormally (a
156
+ * different failure, which a rescue would hide) and human input (the human now
157
+ * owns the conversation). It is *not* cleared by the plugin's own notices.
158
+ * @param state - state after the previous event.
159
+ * @param event - the fact to apply.
160
+ * @param limits - configured cap.
161
+ * @returns the next state and what to do about it.
162
+ */
163
+ function reduce(state, event, limits) {
164
+ switch (event.kind) {
165
+ case "assistant-message": return {
166
+ state: {
167
+ ...state,
168
+ turn: event.turn,
169
+ blocks: event.blocks
170
+ },
171
+ decision: "ignore",
172
+ round: 0
173
+ };
174
+ case "human-message": return {
175
+ state: {
176
+ ...state,
177
+ consecutive: 0
178
+ },
179
+ decision: "ignore",
180
+ round: 0
181
+ };
182
+ case "turn-end": return event.completed ? {
183
+ state,
184
+ decision: "ignore",
185
+ round: 0
186
+ } : {
187
+ state: {
188
+ ...state,
189
+ consecutive: 0
190
+ },
191
+ decision: "ignore",
192
+ round: 0
193
+ };
194
+ case "turn-stopping": {
195
+ const blocks = state.turn === event.turn ? state.blocks : [];
196
+ const outcome = evaluate({
197
+ candidate: !event.outputExhausted && isRescueCandidate({
198
+ completed: true,
199
+ blocks
200
+ }),
201
+ consecutive: state.consecutive,
202
+ maxConsecutive: limits.maxConsecutive,
203
+ humanPending: event.humanPending
204
+ });
205
+ return {
206
+ state: {
207
+ ...state,
208
+ consecutive: outcome.consecutive
209
+ },
210
+ decision: outcome.decision,
211
+ round: outcome.decision === "rescue" ? outcome.consecutive : 0
212
+ };
213
+ }
214
+ }
215
+ }
216
+ // src/messages.ts
217
+ /**
218
+ * The one message this plugin shows the model, and the attribution it carries.
219
+ *
220
+ * Deliberately says nothing about why the previous turn ended empty. The cause
221
+ * sits below the model — a provider route delivering tool-call markup as text —
222
+ * so nothing the model does can avoid it, and naming it would only invite the
223
+ * model to talk about the failure instead of continuing the work. The wording
224
+ * is fixed rather than configurable: one sentence that asks for the next step,
225
+ * plus one legal way out so a rescue cannot become an open-ended loop.
226
+ *
227
+ * @module dsh-dsml-rescue/messages
228
+ */
229
+ /** What the model reads. Two instructions: continue, or close out if finished. */
230
+ const CONTINUE_PROMPT = "Continue with the task. Take the next concrete step now, using a tool if needed. If the work is complete, reply with a short summary.";
231
+ /**
232
+ * Build the continuation turn's message.
233
+ * @param round - this rescue's ordinal in the current streak, from 1.
234
+ * @param max - the configured cap, for the durable one-line summary.
235
+ * @returns an immutable user-role message attributed to this plugin.
236
+ */
237
+ function createRescueMessage(round, max) {
238
+ return createUserMessage({
239
+ content: [{
240
+ type: "text",
241
+ text: CONTINUE_PROMPT
242
+ }],
243
+ source: {
244
+ kind: "dsml-rescue",
245
+ form: "notice",
246
+ summary: boundContextSummary(`Auto-continue ${round}/${max}`)
247
+ }
248
+ });
249
+ }
250
+ // src/runtime.ts
251
+ /** Whether the inbox already holds input a human queued. */
252
+ function humanPending(agent) {
253
+ return agent.inbox.nextTurn.some((message) => message.source.kind === "user") || agent.inbox.nextStep.some((message) => message.source.kind === "user");
254
+ }
255
+ /**
256
+ * Whether the step being closed spent its whole output budget.
257
+ *
258
+ * The seam carries no stop reason, so this compares the closing message's
259
+ * output-token count with the ceiling the request was sent under. Both numbers
260
+ * are the caller's own accounting — the count is what the provider reported and
261
+ * the ceiling is what this session last requested — so equality means the model
262
+ * was cut off, not that it chose to stop.
263
+ *
264
+ * The comparison is deliberately one-sided: an unknown ceiling or a missing
265
+ * count reports "not exhausted", because a wrong `true` would silence the
266
+ * rescue this plugin exists for, while a wrong `false` only costs the rescue
267
+ * one attempt of the streak.
268
+ * @param agent - the agent whose turn is closing.
269
+ * @param turn - the turn being closed.
270
+ * @param usage - the closing message's token accounting, when the host logged it.
271
+ * @returns whether the output budget is spent.
272
+ */
273
+ function outputExhausted(agent, usage) {
274
+ if (usage === void 0) return false;
275
+ const ceiling = agent.session.requestHeader()?.config.maxTokens;
276
+ if (ceiling === void 0 || ceiling <= 0) return false;
277
+ return usage.outputTokens >= ceiling;
278
+ }
279
+ /**
280
+ * Install the turn watcher and its continuation side effect.
281
+ * @param ctx - plugin context; listeners are disposed with it.
282
+ * @param config - validated limits.
283
+ */
284
+ function install(ctx, config) {
285
+ const states = /* @__PURE__ */ new Map();
286
+ /** Output accounting of the last assistant message, per agent. */
287
+ const usageByAgent = /* @__PURE__ */ new Map();
288
+ /** Apply one reducer event and store the result. */
289
+ function advance(agent, event) {
290
+ const step = reduce(states.get(agent) ?? INITIAL_STATE, event, { maxConsecutive: config.maxConsecutive });
291
+ states.set(agent, step.state);
292
+ return step;
293
+ }
294
+ /** Whether this agent was created by another agent instead of at the root. */
295
+ function isSubagent(agent) {
296
+ return !ctx.agents.roots().includes(agent);
297
+ }
298
+ ctx.on("session/event", (session, event) => {
299
+ const agent = ctx.agents.get(session.id);
300
+ if (agent === void 0 || agent.session !== session) return;
301
+ switch (event.type) {
302
+ case "assistant/message":
303
+ advance(agent, {
304
+ kind: "assistant-message",
305
+ turn: event.data.turn,
306
+ blocks: event.data.message.content
307
+ });
308
+ usageByAgent.set(agent, event.data.usage);
309
+ return;
310
+ case "user/message":
311
+ if (event.data.source.kind === "user") advance(agent, { kind: "human-message" });
312
+ return;
313
+ case "turn/end":
314
+ advance(agent, {
315
+ kind: "turn-end",
316
+ turn: event.data.turn,
317
+ completed: event.data.reason.kind === "completed"
318
+ });
319
+ return;
320
+ default: return;
321
+ }
322
+ });
323
+ ctx.on("agent/turn-stopping", ({ agent, turn }) => {
324
+ if (!config.applyToSubagents && isSubagent(agent)) return;
325
+ const step = advance(agent, {
326
+ kind: "turn-stopping",
327
+ turn,
328
+ humanPending: humanPending(agent),
329
+ outputExhausted: outputExhausted(agent, usageByAgent.get(agent))
330
+ });
331
+ if (step.decision === "give-up") {
332
+ ctx.logger.warn(`dsml-rescue: agent "${agent.id}" stalled ${config.maxConsecutive} times in a row; stopped continuing it`);
333
+ return;
334
+ }
335
+ if (step.decision !== "rescue") return;
336
+ try {
337
+ agent.followup(createRescueMessage(step.round, config.maxConsecutive));
338
+ } catch (error) {
339
+ states.set(agent, {
340
+ ...states.get(agent) ?? INITIAL_STATE,
341
+ consecutive: 0
342
+ });
343
+ ctx.logger.warn(`dsml-rescue: could not queue a continuation for agent "${agent.id}": ${String(error)}`);
344
+ }
345
+ });
346
+ ctx.on("agent/disposed", ({ agent }) => {
347
+ states.delete(agent);
348
+ usageByAgent.delete(agent);
349
+ });
350
+ }
351
+ // src/index.ts
352
+ /** Cordis plugin name, used by the loader for diagnostics. */
353
+ const name = "dsh-dsml-rescue";
354
+ /** The agent registry, needed to map a session back to its live agent. */
355
+ const inject = ["agents"];
356
+ const Config = z.object({
357
+ enabled: z.boolean().default(true),
358
+ maxConsecutive: z.natural().min(1).default(3),
359
+ applyToSubagents: z.boolean().default(true)
360
+ });
361
+ /**
362
+ * Mount the plugin.
363
+ * @param ctx - host context.
364
+ * @param config - validated configuration.
365
+ */
366
+ function apply(ctx, config) {
367
+ if (config.enabled === false) return;
368
+ install(ctx, {
369
+ maxConsecutive: config.maxConsecutive ?? 3,
370
+ applyToSubagents: config.applyToSubagents ?? true
371
+ });
372
+ }
373
+ export { Config, apply, inject, name };
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "DSML Rescue",
4
+ "description": "Continues the turn a model stalled by writing tool-call markup into its reasoning instead of calling a tool."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "DSML Rescue",
4
+ "description": "模型把工具调用协议标记写进思考、导致回合空转时,自动补发一条中性指令把回合续上。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,87 @@
1
1
  {
2
2
  "name": "dsh-dsml-rescue",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Continue the turn a model stalled by writing tool-call markup into its reasoning",
5
+ "keywords": [
6
+ "dsh",
7
+ "deepseek-harness",
8
+ "dsh-plugin",
9
+ "reasoning",
10
+ "auto-continue"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "zlZayn",
14
+ "icon": "./icon.svg",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/dsh-plugin-lab/dsh-dsml-rescue.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/dsh-plugin-lab/dsh-dsml-rescue/issues"
21
+ },
22
+ "homepage": "https://github.com/dsh-plugin-lab/dsh-dsml-rescue#readme",
23
+ "type": "module",
24
+ "main": "./lib/index.js",
25
+ "types": "./lib/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./lib/index.d.ts",
29
+ "import": "./lib/index.js"
30
+ },
31
+ "./locale/*.json": "./locale/*.json",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "lib",
36
+ "!lib/**/*.map",
37
+ "icon.svg",
38
+ "locale/*.json",
39
+ "cordis.patch.yml",
40
+ "README.md",
41
+ "LICENSE",
42
+ "README_zh.md"
43
+ ],
44
+ "engines": {
45
+ "node": ">=20",
46
+ "dsh": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1"
47
+ },
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ }
52
+ },
53
+ "scripts": {
54
+ "build": "tsdown",
55
+ "typecheck": "tsc -p tsconfig.json",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest",
58
+ "lint": "eslint .",
59
+ "format": "prettier --write .",
60
+ "format:check": "prettier --check .",
61
+ "check:release": "node scripts/check-release.mjs",
62
+ "check": "pnpm run typecheck && pnpm run build && pnpm run test && pnpm run check:release"
63
+ },
64
+ "peerDependencies": {
65
+ "@deepseek-ai/cordis": "^4.0.4",
66
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
67
+ "@deepseek-ai/schemastery": "^3.18.2"
68
+ },
69
+ "devDependencies": {
70
+ "@deepseek-ai/cordis": "^4.0.4",
71
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
72
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
73
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
74
+ "@deepseek-ai/schemastery": "^3.18.2",
75
+ "@eslint/js": "^10.0.1",
76
+ "@types/node": "^22.20.0",
77
+ "@vitest/coverage-v8": "^4.1.11",
78
+ "eslint": "^10.11.0",
79
+ "eslint-config-prettier": "^10.1.8",
80
+ "globals": "^17.12.0",
81
+ "prettier": "^3.9.9",
82
+ "tsdown": "^0.22.2",
83
+ "typescript": "^5.9.3",
84
+ "typescript-eslint": "^8.70.1",
85
+ "vitest": "^4.1.11"
86
+ }
87
+ }