@oh-my-pi/pi-ai 18.5.1 → 18.6.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.6.1] - 2026-10-04
6
+
7
+ ### Fixed
8
+
9
+ - Fixed compatibility with Command Code DeepSeek and other DeepSeek-family models by preserving the reasoning context required for warm OpenAI Responses sessions and correctly handling incomplete DSML tool-call wrappers in visible output.
10
+
11
+ ## [18.6.0] - 2026-10-03
12
+
13
+ ### Fixed
14
+
15
+ - Fixed Antigravity chat and image requests sending an outdated client version when the model list came from cache, which could make newer models such as Claude Opus 5.5 unavailable.
16
+ - When a DeepSeek model writes a broken DSML tool call (for example with the opening `<|DSML|tool_calls>` and `<|DSML|invoke>` tags missing), its closing tags are now kept in the streamed text instead of being dropped. This lets the agent remove exactly the broken call while keeping any text after it ([#14202](https://github.com/can1357/oh-my-pi/pull/14202) by [@H4vC](https://github.com/H4vC)).
17
+
5
18
  ## [18.5.1] - 2026-10-03
6
19
 
7
20
  ### Fixed
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Removal of DeepSeek DSML tool-call markup left in visible assistant text.
3
+ *
4
+ * DeepSeek models on OpenAI-compatible hosts sometimes emit their tool calls as
5
+ * raw `<|DSML|…>` markup in `content`. A well-formed envelope is healed into a
6
+ * structured call by `StreamMarkupHealing`, but a degraded model can drop the
7
+ * opening `<|DSML|tool_calls>` / `<|DSML|invoke name="…">` tags (or the host
8
+ * runs no healer at all). That markup must not stay in history: replaying it
9
+ * teaches the model to keep writing tool calls as text (issue #10556). Callers
10
+ * strip it before the message is committed and tell the model its call failed.
11
+ */
12
+ import type { AssistantMessage, Model } from "../types.js";
13
+ /**
14
+ * Whether DSML leak recovery applies to `model`: a DeepSeek-class model, or one
15
+ * explicitly configured with the `dsml` stream healer (e.g. a `models.yml` entry
16
+ * whose id the classifier cannot place). Other models never write DSML tool
17
+ * calls, so DSML text from them is prose and is left alone.
18
+ */
19
+ export declare function isDsmlLeakRecoveryTarget(model: Model): boolean;
20
+ /**
21
+ * Strip leaked DSML tool-call markup from `text`, leaving quoted code intact.
22
+ * Returns `undefined` when `text` carries no such markup.
23
+ */
24
+ export declare function stripDsmlToolMarkup(text: string): string | undefined;
25
+ /**
26
+ * Remove leaked DSML tool-call markup from `message`'s text blocks in place,
27
+ * dropping blocks left empty. Returns true when anything was removed.
28
+ */
29
+ export declare function removeDsmlToolMarkupLeak(message: AssistantMessage): boolean;
@@ -63,10 +63,10 @@ export declare class StreamMarkupHealing {
63
63
  /** Drain accumulated tool calls from calls to {@link feed}. */
64
64
  drainCompleted(): HealedToolCall[];
65
65
  /**
66
- * Flush held-back stream-end fragments as ordered events. Partial tool-call
67
- * sections/envelopes are dropped by the delegated scanners; unterminated
68
- * thinking blocks are emitted as thinking, matching the previous MiniMax parser
69
- * behavior.
66
+ * Flush held-back stream-end fragments as ordered events. Partial tool calls
67
+ * are dropped by the delegated scanners; a DSML wrapper without an invoke
68
+ * remains visible for agent-layer leak recovery. Unterminated thinking blocks
69
+ * are emitted as thinking.
70
70
  */
71
71
  flushEvents(): StreamMarkupHealingEvent[];
72
72
  /** Flush held-back text only. Reconstructed calls are retained for {@link drainCompleted}. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.5.1",
3
+ "version": "18.6.1",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.5.1",
159
- "@oh-my-pi/pi-catalog": "18.5.1",
160
- "@oh-my-pi/pi-natives": "18.5.1",
161
- "@oh-my-pi/pi-utils": "18.5.1",
162
- "@oh-my-pi/pi-wire": "18.5.1"
158
+ "@oh-my-pi/omptype": "18.6.1",
159
+ "@oh-my-pi/pi-catalog": "18.6.1",
160
+ "@oh-my-pi/pi-natives": "18.6.1",
161
+ "@oh-my-pi/pi-utils": "18.6.1",
162
+ "@oh-my-pi/pi-wire": "18.6.1"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -99,6 +99,11 @@ const DSML_INVOKE_TOKENS = [
99
99
  ] as const;
100
100
  const DSML_PARAMETER_CLOSE_TOKENS = [DSML_PARAMETER_CLOSE_FULLWIDTH, DSML_PARAMETER_CLOSE_ASCII] as const;
101
101
 
102
+ // A DSML invoke/parameter opener in visible text: a call whose `tool_calls`
103
+ // wrapper is missing, so the envelope scanner never consumed it.
104
+ const BARE_DSML_OPEN = /<[||]DSML[||](?:invoke|parameter)\b/u;
105
+ const VISIBLE_TAIL_LIMIT = 48;
106
+
102
107
  type State =
103
108
  | "outside"
104
109
  | "thinking"
@@ -123,8 +128,19 @@ export class DeepSeekInbandScanner implements InbandScanner {
123
128
  #dsmlParamName = "";
124
129
  #dsmlParamIsString = true;
125
130
  #dsmlParamRaw = "";
131
+ /** Unclassified wrapper text is kept until an invoke proves it is a tool call. */
132
+ #pendingDsmlSection: string | undefined;
126
133
  #rawBlock = "";
127
134
  #stripLeadingWhitespace = false;
135
+ /**
136
+ * Visible text already carries a bare DSML `invoke`/`parameter` opener (a
137
+ * malformed call missing its `tool_calls` wrapper). Its closers are then kept
138
+ * verbatim instead of stripped as orphans, so the agent loop can find where
139
+ * the broken call ends and remove exactly that span, keeping prose after it.
140
+ */
141
+ #bareDsmlOpenVisible = false;
142
+ /** Last few visible characters, so a bare opener split across chunks is still seen. */
143
+ #visibleTail = "";
128
144
 
129
145
  constructor(options: InbandScannerOptions = {}) {
130
146
  this.#parseThinking = options.parseThinking ?? true;
@@ -180,6 +196,12 @@ export class DeepSeekInbandScanner implements InbandScanner {
180
196
  if (!this.#consumeDsmlParam(final, events)) break;
181
197
  }
182
198
  if (final && this.#state === "thinking") this.#endThinking(events);
199
+ if (final && this.#pendingDsmlSection !== undefined) {
200
+ this.#emitText(this.#pendingDsmlSection + this.#buffer, events);
201
+ this.#pendingDsmlSection = undefined;
202
+ this.#buffer = "";
203
+ this.#state = "outside";
204
+ }
183
205
  if (final && this.#buffer.length === 0 && this.#rawBlock.length > 0) this.#rawBlock = "";
184
206
  return events;
185
207
  }
@@ -201,12 +223,11 @@ export class DeepSeekInbandScanner implements InbandScanner {
201
223
  const match = findEarliestToken(this.#buffer, OUTSIDE_TOKENS);
202
224
  if (!match) {
203
225
  const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, OUTSIDE_TOKENS);
204
- const emit = this.#buffer.slice(0, this.#buffer.length - hold);
205
- if (emit.length > 0) events.push({ type: "text", text: emit });
226
+ this.#emitText(this.#buffer.slice(0, this.#buffer.length - hold), events);
206
227
  this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
207
228
  return;
208
229
  }
209
- if (match.index > 0) events.push({ type: "text", text: this.#buffer.slice(0, match.index) });
230
+ this.#emitText(this.#buffer.slice(0, match.index), events);
210
231
  this.#buffer = this.#buffer.slice(match.index);
211
232
  if (this.#buffer.startsWith(DEEPSEEK_TOOL_CALLS_BEGIN)) {
212
233
  this.#buffer = this.#buffer.slice(DEEPSEEK_TOOL_CALLS_BEGIN.length);
@@ -236,17 +257,28 @@ export class DeepSeekInbandScanner implements InbandScanner {
236
257
  ? DSML_TOOL_CALLS_OPEN_FULLWIDTH
237
258
  : DSML_TOOL_CALLS_OPEN_ASCII;
238
259
  this.#buffer = this.#buffer.slice(openToken.length);
260
+ this.#pendingDsmlSection = openToken;
239
261
  this.#state = "dsmlSection";
240
262
  return;
241
263
  }
242
264
  const orphanClose = this.#matchingOrphanDsmlClose();
243
265
  if (orphanClose) {
266
+ if (this.#bareDsmlOpenVisible) this.#emitText(orphanClose, events);
244
267
  this.#buffer = this.#buffer.slice(orphanClose.length);
245
268
  continue;
246
269
  }
247
270
  const control = this.#matchingControlToken();
248
271
  if (control) {
249
272
  this.#buffer = this.#buffer.slice(control.length);
273
+ if (
274
+ this.#bareDsmlOpenVisible &&
275
+ (control === DSML_TOOL_CALLS_CLOSE_FULLWIDTH || control === DSML_TOOL_CALLS_CLOSE_ASCII)
276
+ ) {
277
+ // Closes the malformed call: keep it as its end marker.
278
+ this.#emitText(control, events);
279
+ this.#bareDsmlOpenVisible = false;
280
+ continue;
281
+ }
250
282
  this.#stripLeadingWhitespace = true;
251
283
  continue;
252
284
  }
@@ -254,6 +286,13 @@ export class DeepSeekInbandScanner implements InbandScanner {
254
286
  }
255
287
  }
256
288
 
289
+ #emitText(text: string, events: InbandScanEvent[]): void {
290
+ if (text.length === 0) return;
291
+ events.push({ type: "text", text });
292
+ this.#visibleTail = (this.#visibleTail + text).slice(-VISIBLE_TAIL_LIMIT);
293
+ if (!this.#bareDsmlOpenVisible && BARE_DSML_OPEN.test(this.#visibleTail)) this.#bareDsmlOpenVisible = true;
294
+ }
295
+
257
296
  #consumeThinking(final: boolean, events: InbandScanEvent[]): void {
258
297
  const close = this.#buffer.indexOf(THINK_CLOSE);
259
298
  if (close === -1) {
@@ -351,16 +390,25 @@ export class DeepSeekInbandScanner implements InbandScanner {
351
390
  }
352
391
 
353
392
  #consumeDsmlSection(final: boolean, events: InbandScanEvent[]): boolean {
393
+ const initial = this.#buffer;
354
394
  while (this.#buffer.length > 0) {
355
395
  this.#skipWhitespace();
356
396
  const close = this.#matchingDsmlClose(DSML_TOOL_CALLS_CLOSE_FULLWIDTH, DSML_TOOL_CALLS_CLOSE_ASCII);
357
397
  if (close) {
398
+ if (this.#pendingDsmlSection !== undefined) {
399
+ this.#emitText(
400
+ this.#pendingDsmlSection + initial.slice(0, initial.length - this.#buffer.length) + close,
401
+ events,
402
+ );
403
+ this.#pendingDsmlSection = undefined;
404
+ }
358
405
  this.#buffer = this.#buffer.slice(close.length);
359
406
  this.#state = "outside";
360
407
  return true;
361
408
  }
362
409
  const invoke = this.#matchDsmlOpen("invoke");
363
410
  if (invoke) {
411
+ this.#pendingDsmlSection = undefined;
364
412
  this.#rawBlock = invoke.raw;
365
413
  this.#name = invoke.name;
366
414
  this.#id = mintToolCallId();
@@ -374,12 +422,15 @@ export class DeepSeekInbandScanner implements InbandScanner {
374
422
  (this.#buffer.startsWith("<|DSML|invoke") || this.#buffer.startsWith("<|DSML|invoke")) &&
375
423
  !this.#buffer.includes(">")
376
424
  )
377
- return false;
378
- if (partialSuffixOverlapAny(this.#buffer, DSML_SECTION_TOKENS) === this.#buffer.length) return false;
425
+ break;
426
+ if (partialSuffixOverlapAny(this.#buffer, DSML_SECTION_TOKENS) === this.#buffer.length) break;
379
427
  }
380
- if (this.#buffer.length === 0) return false;
428
+ if (this.#buffer.length === 0) break;
381
429
  this.#buffer = this.#buffer.slice(1);
382
430
  }
431
+ if (this.#pendingDsmlSection !== undefined) {
432
+ this.#pendingDsmlSection += initial.slice(0, initial.length - this.#buffer.length);
433
+ }
383
434
  return final;
384
435
  }
385
436
 
@@ -60,7 +60,6 @@ async function resolveTarget(
60
60
  const advertised = await fetchAntigravityImageModel({
61
61
  token: credentials.accessToken,
62
62
  endpoint: endpoints.length === 1 ? endpoints[0] : undefined,
63
- userAgent: getAntigravityUserAgent(),
64
63
  signal,
65
64
  fetcher: fetchImpl,
66
65
  });
@@ -8,6 +8,7 @@ import { scheduler } from "node:timers/promises";
8
8
  import { type } from "@oh-my-pi/omptype";
9
9
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
10
10
  import {
11
+ ensureAntigravityVersion,
11
12
  getAntigravityModelWireProfile,
12
13
  getAntigravityUserAgent,
13
14
  getGeminiCliHeaders,
@@ -577,6 +578,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
577
578
  if (replacementPayload !== undefined) {
578
579
  requestBody = replacementPayload as typeof requestBody;
579
580
  }
581
+ // The backend gates newer models on the client version; a process that
582
+ // skipped discovery (fresh model cache) must still send the current one.
583
+ if (isAntigravity) await ensureAntigravityVersion(options?.fetch ?? fetch, options?.signal);
580
584
  const headers = isAntigravity ? { "User-Agent": getAntigravityUserAgent() } : getGeminiCliHeaders(model.id);
581
585
 
582
586
  const requestHeaders = {
@@ -2303,6 +2303,30 @@ function createSyntheticResponsesReasoningItem(
2303
2303
  return item as ResponseReasoningItem;
2304
2304
  }
2305
2305
 
2306
+ /**
2307
+ * Fill a replayed reasoning item's missing `reasoning_text` for targets that
2308
+ * require it on every replayed turn (DeepSeek family, #10690). Servers that
2309
+ * stream reasoning as summary text return summary-only items; a warm session
2310
+ * replays them natively, so without this the wire carries no `reasoning_text`
2311
+ * while the cold rebuild of the same turn does (#14288). The summary is the
2312
+ * same text the cold path carries from the thinking block. Items carrying
2313
+ * `encrypted_content` are the server's opaque reasoning and replay untouched:
2314
+ * OpenRouter sets the tool-call requirement for every reasoning model, and its
2315
+ * OpenAI-family items must not gain a summary posing as raw reasoning text.
2316
+ */
2317
+ function withRequiredReasoningText(item: ResponseReasoningItem): ResponseReasoningItem {
2318
+ if (typeof item.encrypted_content === "string") return item;
2319
+ if (item.content?.some(part => part.type === "reasoning_text" && part.text.trim().length > 0)) return item;
2320
+ const summaryText = (item.summary ?? [])
2321
+ .map(part => part.text)
2322
+ .filter(text => text.trim().length > 0)
2323
+ .join("\n\n");
2324
+ return {
2325
+ ...item,
2326
+ content: [{ type: "reasoning_text", text: summaryText || SYNTHETIC_REASONING_REPLAY_PLACEHOLDER }],
2327
+ };
2328
+ }
2329
+
2306
2330
  function isResponsesAssistantTurnBoundary(item: ResponseInput[number]): boolean {
2307
2331
  if (responsesToolOutputKind(item.type) !== undefined) return true;
2308
2332
  if (item.type === "compaction") return true;
@@ -2317,7 +2341,8 @@ function ensureRequiredResponsesReasoningReplay(
2317
2341
  ): ResponseInput {
2318
2342
  if (stopReason === "error" || (!requiresAllTurns && !requiresToolCalls)) return items;
2319
2343
 
2320
- const insertBefore: number[] = [];
2344
+ const repaired: ResponseInput = [];
2345
+ let changed = false;
2321
2346
  let turnStart = 0;
2322
2347
  for (let index = 0; index <= items.length; index++) {
2323
2348
  if (index < items.length && !isResponsesAssistantTurnBoundary(items[index])) continue;
@@ -2334,23 +2359,21 @@ function ensureRequiredResponsesReasoningReplay(
2334
2359
  hasContent = true;
2335
2360
  if (classifyResponsesBatchItem(item) === "call") hasToolCall = true;
2336
2361
  }
2337
- if (hasContent && !hasReasoning && (requiresAllTurns || (requiresToolCalls && hasToolCall))) {
2338
- insertBefore.push(turnStart);
2339
- }
2340
- turnStart = index + 1;
2341
- }
2342
- if (insertBefore.length === 0) return items;
2343
-
2344
- const repaired: ResponseInput = [];
2345
- let insertionIndex = 0;
2346
- for (let index = 0; index < items.length; index++) {
2347
- if (insertBefore[insertionIndex] === index) {
2362
+ const required = hasContent && (requiresAllTurns || (requiresToolCalls && hasToolCall));
2363
+ if (required && !hasReasoning) {
2348
2364
  repaired.push(createSyntheticResponsesReasoningItem());
2349
- insertionIndex++;
2365
+ changed = true;
2350
2366
  }
2351
- repaired.push(items[index]);
2367
+ for (let turnIndex = turnStart; turnIndex < index; turnIndex++) {
2368
+ const item = items[turnIndex];
2369
+ const replayed = required && item.type === "reasoning" ? withRequiredReasoningText(item) : item;
2370
+ if (replayed !== item) changed = true;
2371
+ repaired.push(replayed);
2372
+ }
2373
+ if (index < items.length) repaired.push(items[index]);
2374
+ turnStart = index + 1;
2352
2375
  }
2353
- return repaired;
2376
+ return changed ? repaired : items;
2354
2377
  }
2355
2378
 
2356
2379
  export function convertResponsesAssistantMessage<TApi extends Api>(
@@ -2404,7 +2427,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
2404
2427
  }
2405
2428
  const reasoningItem = parseResponseReasoningReplayItem(block.thinkingSignature);
2406
2429
  if (reasoningItem) {
2407
- outputItems.push(reasoningItem);
2430
+ outputItems.push(requiresReasoningItem ? withRequiredReasoningText(reasoningItem) : reasoningItem);
2408
2431
  reasoningItemEmitted = true;
2409
2432
  }
2410
2433
  continue;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Removal of DeepSeek DSML tool-call markup left in visible assistant text.
3
+ *
4
+ * DeepSeek models on OpenAI-compatible hosts sometimes emit their tool calls as
5
+ * raw `<|DSML|…>` markup in `content`. A well-formed envelope is healed into a
6
+ * structured call by `StreamMarkupHealing`, but a degraded model can drop the
7
+ * opening `<|DSML|tool_calls>` / `<|DSML|invoke name="…">` tags (or the host
8
+ * runs no healer at all). That markup must not stay in history: replaying it
9
+ * teaches the model to keep writing tool calls as text (issue #10556). Callers
10
+ * strip it before the message is committed and tell the model its call failed.
11
+ */
12
+
13
+ import type { AssistantMessage, Model } from "../types";
14
+
15
+ // Structural DSML tags in either pipe spelling (fullwidth `|` U+FF5C or ASCII),
16
+ // tolerating stray whitespace inside the delimiter run.
17
+ const DSML_TAG_PATTERN = /<\s*\/?\s*[||]\s*DSML\s*[||]\s*(?:tool_calls|invoke|parameter)\b[^<>]*>?/gu;
18
+ // Code the model deliberately quoted (fences, inline spans) is not a leak.
19
+ const QUOTED_CODE_PATTERN = /(```|~~~)[\s\S]*?(?:\1|$)|`[^`\n]*`/g;
20
+ // The identifier-only line a degraded model writes in place of
21
+ // `<|DSML|invoke name="…">` (e.g. `bash\n<|DSML|parameter …>`): part of the
22
+ // broken call, so it goes with the markup.
23
+ const TRAILING_TOOL_NAME_LINE = /(^|\n)[ \t]*[A-Za-z_][\w.-]*[ \t]*\r?\n[ \t]*$/;
24
+ const CLOSING_TAG_PATTERN = /^<\s*\//;
25
+ const BLANK_LINE_PATTERN = /\r?\n[ \t]*\r?\n/;
26
+
27
+ /**
28
+ * Remove the broken tool call from one stretch of unquoted text: everything from
29
+ * the first DSML tag (or the tool-name line just before it) through the last.
30
+ * The DSML scanner keeps a malformed call's closers, so its last closer marks
31
+ * the end. When the closers never arrived, the dangling argument value runs to
32
+ * the next blank line (or the end of the stretch); prose after that survives.
33
+ */
34
+ function stripUnquoted(text: string): string {
35
+ const tags = [...text.matchAll(DSML_TAG_PATTERN)];
36
+ if (tags.length === 0) return text;
37
+ const first = tags[0]!;
38
+ const last = tags[tags.length - 1]!;
39
+ let start = first.index;
40
+ const nameLine = TRAILING_TOOL_NAME_LINE.exec(text.slice(0, start));
41
+ if (nameLine) start = nameLine.index + nameLine[1]!.length;
42
+ const lastEnd = last.index + last[0].length;
43
+ let end = lastEnd;
44
+ if (!CLOSING_TAG_PATTERN.test(last[0])) {
45
+ const blankLine = text.slice(lastEnd).search(BLANK_LINE_PATTERN);
46
+ end = blankLine === -1 ? text.length : lastEnd + blankLine;
47
+ }
48
+ const before = text.slice(0, start).trimEnd();
49
+ const after = text.slice(end).trimStart();
50
+ return before.length > 0 && after.length > 0 ? `${before}\n\n${after}` : before + after;
51
+ }
52
+
53
+ /**
54
+ * Whether DSML leak recovery applies to `model`: a DeepSeek-class model, or one
55
+ * explicitly configured with the `dsml` stream healer (e.g. a `models.yml` entry
56
+ * whose id the classifier cannot place). Other models never write DSML tool
57
+ * calls, so DSML text from them is prose and is left alone.
58
+ */
59
+ export function isDsmlLeakRecoveryTarget(model: Model): boolean {
60
+ if (model.identity?.class === "deepseek") return true;
61
+ const compat = model.compat;
62
+ return (
63
+ compat !== undefined && "streamMarkupHealingPattern" in compat && compat.streamMarkupHealingPattern === "dsml"
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Strip leaked DSML tool-call markup from `text`, leaving quoted code intact.
69
+ * Returns `undefined` when `text` carries no such markup.
70
+ */
71
+ export function stripDsmlToolMarkup(text: string): string | undefined {
72
+ if (!text.includes("DSML")) return undefined;
73
+ let out = "";
74
+ let cursor = 0;
75
+ let changed = false;
76
+ const flush = (end: number): void => {
77
+ const segment = text.slice(cursor, end);
78
+ const stripped = stripUnquoted(segment);
79
+ if (stripped !== segment) changed = true;
80
+ out += stripped;
81
+ };
82
+ for (const quoted of text.matchAll(QUOTED_CODE_PATTERN)) {
83
+ flush(quoted.index);
84
+ out += quoted[0];
85
+ cursor = quoted.index + quoted[0].length;
86
+ }
87
+ flush(text.length);
88
+ return changed ? out : undefined;
89
+ }
90
+
91
+ /**
92
+ * Remove leaked DSML tool-call markup from `message`'s text blocks in place,
93
+ * dropping blocks left empty. Returns true when anything was removed.
94
+ */
95
+ export function removeDsmlToolMarkupLeak(message: AssistantMessage): boolean {
96
+ let removed = false;
97
+ const content: AssistantMessage["content"] = [];
98
+ for (const block of message.content) {
99
+ const stripped = block.type === "text" ? stripDsmlToolMarkup(block.text) : undefined;
100
+ if (block.type !== "text" || stripped === undefined) {
101
+ content.push(block);
102
+ continue;
103
+ }
104
+ removed = true;
105
+ if (stripped.trim().length > 0) content.push({ ...block, text: stripped });
106
+ }
107
+ if (removed) message.content = content;
108
+ return removed;
109
+ }
@@ -126,10 +126,10 @@ export class StreamMarkupHealing {
126
126
  }
127
127
 
128
128
  /**
129
- * Flush held-back stream-end fragments as ordered events. Partial tool-call
130
- * sections/envelopes are dropped by the delegated scanners; unterminated
131
- * thinking blocks are emitted as thinking, matching the previous MiniMax parser
132
- * behavior.
129
+ * Flush held-back stream-end fragments as ordered events. Partial tool calls
130
+ * are dropped by the delegated scanners; a DSML wrapper without an invoke
131
+ * remains visible for agent-layer leak recovery. Unterminated thinking blocks
132
+ * are emitted as thinking.
133
133
  */
134
134
  flushEvents(): StreamMarkupHealingEvent[] {
135
135
  const tail = this.#toolScanner ? this.#healThinking(this.#toolScanner.flush()) : [];