aegis-desktop 0.8.20 → 0.8.21
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/lib/local/engine.js +119 -24
- package/lib/local/local.js +293 -27
- package/lib/local/prompt.js +51 -2
- package/lib/local/tools.js +134 -23
- package/package.json +1 -1
- package/vendor/aegis.js +317 -33
- package/vendor/agent-gate.mjs +37 -5
- package/vendor/env-file.js +11 -4
- package/vendor/platform.js +259 -0
package/vendor/aegis.js
CHANGED
|
@@ -66,46 +66,292 @@ function envIdleMs(name) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* DeepSeek
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
69
|
+
* DeepSeek emits its tool calls as DSML — an XML-ish dialect of its chat
|
|
70
|
+
* template that the OpenAI-compatible wire format does NOT structure for us:
|
|
71
|
+
*
|
|
72
|
+
* <|DSML|tool_calls>
|
|
73
|
+
* <|DSML|invoke name="write">
|
|
74
|
+
* <|DSML|parameter name="path" string="true">src/app.js</|DSML|parameter>
|
|
75
|
+
* <|DSML|parameter name="content" string="true">…the file body…</|DSML|parameter>
|
|
76
|
+
* </|DSML|invoke>
|
|
77
|
+
* </|DSML|tool_calls>
|
|
78
|
+
*
|
|
79
|
+
* It arrives as plain `delta.content` text, and that produces two failures:
|
|
80
|
+
* the markup rendering verbatim (reported as the pasted "||DSML||" envelope),
|
|
81
|
+
* and the call itself never running because text is not a tool call. Ported
|
|
82
|
+
* from aegiscodex-dev's src/backend.js, which carries the full history of this
|
|
83
|
+
* grammar's quirks (provider/template/model revision disagree about the exact
|
|
84
|
+
* identifier, angle brackets sometimes absent, tag names drift under
|
|
85
|
+
* sampling) — every part of the pattern below is deliberately optional and
|
|
86
|
+
* spacing-insensitive for that reason. `string="false"` marks a JSON
|
|
87
|
+
* parameter; `string="true"` (or an absent attribute) is the literal value,
|
|
88
|
+
* kept verbatim so a multi-line `content` is never mangled by a JSON
|
|
89
|
+
* round-trip.
|
|
90
|
+
*/
|
|
91
|
+
// `\b` after each tag-name alternative matters: without it, "call" matches as
|
|
92
|
+
// a bare PREFIX of a drifted wrapper tag like "calls" (observed verbatim —
|
|
93
|
+
// `<|| calls>`, no "tool_"/"function_" prefix at all), swallowing everything
|
|
94
|
+
// up to the next real `</invoke>` as that spurious match's unnamed body and
|
|
95
|
+
// silently discarding a whole tool call with it. "call" and "calls" are both
|
|
96
|
+
// word characters, so plain alternation order cannot tell them apart.
|
|
97
|
+
const DSML_RE_INVOKE = /<\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*([^>]*?)\s*>([\s\S]*?)<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*>/g;
|
|
98
|
+
const DSML_RE_PARAM = /<\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*([^>]*?)\s*>([\s\S]*?)<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*>/g;
|
|
99
|
+
const DSML_RE_LEADER = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?:(?:tool|function)[_▁ ]*calls?|reasoning)[_▁ ]*(?:begin|end)?\s*>/gi;
|
|
100
|
+
const DSML_RE_ANY_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*[A-Za-z_][A-Za-z0-9_]*[^>]{0,120}>/g;
|
|
101
|
+
const DSML_RE_SKELETON = /<\s*\/?\s*(?:tool_calls|function_calls|invoke|parameter|param|reasoning)\b[^>]{0,120}>/gi;
|
|
102
|
+
const DSML_RE_JSON_CALLS = /|[ \t]*(?:tool|function)[_▁ ]*calls?[_▁ ]*begin[ \t]*|([\s\S]*?)|[ \t]*(?:tool|function)[_▁ ]*calls?[_▁ ]*end[ \t]*|/gi;
|
|
103
|
+
const DSML_RE_ENVELOPE_TOKEN = /|[ \t]?[A-Za-z0-9_▁]*[ \t]?|/g;
|
|
104
|
+
const DSML_RE_DRIFT_INVOKE = /<\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*([^>]*?)\s*>/gi;
|
|
105
|
+
const DSML_RE_DRIFT_PARAM = /<\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*([^>]*?)\s*>/gi;
|
|
106
|
+
const DSML_RE_PARAM_CLOSE = /<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*>/gi;
|
|
107
|
+
const DSML_RE_ATTR = /([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
108
|
+
const DSML_RE_STRUCT_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call|parameter|param|arg|reasoning|(?:tool|function)[_▁ ]*calls?)\b/gi;
|
|
109
|
+
const DSML_RE_STRUCT_CLOSER = /<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call|parameter|param|arg|reasoning|(?:tool|function)[_▁ ]*calls?)\b/gi;
|
|
110
|
+
/**
|
|
111
|
+
* A COMPLETE (its own '>' has arrived) wrapper/leader tag that carries no
|
|
112
|
+
* call of its own — observed verbatim: a bare `<||calls>`, with neither a
|
|
113
|
+
* "tool_"/"function_" prefix (so DSML_RE_LEADER above misses it) nor a JSON
|
|
114
|
+
* envelope pair. Safe to drop the moment it closes, UNLIKE invoke/parameter —
|
|
115
|
+
* excluded here via negative lookahead so a *partial* invoke/parameter head is
|
|
116
|
+
* never erased before its own close has a chance to pair it up with
|
|
117
|
+
* DSML_RE_INVOKE/DSML_RE_PARAM on a later call once more of it has arrived.
|
|
118
|
+
*/
|
|
119
|
+
const DSML_RE_WRAPPER_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?!(?:invoke|call|tool_call|parameter|param|arg)\b)[A-Za-z_][A-Za-z0-9_]*[^>]{0,120}>/g;
|
|
120
|
+
/** A '<' (or '</') at the tail: the head of a tag whose pipe is still in flight. */
|
|
121
|
+
const DSML_RE_TRAILING_LT = /<\s*\/?\s*$/;
|
|
122
|
+
/** An in-flight identifier token: a pipe whose partner has not arrived yet. */
|
|
123
|
+
const DSML_RE_OPEN_TOKEN = /^|[ \t]?[A-Za-z0-9_▁]*[ \t]?$/;
|
|
124
|
+
/** Past this much buffered markup, a held tail is released as-is. */
|
|
125
|
+
const DSML_HOLD_MAX = 4 * 1024 * 1024;
|
|
126
|
+
|
|
127
|
+
/** The attributes of one tag head, lower-cased: name="write" → { name: 'write' }. */
|
|
128
|
+
function dsmlAttrs(text) {
|
|
129
|
+
const out = {};
|
|
130
|
+
if (!text) return out;
|
|
131
|
+
DSML_RE_ATTR.lastIndex = 0;
|
|
132
|
+
let m;
|
|
133
|
+
while ((m = DSML_RE_ATTR.exec(text))) out[m[1].toLowerCase()] = m[2] != null ? m[2] : m[3];
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A JSON parameter — or, when it is not JSON after all, the text it came as. */
|
|
138
|
+
function dsmlJson(raw) {
|
|
139
|
+
const s = String(raw == null ? '' : raw).trim();
|
|
140
|
+
try {
|
|
141
|
+
const v = JSON.parse(s);
|
|
142
|
+
return v === undefined ? s : v;
|
|
143
|
+
} catch {
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The `parameter` tags of one `invoke` body, as a tool call's args object. */
|
|
149
|
+
function dsmlArgs(body) {
|
|
150
|
+
const args = {};
|
|
151
|
+
DSML_RE_PARAM.lastIndex = 0;
|
|
152
|
+
let p;
|
|
153
|
+
while ((p = DSML_RE_PARAM.exec(body))) {
|
|
154
|
+
const a = dsmlAttrs(p[1]);
|
|
155
|
+
const key = a.name || a.key || `arg${Object.keys(args).length}`;
|
|
156
|
+
const isJson = String(a.string || '').toLowerCase() === 'false' || String(a.json || '').toLowerCase() === 'true';
|
|
157
|
+
args[key] = isJson ? dsmlJson(p[2]) : p[2];
|
|
158
|
+
}
|
|
159
|
+
return args;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decode every DSML tool call in `text` and return the two things a caller
|
|
164
|
+
* needs: what may be shown, and what must be executed — `{ text, calls }`.
|
|
165
|
+
* The generic `|…|` tokens are deliberately left in place here: an `invoke`
|
|
166
|
+
* block still arriving is identified by its delimiters on the next pass, and
|
|
167
|
+
* eating them early would lose the call's name and arguments. They are
|
|
168
|
+
* removed from the *released* text instead (see dsmlResidue).
|
|
169
|
+
*/
|
|
170
|
+
function decodeDeepSeekDSML(input) {
|
|
171
|
+
if (typeof input !== 'string' || !input || input.indexOf('|') === -1) {
|
|
172
|
+
return { text: input || '', calls: [] };
|
|
173
|
+
}
|
|
174
|
+
const calls = [];
|
|
175
|
+
let text = input;
|
|
176
|
+
|
|
177
|
+
// 1. One `invoke` block per call, the well-formed spelling.
|
|
178
|
+
DSML_RE_INVOKE.lastIndex = 0;
|
|
179
|
+
const spans = [];
|
|
180
|
+
let m;
|
|
181
|
+
while ((m = DSML_RE_INVOKE.exec(text))) {
|
|
182
|
+
const a = dsmlAttrs(m[1]);
|
|
183
|
+
const name = a.name || a.function || a.tool || '';
|
|
184
|
+
if (name) calls.push({ name, args: dsmlArgs(m[2]) });
|
|
185
|
+
spans.push([m.index, m.index + m[0].length]);
|
|
186
|
+
}
|
|
187
|
+
for (let i = spans.length - 1; i >= 0; i--) {
|
|
188
|
+
text = text.slice(0, spans[i][0]) + text.slice(spans[i][1]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 2. The older spelling: a JSON array wrapped in envelope tokens.
|
|
192
|
+
text = text.replace(DSML_RE_JSON_CALLS, (full, body) => {
|
|
193
|
+
const parsed = dsmlJson(body);
|
|
194
|
+
const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.tool_calls) ? parsed.tool_calls : []);
|
|
195
|
+
for (const c of list) {
|
|
196
|
+
const fn = (c && c.function) || c || {};
|
|
197
|
+
const name = fn.name || (c && c.name) || '';
|
|
198
|
+
if (!name) continue;
|
|
199
|
+
let args = fn.arguments != null ? fn.arguments : (c && c.arguments);
|
|
200
|
+
if (typeof args === 'string') args = dsmlJson(args);
|
|
201
|
+
calls.push({ name, args: args && typeof args === 'object' ? args : {} });
|
|
202
|
+
}
|
|
203
|
+
return '';
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// 3. The wrapper tags carry no call of their own.
|
|
207
|
+
text = text.replace(DSML_RE_LEADER, '');
|
|
208
|
+
return { text, calls };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Last-resort decode for markup that drifted out of well-formedness: a missing
|
|
213
|
+
* closing tag, or a stream cut mid-call. The call still names a tool and its
|
|
214
|
+
* parameters are still readable, so it is decoded rather than deleted — a call
|
|
215
|
+
* that reached the wire must not vanish because its closing tag did.
|
|
216
|
+
*/
|
|
217
|
+
function decodeDeepSeekDSMLDrift(input) {
|
|
218
|
+
if (typeof input !== 'string' || !input || input.indexOf('|') === -1) return { text: input || '', calls: [] };
|
|
219
|
+
const opens = [];
|
|
220
|
+
DSML_RE_DRIFT_INVOKE.lastIndex = 0;
|
|
221
|
+
let m;
|
|
222
|
+
while ((m = DSML_RE_DRIFT_INVOKE.exec(input))) {
|
|
223
|
+
opens.push({ at: m.index, attrs: m[1], body: DSML_RE_DRIFT_INVOKE.lastIndex });
|
|
224
|
+
}
|
|
225
|
+
if (!opens.length) return { text: input, calls: [] };
|
|
226
|
+
const calls = [];
|
|
227
|
+
let text = '';
|
|
228
|
+
let cursor = 0;
|
|
229
|
+
for (let i = 0; i < opens.length; i++) {
|
|
230
|
+
// One call runs to the next `invoke` head, or to the end of the text.
|
|
231
|
+
const end = i + 1 < opens.length ? opens[i + 1].at : input.length;
|
|
232
|
+
const body = input.slice(opens[i].body, end);
|
|
233
|
+
const a = dsmlAttrs(opens[i].attrs);
|
|
234
|
+
const name = a.name || a.function || a.tool || '';
|
|
235
|
+
const args = {};
|
|
236
|
+
const params = [];
|
|
237
|
+
DSML_RE_DRIFT_PARAM.lastIndex = 0;
|
|
238
|
+
let pm;
|
|
239
|
+
while ((pm = DSML_RE_DRIFT_PARAM.exec(body))) {
|
|
240
|
+
params.push({ at: pm.index, attrs: pm[1], from: DSML_RE_DRIFT_PARAM.lastIndex });
|
|
241
|
+
}
|
|
242
|
+
for (let k = 0; k < params.length; k++) {
|
|
243
|
+
// A value runs to the next parameter tag — its closing tag may be the
|
|
244
|
+
// thing that never arrived.
|
|
245
|
+
const stop = k + 1 < params.length ? params[k + 1].at : body.length;
|
|
246
|
+
const raw = body.slice(params[k].from, stop).replace(DSML_RE_PARAM_CLOSE, '');
|
|
247
|
+
const pa = dsmlAttrs(params[k].attrs);
|
|
248
|
+
const key = pa.name || pa.key || `arg${Object.keys(args).length}`;
|
|
249
|
+
const isJson = String(pa.string || '').toLowerCase() === 'false';
|
|
250
|
+
args[key] = isJson ? dsmlJson(raw) : raw;
|
|
251
|
+
}
|
|
252
|
+
text += input.slice(cursor, opens[i].at);
|
|
253
|
+
cursor = end;
|
|
254
|
+
if (name) calls.push({ name, args });
|
|
255
|
+
}
|
|
256
|
+
text += input.slice(cursor);
|
|
257
|
+
return { text, calls };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Residue removal for text that is about to be shown. Two spellings have to go:
|
|
262
|
+
* a leftover envelope token (`|DSML|`, `|tool▁calls▁begin|`, `|Assistant|`),
|
|
263
|
+
* and the skeleton an earlier release left behind by stripping the delimiters
|
|
264
|
+
* out of a DSML block — `<tool_calls> <invoke name="write"> <parameter …>`,
|
|
265
|
+
* which is exactly what a user pasted back at us.
|
|
266
|
+
*/
|
|
267
|
+
function dsmlResidue(text) {
|
|
268
|
+
if (!text) return text;
|
|
269
|
+
if (text.indexOf('|') === -1) return text.replace(DSML_RE_SKELETON, '');
|
|
270
|
+
return text.replace(DSML_RE_ANY_TAG, '').replace(DSML_RE_SKELETON, '').replace(DSML_RE_ENVELOPE_TOKEN, '');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Is the buffer, read from the first `|` in it, a construct that is still
|
|
275
|
+
* arriving? Only two shapes may be held back: a pipe whose partner has not
|
|
276
|
+
* arrived, and a DSML opener whose matching close has not landed. Everything
|
|
277
|
+
* else is prose — a `|` in an answer is a character, not a promise of markup,
|
|
278
|
+
* and holding the rest of the answer behind it would swallow the reply.
|
|
279
|
+
*/
|
|
280
|
+
function dsmlUnterminated(s) {
|
|
281
|
+
if (DSML_RE_OPEN_TOKEN.test(s)) return true;
|
|
282
|
+
const openers = (s.match(DSML_RE_STRUCT_TAG) || []).length;
|
|
283
|
+
const closers = (s.match(DSML_RE_STRUCT_CLOSER) || []).length;
|
|
284
|
+
return openers > closers;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Where, if anywhere, an in-flight DSML construct still needs the rest of the
|
|
289
|
+
* buffer held back from view. By the time this runs, decodeDeepSeekDSML() has
|
|
290
|
+
* already removed every COMPLETE invoke/parameter span and the caller has
|
|
291
|
+
* already removed every complete non-invoke wrapper tag (DSML_RE_WRAPPER_TAG),
|
|
292
|
+
* so whatever is left can only be: clean prose, or the start of exactly ONE
|
|
293
|
+
* incomplete construct running to the end of the buffer — an opener whose own
|
|
294
|
+
* '|' has arrived but not its '>' (`<\s*\/?\s*|`, found wherever it starts,
|
|
295
|
+
* not just at the tail: a Write's `content` can be arbitrarily long, so the
|
|
296
|
+
* unresolved opener is usually far behind the cursor, not at it), a '<' that
|
|
297
|
+
* arrived with no '|' yet (the chunk boundary landed first), or a bare pipe
|
|
298
|
+
* token still accumulating its identifier (the plain envelope-leak case, e.g.
|
|
299
|
+
* '|tool▁cal'). Returns -1 when none apply — safe to release in full.
|
|
300
|
+
*/
|
|
301
|
+
function dsmlHoldFrom(buf) {
|
|
302
|
+
const tagStart = buf.search(/<\s*\/?\s*|/);
|
|
303
|
+
if (tagStart !== -1) return tagStart;
|
|
304
|
+
const lt = DSML_RE_TRAILING_LT.exec(buf);
|
|
305
|
+
if (lt) return lt.index;
|
|
306
|
+
const openIdx = buf.indexOf('|');
|
|
307
|
+
if (openIdx !== -1 && dsmlUnterminated(buf.slice(openIdx))) return openIdx;
|
|
308
|
+
return -1;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The per-stream stripper the transports share. Feed it each `delta.content`
|
|
313
|
+
* chunk: it returns the text that is safe to show, holds back whatever is
|
|
314
|
+
* still arriving (a Write's `content` parameter is a whole file — it belongs
|
|
315
|
+
* to the call, not to the screen), and collects the tool calls the model made
|
|
316
|
+
* in text form. `flush()` releases the tail once the stream is drained, and
|
|
317
|
+
* `calls()` returns everything decoded so far, which the caller merges into
|
|
318
|
+
* the turn's tool calls so a Write that arrived as DSML is executed like any
|
|
319
|
+
* other.
|
|
78
320
|
*/
|
|
79
|
-
const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
|
|
80
321
|
function makeDeepSeekEnvelopeStripper() {
|
|
81
322
|
let buf = '';
|
|
323
|
+
const calls = [];
|
|
82
324
|
const strip = (chunk) => {
|
|
83
|
-
buf += chunk;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
325
|
+
buf += typeof chunk === 'string' ? chunk : '';
|
|
326
|
+
const dec = decodeDeepSeekDSML(buf);
|
|
327
|
+
buf = dec.text;
|
|
328
|
+
for (const c of dec.calls) calls.push(c);
|
|
329
|
+
buf = buf.replace(DSML_RE_WRAPPER_TAG, '');
|
|
330
|
+
const hold = dsmlHoldFrom(buf);
|
|
331
|
+
let out;
|
|
332
|
+
if (hold !== -1 && buf.length - hold <= DSML_HOLD_MAX) {
|
|
333
|
+
out = buf.slice(0, hold);
|
|
334
|
+
buf = buf.slice(hold);
|
|
335
|
+
} else {
|
|
336
|
+
out = buf;
|
|
337
|
+
buf = '';
|
|
95
338
|
}
|
|
96
|
-
|
|
97
|
-
buf = '';
|
|
98
|
-
return out;
|
|
339
|
+
return dsmlResidue(out);
|
|
99
340
|
};
|
|
100
341
|
// Unconditional release of whatever is left buffered — called once the
|
|
101
|
-
// stream is fully drained, when a held-back
|
|
102
|
-
// (dropped connection, or it was real prose all along) rather than an
|
|
342
|
+
// stream is fully drained, when a held-back construct is known to never
|
|
343
|
+
// close (dropped connection, or it was real prose all along) rather than an
|
|
103
344
|
// in-progress token.
|
|
104
345
|
strip.flush = () => {
|
|
105
|
-
const
|
|
346
|
+
const dec = decodeDeepSeekDSML(buf);
|
|
106
347
|
buf = '';
|
|
107
|
-
|
|
348
|
+
for (const c of dec.calls) calls.push(c);
|
|
349
|
+
const drift = decodeDeepSeekDSMLDrift(dec.text);
|
|
350
|
+
for (const c of drift.calls) calls.push(c);
|
|
351
|
+
return dsmlResidue(drift.text);
|
|
108
352
|
};
|
|
353
|
+
/** Every tool call decoded from text so far, as `{ name, args }`. */
|
|
354
|
+
strip.calls = () => calls.slice();
|
|
109
355
|
return strip;
|
|
110
356
|
}
|
|
111
357
|
|
|
@@ -678,7 +924,7 @@ function createClient(opts = {}) {
|
|
|
678
924
|
else if (mode) body.mode = mode;
|
|
679
925
|
const headers = authHeaders(agentKey ? { 'X-AEGIS-Agent-Key': agentKey } : undefined);
|
|
680
926
|
if (!stream || typeof onStream !== 'function') {
|
|
681
|
-
return apiPost('/api/v1/chat/completions', { ...body, stream: false }, headers);
|
|
927
|
+
return decodeDsmlInResult(await apiPost('/api/v1/chat/completions', { ...body, stream: false }, headers));
|
|
682
928
|
}
|
|
683
929
|
// An OpenAI-compatible SSE response carries no `usage` unless the caller
|
|
684
930
|
// asks for it (`stream_options.include_usage`). Without this the streamed
|
|
@@ -784,7 +1030,7 @@ function createClient(opts = {}) {
|
|
|
784
1030
|
// billable traffic instead of a free ride.
|
|
785
1031
|
if (apiKey) headers['X-AEGIS-Key'] = apiKey;
|
|
786
1032
|
if (!stream || typeof onStream !== 'function') {
|
|
787
|
-
return apiPost('/api/v1/byok/chat/completions', { ...body, stream: false }, headers);
|
|
1033
|
+
return decodeDsmlInResult(await apiPost('/api/v1/byok/chat/completions', { ...body, stream: false }, headers));
|
|
788
1034
|
}
|
|
789
1035
|
return postStream('/api/v1/byok/chat/completions', body, headers, onStream, signal);
|
|
790
1036
|
}
|
|
@@ -796,6 +1042,31 @@ function createClient(opts = {}) {
|
|
|
796
1042
|
return typeof content === 'string' ? content : '';
|
|
797
1043
|
}
|
|
798
1044
|
|
|
1045
|
+
/**
|
|
1046
|
+
* One-shot DSML decode for a non-streamed completion: the whole answer
|
|
1047
|
+
* already arrived in one JSON payload, so there is no partial-token hold-
|
|
1048
|
+
* back to do — just decode, drift-decode what the well-formed pass missed,
|
|
1049
|
+
* and strip whatever residue would otherwise render as garble. Mutates and
|
|
1050
|
+
* returns `data` so every non-stream return site in this module can wrap
|
|
1051
|
+
* its result the same way the streaming path's `message.content` is clean.
|
|
1052
|
+
*/
|
|
1053
|
+
function decodeDsmlInResult(data) {
|
|
1054
|
+
const choice = data && data.choices && data.choices[0];
|
|
1055
|
+
const content = choice && choice.message && choice.message.content;
|
|
1056
|
+
if (typeof content !== 'string' || content.indexOf('|') === -1) return data;
|
|
1057
|
+
const once = decodeDeepSeekDSML(content);
|
|
1058
|
+
const drift = decodeDeepSeekDSMLDrift(once.text);
|
|
1059
|
+
choice.message.content = dsmlResidue(drift.text);
|
|
1060
|
+
const calls = [...once.calls, ...drift.calls];
|
|
1061
|
+
if (calls.length) {
|
|
1062
|
+
data.toolCalls = [
|
|
1063
|
+
...(Array.isArray(data.toolCalls) ? data.toolCalls : []),
|
|
1064
|
+
...calls.map((c) => ({ id: '', name: c.name, args: c.args || {} })),
|
|
1065
|
+
];
|
|
1066
|
+
}
|
|
1067
|
+
return data;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
799
1070
|
/**
|
|
800
1071
|
* POST body with `stream: true` and forward SSE deltas to onStream.
|
|
801
1072
|
* Shared by the AEGIS pool and the BYOK relay so every host parses exactly
|
|
@@ -881,7 +1152,7 @@ function createClient(opts = {}) {
|
|
|
881
1152
|
// request with plain JSON. Dropping the signal here meant an aborted turn
|
|
882
1153
|
// kept waiting on a request nobody was listening for any more — the
|
|
883
1154
|
// caller had already moved on, and the host held the socket.
|
|
884
|
-
const data = await apiPost(path, { ...cleanBody, stream: false }, headers, { signal });
|
|
1155
|
+
const data = decodeDsmlInResult(await apiPost(path, { ...cleanBody, stream: false }, headers, { signal }));
|
|
885
1156
|
const fullText = textOf(data);
|
|
886
1157
|
if (fullText) onStream({ delta: fullText });
|
|
887
1158
|
return data;
|
|
@@ -890,7 +1161,7 @@ function createClient(opts = {}) {
|
|
|
890
1161
|
const contentType = res.headers.get('content-type') || '';
|
|
891
1162
|
if (!contentType.includes('text/event-stream')) {
|
|
892
1163
|
// Server ignored the stream flag and answered with plain JSON.
|
|
893
|
-
const data = await parseResponse(res);
|
|
1164
|
+
const data = decodeDsmlInResult(await parseResponse(res));
|
|
894
1165
|
const fullText = textOf(data);
|
|
895
1166
|
if (fullText) {
|
|
896
1167
|
onStream({ delta: fullText });
|
|
@@ -1146,6 +1417,19 @@ function createClient(opts = {}) {
|
|
|
1146
1417
|
],
|
|
1147
1418
|
};
|
|
1148
1419
|
if (usage) result.usage = usage;
|
|
1420
|
+
// DSML calls arrive as plain text, decoded by stripEnvelope rather than
|
|
1421
|
+
// accumulated by index like structured delta.tool_calls — merged into one
|
|
1422
|
+
// normalised `{ id, name, args }` list so a tool call that arrived as DSML
|
|
1423
|
+
// (no provider-native delta.tool_calls at all) is executed like any other.
|
|
1424
|
+
// engine.js's extractToolCalls() reads this field first, ahead of
|
|
1425
|
+
// message.tool_calls.
|
|
1426
|
+
const dsmlCalls = stripEnvelope.calls();
|
|
1427
|
+
if (toolCalls.length || dsmlCalls.length) {
|
|
1428
|
+
result.toolCalls = [
|
|
1429
|
+
...toolCalls.map((c) => ({ id: c.id, name: c.function.name, args: c.function.arguments })),
|
|
1430
|
+
...dsmlCalls.map((c) => ({ id: '', name: c.name, args: c.args || {} })),
|
|
1431
|
+
];
|
|
1432
|
+
}
|
|
1149
1433
|
return result;
|
|
1150
1434
|
}
|
|
1151
1435
|
|
package/vendor/agent-gate.mjs
CHANGED
|
@@ -121,6 +121,32 @@ const KEY_FILE_MODE = 0o600;
|
|
|
121
121
|
const DIR_MODE = 0o700;
|
|
122
122
|
const GROUP_OTHER_BITS = 0o077;
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Do `fs.stat().mode` permission bits mean anything on this host?
|
|
126
|
+
*
|
|
127
|
+
* Windows: no. libuv has no NTFS ACL to report, so it synthesises the mode from
|
|
128
|
+
* the single read-only attribute and returns `0o666` for every writable file —
|
|
129
|
+
* the agent key included. `(mode & GROUP_OTHER_BITS) !== 0` is therefore ALWAYS
|
|
130
|
+
* true there, so the check below refused the agent key unconditionally: on
|
|
131
|
+
* Windows a correctly locked-down key at 0600 was reported as "readable by other
|
|
132
|
+
* users, refusing to treat it as a secret", and no chmod could ever clear it.
|
|
133
|
+
* `writeAgentKey` then threw on its own post-write assert for the same reason,
|
|
134
|
+
* so the gate could not even be initialised there.
|
|
135
|
+
*
|
|
136
|
+
* Where this returns false the boundary is the DIRECTORY's ACL — a per-user
|
|
137
|
+
* profile directory is already inaccessible to other non-admin accounts — which
|
|
138
|
+
* is what client/authorship-key.js has always done for the key file. The right
|
|
139
|
+
* behaviour is to skip the judgement, never to assume the worst.
|
|
140
|
+
*
|
|
141
|
+
* Duplicated from client/platform.js `hasPosixModes()` rather than imported: this
|
|
142
|
+
* file is deliberately standalone so it still runs from a copied hook, a staged
|
|
143
|
+
* tarball, or a vendored tools/ directory that has no sibling `client/`. Same
|
|
144
|
+
* reason cli/src/securefs.js carries its own copy.
|
|
145
|
+
*/
|
|
146
|
+
function hasPosixModes(platform) {
|
|
147
|
+
return (platform || process.platform) !== 'win32';
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
// ── derivations ─────────────────────────────────────────────────────────────
|
|
125
151
|
|
|
126
152
|
/** @param {string} key @returns {string} hex sha256 under the gate domain */
|
|
@@ -350,7 +376,7 @@ function protectedUnder(manifest, absPaths) {
|
|
|
350
376
|
*
|
|
351
377
|
* @returns {{ key: string, source: string, error: string|null }}
|
|
352
378
|
*/
|
|
353
|
-
export function resolveAgentKey({ env = process.env, manifestRoot = null, key = null } = {}) {
|
|
379
|
+
export function resolveAgentKey({ env = process.env, manifestRoot = null, key = null, platform = null } = {}) {
|
|
354
380
|
if (key) return { key: String(key).trim(), source: 'argument', error: null };
|
|
355
381
|
|
|
356
382
|
const fromEnv = env.AEGIS_AGENT_KEY;
|
|
@@ -367,7 +393,9 @@ export function resolveAgentKey({ env = process.env, manifestRoot = null, key =
|
|
|
367
393
|
}
|
|
368
394
|
let mode = null;
|
|
369
395
|
try { mode = st.mode & 0o777; } catch { /* non-POSIX */ }
|
|
370
|
-
|
|
396
|
+
// Skipped where the mode is synthesised (see hasPosixModes): the test is true
|
|
397
|
+
// for every file on Windows, so it refused the key on every read there.
|
|
398
|
+
if (hasPosixModes(platform) && mode !== null && (mode & GROUP_OTHER_BITS)) {
|
|
371
399
|
return {
|
|
372
400
|
key: '',
|
|
373
401
|
source: file,
|
|
@@ -385,13 +413,17 @@ export function resolveAgentKey({ env = process.env, manifestRoot = null, key =
|
|
|
385
413
|
return { key: value, source: file, error: null };
|
|
386
414
|
}
|
|
387
415
|
|
|
388
|
-
export function writeAgentKey(key, env = process.env) {
|
|
416
|
+
export function writeAgentKey(key, env = process.env, platform = null) {
|
|
389
417
|
const file = path.join(aegisHome(env), KEY_FILE_REL);
|
|
390
418
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: DIR_MODE });
|
|
391
419
|
fs.writeFileSync(file, `${key}\n`, { mode: KEY_FILE_MODE });
|
|
392
420
|
fs.chmodSync(file, KEY_FILE_MODE); // writeFileSync's mode is masked by umask
|
|
393
|
-
|
|
394
|
-
|
|
421
|
+
// The re-read assert only means something where stat reports what chmod wrote;
|
|
422
|
+
// on Windows it reports 0666 for this 0600 file, so this threw on every init.
|
|
423
|
+
if (hasPosixModes(platform)) {
|
|
424
|
+
const after = fs.statSync(file).mode & 0o777;
|
|
425
|
+
if (after !== KEY_FILE_MODE) throw new Error(`key file landed at mode 0${after.toString(8)}, expected 0600`);
|
|
426
|
+
}
|
|
395
427
|
return file;
|
|
396
428
|
}
|
|
397
429
|
|
package/vendor/env-file.js
CHANGED
|
@@ -87,6 +87,11 @@
|
|
|
87
87
|
const fs = require('node:fs');
|
|
88
88
|
const path = require('node:path');
|
|
89
89
|
const credentials = require('./credentials.js');
|
|
90
|
+
// `hasPosixModes()` — on Windows fs.stat's mode is synthesised from the read-only
|
|
91
|
+
// attribute, so 0o666 comes back for every file and the group/other test below is
|
|
92
|
+
// always true. Reporting a Windows user's .env as world-readable on every single
|
|
93
|
+
// load is a false alarm they cannot act on.
|
|
94
|
+
const platform = require('./platform.js');
|
|
90
95
|
|
|
91
96
|
const ENV_FILE = '.env';
|
|
92
97
|
|
|
@@ -339,8 +344,9 @@ function loadEnvFile(o = {}) {
|
|
|
339
344
|
const st = fs.statSync(file);
|
|
340
345
|
result.mode = st.mode & 0o777;
|
|
341
346
|
// Group- or world-readable: worth one line to the user, never a rewrite of
|
|
342
|
-
// a file we did not create.
|
|
343
|
-
|
|
347
|
+
// a file we did not create. Only asked where the answer can be true or false
|
|
348
|
+
// (see the platform require above).
|
|
349
|
+
result.loose = platform.hasPosixModes() && (st.mode & 0o077) !== 0;
|
|
344
350
|
} catch { /* stat is advisory only */ }
|
|
345
351
|
|
|
346
352
|
const parsed = parseEnvText(text);
|
|
@@ -406,7 +412,7 @@ function loadForeignSecrets(o = {}) {
|
|
|
406
412
|
try {
|
|
407
413
|
const st = fs.statSync(file);
|
|
408
414
|
result.mode = st.mode & 0o777;
|
|
409
|
-
result.loose = (st.mode & 0o077) !== 0;
|
|
415
|
+
result.loose = platform.hasPosixModes() && (st.mode & 0o077) !== 0;
|
|
410
416
|
} catch { /* advisory */ }
|
|
411
417
|
|
|
412
418
|
const parsed = parseEnvText(text);
|
|
@@ -708,7 +714,8 @@ function setEnvValue(name, value, o = {}) {
|
|
|
708
714
|
out.changed = true;
|
|
709
715
|
out.replaced = replaced;
|
|
710
716
|
out.mode = SECRET_FILE_MODE;
|
|
711
|
-
out.tightened =
|
|
717
|
+
out.tightened =
|
|
718
|
+
platform.hasPosixModes() && !out.created && previousMode !== 0 && (previousMode & 0o077) !== 0;
|
|
712
719
|
if (o.apply !== false) {
|
|
713
720
|
env[name] = secret;
|
|
714
721
|
// Just written to the file and applied here: file-sourced (see above).
|