dsh-dsml-rescue 0.0.0-stage → 0.2.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,89 @@
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>A stalled turn gets another one</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-square&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="https://www.npmjs.com/package/dsh-dsml-rescue"><img src="https://img.shields.io/npm/v/dsh-dsml-rescue.svg?style=flat" alt="npm"></a>
18
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat" alt="MIT License"></a>
19
+ </p>
20
+
21
+ <p>
22
+ Per-version changes → <a href="https://github.com/dsh-plugin-lab/dsh-dsml-rescue/releases">Releases</a>
23
+ </p>
24
+
25
+ <p>
26
+ <strong><a href="README.md">English</a></strong> · <a href="README_zh.md">简体中文</a>
27
+ </p>
28
+ </div>
29
+
30
+ ---
31
+
32
+ ## Does this look familiar?
33
+
34
+ You asked for something. The model reasoned for a while. Then the turn simply ended — no answer, no
35
+ tool call, nothing. The reasoning output ended with lines like these:
36
+
37
+ ```
38
+ </||DSML|| parameter>
39
+ </||DSML|| invoke>
40
+ </||DSML|| calls>
41
+ ```
42
+
43
+ DSML (DeepSeek Markup Language) is how DeepSeek formats a tool call. Some provider routes hand that
44
+ markup back as **ordinary reasoning text** instead of an actual call, so the call never happens, the
45
+ turn closes with no output, and everything waits for someone to type "continue".
46
+
47
+ This plugin types it.
48
+
49
+ ## What it does
50
+
51
+ - Continues a stalled turn: DSML markup at the end of the reasoning, and nothing else produced.
52
+ - Sends one short, neutral line — carry on, or wrap up if the work is done.
53
+ - Backs off while you are typing.
54
+ - Gives up after a few attempts instead of burning requests.
55
+ - Covers subagents too, so a delegated task is not falsely reported as completed while it is stuck.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ dsh plugin --profile web add dsh-dsml-rescue
61
+ ```
62
+
63
+ Then restart `dsh --profile web`. There is nothing to configure — the defaults are the intended
64
+ behaviour.
65
+
66
+ Working from source: clone, `pnpm install && pnpm run build`, then
67
+ `dsh plugin --profile web add "$PWD"`.
68
+
69
+ ## What it will not do
70
+
71
+ - **It does not fix the cause.** The markup leaks below the model, out of reach of any prompt. What
72
+ it buys you is the wait, shortened.
73
+ - **It does not rescue other empty turns.** No DSML markup, no rescue: a cancellation, a failed
74
+ request, or a reply cut off at the output limit is left alone on purpose.
75
+ - **It does not touch your conversation.** Nothing is rewritten or replayed; it adds one message and
76
+ lets the host carry on.
77
+
78
+ ## Going deeper
79
+
80
+ - Which three settings exist and what each one changes →
81
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#configuration)
82
+ - How a stalled turn is recognised, and the rules that must not be broken →
83
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
84
+ - What the tests prove, and how to reproduce the whole thing locally →
85
+ [docs/VERIFICATION.md](docs/VERIFICATION.md)
86
+
87
+ ## License
88
+
89
+ [MIT](LICENSE)
package/README_zh.md ADDED
@@ -0,0 +1,78 @@
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>A stalled turn gets another one</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-square&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="https://www.npmjs.com/package/dsh-dsml-rescue"><img src="https://img.shields.io/npm/v/dsh-dsml-rescue.svg?style=flat" alt="npm"></a>
18
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat" alt="MIT License"></a>
19
+ </p>
20
+
21
+ <p>
22
+ 每个版本改了什么 → <a href="https://github.com/dsh-plugin-lab/dsh-dsml-rescue/releases">Releases</a>
23
+ </p>
24
+
25
+ <p>
26
+ <a href="README.md">English</a> · <strong><a href="README_zh.md">简体中文</a></strong>
27
+ </p>
28
+ </div>
29
+
30
+ ---
31
+
32
+ ## 这场景眼熟吗
33
+
34
+ 你提了要求,模型推理了一会儿,然后这一轮就这么结束了——没有回复、没有工具调用,什么都没有。推理过程的最后几行是这样:
35
+
36
+ ```
37
+ </||DSML|| parameter>
38
+ </||DSML|| invoke>
39
+ </||DSML|| calls>
40
+ ```
41
+
42
+ DSML(DeepSeek Markup Language)是 DeepSeek 表示工具调用的协议标记。某些 provider 路由会把这段标记当成**普通推理文本**返回,而不是真的发出调用;于是调用没有发生,这一轮空着结束,所有人都在等一句「继续」。
43
+
44
+ 这个插件替你把这句「继续」发出去。
45
+
46
+ ## 它做什么
47
+
48
+ - 只救空转的轮次:推理过程末尾带着 DSML 标记,而且没有产出任何别的内容。
49
+ - 发一句很短、很中性的话:接着做,或者已经做完就收尾。
50
+ - 你正在打字时它让开。
51
+ - 连着几次没救回来就收手,不会一直烧请求。
52
+ - 子代理也管:被派出去的任务不会在卡住时被误报为已完成。
53
+
54
+ ## 安装
55
+
56
+ ```bash
57
+ dsh plugin --profile web add dsh-dsml-rescue
58
+ ```
59
+
60
+ 然后重启 `dsh --profile web`。不需要配置,默认值就是预期行为。
61
+
62
+ 从源码装:先 clone,`pnpm install && pnpm run build`,再 `dsh plugin --profile web add "$PWD"`。
63
+
64
+ ## 它不会做什么
65
+
66
+ - **不修根因。** 标记泄漏发生在模型之下,提示词够不着。它换来的只有那段等待被缩短。
67
+ - **不救别的空转轮次。** 没有 DSML 标记就不出手:你主动取消、请求失败、回复被输出上限截断,这些都刻意不碰。
68
+ - **不动你的对话。** 不改写、不重放历史,只多加一条消息,剩下的交给宿主。
69
+
70
+ ## 想了解更多
71
+
72
+ - 有哪三个配置项、各自会改变什么 → [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#configuration)
73
+ - 怎么认定一个空转轮次、哪些规则不能破 → [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
74
+ - 测试能证明什么、怎么在本机完整复现 → [docs/VERIFICATION.md](docs/VERIFICATION.md)
75
+
76
+ ## 许可
77
+
78
+ [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,33 @@
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
+ /** Rescues allowed in a row before an agent is left alone. */
8
+ declare const DEFAULT_MAX_CONSECUTIVE = 3;
9
+ /** Whether child agents are rescued by default. */
10
+ declare const DEFAULT_APPLY_TO_SUBAGENTS = true;
11
+ /** Validated configuration; the defaults are the documented behaviour. */
12
+ interface Config {
13
+ /** Whether the plugin acts at all. Turn it off without uninstalling. */
14
+ enabled?: boolean;
15
+ /**
16
+ * Consecutive rescues allowed for one agent before it is left alone. Each
17
+ * rescue costs one model request, so this is the only spend control here.
18
+ */
19
+ maxConsecutive?: number;
20
+ /**
21
+ * Whether child agents are rescued too. Their stalls otherwise settle
22
+ * silently and reach their parent only as "finished".
23
+ */
24
+ applyToSubagents?: boolean;
25
+ }
26
+ declare const Config: z<Config>;
27
+ /**
28
+ * Mount the plugin.
29
+ * @param ctx - host context.
30
+ * @param config - validated configuration; the schema fills in every default.
31
+ */
32
+ declare function apply(ctx: Context, config: Config): void;
33
+ export { Config, DEFAULT_APPLY_TO_SUBAGENTS, DEFAULT_MAX_CONSECUTIVE, apply, inject, name };
package/lib/index.js ADDED
@@ -0,0 +1,358 @@
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
+ * It deserves one when the turn 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
+ /**
74
+ * Whether one block produced something a caller could act on.
75
+ *
76
+ * Reasoning never counts — that is where the leak lives, not where the work
77
+ * lands. An unknown block type does count, on purpose: a provider that adds a
78
+ * content kind this plugin has never seen is far likelier to have produced
79
+ * output than to have produced nothing, and a false rescue costs a request.
80
+ * @param block - one content block of the closing message.
81
+ * @returns whether the block is visible text, a tool call, or an unknown kind.
82
+ */
83
+ function isActionable(block) {
84
+ if (block.type === "reasoning") return false;
85
+ if (block.type !== "text") return true;
86
+ return block.text !== void 0 && /\S/.test(block.text);
87
+ }
88
+ /**
89
+ * Concatenate every reasoning block of a message.
90
+ * @param blocks - the turn's closing content blocks.
91
+ * @returns the joined reasoning text, or the empty string when there is none.
92
+ */
93
+ function joinReasoning(blocks) {
94
+ return blocks.filter((block) => block.type === "reasoning" && block.text !== void 0).map((block) => block.text ?? "").join("\n");
95
+ }
96
+ /**
97
+ * Decide whether one finished turn is a leaking stall.
98
+ * @param observation - the turn's end reason and its closing message blocks.
99
+ * @returns true only when the turn completed, produced nothing actionable, and
100
+ * its reasoning ends in protocol markup.
101
+ */
102
+ function isRescueCandidate(observation) {
103
+ if (!observation.completed) return false;
104
+ if (observation.blocks.some(isActionable)) return false;
105
+ return hasLeakedProtocolTail(joinReasoning(observation.blocks));
106
+ }
107
+ // src/policy.ts
108
+ /**
109
+ * Decide what one finished turn deserves.
110
+ *
111
+ * Order matters: a spent cap outranks a waiting human, because giving way would
112
+ * leave the cap unrecorded and the streak silently unbounded.
113
+ * @param input - detection verdict, current streak, cap, and human presence.
114
+ * @returns the decision and the streak to store.
115
+ */
116
+ function evaluate(input) {
117
+ if (!input.candidate) return {
118
+ decision: "ignore",
119
+ consecutive: 0
120
+ };
121
+ if (input.consecutive >= input.maxConsecutive) return {
122
+ decision: "give-up",
123
+ consecutive: 0
124
+ };
125
+ if (input.humanPending) return {
126
+ decision: "yield",
127
+ consecutive: input.consecutive
128
+ };
129
+ return {
130
+ decision: "rescue",
131
+ consecutive: input.consecutive + 1
132
+ };
133
+ }
134
+ // src/machine.ts
135
+ /**
136
+ * One agent's turn-to-turn decision, as a pure reducer.
137
+ *
138
+ * The runtime's only job is translation: Cordis events in, this reducer called,
139
+ * one side effect out. Keeping the sequencing here means the recorded sessions
140
+ * under `test/fixtures/` exercise the same code the host runs, instead of a
141
+ * second implementation written for the tests.
142
+ *
143
+ * @module dsh-dsml-rescue/machine
144
+ */
145
+ /** The state a machine starts in. */
146
+ const INITIAL_STATE = {
147
+ consecutive: 0,
148
+ turn: void 0,
149
+ blocks: [],
150
+ outputTokens: void 0
151
+ };
152
+ /** Nothing to do, with the state unchanged. */
153
+ function nothing(state) {
154
+ return {
155
+ state,
156
+ decision: "ignore",
157
+ round: 0
158
+ };
159
+ }
160
+ /**
161
+ * Whether the step being closed spent its whole output budget.
162
+ *
163
+ * Reads as exhausted only when both numbers are known: an unknown ceiling or a
164
+ * missing count reports "not exhausted" on purpose, because a wrong `true`
165
+ * would silence the rescue this plugin exists for, while a wrong `false` only
166
+ * costs the streak one attempt.
167
+ * @param state - state carrying the closing message's output tokens.
168
+ * @param ceiling - the ceiling the request was sent under, when known.
169
+ * @returns whether the model was cut off rather than finished.
170
+ */
171
+ function spentBudget(state, ceiling) {
172
+ if (state.outputTokens === void 0) return false;
173
+ if (ceiling === void 0 || ceiling <= 0) return false;
174
+ return state.outputTokens >= ceiling;
175
+ }
176
+ /**
177
+ * Advance one agent's machine by one event.
178
+ *
179
+ * The streak clears on two facts only: a turn that ended abnormally (a
180
+ * different failure, which a rescue would hide) and human input (the human now
181
+ * owns the conversation). It is *not* cleared by the plugin's own notices.
182
+ * @param state - state after the previous event.
183
+ * @param event - the fact to apply.
184
+ * @param limits - configured cap.
185
+ * @returns the next state and what to do about it.
186
+ */
187
+ function reduce(state, event, limits) {
188
+ switch (event.kind) {
189
+ case "assistant-message": return nothing({
190
+ ...state,
191
+ turn: event.turn,
192
+ blocks: event.blocks,
193
+ outputTokens: event.outputTokens
194
+ });
195
+ case "human-message": return nothing({
196
+ ...state,
197
+ consecutive: 0
198
+ });
199
+ case "turn-end": return event.completed ? nothing(state) : nothing({
200
+ ...state,
201
+ consecutive: 0
202
+ });
203
+ case "turn-stopping": {
204
+ const outcome = evaluate({
205
+ candidate: isRescueCandidate({
206
+ completed: true,
207
+ blocks: state.turn === event.turn && !spentBudget(state, event.outputCeiling) ? state.blocks : []
208
+ }),
209
+ consecutive: state.consecutive,
210
+ maxConsecutive: limits.maxConsecutive,
211
+ humanPending: event.humanPending
212
+ });
213
+ return {
214
+ state: {
215
+ ...state,
216
+ consecutive: outcome.consecutive
217
+ },
218
+ decision: outcome.decision,
219
+ round: outcome.decision === "rescue" ? outcome.consecutive : 0
220
+ };
221
+ }
222
+ }
223
+ }
224
+ // src/messages.ts
225
+ /**
226
+ * The one message this plugin shows the model, and the attribution it carries.
227
+ *
228
+ * Deliberately says nothing about why the previous turn ended empty. The cause
229
+ * sits below the model — a provider route delivering tool-call markup as text —
230
+ * so nothing the model does can avoid it, and naming it would only invite the
231
+ * model to talk about the failure instead of continuing the work. The wording
232
+ * is fixed rather than configurable: one sentence that asks for the next step,
233
+ * plus one legal way out so a rescue cannot become an open-ended loop.
234
+ *
235
+ * @module dsh-dsml-rescue/messages
236
+ */
237
+ /** What the model reads. Two instructions: continue, or close out if finished. */
238
+ 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.";
239
+ /**
240
+ * Build the continuation turn's message.
241
+ * @param round - this rescue's ordinal in the current streak, from 1.
242
+ * @param max - the configured cap, for the durable one-line summary.
243
+ * @returns an immutable user-role message attributed to this plugin.
244
+ */
245
+ function createRescueMessage(round, max) {
246
+ return createUserMessage({
247
+ content: [{
248
+ type: "text",
249
+ text: CONTINUE_PROMPT
250
+ }],
251
+ source: {
252
+ kind: "dsml-rescue",
253
+ form: "notice",
254
+ summary: boundContextSummary(`Auto-continue ${round}/${max}`)
255
+ }
256
+ });
257
+ }
258
+ // src/runtime.ts
259
+ /** Whether the inbox already holds input a human queued. */
260
+ function humanPending(agent) {
261
+ return agent.inbox.nextTurn.some((message) => message.source.kind === "user") || agent.inbox.nextStep.some((message) => message.source.kind === "user");
262
+ }
263
+ /**
264
+ * Install the turn watcher and its continuation side effect.
265
+ * @param ctx - plugin context; listeners are disposed with it.
266
+ * @param config - validated limits.
267
+ */
268
+ function install(ctx, config) {
269
+ const states = /* @__PURE__ */ new Map();
270
+ /** Apply one reducer event and store the result. */
271
+ function advance(agent, event) {
272
+ const step = reduce(states.get(agent) ?? INITIAL_STATE, event, { maxConsecutive: config.maxConsecutive });
273
+ states.set(agent, step.state);
274
+ return step;
275
+ }
276
+ /** Whether this agent was created by another agent instead of at the root. */
277
+ function isSubagent(agent) {
278
+ return !ctx.agents.roots().includes(agent);
279
+ }
280
+ ctx.on("session/event", (session, event) => {
281
+ const agent = ctx.agents.get(session.id);
282
+ if (agent === void 0 || agent.session !== session) return;
283
+ switch (event.type) {
284
+ case "assistant/message":
285
+ advance(agent, {
286
+ kind: "assistant-message",
287
+ turn: event.data.turn,
288
+ blocks: event.data.message.content,
289
+ outputTokens: event.data.usage?.outputTokens
290
+ });
291
+ return;
292
+ case "user/message":
293
+ if (event.data.source.kind === "user") advance(agent, { kind: "human-message" });
294
+ return;
295
+ case "turn/end":
296
+ advance(agent, {
297
+ kind: "turn-end",
298
+ turn: event.data.turn,
299
+ completed: event.data.reason.kind === "completed"
300
+ });
301
+ return;
302
+ default: return;
303
+ }
304
+ });
305
+ ctx.on("agent/turn-stopping", ({ agent, turn }) => {
306
+ if (!config.applyToSubagents && isSubagent(agent)) return;
307
+ const step = advance(agent, {
308
+ kind: "turn-stopping",
309
+ turn,
310
+ humanPending: humanPending(agent),
311
+ outputCeiling: agent.session.requestHeader()?.config.maxTokens
312
+ });
313
+ if (step.decision === "give-up") {
314
+ ctx.logger.warn(`dsml-rescue: agent "${agent.id}" stalled ${config.maxConsecutive} times in a row; stopped continuing it`);
315
+ return;
316
+ }
317
+ if (step.decision !== "rescue") return;
318
+ try {
319
+ agent.followup(createRescueMessage(step.round, config.maxConsecutive));
320
+ } catch (error) {
321
+ states.set(agent, {
322
+ ...states.get(agent) ?? INITIAL_STATE,
323
+ consecutive: 0
324
+ });
325
+ ctx.logger.warn(`dsml-rescue: could not queue a continuation for agent "${agent.id}": ${String(error)}`);
326
+ }
327
+ });
328
+ ctx.on("agent/disposed", ({ agent }) => {
329
+ states.delete(agent);
330
+ });
331
+ }
332
+ // src/index.ts
333
+ /** Cordis plugin name, used by the loader for diagnostics. */
334
+ const name = "dsh-dsml-rescue";
335
+ /** The agent registry, needed to map a session back to its live agent. */
336
+ const inject = ["agents"];
337
+ /** Rescues allowed in a row before an agent is left alone. */
338
+ const DEFAULT_MAX_CONSECUTIVE = 3;
339
+ /** Whether child agents are rescued by default. */
340
+ const DEFAULT_APPLY_TO_SUBAGENTS = true;
341
+ const Config = z.object({
342
+ enabled: z.boolean().default(true),
343
+ maxConsecutive: z.natural().min(1).default(3),
344
+ applyToSubagents: z.boolean().default(true)
345
+ });
346
+ /**
347
+ * Mount the plugin.
348
+ * @param ctx - host context.
349
+ * @param config - validated configuration; the schema fills in every default.
350
+ */
351
+ function apply(ctx, config) {
352
+ if (config.enabled === false) return;
353
+ install(ctx, {
354
+ maxConsecutive: config.maxConsecutive ?? 3,
355
+ applyToSubagents: config.applyToSubagents ?? true
356
+ });
357
+ }
358
+ export { Config, DEFAULT_APPLY_TO_SUBAGENTS, DEFAULT_MAX_CONSECUTIVE, 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"
3
+ "version": "0.2.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
+ "peerDependencies": {
54
+ "@deepseek-ai/cordis": "^4.0.4",
55
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
56
+ "@deepseek-ai/schemastery": "^3.18.2"
57
+ },
58
+ "devDependencies": {
59
+ "@deepseek-ai/cordis": "^4.0.4",
60
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
61
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
62
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
63
+ "@deepseek-ai/schemastery": "^3.18.2",
64
+ "@eslint/js": "^10.0.1",
65
+ "@types/node": "^22.20.0",
66
+ "@vitest/coverage-v8": "^4.1.11",
67
+ "eslint": "^10.11.0",
68
+ "eslint-config-prettier": "^10.1.8",
69
+ "globals": "^17.12.0",
70
+ "prettier": "^3.9.9",
71
+ "tsdown": "^0.22.2",
72
+ "typescript": "^5.9.3",
73
+ "typescript-eslint": "^8.70.1",
74
+ "vitest": "^4.1.11"
75
+ },
76
+ "scripts": {
77
+ "build": "tsdown",
78
+ "typecheck": "tsc -p tsconfig.json",
79
+ "test": "vitest run",
80
+ "test:watch": "vitest",
81
+ "lint": "eslint .",
82
+ "format": "prettier --write .",
83
+ "format:check": "prettier --check .",
84
+ "check:release": "node scripts/check-release.mjs",
85
+ "check": "pnpm run typecheck && pnpm run build && pnpm run test && pnpm run check:release"
86
+ }
6
87
  }