@aexol/spectral 0.9.227 → 0.9.229
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/dist/sdk/ai/providers/leaked-tool-call-parser.d.ts +64 -0
- package/dist/sdk/ai/providers/leaked-tool-call-parser.d.ts.map +1 -0
- package/dist/sdk/ai/providers/leaked-tool-call-parser.js +134 -0
- package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openai-completions.js +136 -4
- package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for tool calls that a model leaked into plain text output as
|
|
3
|
+
* antml-style XML markup instead of emitting them through the native
|
|
4
|
+
* `tool_calls` stream.
|
|
5
|
+
*
|
|
6
|
+
* Observed shape (OpenRouter models occasionally emit this despite native
|
|
7
|
+
* tools being passed in the request):
|
|
8
|
+
*
|
|
9
|
+
* wrapper-open tag
|
|
10
|
+
* invoke-open tag with name attribute
|
|
11
|
+
* parameter-open tag with name attribute ... value ... parameter-close tag
|
|
12
|
+
* invoke-close tag
|
|
13
|
+
* wrapper-close tag
|
|
14
|
+
*
|
|
15
|
+
* where the wrapper tag is `function_calls` (optionally with an `antml:`
|
|
16
|
+
* namespace prefix) and invoke/parameter tags follow the same convention.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is pure string logic — no provider imports — so the tests
|
|
19
|
+
* cover it without any streaming harness.
|
|
20
|
+
*/
|
|
21
|
+
/** Longest opener tag is the antml-prefixed one (22 chars). Keep a margin. */
|
|
22
|
+
export declare const LEAK_TAIL_LENGTH = 32;
|
|
23
|
+
export type LeakedToolCall = {
|
|
24
|
+
name: string;
|
|
25
|
+
args: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
export type LeakOpenerMatch = "full" | "partial" | "none";
|
|
28
|
+
/**
|
|
29
|
+
* Locate a leaked wrapper-opener tag in `text`.
|
|
30
|
+
* - "full": a complete opener tag exists; `index` points at its `<`.
|
|
31
|
+
* - "partial": the text ENDS with a strict prefix of an opener tag (an opener
|
|
32
|
+
* split across streaming deltas); `index` points at the start of that tail.
|
|
33
|
+
* - "none": no opener, complete or partial.
|
|
34
|
+
*/
|
|
35
|
+
export declare function findLeakedToolCallOpener(text: string): {
|
|
36
|
+
match: LeakOpenerMatch;
|
|
37
|
+
index: number;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Extract tool calls from a leaked wrapper block.
|
|
41
|
+
*
|
|
42
|
+
* Returns:
|
|
43
|
+
* - `complete: true` → the wrapper closer was found; `calls` holds every
|
|
44
|
+
* invoke in order and `remaining` is the text outside the block (prose
|
|
45
|
+
* before + after). `consumed` is the raw block text (opener→closer
|
|
46
|
+
* inclusive) so callers can fall back to emitting it as text when the
|
|
47
|
+
* parsed tool names turn out to be unknown.
|
|
48
|
+
* - `complete: false` → no closed wrapper block yet; `calls` is empty and
|
|
49
|
+
* `remaining`/`consumed` echo the input so nothing is cut prematurely.
|
|
50
|
+
*/
|
|
51
|
+
export declare function extractLeakedToolCalls(text: string): {
|
|
52
|
+
calls: LeakedToolCall[];
|
|
53
|
+
complete: boolean;
|
|
54
|
+
remaining: string;
|
|
55
|
+
consumed: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Coerce a leaked parameter string into a JSON value when it clearly parses
|
|
59
|
+
* as one — native `tool_calls` arguments are real JSON, so a leaked
|
|
60
|
+
* `<parameter name="x">[1,2]</parameter>` should arrive as an array, not a
|
|
61
|
+
* string. Anything that does not fully parse stays a string.
|
|
62
|
+
*/
|
|
63
|
+
export declare function coerceLeakedParameterValue(raw: string): unknown;
|
|
64
|
+
//# sourceMappingURL=leaked-tool-call-parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"leaked-tool-call-parser.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/leaked-tool-call-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAiBH,8EAA8E;AAC9E,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC,MAAM,MAAM,cAAc,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAI1D;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACvC,IAAI,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA0B3C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG;IACrD,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CACjB,CAoCA;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAW/D"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for tool calls that a model leaked into plain text output as
|
|
3
|
+
* antml-style XML markup instead of emitting them through the native
|
|
4
|
+
* `tool_calls` stream.
|
|
5
|
+
*
|
|
6
|
+
* Observed shape (OpenRouter models occasionally emit this despite native
|
|
7
|
+
* tools being passed in the request):
|
|
8
|
+
*
|
|
9
|
+
* wrapper-open tag
|
|
10
|
+
* invoke-open tag with name attribute
|
|
11
|
+
* parameter-open tag with name attribute ... value ... parameter-close tag
|
|
12
|
+
* invoke-close tag
|
|
13
|
+
* wrapper-close tag
|
|
14
|
+
*
|
|
15
|
+
* where the wrapper tag is `function_calls` (optionally with an `antml:`
|
|
16
|
+
* namespace prefix) and invoke/parameter tags follow the same convention.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is pure string logic — no provider imports — so the tests
|
|
19
|
+
* cover it without any streaming harness.
|
|
20
|
+
*/
|
|
21
|
+
/** Namespace prefix variants are matched verbatim, case-sensitively. */
|
|
22
|
+
const NS = "(?:antml:)?";
|
|
23
|
+
const INVOKE_RE = new RegExp(`<${NS}invoke\\s+name="([^"]*)"\\s*>([\\s\\S]*?)</${NS}invoke\\s*>`, "g");
|
|
24
|
+
const PARAMETER_RE = new RegExp(`<${NS}parameter\\s+name="([^"]*)"\\s*>([\\s\\S]*?)</${NS}parameter\\s*>`, "g");
|
|
25
|
+
const WRAPPER_OPEN_RE = /<(?:antml:)?function_calls\s*>/;
|
|
26
|
+
const WRAPPER_CLOSE = /<\/(?:antml:)?function_calls\s*>/;
|
|
27
|
+
/** Longest opener tag is the antml-prefixed one (22 chars). Keep a margin. */
|
|
28
|
+
export const LEAK_TAIL_LENGTH = 32;
|
|
29
|
+
const OPENER_PREFIXES = ["<function_calls", "<antml:function_calls"];
|
|
30
|
+
/**
|
|
31
|
+
* Locate a leaked wrapper-opener tag in `text`.
|
|
32
|
+
* - "full": a complete opener tag exists; `index` points at its `<`.
|
|
33
|
+
* - "partial": the text ENDS with a strict prefix of an opener tag (an opener
|
|
34
|
+
* split across streaming deltas); `index` points at the start of that tail.
|
|
35
|
+
* - "none": no opener, complete or partial.
|
|
36
|
+
*/
|
|
37
|
+
export function findLeakedToolCallOpener(text) {
|
|
38
|
+
let best = {
|
|
39
|
+
match: "none",
|
|
40
|
+
index: -1,
|
|
41
|
+
};
|
|
42
|
+
// Full openers allow trailing whitespace before `>`, mirroring
|
|
43
|
+
// WRAPPER_OPEN_RE so both matchers agree on what an opener is.
|
|
44
|
+
for (const m of text.matchAll(/<(?:antml:)?function_calls\s*>/g)) {
|
|
45
|
+
if (best.match !== "full" || m.index < best.index) {
|
|
46
|
+
best = { match: "full", index: m.index };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (best.match === "full")
|
|
50
|
+
return best;
|
|
51
|
+
for (const opener of OPENER_PREFIXES) {
|
|
52
|
+
const maxLen = Math.min(opener.length - 1, text.length);
|
|
53
|
+
for (let size = maxLen; size > 0; size--) {
|
|
54
|
+
if (text.endsWith(opener.slice(0, size))) {
|
|
55
|
+
const index = text.length - size;
|
|
56
|
+
if (best.match === "none" || index < best.index) {
|
|
57
|
+
best = { match: "partial", index };
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return best;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Extract tool calls from a leaked wrapper block.
|
|
67
|
+
*
|
|
68
|
+
* Returns:
|
|
69
|
+
* - `complete: true` → the wrapper closer was found; `calls` holds every
|
|
70
|
+
* invoke in order and `remaining` is the text outside the block (prose
|
|
71
|
+
* before + after). `consumed` is the raw block text (opener→closer
|
|
72
|
+
* inclusive) so callers can fall back to emitting it as text when the
|
|
73
|
+
* parsed tool names turn out to be unknown.
|
|
74
|
+
* - `complete: false` → no closed wrapper block yet; `calls` is empty and
|
|
75
|
+
* `remaining`/`consumed` echo the input so nothing is cut prematurely.
|
|
76
|
+
*/
|
|
77
|
+
export function extractLeakedToolCalls(text) {
|
|
78
|
+
const openMatch = WRAPPER_OPEN_RE.exec(text);
|
|
79
|
+
if (!openMatch) {
|
|
80
|
+
return { calls: [], complete: false, remaining: text, consumed: "" };
|
|
81
|
+
}
|
|
82
|
+
const openStart = openMatch.index;
|
|
83
|
+
const bodyStart = openStart + openMatch[0].length;
|
|
84
|
+
let closeStart = -1;
|
|
85
|
+
let closeEnd = -1;
|
|
86
|
+
for (const closer of ["</function_calls", "</antml:function_calls"]) {
|
|
87
|
+
const idx = text.indexOf(closer, bodyStart);
|
|
88
|
+
if (idx >= 0 && (closeStart === -1 || idx < closeStart)) {
|
|
89
|
+
closeStart = idx;
|
|
90
|
+
const gt = text.indexOf(">", idx);
|
|
91
|
+
if (gt === -1)
|
|
92
|
+
return { calls: [], complete: false, remaining: text, consumed: "" };
|
|
93
|
+
closeEnd = gt;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (closeStart === -1) {
|
|
97
|
+
return { calls: [], complete: false, remaining: text, consumed: "" };
|
|
98
|
+
}
|
|
99
|
+
const region = text.slice(bodyStart, closeStart);
|
|
100
|
+
const calls = [];
|
|
101
|
+
for (const m of region.matchAll(INVOKE_RE)) {
|
|
102
|
+
const args = {};
|
|
103
|
+
for (const p of m[2].matchAll(PARAMETER_RE)) {
|
|
104
|
+
args[p[1]] = p[2].trim();
|
|
105
|
+
}
|
|
106
|
+
calls.push({ name: m[1], args });
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
calls,
|
|
110
|
+
complete: true,
|
|
111
|
+
consumed: text.slice(openStart, closeEnd + 1),
|
|
112
|
+
remaining: text.slice(0, openStart) + text.slice(closeEnd + 1),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Coerce a leaked parameter string into a JSON value when it clearly parses
|
|
117
|
+
* as one — native `tool_calls` arguments are real JSON, so a leaked
|
|
118
|
+
* `<parameter name="x">[1,2]</parameter>` should arrive as an array, not a
|
|
119
|
+
* string. Anything that does not fully parse stays a string.
|
|
120
|
+
*/
|
|
121
|
+
export function coerceLeakedParameterValue(raw) {
|
|
122
|
+
const trimmed = raw.trim();
|
|
123
|
+
if (!trimmed)
|
|
124
|
+
return raw;
|
|
125
|
+
if (!/^[[{"]/.test(trimmed) && !/^(true|false|null)$/.test(trimmed) && !/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(trimmed)) {
|
|
126
|
+
return raw;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(trimmed);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"openai-completions.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openai-completions.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"openai-completions.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openai-completions.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAMX,0BAA0B,EAE1B,MAAM,sCAAsC,CAAC;AAI9C,OAAO,KAAK,EAGX,OAAO,EAGP,KAAK,EACL,uBAAuB,EACvB,mBAAmB,EAEnB,cAAc,EACd,aAAa,EAMb,MAAM,aAAa,CAAC;AAmFrB,MAAM,WAAW,wBAAyB,SAAQ,aAAa;IAC9D,UAAU,CAAC,EACR,MAAM,GACN,MAAM,GACN,UAAU,GACV;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,QAAQ,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IACpD,eAAe,CAAC,EAAE,SAAS,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;CAClE;AAED,KAAK,+BAA+B,GAAG,QAAQ,CAAC,uBAAuB,CAAC,CAAC;AAqRzE,eAAO,MAAM,uBAAuB,EAAE,cAAc,CACnD,oBAAoB,EACpB,wBAAwB,CAylBxB,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,cAAc,CACzD,oBAAoB,EACpB,mBAAmB,CAyBnB,CAAC;AAkNF,wBAAgB,eAAe,CAC9B,KAAK,EAAE,KAAK,CAAC,oBAAoB,CAAC,EAClC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,+BAA+B,GACrC,0BAA0B,EAAE,CA0R9B"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
+
import { coerceLeakedParameterValue, extractLeakedToolCalls, findLeakedToolCallOpener, } from "./leaked-tool-call-parser.js";
|
|
2
3
|
import { backendHostedImageResolver } from "../utils/hosted-image-resolver.js";
|
|
3
4
|
import { getApiUrl, readConfig } from "../../../config.js";
|
|
4
5
|
import { getEnvApiKey } from "../env-api-keys.js";
|
|
@@ -30,6 +31,19 @@ function hasToolHistory(messages) {
|
|
|
30
31
|
}
|
|
31
32
|
return false;
|
|
32
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse streaming tool-call arguments without throwing. Returns undefined
|
|
36
|
+
* when the accumulated JSON fragment cannot be parsed (yet), so a malformed
|
|
37
|
+
* delta never aborts the whole agent turn.
|
|
38
|
+
*/
|
|
39
|
+
function safeParseArgs(partial) {
|
|
40
|
+
try {
|
|
41
|
+
return parseStreamingJson(partial);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
33
47
|
function isTextContentBlock(block) {
|
|
34
48
|
return block.type === "text";
|
|
35
49
|
}
|
|
@@ -366,6 +380,16 @@ export const streamOpenAICompletions = (model, context, options) => {
|
|
|
366
380
|
let rawChunkCount = 0;
|
|
367
381
|
let inlineThinkingPending = "";
|
|
368
382
|
let inInlineThinking = false;
|
|
383
|
+
// Leaked tool-call XML: some OpenRouter models emit antml-style
|
|
384
|
+
// markup in plain content instead of native tool_calls. Text is
|
|
385
|
+
// buffered here until a complete wrapper block arrives, then parsed
|
|
386
|
+
// into real toolCall blocks (see leaked-tool-call-parser.ts).
|
|
387
|
+
let leakMode = false;
|
|
388
|
+
let leakBuffer = "";
|
|
389
|
+
let leakPending = "";
|
|
390
|
+
let leakedToolCallSeq = 0;
|
|
391
|
+
let emittedLeakedToolCalls = false;
|
|
392
|
+
const availableToolNames = new Set((context.tools ?? []).map((tool) => tool.name));
|
|
369
393
|
const toolCallBlocksByIndex = new Map();
|
|
370
394
|
const toolCallBlocksById = new Map();
|
|
371
395
|
const blocks = output.content;
|
|
@@ -393,7 +417,7 @@ export const streamOpenAICompletions = (model, context, options) => {
|
|
|
393
417
|
});
|
|
394
418
|
}
|
|
395
419
|
else if (block.type === "toolCall") {
|
|
396
|
-
block.arguments =
|
|
420
|
+
block.arguments = safeParseArgs(block.partialArgs) ?? {};
|
|
397
421
|
// Finalize in-place and strip the scratch buffers so replay only
|
|
398
422
|
// carries parsed arguments.
|
|
399
423
|
delete block.partialArgs;
|
|
@@ -486,7 +510,7 @@ export const streamOpenAICompletions = (model, context, options) => {
|
|
|
486
510
|
emitThinkingDelta("inline_think", part.delta);
|
|
487
511
|
}
|
|
488
512
|
else {
|
|
489
|
-
|
|
513
|
+
processTextDelta(part.delta);
|
|
490
514
|
}
|
|
491
515
|
}
|
|
492
516
|
};
|
|
@@ -529,6 +553,91 @@ export const streamOpenAICompletions = (model, context, options) => {
|
|
|
529
553
|
}
|
|
530
554
|
return block;
|
|
531
555
|
};
|
|
556
|
+
// Synthetic tool calls parsed from leaked XML travel the same path
|
|
557
|
+
// as native ones (ensureToolCallBlock → finishBlock → toolcall_end),
|
|
558
|
+
// but use negative stream indexes so they can never collide with
|
|
559
|
+
// native tool_call indexes in the same response.
|
|
560
|
+
const emitSyntheticToolCall = (call) => {
|
|
561
|
+
const args = {};
|
|
562
|
+
for (const [key, value] of Object.entries(call.args)) {
|
|
563
|
+
args[key] = coerceLeakedParameterValue(value);
|
|
564
|
+
}
|
|
565
|
+
const block = ensureToolCallBlock({
|
|
566
|
+
id: `leaked_tc_${Date.now().toString(36)}_${Math.random()
|
|
567
|
+
.toString(36)
|
|
568
|
+
.slice(2, 8)}_${++leakedToolCallSeq}`,
|
|
569
|
+
index: -1 - leakedToolCallSeq,
|
|
570
|
+
function: { name: call.name, arguments: JSON.stringify(args) },
|
|
571
|
+
});
|
|
572
|
+
block.partialArgs = JSON.stringify(args);
|
|
573
|
+
block.arguments = args;
|
|
574
|
+
sawStreamOutput = true;
|
|
575
|
+
stream.push({
|
|
576
|
+
type: "toolcall_delta",
|
|
577
|
+
contentIndex: getContentIndex(block),
|
|
578
|
+
delta: block.partialArgs,
|
|
579
|
+
partial: output,
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
const drainLeakBuffer = () => {
|
|
583
|
+
while (true) {
|
|
584
|
+
if (!leakMode) {
|
|
585
|
+
const m = findLeakedToolCallOpener(leakPending);
|
|
586
|
+
if (m.match === "full") {
|
|
587
|
+
const before = leakPending.slice(0, m.index);
|
|
588
|
+
if (before)
|
|
589
|
+
emitTextDelta(before);
|
|
590
|
+
leakBuffer = leakPending.slice(m.index);
|
|
591
|
+
leakPending = "";
|
|
592
|
+
leakMode = true;
|
|
593
|
+
}
|
|
594
|
+
else if (m.match === "partial") {
|
|
595
|
+
const before = leakPending.slice(0, m.index);
|
|
596
|
+
if (before)
|
|
597
|
+
emitTextDelta(before);
|
|
598
|
+
leakPending = leakPending.slice(m.index);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
if (leakPending)
|
|
603
|
+
emitTextDelta(leakPending);
|
|
604
|
+
leakPending = "";
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const parsed = extractLeakedToolCalls(leakBuffer);
|
|
609
|
+
if (!parsed.complete)
|
|
610
|
+
return; // keep buffering until the closer arrives
|
|
611
|
+
// All-or-nothing tool-name validation: a block naming a tool
|
|
612
|
+
// that does not exist in this context is prose (legal XML in
|
|
613
|
+
// an answer), not a tool call — emit it as text instead.
|
|
614
|
+
if (parsed.calls.length > 0 &&
|
|
615
|
+
parsed.calls.every((c) => availableToolNames.has(c.name))) {
|
|
616
|
+
for (const call of parsed.calls)
|
|
617
|
+
emitSyntheticToolCall(call);
|
|
618
|
+
emittedLeakedToolCalls = true;
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
emitTextDelta(parsed.consumed);
|
|
622
|
+
}
|
|
623
|
+
leakBuffer = "";
|
|
624
|
+
leakMode = false;
|
|
625
|
+
leakPending = parsed.remaining; // may contain another block
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
// Route plain content text through leak detection. Holds back a
|
|
629
|
+
// bounded tail so an opener split across deltas is still caught.
|
|
630
|
+
const processTextDelta = (text) => {
|
|
631
|
+
if (!text)
|
|
632
|
+
return;
|
|
633
|
+
if (leakMode) {
|
|
634
|
+
leakBuffer += text;
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
leakPending += text;
|
|
638
|
+
}
|
|
639
|
+
drainLeakBuffer();
|
|
640
|
+
};
|
|
532
641
|
for await (const chunk of openaiStream) {
|
|
533
642
|
rawChunkCount++;
|
|
534
643
|
if (!chunk || typeof chunk !== "object")
|
|
@@ -597,7 +706,12 @@ export const streamOpenAICompletions = (model, context, options) => {
|
|
|
597
706
|
delta = toolCall.function.arguments;
|
|
598
707
|
block.partialArgs =
|
|
599
708
|
(block.partialArgs ?? "") + toolCall.function.arguments;
|
|
600
|
-
|
|
709
|
+
const parsed = safeParseArgs(block.partialArgs);
|
|
710
|
+
if (parsed !== undefined) {
|
|
711
|
+
// Keep the last-good arguments if this fragment fails to
|
|
712
|
+
// parse; a later delta usually completes the JSON.
|
|
713
|
+
block.arguments = parsed;
|
|
714
|
+
}
|
|
601
715
|
}
|
|
602
716
|
sawStreamOutput = true;
|
|
603
717
|
stream.push({
|
|
@@ -632,13 +746,31 @@ export const streamOpenAICompletions = (model, context, options) => {
|
|
|
632
746
|
emitThinkingDelta("inline_think", inlineThinkingPending);
|
|
633
747
|
}
|
|
634
748
|
else {
|
|
635
|
-
|
|
749
|
+
processTextDelta(inlineThinkingPending);
|
|
636
750
|
}
|
|
637
751
|
inlineThinkingPending = "";
|
|
638
752
|
}
|
|
753
|
+
// Flush leak buffers. A never-closed block is emitted as plain text
|
|
754
|
+
// (graceful degradation) so no content is lost; a held-back tail
|
|
755
|
+
// without an opener is just prose.
|
|
756
|
+
if (leakMode && leakBuffer) {
|
|
757
|
+
emitTextDelta(leakBuffer);
|
|
758
|
+
leakBuffer = "";
|
|
759
|
+
leakMode = false;
|
|
760
|
+
}
|
|
761
|
+
if (leakPending) {
|
|
762
|
+
emitTextDelta(leakPending);
|
|
763
|
+
leakPending = "";
|
|
764
|
+
}
|
|
639
765
|
for (const block of blocks) {
|
|
640
766
|
finishBlock(block);
|
|
641
767
|
}
|
|
768
|
+
// The provider finished with "stop" even though the model leaked its
|
|
769
|
+
// tool calls into text; the agent loop only continues on "toolUse",
|
|
770
|
+
// so promote the parsed synthetic calls.
|
|
771
|
+
if (emittedLeakedToolCalls && output.stopReason === "stop") {
|
|
772
|
+
output.stopReason = "toolUse";
|
|
773
|
+
}
|
|
642
774
|
if (options?.signal?.aborted) {
|
|
643
775
|
throw new Error("Request was aborted");
|
|
644
776
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"system-prompt.d.ts","sourceRoot":"","sources":["../../../../src/sdk/coding-agent/core/system-prompt.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,EAAyB,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,wBAAwB;IACxC,+CAA+C;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,6FAA6F;IAC7F,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,YAAY,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,yBAAyB;IACzB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;CACjB;AAED,kEAAkE;AAClE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"system-prompt.d.ts","sourceRoot":"","sources":["../../../../src/sdk/coding-agent/core/system-prompt.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,EAAyB,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,wBAAwB;IACxC,+CAA+C;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,6FAA6F;IAC7F,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,YAAY,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,yBAAyB;IACzB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;CACjB;AAED,kEAAkE;AAClE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,CA6M3E"}
|
|
@@ -75,6 +75,11 @@ export function buildSystemPrompt(options) {
|
|
|
75
75
|
}
|
|
76
76
|
addGuideline("When creating new files, maintain a tree-like directory structure. Place files in semantically meaningful subdirectories (e.g. `handlers/auth/`, `utils/string/`) rather than dumping them at the project root or in shallow flat folders. Group related files into dedicated folders, prefer `feature/module/file.ts` over `feature-file.ts` at root. Avoid creating files directly in root unless they are truly project-wide (e.g. config, package.json). Keep folder depth reasonable (max 5–6 levels) but prefer nesting over flattening — tree-like structure improves LLM navigability and agentic coding efficiency.");
|
|
77
77
|
addGuideline("When editing code, first inspect nearby files and existing patterns; follow the codebase’s conventions and libraries");
|
|
78
|
+
// Some models (notably via OpenRouter) emit antml-style XML tool calls in
|
|
79
|
+
// plain text instead of using the native tool-call mechanism. The provider
|
|
80
|
+
// parses those as a fallback, but the native path is strictly better
|
|
81
|
+
// (streaming, validation, real tool results in the transcript) — say so.
|
|
82
|
+
addGuideline("Always invoke tools through the native tool-call mechanism. Never write tool calls as XML markup or fake tags in plain text (e.g. wrapper/invoke/parameter style blocks) — they must never appear in your prose output.");
|
|
78
83
|
// Always include these
|
|
79
84
|
if (hasSubagent) {
|
|
80
85
|
addGuideline("Subagent mode is enabled: use the subagent tool for non-trivial work when specialized or parallel delegation improves quality or speed.");
|