dsh-dsml-rescue 0.3.0 → 0.4.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/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  <h1 align="center">dsh-dsml-rescue</h1>
6
6
 
7
7
  <div align="center">
8
- <p><strong>A stalled turn gets another one</strong></p>
9
- <p><em>空转的轮次,替你续上</em></p>
8
+ <p><strong>A turn stalled by leaked DSML markup gets another one</strong></p>
9
+ <p><em>DSML 泄漏导致的空转,替你续上</em></p>
10
10
 
11
11
  <p>
12
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>
@@ -32,7 +32,7 @@
32
32
  ## Does this look familiar?
33
33
 
34
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:
35
+ tool call, nothing. What it produced ended with lines like these:
36
36
 
37
37
  ```
38
38
  </||DSML|| parameter>
@@ -40,17 +40,22 @@ tool call, nothing. The reasoning output ended with lines like these:
40
40
  </||DSML|| calls>
41
41
  ```
42
42
 
43
+ <p align="center">
44
+ <img src="assets/dsml-leak-rescue.png" width="900" alt="The web UI after a stalled turn: what the model produced ends with leaked DSML markup, then the turn stops with no answer">
45
+ </p>
46
+
43
47
  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".
48
+ markup back as ordinary message content instead of an actual call — in the reasoning, or in the
49
+ visible reply. Either way the call never happens, the turn closes with no output, and everything
50
+ waits for someone to type "continue".
46
51
 
47
52
  This plugin types it.
48
53
 
49
54
  ## What it does
50
55
 
51
- - Continues a stalled turn: DSML markup at the end of the reasoning, and nothing else produced.
52
- Neither half alone is enough — an empty turn without the markup is a different failure.
53
- - Sends one short, neutral line — carry on, or wrap up if the work is done.
56
+ - Continues a stalled turn: DSML markup where the message ends, and no tool call. Both halves are
57
+ needed — an empty turn without the markup is a different failure.
58
+ - Sends one short, neutral line: take the next step — never an offer to declare the work finished.
54
59
  - Backs off while you are typing.
55
60
  - Gives up after a few attempts instead of burning requests.
56
61
  - Covers subagents too, so a delegated task is not falsely reported as completed while it is stuck.
package/README_zh.md CHANGED
@@ -5,8 +5,8 @@
5
5
  <h1 align="center">dsh-dsml-rescue</h1>
6
6
 
7
7
  <div align="center">
8
- <p><strong>空转的轮次,替你续上</strong></p>
9
- <p><em>A stalled turn gets another one</em></p>
8
+ <p><strong>DSML 泄漏导致的空转,替你续上</strong></p>
9
+ <p><em>A turn stalled by leaked DSML markup gets another one</em></p>
10
10
 
11
11
  <p>
12
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>
@@ -31,7 +31,7 @@
31
31
 
32
32
  ## 这场景眼熟吗
33
33
 
34
- 你提了要求,模型推理了一会儿,然后这一轮就这么结束了——没有回复、没有工具调用,什么都没有。推理过程的最后几行是这样:
34
+ 你提了要求,模型推理了一会儿,然后这一轮就这么结束了——没有回复、没有工具调用,什么都没有。它产出的内容,最后几行是这样:
35
35
 
36
36
  ```
37
37
  </||DSML|| parameter>
@@ -39,14 +39,18 @@
39
39
  </||DSML|| calls>
40
40
  ```
41
41
 
42
- DSML(DeepSeek Markup Language)是 DeepSeek 表示工具调用的协议标记。某些 provider 路由会把这段标记当成**普通推理文本**返回,而不是真的发出调用;于是调用没有发生,这一轮空着结束,所有人都在等一句「继续」。
42
+ <p align="center">
43
+ <img src="assets/dsml-leak-rescue.png" width="900" alt="网页界面里的一次空转:模型产出的内容末尾是泄漏的 DSML 标记,这一轮就此停下,没有回答">
44
+ </p>
45
+
46
+ DSML(DeepSeek Markup Language)是 DeepSeek 表示工具调用的协议标记。某些 provider 路由会把这段标记当成普通消息内容返回——可能落在推理里,也可能落在正文里——而不是真的发出调用;于是调用没有发生,这一轮空着结束,所有人都在等一句「继续」。
43
47
 
44
48
  这个插件替你把这句「继续」发出去。
45
49
 
46
50
  ## 它做什么
47
51
 
48
- - 只救空转的轮次:推理过程末尾带着 DSML 标记,而且没有产出任何别的内容。两个条件缺一不可——空转但末尾没有标记,是另一种失败。
49
- - 发一句很短、很中性的话:接着做,或者已经做完就收尾。
52
+ - 救空转的轮次:标记落在消息末尾,而且没有工具调用。两个条件缺一不可——空转但没有标记,是另一种失败。
53
+ - 发一句很短、很中性的话:接着做下一步,不给「已经做完了」这种台阶。
50
54
  - 你正在打字时它让开。
51
55
  - 连着几次没救回来就收手,不会一直烧请求。
52
56
  - 子代理也管:被派出去的任务不会在卡住时被误报为已完成。
package/lib/index.js CHANGED
@@ -2,13 +2,14 @@ import z from "@deepseek-ai/schemastery";
2
2
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
3
3
  // src/protocol.ts
4
4
  /**
5
- * Protocol-mark detection over one reasoning stream.
5
+ * Protocol-mark detection over one message's content.
6
6
  *
7
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.
8
+ * content instead of a structured tool call. The dialect's closing tokens then
9
+ * sit at the very end of the message, and the turn ends having done nothing.
10
+ * This module answers one question about that text: does it end with such a
11
+ * token? It knows nothing about turns, agents, or sessions, and nothing about
12
+ * which content kind the route chose to put it in.
12
13
  *
13
14
  * @module dsh-dsml-rescue/protocol
14
15
  */
@@ -19,11 +20,32 @@ import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
19
20
  */
20
21
  const MARKER = /\|\|\s*dsml\s*\|\|/i;
21
22
  /**
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.
23
+ * Remove fenced code blocks, keeping a fence opener's line prefix.
24
+ *
25
+ * A fence is quotation: a model that transcribes the call it meant to make puts
26
+ * the transcription inside one, and that transcription is not what the message
27
+ * says. Fences can open mid-line, so this cuts at the delimiter rather than at
28
+ * the start of the line.
29
+ * @param text - one message's content.
30
+ * @returns the same text with fenced spans blanked out.
25
31
  */
26
- const TAIL_LINES = 3;
32
+ function withoutFences(text) {
33
+ const kept = [];
34
+ let fenced = false;
35
+ for (const line of text.split(/\r?\n/)) {
36
+ let rest = line;
37
+ let visible = "";
38
+ for (;;) {
39
+ const at = rest.search(/```|~~~/);
40
+ if (at < 0) break;
41
+ if (!fenced) visible += rest.slice(0, at);
42
+ rest = rest.slice(at + 3);
43
+ fenced = !fenced;
44
+ }
45
+ kept.push(fenced ? visible : visible + rest);
46
+ }
47
+ return kept.join("\n");
48
+ }
27
49
  /**
28
50
  * A line that is protocol markup rather than a sentence.
29
51
  *
@@ -37,22 +59,23 @@ function isMarkupLine(line) {
37
59
  return line.startsWith("<") && MARKER.test(line);
38
60
  }
39
61
  /**
40
- * Whether a reasoning stream ends with leaked protocol markup.
62
+ * Whether a message's content ends with leaked protocol markup.
41
63
  *
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.
64
+ * Only the last non-blank line is read. A leak is what the message stops on, so
65
+ * anything said after the markup is output, and a turn that produced output is
66
+ * not a stall. Fences are read per stream: a fence cannot span two blocks, and
67
+ * carrying one block's fence state into the next would flip the reading of a
68
+ * block that never opened one.
69
+ * @param streams - one text per text-bearing block, in stream order.
70
+ * @returns whether the last non-blank line of the content is protocol markup.
46
71
  */
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) {
72
+ function hasLeakedProtocolTail(streams) {
73
+ const lines = [];
74
+ for (const stream of streams) for (const line of withoutFences(stream).normalize("NFKC").split(/\r?\n/)) lines.push(line);
75
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
52
76
  const line = (lines[index] ?? "").trim();
53
77
  if (line === "") continue;
54
- scanned += 1;
55
- if (isMarkupLine(line)) return true;
78
+ return isMarkupLine(line);
56
79
  }
57
80
  return false;
58
81
  }
@@ -60,10 +83,10 @@ function hasLeakedProtocolTail(reasoning) {
60
83
  /**
61
84
  * The one judgment this plugin makes: does a finished turn deserve a rescue?
62
85
  *
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.
86
+ * It deserves one when the turn ended normally and its closing message ends in
87
+ * leaked protocol markup. Reasoning and visible text are both content: which of
88
+ * them the route put the markup in says nothing about whether the work happened,
89
+ * and the message's last line says everything.
67
90
  *
68
91
  * Pure: no host types, no events, no clock. The caller adapts real session
69
92
  * events into {@link TurnObservation}.
@@ -71,38 +94,34 @@ function hasLeakedProtocolTail(reasoning) {
71
94
  * @module dsh-dsml-rescue/detect
72
95
  */
73
96
  /**
74
- * Whether one block produced something a caller could act on.
97
+ * Whether a block carries message content rather than an effect.
75
98
  *
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.
99
+ * Everything else — a tool call, or a content kind this plugin has never seen —
100
+ * is output the turn produced, so it ends the question.
80
101
  * @param block - one content block of the closing message.
81
- * @returns whether the block is visible text, a tool call, or an unknown kind.
102
+ * @returns whether the block is reasoning or visible text.
82
103
  */
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);
104
+ function isTextBearing(block) {
105
+ return block.type === "reasoning" || block.type === "text";
87
106
  }
88
107
  /**
89
- * Concatenate every reasoning block of a message.
108
+ * The text of every text-bearing block, in stream order.
90
109
  * @param blocks - the turn's closing content blocks.
91
- * @returns the joined reasoning text, or the empty string when there is none.
110
+ * @returns one entry per block that carries text, empty entries included.
92
111
  */
93
- function joinReasoning(blocks) {
94
- return blocks.filter((block) => block.type === "reasoning" && block.text !== void 0).map((block) => block.text ?? "").join("\n");
112
+ function streams(blocks) {
113
+ return blocks.filter((block) => isTextBearing(block) && block.text !== void 0).map((block) => block.text ?? "");
95
114
  }
96
115
  /**
97
116
  * Decide whether one finished turn is a leaking stall.
98
117
  * @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.
118
+ * @returns true only when the turn completed, produced no non-text block, and
119
+ * its content ends in protocol markup.
101
120
  */
102
121
  function isRescueCandidate(observation) {
103
122
  if (!observation.completed) return false;
104
- if (observation.blocks.some(isActionable)) return false;
105
- return hasLeakedProtocolTail(joinReasoning(observation.blocks));
123
+ if (observation.blocks.some((block) => !isTextBearing(block))) return false;
124
+ return hasLeakedProtocolTail(streams(observation.blocks));
106
125
  }
107
126
  // src/policy.ts
108
127
  /**
@@ -229,13 +248,23 @@ function reduce(state, event, limits) {
229
248
  * sits below the model — a provider route delivering tool-call markup as text —
230
249
  * so nothing the model does can avoid it, and naming it would only invite the
231
250
  * 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.
251
+ * is fixed rather than configurable: one instruction, and no clause inviting the
252
+ * model to declare the work finished. The cap is what bounds a rescue, so the
253
+ * text owes no exit — and offering one gives a stalled model a cheaper way to
254
+ * end the turn than doing the work.
234
255
  *
235
256
  * @module dsh-dsml-rescue/messages
236
257
  */
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.";
258
+ /**
259
+ * What the model reads: one instruction, and nothing about being finished.
260
+ *
261
+ * The rescued turn ended with a tool call that never landed, so the next step
262
+ * has not happened yet; a "say you are done" clause has nothing true to be right
263
+ * about. Taking one is not free either: a prose answer is what detection reads as
264
+ * the message's end, so the turn stops counting as a stall and the run ends with
265
+ * the work undone.
266
+ */
267
+ const CONTINUE_PROMPT = "Continue with the task. Take the next concrete step now, using a tool if needed.";
239
268
  /**
240
269
  * Build the continuation turn's message.
241
270
  * @param round - this rescue's ordinal in the current streak, from 1.
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "DSML Rescue",
4
- "description": "Auto-continues turns stalled by DSML tool-call markup leaking into reasoning."
4
+ "description": "Auto-continues turns stalled by DSML tool-call markup, wherever the route leaks it."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "DSML 续跑",
4
- "description": "当 DSML 工具调用标记泄漏进推理过程导致回合停滞时,自动续跑。"
4
+ "description": "当 DSML 工具调用标记泄漏在消息末尾(推理或正文皆可)导致回合停滞时,自动续跑。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-dsml-rescue",
3
- "version": "0.3.0",
4
- "description": "Auto-continues turns stalled by DSML tool-call markup leaking into reasoning.",
3
+ "version": "0.4.0",
4
+ "description": "Auto-continues turns stalled by DSML tool-call markup, wherever the route leaks it.",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "deepseek-harness",