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/lib/local/engine.js
CHANGED
|
@@ -80,6 +80,107 @@ try {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// The ONE platform module (client/platform.js), resolved the same two ways, for
|
|
84
|
+
// `desktopDir()` — the environment preamble names the user's REAL Desktop so a
|
|
85
|
+
// model asked to put a file there stops guessing `~/Desktop`, which is wrong
|
|
86
|
+
// under OneDrive redirection on Windows and on any localised Linux session.
|
|
87
|
+
let platformLib = null;
|
|
88
|
+
try {
|
|
89
|
+
platformLib = require('../../../client/platform.js');
|
|
90
|
+
} catch {
|
|
91
|
+
try {
|
|
92
|
+
platformLib = require('../../vendor/platform.js');
|
|
93
|
+
} catch {
|
|
94
|
+
platformLib = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The environment facts the model needs to stop asking which OS it is on.
|
|
100
|
+
*
|
|
101
|
+
* Module scope, not a closure inside `createLocalEngine`, because there are now
|
|
102
|
+
* TWO hosts that must state these facts the same way: the desktop engine (whose
|
|
103
|
+
* `envFor` below is this function with the host's own `env` bag) and the CLI
|
|
104
|
+
* (`cli/src/app.js`), which had no environment block at all. A second copy over
|
|
105
|
+
* there is precisely how the two hosts would drift — the Windows report that
|
|
106
|
+
* started this ("it still writes to /home/you/... on my PC") was the CLI path,
|
|
107
|
+
* where a Linux-shaped persona was built from `process.platform` and nothing
|
|
108
|
+
* else.
|
|
109
|
+
*
|
|
110
|
+
* Everything is best-effort: a missing field is simply omitted, never guessed.
|
|
111
|
+
*
|
|
112
|
+
* base the host's own env bag ({ platform, homedir, cwd, … } or {})
|
|
113
|
+
* supplied a per-turn override bag (payload.env; wins key by key)
|
|
114
|
+
* payload the turn payload, for `model`
|
|
115
|
+
*/
|
|
116
|
+
/**
|
|
117
|
+
* Resolve a tool call's path arguments, tolerating an injected tools module that
|
|
118
|
+
* does not implement it.
|
|
119
|
+
*
|
|
120
|
+
* `createLocalEngine({ tools })` accepts a PARTIAL tool layer — the engine tests
|
|
121
|
+
* inject `{ MUTATING_TOOLS, executeTool, toolsFor }` and nothing else — so
|
|
122
|
+
* calling `T.normalizeArgs` unconditionally turns every such caller into a
|
|
123
|
+
* TypeError. The injected module's own implementation wins when it has one; the
|
|
124
|
+
* real tools.js is the fallback, because path resolution is a property of the
|
|
125
|
+
* MACHINE (client/platform.js) and not of which executors a caller supplied.
|
|
126
|
+
*/
|
|
127
|
+
function normalizeToolArgs(T, name, args, ctx) {
|
|
128
|
+
const fn =
|
|
129
|
+
(T && typeof T.normalizeArgs === 'function' && T.normalizeArgs) ||
|
|
130
|
+
(typeof toolsModule.normalizeArgs === 'function' && toolsModule.normalizeArgs) ||
|
|
131
|
+
null;
|
|
132
|
+
if (!fn) return { ok: true, args: args && typeof args === 'object' ? args : {}, error: null };
|
|
133
|
+
return fn(name, args, ctx);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function hostEnv(base = {}, supplied = {}, payload = null) {
|
|
137
|
+
const b = base || {};
|
|
138
|
+
const s = supplied || {};
|
|
139
|
+
const pick = (key, value) => (s[key] != null ? s[key] : value);
|
|
140
|
+
let homedir = b.homedir;
|
|
141
|
+
let cwd = b.cwd;
|
|
142
|
+
try {
|
|
143
|
+
if (!homedir) homedir = os.homedir();
|
|
144
|
+
if (!cwd) cwd = process.cwd();
|
|
145
|
+
} catch {
|
|
146
|
+
/* keep whatever we have */
|
|
147
|
+
}
|
|
148
|
+
// The shell the `exec` tool really runs in, from the module that decides it
|
|
149
|
+
// (shell.js resolveSessionShell) rather than a second guess here: on win32
|
|
150
|
+
// that is PowerShell, and a model told nothing reaches for `ls`/`cat`/`grep`
|
|
151
|
+
// — commands that do not exist there. Best-effort; a failed probe simply
|
|
152
|
+
// omits the line.
|
|
153
|
+
let shellName = b.shell;
|
|
154
|
+
let shellKind = b.shellKind;
|
|
155
|
+
try {
|
|
156
|
+
if (!shellName || !shellKind) {
|
|
157
|
+
const spec = require('./shell.js').resolveSessionShell();
|
|
158
|
+
shellName = shellName || spec.cmd;
|
|
159
|
+
shellKind = shellKind || spec.kind;
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
/* no shell line */
|
|
163
|
+
}
|
|
164
|
+
let desktopdir = b.desktopdir;
|
|
165
|
+
try {
|
|
166
|
+
if (!desktopdir && platformLib) desktopdir = platformLib.desktopDir();
|
|
167
|
+
} catch {
|
|
168
|
+
/* no desktop line */
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
platform: pick('platform', b.platform || process.platform),
|
|
172
|
+
arch: pick('arch', b.arch || process.arch),
|
|
173
|
+
homedir: pick('homedir', homedir),
|
|
174
|
+
desktopdir: pick('desktopdir', desktopdir),
|
|
175
|
+
cwd: pick('cwd', cwd),
|
|
176
|
+
shell: pick('shell', shellName),
|
|
177
|
+
shellKind: pick('shellKind', shellKind),
|
|
178
|
+
roots: pick('roots', b.roots),
|
|
179
|
+
appVersion: pick('appVersion', b.appVersion),
|
|
180
|
+
model: pick('model', payload && payload.model),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
83
184
|
/**
|
|
84
185
|
* The shared pooled-brain selection rule (`client/brain-catalog.js`), resolved
|
|
85
186
|
* the same two ways as every other shared module: repo-relative from a source
|
|
@@ -773,6 +874,13 @@ function createLocalEngine({
|
|
|
773
874
|
* channel — the same reason it works identically in the GUI and the CLI.
|
|
774
875
|
*/
|
|
775
876
|
async function guardedExecute(name, args, toolCtx) {
|
|
877
|
+
// Resolve `~/…` and refuse a foreign-OS path BEFORE anything below reads it
|
|
878
|
+
// (tools.js normalizeArgs; idempotent, so executeTool normalising again is a
|
|
879
|
+
// no-op). The covenant assert and recordWrite both inspect `args.file_path`,
|
|
880
|
+
// and a path they rule on must be the path that actually gets written.
|
|
881
|
+
const norm = normalizeToolArgs(T, name, args, toolCtx || {});
|
|
882
|
+
if (!norm.ok) return { ok: false, error: norm.error };
|
|
883
|
+
args = norm.args;
|
|
776
884
|
const guard = toolCtx && toolCtx.guard;
|
|
777
885
|
if (guard && name === 'exec') {
|
|
778
886
|
const verdict = blocksDestructive(guard, args && args.command);
|
|
@@ -816,7 +924,13 @@ function createLocalEngine({
|
|
|
816
924
|
}
|
|
817
925
|
|
|
818
926
|
async function gatedExecuteTool(call, { toolCtx, rootSessionId, rootOnDelta, signal }) {
|
|
819
|
-
const { name
|
|
927
|
+
const { name } = call;
|
|
928
|
+
// Same normalisation as guardedExecute, and for the same reason one layer
|
|
929
|
+
// up: the diff the user approves is rendered from this path, the hash guard
|
|
930
|
+
// re-reads it, and recordWrite claims it. All three must mean one file.
|
|
931
|
+
const normalized = normalizeToolArgs(T, name, call.args, toolCtx || {});
|
|
932
|
+
if (!normalized.ok) return { ok: false, error: normalized.error };
|
|
933
|
+
const args = normalized.args;
|
|
820
934
|
if (!T.MUTATING_TOOLS.has(name)) return guardedExecute(name, args, toolCtx);
|
|
821
935
|
if (!confirmModeEnabled()) return guardedExecute(name, args, toolCtx);
|
|
822
936
|
if (sessionAllows(rootSessionId, name)) return guardedExecute(name, args, toolCtx);
|
|
@@ -830,7 +944,7 @@ function createLocalEngine({
|
|
|
830
944
|
|
|
831
945
|
let preview = null;
|
|
832
946
|
if (name === 'writeFile' || name === 'editFile') {
|
|
833
|
-
preview = T.previewMutation(name, args);
|
|
947
|
+
preview = T.previewMutation(name, args, toolCtx || {});
|
|
834
948
|
if (!preview.ok) return { ok: false, error: preview.error };
|
|
835
949
|
}
|
|
836
950
|
|
|
@@ -853,7 +967,7 @@ function createLocalEngine({
|
|
|
853
967
|
if (decision === 'session') allowForSession(rootSessionId, name);
|
|
854
968
|
|
|
855
969
|
if (preview) {
|
|
856
|
-
const res = T.applyChecked(name, args, preview);
|
|
970
|
+
const res = T.applyChecked(name, args, preview, toolCtx || {});
|
|
857
971
|
// Recorded on the same condition as guardedExecute's path: only a write
|
|
858
972
|
// that landed is this session's. (The previous form returned here
|
|
859
973
|
// unconditionally, making every line below it unreachable — the
|
|
@@ -1039,26 +1153,7 @@ function createLocalEngine({
|
|
|
1039
1153
|
* Everything is best-effort: a missing field is simply omitted.
|
|
1040
1154
|
*/
|
|
1041
1155
|
function envFor(payload) {
|
|
1042
|
-
|
|
1043
|
-
const base = env || {};
|
|
1044
|
-
const pick = (key, value) => (supplied[key] != null ? supplied[key] : value);
|
|
1045
|
-
let homedir = base.homedir;
|
|
1046
|
-
let cwd = base.cwd;
|
|
1047
|
-
try {
|
|
1048
|
-
if (!homedir) homedir = os.homedir();
|
|
1049
|
-
if (!cwd) cwd = process.cwd();
|
|
1050
|
-
} catch {
|
|
1051
|
-
/* keep whatever we have */
|
|
1052
|
-
}
|
|
1053
|
-
return {
|
|
1054
|
-
platform: pick('platform', base.platform || process.platform),
|
|
1055
|
-
arch: pick('arch', base.arch || process.arch),
|
|
1056
|
-
homedir: pick('homedir', homedir),
|
|
1057
|
-
cwd: pick('cwd', cwd),
|
|
1058
|
-
roots: pick('roots', base.roots),
|
|
1059
|
-
appVersion: pick('appVersion', base.appVersion),
|
|
1060
|
-
model: pick('model', payload && payload.model),
|
|
1061
|
-
};
|
|
1156
|
+
return hostEnv(env || {}, (payload && payload.env) || {}, payload);
|
|
1062
1157
|
}
|
|
1063
1158
|
|
|
1064
1159
|
/** One transport round for the chosen class. */
|
|
@@ -1938,4 +2033,4 @@ function createLocalEngine({
|
|
|
1938
2033
|
};
|
|
1939
2034
|
}
|
|
1940
2035
|
|
|
1941
|
-
module.exports = { CLASSES, createLocalEngine, extractToolCalls, parseArgs, reasoningBudget, nativeEffort };
|
|
2036
|
+
module.exports = { CLASSES, createLocalEngine, extractToolCalls, parseArgs, reasoningBudget, nativeEffort, hostEnv };
|
package/lib/local/local.js
CHANGED
|
@@ -59,41 +59,290 @@ function baseOf(baseURL) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* DeepSeek
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* "
|
|
70
|
-
*
|
|
71
|
-
*
|
|
62
|
+
* DeepSeek (also the chat template a locally-pulled `deepseek-*` Ollama model
|
|
63
|
+
* uses) emits its tool calls as DSML — an XML-ish dialect that the
|
|
64
|
+
* OpenAI-compatible wire format does NOT structure for us:
|
|
65
|
+
*
|
|
66
|
+
* <|DSML|tool_calls>
|
|
67
|
+
* <|DSML|invoke name="write">
|
|
68
|
+
* <|DSML|parameter name="path" string="true">src/app.js</|DSML|parameter>
|
|
69
|
+
* <|DSML|parameter name="content" string="true">…the file body…</|DSML|parameter>
|
|
70
|
+
* </|DSML|invoke>
|
|
71
|
+
* </|DSML|tool_calls>
|
|
72
|
+
*
|
|
73
|
+
* It arrives as plain `delta.content` text, and that produces two failures:
|
|
74
|
+
* the markup rendering verbatim (reported as the pasted "||DSML||" envelope),
|
|
75
|
+
* and the call itself never running because text is not a tool call. Ported
|
|
76
|
+
* from aegiscodex-dev's src/backend.js (also client/aegis.js, the other
|
|
77
|
+
* transport this engine shares), which carries the full history of this
|
|
78
|
+
* grammar's quirks (provider/template/model revision disagree about the exact
|
|
79
|
+
* identifier, angle brackets sometimes absent, tag names drift under
|
|
80
|
+
* sampling) — every part of the pattern below is deliberately optional and
|
|
81
|
+
* spacing-insensitive for that reason. `string="false"` marks a JSON
|
|
82
|
+
* parameter; `string="true"` (or an absent attribute) is the literal value,
|
|
83
|
+
* kept verbatim so a multi-line `content` is never mangled by a JSON
|
|
84
|
+
* round-trip.
|
|
85
|
+
*/
|
|
86
|
+
// `\b` after each tag-name alternative matters: without it, "call" matches as
|
|
87
|
+
// a bare PREFIX of a drifted wrapper tag like "calls" (observed verbatim —
|
|
88
|
+
// `<|| calls>`, no "tool_"/"function_" prefix at all), swallowing everything
|
|
89
|
+
// up to the next real `</invoke>` as that spurious match's unnamed body and
|
|
90
|
+
// silently discarding a whole tool call with it. "call" and "calls" are both
|
|
91
|
+
// word characters, so plain alternation order cannot tell them apart.
|
|
92
|
+
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;
|
|
93
|
+
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;
|
|
94
|
+
const DSML_RE_LEADER = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?:(?:tool|function)[_▁ ]*calls?|reasoning)[_▁ ]*(?:begin|end)?\s*>/gi;
|
|
95
|
+
const DSML_RE_ANY_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*[A-Za-z_][A-Za-z0-9_]*[^>]{0,120}>/g;
|
|
96
|
+
const DSML_RE_SKELETON = /<\s*\/?\s*(?:tool_calls|function_calls|invoke|parameter|param|reasoning)\b[^>]{0,120}>/gi;
|
|
97
|
+
const DSML_RE_JSON_CALLS = /|[ \t]*(?:tool|function)[_▁ ]*calls?[_▁ ]*begin[ \t]*|([\s\S]*?)|[ \t]*(?:tool|function)[_▁ ]*calls?[_▁ ]*end[ \t]*|/gi;
|
|
98
|
+
const DSML_RE_ENVELOPE_TOKEN = /|[ \t]?[A-Za-z0-9_▁]*[ \t]?|/g;
|
|
99
|
+
const DSML_RE_DRIFT_INVOKE = /<\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*([^>]*?)\s*>/gi;
|
|
100
|
+
const DSML_RE_DRIFT_PARAM = /<\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*([^>]*?)\s*>/gi;
|
|
101
|
+
const DSML_RE_PARAM_CLOSE = /<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*>/gi;
|
|
102
|
+
const DSML_RE_ATTR = /([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
103
|
+
const DSML_RE_STRUCT_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call|parameter|param|arg|reasoning|(?:tool|function)[_▁ ]*calls?)\b/gi;
|
|
104
|
+
const DSML_RE_STRUCT_CLOSER = /<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call|parameter|param|arg|reasoning|(?:tool|function)[_▁ ]*calls?)\b/gi;
|
|
105
|
+
/**
|
|
106
|
+
* A COMPLETE (its own '>' has arrived) wrapper/leader tag that carries no
|
|
107
|
+
* call of its own — observed verbatim: a bare `<||calls>`, with neither a
|
|
108
|
+
* "tool_"/"function_" prefix (so DSML_RE_LEADER above misses it) nor a JSON
|
|
109
|
+
* envelope pair. Safe to drop the moment it closes, UNLIKE invoke/parameter —
|
|
110
|
+
* excluded here via negative lookahead so a *partial* invoke/parameter head is
|
|
111
|
+
* never erased before its own close has a chance to pair it up with
|
|
112
|
+
* DSML_RE_INVOKE/DSML_RE_PARAM on a later call once more of it has arrived.
|
|
113
|
+
*/
|
|
114
|
+
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;
|
|
115
|
+
/** A '<' (or '</') at the tail: the head of a tag whose pipe is still in flight. */
|
|
116
|
+
const DSML_RE_TRAILING_LT = /<\s*\/?\s*$/;
|
|
117
|
+
/** An in-flight identifier token: a pipe whose partner has not arrived yet. */
|
|
118
|
+
const DSML_RE_OPEN_TOKEN = /^|[ \t]?[A-Za-z0-9_▁]*[ \t]?$/;
|
|
119
|
+
/** Past this much buffered markup, a held tail is released as-is. */
|
|
120
|
+
const DSML_HOLD_MAX = 4 * 1024 * 1024;
|
|
121
|
+
|
|
122
|
+
/** The attributes of one tag head, lower-cased: name="write" → { name: 'write' }. */
|
|
123
|
+
function dsmlAttrs(text) {
|
|
124
|
+
const out = {};
|
|
125
|
+
if (!text) return out;
|
|
126
|
+
DSML_RE_ATTR.lastIndex = 0;
|
|
127
|
+
let m;
|
|
128
|
+
while ((m = DSML_RE_ATTR.exec(text))) out[m[1].toLowerCase()] = m[2] != null ? m[2] : m[3];
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A JSON parameter — or, when it is not JSON after all, the text it came as. */
|
|
133
|
+
function dsmlJson(raw) {
|
|
134
|
+
const s = String(raw == null ? '' : raw).trim();
|
|
135
|
+
try {
|
|
136
|
+
const v = JSON.parse(s);
|
|
137
|
+
return v === undefined ? s : v;
|
|
138
|
+
} catch {
|
|
139
|
+
return s;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The `parameter` tags of one `invoke` body, as a tool call's args object. */
|
|
144
|
+
function dsmlArgs(body) {
|
|
145
|
+
const args = {};
|
|
146
|
+
DSML_RE_PARAM.lastIndex = 0;
|
|
147
|
+
let p;
|
|
148
|
+
while ((p = DSML_RE_PARAM.exec(body))) {
|
|
149
|
+
const a = dsmlAttrs(p[1]);
|
|
150
|
+
const key = a.name || a.key || `arg${Object.keys(args).length}`;
|
|
151
|
+
const isJson = String(a.string || '').toLowerCase() === 'false' || String(a.json || '').toLowerCase() === 'true';
|
|
152
|
+
args[key] = isJson ? dsmlJson(p[2]) : p[2];
|
|
153
|
+
}
|
|
154
|
+
return args;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Decode every DSML tool call in `text` and return the two things a caller
|
|
159
|
+
* needs: what may be shown, and what must be executed — `{ text, calls }`.
|
|
160
|
+
* The generic `|…|` tokens are deliberately left in place here: an `invoke`
|
|
161
|
+
* block still arriving is identified by its delimiters on the next pass, and
|
|
162
|
+
* eating them early would lose the call's name and arguments. They are
|
|
163
|
+
* removed from the *released* text instead (see dsmlResidue).
|
|
164
|
+
*/
|
|
165
|
+
function decodeDeepSeekDSML(input) {
|
|
166
|
+
if (typeof input !== 'string' || !input || input.indexOf('|') === -1) {
|
|
167
|
+
return { text: input || '', calls: [] };
|
|
168
|
+
}
|
|
169
|
+
const calls = [];
|
|
170
|
+
let text = input;
|
|
171
|
+
|
|
172
|
+
// 1. One `invoke` block per call, the well-formed spelling.
|
|
173
|
+
DSML_RE_INVOKE.lastIndex = 0;
|
|
174
|
+
const spans = [];
|
|
175
|
+
let m;
|
|
176
|
+
while ((m = DSML_RE_INVOKE.exec(text))) {
|
|
177
|
+
const a = dsmlAttrs(m[1]);
|
|
178
|
+
const name = a.name || a.function || a.tool || '';
|
|
179
|
+
if (name) calls.push({ name, args: dsmlArgs(m[2]) });
|
|
180
|
+
spans.push([m.index, m.index + m[0].length]);
|
|
181
|
+
}
|
|
182
|
+
for (let i = spans.length - 1; i >= 0; i--) {
|
|
183
|
+
text = text.slice(0, spans[i][0]) + text.slice(spans[i][1]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 2. The older spelling: a JSON array wrapped in envelope tokens.
|
|
187
|
+
text = text.replace(DSML_RE_JSON_CALLS, (full, body) => {
|
|
188
|
+
const parsed = dsmlJson(body);
|
|
189
|
+
const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.tool_calls) ? parsed.tool_calls : []);
|
|
190
|
+
for (const c of list) {
|
|
191
|
+
const fn = (c && c.function) || c || {};
|
|
192
|
+
const name = fn.name || (c && c.name) || '';
|
|
193
|
+
if (!name) continue;
|
|
194
|
+
let args = fn.arguments != null ? fn.arguments : (c && c.arguments);
|
|
195
|
+
if (typeof args === 'string') args = dsmlJson(args);
|
|
196
|
+
calls.push({ name, args: args && typeof args === 'object' ? args : {} });
|
|
197
|
+
}
|
|
198
|
+
return '';
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// 3. The wrapper tags carry no call of their own.
|
|
202
|
+
text = text.replace(DSML_RE_LEADER, '');
|
|
203
|
+
return { text, calls };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Last-resort decode for markup that drifted out of well-formedness: a missing
|
|
208
|
+
* closing tag, or a stream cut mid-call. The call still names a tool and its
|
|
209
|
+
* parameters are still readable, so it is decoded rather than deleted — a call
|
|
210
|
+
* that reached the wire must not vanish because its closing tag did.
|
|
211
|
+
*/
|
|
212
|
+
function decodeDeepSeekDSMLDrift(input) {
|
|
213
|
+
if (typeof input !== 'string' || !input || input.indexOf('|') === -1) return { text: input || '', calls: [] };
|
|
214
|
+
const opens = [];
|
|
215
|
+
DSML_RE_DRIFT_INVOKE.lastIndex = 0;
|
|
216
|
+
let m;
|
|
217
|
+
while ((m = DSML_RE_DRIFT_INVOKE.exec(input))) {
|
|
218
|
+
opens.push({ at: m.index, attrs: m[1], body: DSML_RE_DRIFT_INVOKE.lastIndex });
|
|
219
|
+
}
|
|
220
|
+
if (!opens.length) return { text: input, calls: [] };
|
|
221
|
+
const calls = [];
|
|
222
|
+
let text = '';
|
|
223
|
+
let cursor = 0;
|
|
224
|
+
for (let i = 0; i < opens.length; i++) {
|
|
225
|
+
// One call runs to the next `invoke` head, or to the end of the text.
|
|
226
|
+
const end = i + 1 < opens.length ? opens[i + 1].at : input.length;
|
|
227
|
+
const body = input.slice(opens[i].body, end);
|
|
228
|
+
const a = dsmlAttrs(opens[i].attrs);
|
|
229
|
+
const name = a.name || a.function || a.tool || '';
|
|
230
|
+
const args = {};
|
|
231
|
+
const params = [];
|
|
232
|
+
DSML_RE_DRIFT_PARAM.lastIndex = 0;
|
|
233
|
+
let pm;
|
|
234
|
+
while ((pm = DSML_RE_DRIFT_PARAM.exec(body))) {
|
|
235
|
+
params.push({ at: pm.index, attrs: pm[1], from: DSML_RE_DRIFT_PARAM.lastIndex });
|
|
236
|
+
}
|
|
237
|
+
for (let k = 0; k < params.length; k++) {
|
|
238
|
+
// A value runs to the next parameter tag — its closing tag may be the
|
|
239
|
+
// thing that never arrived.
|
|
240
|
+
const stop = k + 1 < params.length ? params[k + 1].at : body.length;
|
|
241
|
+
const raw = body.slice(params[k].from, stop).replace(DSML_RE_PARAM_CLOSE, '');
|
|
242
|
+
const pa = dsmlAttrs(params[k].attrs);
|
|
243
|
+
const key = pa.name || pa.key || `arg${Object.keys(args).length}`;
|
|
244
|
+
const isJson = String(pa.string || '').toLowerCase() === 'false';
|
|
245
|
+
args[key] = isJson ? dsmlJson(raw) : raw;
|
|
246
|
+
}
|
|
247
|
+
text += input.slice(cursor, opens[i].at);
|
|
248
|
+
cursor = end;
|
|
249
|
+
if (name) calls.push({ name, args });
|
|
250
|
+
}
|
|
251
|
+
text += input.slice(cursor);
|
|
252
|
+
return { text, calls };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Residue removal for text that is about to be shown. Two spellings have to go:
|
|
257
|
+
* a leftover envelope token (`|DSML|`, `|tool▁calls▁begin|`, `|Assistant|`),
|
|
258
|
+
* and the skeleton an earlier release left behind by stripping the delimiters
|
|
259
|
+
* out of a DSML block — `<tool_calls> <invoke name="write"> <parameter …>`,
|
|
260
|
+
* which is exactly what a user pasted back at us.
|
|
261
|
+
*/
|
|
262
|
+
function dsmlResidue(text) {
|
|
263
|
+
if (!text) return text;
|
|
264
|
+
if (text.indexOf('|') === -1) return text.replace(DSML_RE_SKELETON, '');
|
|
265
|
+
return text.replace(DSML_RE_ANY_TAG, '').replace(DSML_RE_SKELETON, '').replace(DSML_RE_ENVELOPE_TOKEN, '');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Is the buffer, read from the first `|` in it, a construct that is still
|
|
270
|
+
* arriving? Only two shapes may be held back: a pipe whose partner has not
|
|
271
|
+
* arrived, and a DSML opener whose matching close has not landed. Everything
|
|
272
|
+
* else is prose — a `|` in an answer is a character, not a promise of markup,
|
|
273
|
+
* and holding the rest of the answer behind it would swallow the reply.
|
|
274
|
+
*/
|
|
275
|
+
function dsmlUnterminated(s) {
|
|
276
|
+
if (DSML_RE_OPEN_TOKEN.test(s)) return true;
|
|
277
|
+
const openers = (s.match(DSML_RE_STRUCT_TAG) || []).length;
|
|
278
|
+
const closers = (s.match(DSML_RE_STRUCT_CLOSER) || []).length;
|
|
279
|
+
return openers > closers;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Where, if anywhere, an in-flight DSML construct still needs the rest of the
|
|
284
|
+
* buffer held back from view. By the time this runs, decodeDeepSeekDSML() has
|
|
285
|
+
* already removed every COMPLETE invoke/parameter span and the caller has
|
|
286
|
+
* already removed every complete non-invoke wrapper tag (DSML_RE_WRAPPER_TAG),
|
|
287
|
+
* so whatever is left can only be: clean prose, or the start of exactly ONE
|
|
288
|
+
* incomplete construct running to the end of the buffer — an opener whose own
|
|
289
|
+
* '|' has arrived but not its '>' (`<\s*\/?\s*|`, found wherever it starts,
|
|
290
|
+
* not just at the tail: a Write's `content` can be arbitrarily long, so the
|
|
291
|
+
* unresolved opener is usually far behind the cursor, not at it), a '<' that
|
|
292
|
+
* arrived with no '|' yet (the chunk boundary landed first), or a bare pipe
|
|
293
|
+
* token still accumulating its identifier (the plain envelope-leak case, e.g.
|
|
294
|
+
* '|tool▁cal'). Returns -1 when none apply — safe to release in full.
|
|
295
|
+
*/
|
|
296
|
+
function dsmlHoldFrom(buf) {
|
|
297
|
+
const tagStart = buf.search(/<\s*\/?\s*|/);
|
|
298
|
+
if (tagStart !== -1) return tagStart;
|
|
299
|
+
const lt = DSML_RE_TRAILING_LT.exec(buf);
|
|
300
|
+
if (lt) return lt.index;
|
|
301
|
+
const openIdx = buf.indexOf('|');
|
|
302
|
+
if (openIdx !== -1 && dsmlUnterminated(buf.slice(openIdx))) return openIdx;
|
|
303
|
+
return -1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The per-turn stripper this transport uses. Feed it each `delta.content`
|
|
308
|
+
* chunk: it returns the text that is safe to show, holds back whatever is
|
|
309
|
+
* still arriving (a Write's `content` parameter is a whole file — it belongs
|
|
310
|
+
* to the call, not to the screen), and collects the tool calls the model made
|
|
311
|
+
* in text form. `flush()` releases the tail once the stream is drained, and
|
|
312
|
+
* `calls()` returns everything decoded so far, which the caller merges into
|
|
313
|
+
* the turn's tool calls so a Write that arrived as DSML is executed like any
|
|
314
|
+
* other.
|
|
72
315
|
*/
|
|
73
|
-
const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
|
|
74
316
|
function makeDeepSeekEnvelopeStripper() {
|
|
75
317
|
let buf = '';
|
|
318
|
+
const calls = [];
|
|
76
319
|
const strip = (chunk) => {
|
|
77
|
-
buf += chunk;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
320
|
+
buf += typeof chunk === 'string' ? chunk : '';
|
|
321
|
+
const dec = decodeDeepSeekDSML(buf);
|
|
322
|
+
buf = dec.text;
|
|
323
|
+
for (const c of dec.calls) calls.push(c);
|
|
324
|
+
buf = buf.replace(DSML_RE_WRAPPER_TAG, '');
|
|
325
|
+
const hold = dsmlHoldFrom(buf);
|
|
326
|
+
let out;
|
|
327
|
+
if (hold !== -1 && buf.length - hold <= DSML_HOLD_MAX) {
|
|
328
|
+
out = buf.slice(0, hold);
|
|
329
|
+
buf = buf.slice(hold);
|
|
330
|
+
} else {
|
|
331
|
+
out = buf;
|
|
332
|
+
buf = '';
|
|
87
333
|
}
|
|
88
|
-
|
|
89
|
-
buf = '';
|
|
90
|
-
return out;
|
|
334
|
+
return dsmlResidue(out);
|
|
91
335
|
};
|
|
92
336
|
strip.flush = () => {
|
|
93
|
-
const
|
|
337
|
+
const dec = decodeDeepSeekDSML(buf);
|
|
94
338
|
buf = '';
|
|
95
|
-
|
|
339
|
+
for (const c of dec.calls) calls.push(c);
|
|
340
|
+
const drift = decodeDeepSeekDSMLDrift(dec.text);
|
|
341
|
+
for (const c of drift.calls) calls.push(c);
|
|
342
|
+
return dsmlResidue(drift.text);
|
|
96
343
|
};
|
|
344
|
+
/** Every tool call decoded from text so far, as `{ name, args }`. */
|
|
345
|
+
strip.calls = () => calls.slice();
|
|
97
346
|
return strip;
|
|
98
347
|
}
|
|
99
348
|
|
|
@@ -463,12 +712,29 @@ async function chat({
|
|
|
463
712
|
const message = { role: 'assistant', content };
|
|
464
713
|
if (toolCalls.length) message.tool_calls = toolCalls;
|
|
465
714
|
|
|
466
|
-
|
|
715
|
+
// DSML calls arrive as plain text, decoded by stripEnvelope rather than
|
|
716
|
+
// accumulated by index like structured delta.tool_calls — merged into one
|
|
717
|
+
// normalised `{ id, name, args }` list so a call that arrived as DSML (a
|
|
718
|
+
// locally-pulled deepseek-* model with no provider-native delta.tool_calls
|
|
719
|
+
// at all) is executed like any other. engine.js's extractToolCalls() reads
|
|
720
|
+
// this field first, ahead of message.tool_calls.
|
|
721
|
+
const dsmlCalls = stripEnvelope.calls();
|
|
722
|
+
const result = {
|
|
467
723
|
id: 'local',
|
|
468
724
|
model: String(model),
|
|
469
725
|
choices: [{ index: 0, message, finish_reason: toolCalls.length ? 'tool_calls' : 'stop' }],
|
|
470
726
|
...(usage ? { usage } : {}),
|
|
471
727
|
};
|
|
728
|
+
if (toolCalls.length || dsmlCalls.length) {
|
|
729
|
+
result.toolCalls = [
|
|
730
|
+
...toolCalls.map((c) => ({ id: c.id, name: c.function.name, args: c.function.arguments })),
|
|
731
|
+
...dsmlCalls.map((c) => ({ id: '', name: c.name, args: c.args || {} })),
|
|
732
|
+
];
|
|
733
|
+
if (dsmlCalls.length && result.choices[0].finish_reason !== 'tool_calls') {
|
|
734
|
+
result.choices[0].finish_reason = 'tool_calls';
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return result;
|
|
472
738
|
}
|
|
473
739
|
|
|
474
740
|
module.exports = {
|
package/lib/local/prompt.js
CHANGED
|
@@ -61,7 +61,11 @@ const MAIN_CHAT_PROMPT =
|
|
|
61
61
|
|
|
62
62
|
/** The tools the desktop actually advertises (kept in step with tools.js). */
|
|
63
63
|
const TOOL_LINE =
|
|
64
|
-
'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute
|
|
64
|
+
'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute and ' +
|
|
65
|
+
'must be written in the form THIS machine uses — read the platform and home directory in the ' +
|
|
66
|
+
'Environment block below and use them: on win32 that means C:\\Users\\you\\Desktop\\file.txt, ' +
|
|
67
|
+
'not /home/you/Desktop/file.txt or /tmp/file.txt. "~" and "~/…" mean the home directory and are ' +
|
|
68
|
+
'expanded for you; a relative path resolves against the working directory. ' +
|
|
65
69
|
'exec runs in a persistent shell session on this machine — cd and exported env vars carry ' +
|
|
66
70
|
'across calls within the turn, like a real terminal. task spawns a specialist subagent ' +
|
|
67
71
|
'(its own tool loop, same model) for a focused, self-contained piece of work.';
|
|
@@ -72,11 +76,25 @@ const TOOL_LINE =
|
|
|
72
76
|
* the identity block.
|
|
73
77
|
*/
|
|
74
78
|
function environmentPreamble(env = {}) {
|
|
75
|
-
const { platform, arch, homedir, cwd, roots, appVersion, model } =
|
|
79
|
+
const { platform, arch, homedir, desktopdir, cwd, shell, shellKind, roots, appVersion, model } =
|
|
80
|
+
env || {};
|
|
76
81
|
const bits = [];
|
|
77
82
|
if (platform) bits.push(`platform: ${platform}${arch ? ` (${arch})` : ''}`);
|
|
78
83
|
if (homedir) bits.push(`home directory: ${homedir}`);
|
|
84
|
+
// Named explicitly because `~/Desktop` is a GUESS that is wrong on two of the
|
|
85
|
+
// three platforms this app ships to: OneDrive folder redirection moves it on
|
|
86
|
+
// Windows, and a localised Linux session translates the directory itself
|
|
87
|
+
// (a Swedish desktop has ~/Skrivbord and no ~/Desktop). "Put this on my
|
|
88
|
+
// desktop" is a real request, and this is the line that makes it land.
|
|
89
|
+
if (desktopdir) {
|
|
90
|
+
bits.push(
|
|
91
|
+
`desktop directory: ${desktopdir} — use this exact path when the user says "my desktop"; ` +
|
|
92
|
+
'do NOT build it yourself from the home directory, because it is not always a folder ' +
|
|
93
|
+
'called "Desktop" there',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
79
96
|
if (cwd) bits.push(`working directory: ${cwd}`);
|
|
97
|
+
if (shell) bits.push(`exec shell: ${shell}${shellKind ? ` (${shellKind})` : ''}`);
|
|
80
98
|
if (model) bits.push(`model: ${model}`);
|
|
81
99
|
if (appVersion) bits.push(`AEGIS Desktop ${appVersion}`);
|
|
82
100
|
|
|
@@ -86,10 +104,40 @@ function environmentPreamble(env = {}) {
|
|
|
86
104
|
if (rootList.length) {
|
|
87
105
|
lines.push(`repo roots:\n${rootList.map((r) => ` - ${r}`).join('\n')}`);
|
|
88
106
|
}
|
|
107
|
+
// The command dialect, stated as an instruction rather than left for the model
|
|
108
|
+
// to infer from `platform: win32`. Inference is what failed: a model handed a
|
|
109
|
+
// Windows machine still reached for `ls`, `cat`, `grep` and `mkdir -p`, none
|
|
110
|
+
// of which exist in PowerShell, so every exec call failed until it guessed.
|
|
111
|
+
const dialect = commandDialectLine(platform, shellKind);
|
|
112
|
+
if (dialect) lines.push(dialect);
|
|
89
113
|
if (!lines.length) return '';
|
|
90
114
|
return `# Environment\n${lines.join('\n')}`;
|
|
91
115
|
}
|
|
92
116
|
|
|
117
|
+
/**
|
|
118
|
+
* How `exec` commands must be written on this host. Empty when the platform is
|
|
119
|
+
* unknown — a guess here would be worse than silence.
|
|
120
|
+
*/
|
|
121
|
+
function commandDialectLine(platform, shellKind) {
|
|
122
|
+
if (!platform) return '';
|
|
123
|
+
if (platform === 'win32') {
|
|
124
|
+
return (
|
|
125
|
+
`This is Windows: exec runs ${shellKind === 'powershell' ? 'PowerShell' : 'a Windows shell'}, ` +
|
|
126
|
+
'so use Windows-native commands — Get-ChildItem/dir, Get-Content/type, Select-String/findstr, ' +
|
|
127
|
+
'where, New-Item -ItemType Directory — NOT ls, cat, grep, mkdir -p, touch or rm -rf, which do ' +
|
|
128
|
+
'not exist here. Separate paths with \\, and never assume /tmp, /usr or /home exist.'
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (platform === 'darwin') {
|
|
132
|
+
return (
|
|
133
|
+
'This is macOS: exec runs a POSIX shell and the standard Unix commands are available, but the ' +
|
|
134
|
+
'userland is BSD, not GNU — sed -i needs an argument (sed -i \'\'), and readlink -f, ' +
|
|
135
|
+
'date -d and sed -r are absent. Prefer portable forms, or python3.'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return 'This is a POSIX host: the standard Unix commands are available in exec.';
|
|
139
|
+
}
|
|
140
|
+
|
|
93
141
|
/**
|
|
94
142
|
* The full system prompt for a desktop chat turn: identity + work rules +
|
|
95
143
|
* the tool line + the environment block. Never empty.
|
|
@@ -105,5 +153,6 @@ module.exports = {
|
|
|
105
153
|
MAIN_CHAT_PROMPT,
|
|
106
154
|
TOOL_LINE,
|
|
107
155
|
environmentPreamble,
|
|
156
|
+
commandDialectLine,
|
|
108
157
|
buildSystemPrompt,
|
|
109
158
|
};
|