@aexol/spectral 0.9.228 → 0.9.230
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/auth-helper.d.ts.map +1 -1
- package/dist/auth-helper.js +14 -8
- package/dist/commands/login.d.ts +12 -4
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +36 -15
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +3 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -1
- package/dist/generated/zeus/const.d.ts.map +1 -1
- package/dist/generated/zeus/const.js +21 -0
- package/dist/generated/zeus/index.d.ts +98 -0
- package/dist/generated/zeus/index.d.ts.map +1 -1
- package/dist/relay/registration.d.ts +10 -2
- package/dist/relay/registration.d.ts.map +1 -1
- package/dist/relay/registration.js +32 -3
- 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
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Contract (Batch 1 backend):
|
|
5
5
|
* POST <backend>/api/machines/register
|
|
6
|
-
* Headers: Authorization: Bearer <teamApiKey
|
|
6
|
+
* Headers: Authorization: Bearer <userJwt|userApiKey|teamApiKey>
|
|
7
7
|
* Body: { name?: string, hostname: string, version: string, pid: number }
|
|
8
|
-
* 200: { machineId: string, jwt: string, name: string, ownerId
|
|
8
|
+
* 200: { machineId: string, jwt: string, name: string, teamId?, ownerId?, visibility? }
|
|
9
9
|
*
|
|
10
10
|
* Auth priority:
|
|
11
11
|
* - If `userJwt` is set → used as Bearer token (machine gets ownerId)
|
|
12
|
+
* - Else if `userApiKey` is set → used as Bearer token (machine is PRIVATE
|
|
13
|
+
* with ownerId derived from the key by the backend)
|
|
12
14
|
* - Else → `apiKey` (team API key, machine gets no owner)
|
|
13
15
|
*
|
|
14
16
|
* Why this lives in its own module:
|
|
@@ -30,6 +32,12 @@ export interface RegistrationDeps {
|
|
|
30
32
|
backendUrl: string;
|
|
31
33
|
/** Team API key — used when `userJwt` is not set. */
|
|
32
34
|
apiKey: string;
|
|
35
|
+
/**
|
|
36
|
+
* Optional user API key ("sk-aexol-user-"), pinned to a team but owned by a
|
|
37
|
+
* single user. Used when `userJwt` is not set; takes priority over `apiKey`.
|
|
38
|
+
* The backend registers the machine as PRIVATE with ownerId from the key.
|
|
39
|
+
*/
|
|
40
|
+
userApiKey?: string;
|
|
33
41
|
/** Optional user JWT from OAuth login. When set, used instead of `apiKey` for registration. */
|
|
34
42
|
userJwt?: string;
|
|
35
43
|
/** Display name override (from `--machine-name`). Falls back to hostname. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registration.d.ts","sourceRoot":"","sources":["../../src/relay/registration.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"registration.d.ts","sourceRoot":"","sources":["../../src/relay/registration.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,OAAO,EAA4B,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAElF,MAAM,WAAW,gBAAgB;IAC/B,wEAAwE;IACxE,UAAU,EAAE,MAAM,CAAC;IACnB,qDAAqD;IACrD,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8BAA8B;IAC9B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,0EAA0E;IAC1E,MAAM,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAWvD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUvD;AAED,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,SAAK,GAAG,OAAO,CAI/D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,gBAAgB,CAAC,CA2I3B"}
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Contract (Batch 1 backend):
|
|
5
5
|
* POST <backend>/api/machines/register
|
|
6
|
-
* Headers: Authorization: Bearer <teamApiKey
|
|
6
|
+
* Headers: Authorization: Bearer <userJwt|userApiKey|teamApiKey>
|
|
7
7
|
* Body: { name?: string, hostname: string, version: string, pid: number }
|
|
8
|
-
* 200: { machineId: string, jwt: string, name: string, ownerId
|
|
8
|
+
* 200: { machineId: string, jwt: string, name: string, teamId?, ownerId?, visibility? }
|
|
9
9
|
*
|
|
10
10
|
* Auth priority:
|
|
11
11
|
* - If `userJwt` is set → used as Bearer token (machine gets ownerId)
|
|
12
|
+
* - Else if `userApiKey` is set → used as Bearer token (machine is PRIVATE
|
|
13
|
+
* with ownerId derived from the key by the backend)
|
|
12
14
|
* - Else → `apiKey` (team API key, machine gets no owner)
|
|
13
15
|
*
|
|
14
16
|
* Why this lives in its own module:
|
|
@@ -92,6 +94,7 @@ export async function ensureMachineRegistered(deps) {
|
|
|
92
94
|
const skew = deps.expirySkewSec ?? 60;
|
|
93
95
|
const existing = await loadMachine();
|
|
94
96
|
const forceRefresh = deps.forceRefresh === true;
|
|
97
|
+
const usingUserApiKey = !deps.userJwt && !!deps.userApiKey;
|
|
95
98
|
if (!forceRefresh && existing && !isJwtExpired(existing.machineJwt, skew)) {
|
|
96
99
|
// The cached machine JWT is still valid. If the caller authenticated with a
|
|
97
100
|
// user JWT (OAuth login) and the cached machine was previously owned by a
|
|
@@ -106,6 +109,21 @@ export async function ensureMachineRegistered(deps) {
|
|
|
106
109
|
shouldReuse = false;
|
|
107
110
|
}
|
|
108
111
|
}
|
|
112
|
+
else if (usingUserApiKey) {
|
|
113
|
+
// A user API key carries no `sub` claim, so identity is tracked by the
|
|
114
|
+
// key itself: `ensureMachineRegistered` records the key used for the
|
|
115
|
+
// last registration in `machine.json` extra. A different key means a
|
|
116
|
+
// different user (the backend derives `ownerId` from the key), so the
|
|
117
|
+
// machine must be re-registered to transfer ownership and flip
|
|
118
|
+
// visibility to PRIVATE. A missing marker means the cached machine was
|
|
119
|
+
// registered with a team key — re-register so the backend can make it
|
|
120
|
+
// PRIVATE for this user.
|
|
121
|
+
const previousKey = existing.extra?.userApiKey;
|
|
122
|
+
if (previousKey !== deps.userApiKey) {
|
|
123
|
+
console.log("Machine registered under a different user API key, re-registering to transfer ownership...");
|
|
124
|
+
shouldReuse = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
109
127
|
if (shouldReuse) {
|
|
110
128
|
return { reused: true, record: existing };
|
|
111
129
|
}
|
|
@@ -114,6 +132,8 @@ export async function ensureMachineRegistered(deps) {
|
|
|
114
132
|
console.warn("Re-registering machine: cached JWT was rejected by the backend (forceRefresh).");
|
|
115
133
|
}
|
|
116
134
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
135
|
+
// Token priority: userJwt (OAuth) > userApiKey (user-pinned key) > team key.
|
|
136
|
+
const authToken = deps.userJwt ?? deps.userApiKey ?? deps.apiKey;
|
|
117
137
|
const url = `${deps.backendUrl.replace(/\/$/, "")}/api/machines/register`;
|
|
118
138
|
const body = {
|
|
119
139
|
// Reuse the cached display name on JWT-expiry refresh so the backend's
|
|
@@ -129,7 +149,7 @@ export async function ensureMachineRegistered(deps) {
|
|
|
129
149
|
res = await fetchImpl(url, {
|
|
130
150
|
method: "POST",
|
|
131
151
|
headers: {
|
|
132
|
-
Authorization: `Bearer ${
|
|
152
|
+
Authorization: `Bearer ${authToken}`,
|
|
133
153
|
"Content-Type": "application/json",
|
|
134
154
|
},
|
|
135
155
|
body: JSON.stringify(body),
|
|
@@ -163,6 +183,14 @@ export async function ensureMachineRegistered(deps) {
|
|
|
163
183
|
typeof obj.name !== "string") {
|
|
164
184
|
throw new Error(`Machine registration: backend response missing required fields (machineId, jwt, name)`);
|
|
165
185
|
}
|
|
186
|
+
const extra = { ...(existing?.extra ?? {}) };
|
|
187
|
+
delete extra.userApiKey;
|
|
188
|
+
if (usingUserApiKey) {
|
|
189
|
+
// Record which key was used so a later run with a different user key
|
|
190
|
+
// (a different account) forces re-registration. machine.json is 0600,
|
|
191
|
+
// same as config.json where the key already lives.
|
|
192
|
+
extra.userApiKey = deps.userApiKey;
|
|
193
|
+
}
|
|
166
194
|
const record = {
|
|
167
195
|
machineId: obj.machineId,
|
|
168
196
|
machineName: obj.name,
|
|
@@ -173,6 +201,7 @@ export async function ensureMachineRegistered(deps) {
|
|
|
173
201
|
registeredAt: Date.now(),
|
|
174
202
|
hostname: hostname(),
|
|
175
203
|
version: deps.version,
|
|
204
|
+
extra: Object.keys(extra).length > 0 ? extra : undefined,
|
|
176
205
|
};
|
|
177
206
|
await saveMachine(record);
|
|
178
207
|
return { reused: false, forced: forceRefresh, record };
|
|
@@ -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.");
|