aegiscode 6.1.0 → 6.2.0
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/README.md +121 -78
- package/bin/aegiscode.js +9 -1
- package/package.json +3 -3
- package/scripts/predist.mjs +11 -1
- package/src/agents.js +136 -0
- package/src/app.js +522 -164
- package/src/chatflow.js +1475 -0
- package/src/checkpoint.js +85 -0
- package/src/clipboard.js +62 -0
- package/src/commands.js +1234 -150
- package/src/config.js +163 -0
- package/src/deps.js +14 -1
- package/src/devrun.js +110 -0
- package/src/engine.js +62 -0
- package/src/events.js +278 -0
- package/src/export.js +64 -0
- package/src/history.js +201 -0
- package/src/init.js +162 -0
- package/src/input.js +136 -0
- package/src/keys.js +141 -0
- package/src/panels.js +1171 -0
- package/src/permissions.js +102 -0
- package/src/render.js +33 -1
- package/src/summarize.js +90 -0
- package/src/system.js +37 -0
- package/src/tokens.js +166 -0
- package/vendor/desktop/lib/local/agents.js +102 -0
- package/vendor/desktop/lib/local/engine.js +972 -0
- package/vendor/desktop/lib/local/prompt.js +91 -0
- package/vendor/desktop/lib/local/shell.js +208 -0
- package/vendor/desktop/lib/local/tools.js +882 -0
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* engine.js — the desktop LocalEngine registry (plan P1 §5.2). Transport-only:
|
|
5
|
+
* it owns the provider settings/credentials and routes a chat payload to the
|
|
6
|
+
* cloud client, the local Ollama module, or a custom OpenAI/Anthropic-
|
|
7
|
+
* compatible endpoint. No tier/brain/routing decisions are made here — the
|
|
8
|
+
* chosen class is the user's explicit selection.
|
|
9
|
+
*
|
|
10
|
+
* Since the tool-calling port it ALSO owns the agent loop (the client half of
|
|
11
|
+
* aegiscodex-dev's src/backend.js runProvider): every turn carries a real
|
|
12
|
+
* system prompt (prompt.js) and the builtin tool schemas (tools.js), and when
|
|
13
|
+
* a provider answers with tool calls the loop executes them in-process and
|
|
14
|
+
* feeds the results back for as many rounds as the model keeps calling tools
|
|
15
|
+
* — there is no round cap; a turn ends when the model answers with text, or
|
|
16
|
+
* the user cancels it (cancel()/AbortController).
|
|
17
|
+
*
|
|
18
|
+
* Because a provider can signal "finished" with no answer attached, the two
|
|
19
|
+
* exit paths are guarded against the empty turn (see the loop's comment):
|
|
20
|
+
* a `finish_reason: 'length'` completion with no text is retried once with a
|
|
21
|
+
* doubled budget, and a turn that ends with neither text nor a tool call is
|
|
22
|
+
* re-dispatched once with `tools: []` plus a nudge so the model has to write
|
|
23
|
+
* up what it already gathered. A turn still empty after both throws instead
|
|
24
|
+
* of returning a blank completion for the renderer to paint "(empty
|
|
25
|
+
* response)" over. The window stays
|
|
26
|
+
* contextIsolated + sandboxed: this module runs in the MAIN process, so the
|
|
27
|
+
* executor never has to be exposed to the renderer.
|
|
28
|
+
*
|
|
29
|
+
* Two turn-scoped resources ride along with the loop, mirroring
|
|
30
|
+
* aegiscodex-dev's runProvider exactly:
|
|
31
|
+
* - a lazily-started ShellSession (shell.js) that the `exec` tool shares,
|
|
32
|
+
* so cd/export state persists across calls within one turn instead of
|
|
33
|
+
* each call spawning a fresh process;
|
|
34
|
+
* - the `task` tool, executed as a nested `chat()` call on the chosen
|
|
35
|
+
* specialist preset (agents.js) rather than a local tool — a subagent
|
|
36
|
+
* turn with its own tool rounds, bounded by MAX_SUBAGENT_DEPTH so a
|
|
37
|
+
* delegation chain can't recurse forever.
|
|
38
|
+
*
|
|
39
|
+
* Pure Node + dependency-injected (aegis client, settings store, ollama,
|
|
40
|
+
* providers, optionally tools/prompt) so it unit-tests without Electron.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const { randomUUID } = require('node:crypto');
|
|
44
|
+
const os = require('node:os');
|
|
45
|
+
|
|
46
|
+
const toolsModule = require('./tools.js');
|
|
47
|
+
const promptModule = require('./prompt.js');
|
|
48
|
+
const { ShellSession } = require('./shell.js');
|
|
49
|
+
const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
|
|
50
|
+
|
|
51
|
+
/** Classes whose transport is a user-supplied endpoint + credential. */
|
|
52
|
+
const CUSTOM_CLASSES = Object.freeze(['openai-compat', 'anthropic']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Depth at which the task tool stops being offered. The main chat (depth 0)
|
|
56
|
+
* and subagents down to depth MAX_SUBAGENT_DEPTH - 1 can all delegate, so
|
|
57
|
+
* legitimate hierarchical work (scan -> review -> patch, etc.) has room to
|
|
58
|
+
* nest without hitting a wall. Past that the tool is dropped, hard-cutting a
|
|
59
|
+
* runaway chain instead of letting it recurse unbounded.
|
|
60
|
+
*/
|
|
61
|
+
const MAX_SUBAGENT_DEPTH = 4;
|
|
62
|
+
|
|
63
|
+
const CLASSES = [
|
|
64
|
+
{ class: 'aegis', label: 'Aegis Cloud', kind: 'cloud' },
|
|
65
|
+
{ class: 'ollama', label: 'Ollama (local)', kind: 'local' },
|
|
66
|
+
{ class: 'openai-compat', label: 'Custom OpenAI-compatible', kind: 'custom' },
|
|
67
|
+
{ class: 'anthropic', label: 'Anthropic-compatible', kind: 'custom' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mirrors aegiscodex-dev's src/backend.js DEEPSEEK_REASONING_MODEL_RE +
|
|
72
|
+
* EFFORT_TOKEN_BUDGET verbatim. DeepSeek's reasoning models (deepseek-flash,
|
|
73
|
+
* deepseek-v4-pro, the deprecated deepseek-reasoner, and the legacy
|
|
74
|
+
* v4-flash/v4.1-flash aliases some configs still carry) spend part of
|
|
75
|
+
* max_tokens on hidden chain-of-thought before ever emitting visible
|
|
76
|
+
* content — DeepSeek counts reasoning tokens against the same budget as
|
|
77
|
+
* content. At the renderer's 4k default (index.html's max-tokens select),
|
|
78
|
+
* any non-trivial question can burn the whole budget reasoning and finish
|
|
79
|
+
* with empty content: no error, no tool calls, just a turn that "completes"
|
|
80
|
+
* with nothing to show for it (the empty-response bug). A user pointing the
|
|
81
|
+
* Custom OpenAI-compatible class straight at DeepSeek's API hits exactly
|
|
82
|
+
* this, so the request floors to the same effort budget aegiscodex-dev uses
|
|
83
|
+
* for its own direct DeepSeek calls instead of shipping whatever the
|
|
84
|
+
* dropdown happens to have selected.
|
|
85
|
+
*/
|
|
86
|
+
const DEEPSEEK_REASONING_MODEL_RE = /^deepseek-(v4(\.\d+)?-(flash|pro)|flash|pro|reasoner)$/;
|
|
87
|
+
const EFFORT_TOKEN_BUDGET = { low: 8192, medium: 16384, high: 32768 };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Idle-stream budget for a pooled brain call ("work autonomously"). The
|
|
91
|
+
* generic watchdog in vendor/aegis.js kills a stream that goes 60s without a
|
|
92
|
+
* byte — right default for one provider call answering, wrong for a worker
|
|
93
|
+
* fan-out: pool_brain yields a header chunk, then stays silent until the
|
|
94
|
+
* FIRST worker pass *returns*, and each worker is a full reasoning-model call
|
|
95
|
+
* at roughly 1/(workers+1) of the effort budget. At high effort, 3 workers,
|
|
96
|
+
* that is a multi-thousand-token reasoning pass per worker — easily past a
|
|
97
|
+
* minute. Timing out there aborts a perfectly healthy autonomous turn
|
|
98
|
+
* mid-flight, after the server has already run and billed every worker.
|
|
99
|
+
*
|
|
100
|
+
* 15 minutes deliberately outlasts the server's OWN ceiling for that window
|
|
101
|
+
* (aegis1 services/pool_brain.py: NEXUS_BRAIN_WORKER_TIMEOUT, default 600s),
|
|
102
|
+
* because aborting first leaves the server running and billing a fan-out
|
|
103
|
+
* nobody will ever see. Raising that env var past ~14 minutes means raising
|
|
104
|
+
* this constant too; the shared client applies the same budget from the
|
|
105
|
+
* server's X-AEGIS-Brain response header (see client/aegis.js idleBudgetFor),
|
|
106
|
+
* which is what covers a fan-out the caller did not flag — test/
|
|
107
|
+
* autonomous-mode.test.mjs pins both halves.
|
|
108
|
+
*/
|
|
109
|
+
const AUTONOMOUS_IDLE_TIMEOUT_MS = 15 * 60_000;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Only ever raises a too-low budget for a DeepSeek reasoning model — never
|
|
113
|
+
* lowers whatever the caller (renderer dropdown, or "adaptive" ceiling)
|
|
114
|
+
* already asked for. Everything else (non-DeepSeek models, non-reasoning
|
|
115
|
+
* DeepSeek ids like deepseek-chat) passes through untouched. Effort defaults
|
|
116
|
+
* to 'high' since custom endpoints have no effort selector of their own
|
|
117
|
+
* (that UI is aegis-class/autonomous-only) — matching aegiscodex-dev's own
|
|
118
|
+
* default effort.
|
|
119
|
+
*/
|
|
120
|
+
function deepseekReasoningFloor(model, maxTokens, effort) {
|
|
121
|
+
if (!DEEPSEEK_REASONING_MODEL_RE.test(String(model || ''))) return maxTokens;
|
|
122
|
+
const eff = effort === 'low' || effort === 'medium' ? effort : 'high';
|
|
123
|
+
return Math.max(Number(maxTokens) || 0, EFFORT_TOKEN_BUDGET[eff]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Relay model entries arrive as ids or objects; keep only real model ids. */
|
|
127
|
+
function normalizeCatalog(models) {
|
|
128
|
+
if (!Array.isArray(models)) return [];
|
|
129
|
+
return models
|
|
130
|
+
.map((m) => (typeof m === 'string' ? { id: m } : m))
|
|
131
|
+
.filter((m) => m && m.id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The Aegis Cloud catalog (`/api/v1/models`) lists every backend the pool can
|
|
136
|
+
* reach: per-provider ids (`openai`, `anthropic`, `groq`, `gemini`, ...) and
|
|
137
|
+
* six pooled-brain tier ids (`{aegis,nexus}-brain[-smart|-neo]`) that all run
|
|
138
|
+
* the same worker pool on the same backend model. None of that is a human's
|
|
139
|
+
* model choice — which providers currently hold a valid key is an ops detail
|
|
140
|
+
* (today: deepseek/anthropic/groq; openai and gemini drift in and out), and
|
|
141
|
+
* surfacing it invites picking a provider that happens to be dead right now.
|
|
142
|
+
* The pool already auto-routes across whichever providers are live, so the
|
|
143
|
+
* desktop dropdown offers exactly one entry for the "aegis" class: the
|
|
144
|
+
* collapsed "Nexus" brain — never the raw provider list.
|
|
145
|
+
*/
|
|
146
|
+
const NEXUS_BRAIN_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
|
|
147
|
+
const NEXUS_LABEL = 'Nexus';
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Pick the one entry standing in for the pooled brain. The server serves
|
|
151
|
+
* `nexus-brain` as the canonical tier (`hidden: false`, no `alias_of`) and
|
|
152
|
+
* marks every other spelling — `aegis-brain` and the `-smart`/`-neo` tiers —
|
|
153
|
+
* as `hidden: true, alias_of: "nexus-brain"`. Prefer the canonical id so the
|
|
154
|
+
* request travels on the name the server owns; fall back to the alias, then to
|
|
155
|
+
* any tier whose `alias_of` names the brain, so a renamed or trimmed catalog
|
|
156
|
+
* still resolves to something selectable instead of silently emptying the
|
|
157
|
+
* dropdown (the previous fixed-id lookup returned [] if `aegis-brain` was ever
|
|
158
|
+
* retired, leaving the class with no model to choose).
|
|
159
|
+
*/
|
|
160
|
+
function selectBrainEntry(models) {
|
|
161
|
+
return (
|
|
162
|
+
models.find((m) => NEXUS_BRAIN_IDS.includes(m.id) && !m.hidden && m.alias_of === undefined)
|
|
163
|
+
|| models.find((m) => NEXUS_BRAIN_IDS.includes(m.id))
|
|
164
|
+
|| models.find((m) => m.alias_of && NEXUS_BRAIN_IDS.includes(m.alias_of))
|
|
165
|
+
|| null
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function filterAegisCatalog(models) {
|
|
170
|
+
const nexus = selectBrainEntry(models);
|
|
171
|
+
if (!nexus) return [];
|
|
172
|
+
// Drop the alias bookkeeping: this entry *is* the selection, so the renderer
|
|
173
|
+
// must never treat it as a hidden alias and filter it back out.
|
|
174
|
+
const { hidden, alias_of, ...rest } = nexus;
|
|
175
|
+
return [{ ...rest, label: NEXUS_LABEL }];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Agent-loop helpers ──────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** Parse a model-supplied argument blob (string or already-parsed object). */
|
|
181
|
+
function parseArgs(raw) {
|
|
182
|
+
if (raw == null) return {};
|
|
183
|
+
if (typeof raw === 'object') return raw;
|
|
184
|
+
try {
|
|
185
|
+
const v = JSON.parse(String(raw));
|
|
186
|
+
return v && typeof v === 'object' ? v : {};
|
|
187
|
+
} catch {
|
|
188
|
+
return {};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Pull normalised tool calls out of whichever shape the transport returned:
|
|
194
|
+
* the shared `result.toolCalls` both local parsers emit, or a provider-native
|
|
195
|
+
* `choices[0].message.tool_calls` (the Aegis pool relays the upstream OpenAI
|
|
196
|
+
* shape verbatim).
|
|
197
|
+
*/
|
|
198
|
+
function extractToolCalls(res) {
|
|
199
|
+
if (!res) return [];
|
|
200
|
+
if (Array.isArray(res.toolCalls) && res.toolCalls.length) {
|
|
201
|
+
return res.toolCalls
|
|
202
|
+
.map((c) => ({ id: c.id || '', name: c.name || '', args: parseArgs(c.args) }))
|
|
203
|
+
.filter((c) => c.name);
|
|
204
|
+
}
|
|
205
|
+
const msg = res.choices && res.choices[0] && res.choices[0].message;
|
|
206
|
+
const raw = (msg && msg.tool_calls) || [];
|
|
207
|
+
if (!Array.isArray(raw)) return [];
|
|
208
|
+
return raw
|
|
209
|
+
.map((tc) => {
|
|
210
|
+
const fn = (tc && tc.function) || {};
|
|
211
|
+
return { id: (tc && tc.id) || '', name: fn.name || (tc && tc.name) || '', args: parseArgs(fn.arguments) };
|
|
212
|
+
})
|
|
213
|
+
.filter((c) => c.name);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The textual content of one assistant turn (empty when it only called tools). */
|
|
217
|
+
function assistantText(res) {
|
|
218
|
+
const msg = res && res.choices && res.choices[0] && res.choices[0].message;
|
|
219
|
+
return (msg && typeof msg.content === 'string' && msg.content) || '';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The provider's stop reason for a completion ('' when it sent none). The two
|
|
224
|
+
* wire formats report it in different places and both have to be read: the
|
|
225
|
+
* OpenAI shape carries `choices[0].finish_reason` (this is what providers.js
|
|
226
|
+
* and vendor/aegis.js emit), while providers.js's Anthropic parser puts
|
|
227
|
+
* `stop_reason` on the result and never sets a finish_reason on the choice.
|
|
228
|
+
*/
|
|
229
|
+
function finishReasonOf(res) {
|
|
230
|
+
const choice = res && res.choices && res.choices[0];
|
|
231
|
+
return (choice && choice.finish_reason) || (res && res.stop_reason) || '';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* True when the provider cut the answer off at the token budget instead of
|
|
236
|
+
* the model choosing to stop. This is the diagnosis for the most common
|
|
237
|
+
* flavour of the empty turn: DeepSeek (and other reasoning models that bill
|
|
238
|
+
* hidden chain-of-thought against max_tokens) can spend the entire budget
|
|
239
|
+
* before emitting a single visible token, and the completion still arrives
|
|
240
|
+
* as a clean `finish_reason: 'length'` — no error, empty content. A tool
|
|
241
|
+
* call whose JSON was truncated mid-argument lands here too, where
|
|
242
|
+
* parseArgs() would otherwise silently yield `{}` and run the tool with no
|
|
243
|
+
* arguments, which is worse than retrying.
|
|
244
|
+
*/
|
|
245
|
+
function isTruncated(res) {
|
|
246
|
+
const reason = finishReasonOf(res);
|
|
247
|
+
// 'length' is OpenAI's wording, 'max_tokens' is Anthropic's — same event.
|
|
248
|
+
return reason === 'length' || reason === 'max_tokens';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Double a budget for the one-shot truncation retry. A budget the caller set
|
|
253
|
+
* on purpose (the flow lane's deliberate 1024-token cap, say) is merely
|
|
254
|
+
* doubled — the floor only applies when nothing was set at all, so a retry
|
|
255
|
+
* can never silently override a small cap by an order of magnitude.
|
|
256
|
+
*/
|
|
257
|
+
function doubledBudget(maxTokens) {
|
|
258
|
+
const n = Number(maxTokens) || 0;
|
|
259
|
+
return n > 0 ? n * 2 : 8192;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The follow-up shown to a model that ended its turn with neither text nor a
|
|
264
|
+
* tool call. Sent as a plain user message (never as a tool result — there is
|
|
265
|
+
* no pending tool call to answer) so every provider accepts it verbatim.
|
|
266
|
+
*/
|
|
267
|
+
const EMPTY_TURN_NUDGE =
|
|
268
|
+
'Your previous reply came back empty — it contained no answer and no tool call. ' +
|
|
269
|
+
'Write your answer now, using only the information already gathered above. ' +
|
|
270
|
+
'No tools are available in this reply, so do not call any: respond in plain ' +
|
|
271
|
+
'prose or markdown.';
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Last resort for a turn that is still empty after the synthesis pass.
|
|
275
|
+
*
|
|
276
|
+
* Throws rather than returning the blank completion. Returning it is what
|
|
277
|
+
* produces the renderer's undiagnosable "(empty response)" bubble, and
|
|
278
|
+
* synthesising fake assistant text instead would be worse: renderer/app.js
|
|
279
|
+
* persists whatever comes back as the assistant's own message and syncs it
|
|
280
|
+
* to the aegis account, so the notice would re-enter the model's context on
|
|
281
|
+
* the next turn as something it had said. Failing loudly leaves the turn's
|
|
282
|
+
* real tool log on screen, keeps the transcript honest, and names the cause
|
|
283
|
+
* (renderer prints `Error: <message>`).
|
|
284
|
+
*/
|
|
285
|
+
function emptyTurnError({ cls, model, maxTokens, finishReason }) {
|
|
286
|
+
const err = new Error(
|
|
287
|
+
`The model returned no answer after ${cls}/${model} was asked to summarise its results ` +
|
|
288
|
+
`(stop reason: ${finishReason || 'none'}, max_tokens: ${maxTokens}). The token budget ` +
|
|
289
|
+
'was most likely consumed before any visible text — raise the max-tokens setting, ' +
|
|
290
|
+
'or lower effort.'
|
|
291
|
+
);
|
|
292
|
+
err.status = 502;
|
|
293
|
+
return err;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBuilder, env, getConfirmMode }) {
|
|
297
|
+
const controllers = new Map(); // sessionId -> AbortController
|
|
298
|
+
const T = tools || toolsModule;
|
|
299
|
+
const buildSystemPrompt = (promptBuilder && promptBuilder.buildSystemPrompt) || promptModule.buildSystemPrompt;
|
|
300
|
+
|
|
301
|
+
/** "Confirm before running tools" (Settings toggle, persisted by
|
|
302
|
+
* lib/settings.js. Because gatedExecuteTool is called for EVERY tool round
|
|
303
|
+
* it is read per call, not captured once at construction: flipping the
|
|
304
|
+
* switch takes effect on the next tool call, with no restart.
|
|
305
|
+
* An explicit `getConfirmMode` factory arg wins (used by tests); then the
|
|
306
|
+
* settings store's own accessor; then the safe default — ON, i.e. the gate
|
|
307
|
+
* stays up, so a store that predates the toggle can never silently
|
|
308
|
+
* disable it. */
|
|
309
|
+
const confirmModeEnabled = () => {
|
|
310
|
+
if (typeof getConfirmMode === 'function') return getConfirmMode() !== false;
|
|
311
|
+
if (settings && typeof settings.getConfirmMode === 'function') {
|
|
312
|
+
const value = settings.getConfirmMode();
|
|
313
|
+
return value === undefined ? true : Boolean(value);
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// ── Tool-call approval gate (renderer confirms exec/writeFile/editFile
|
|
319
|
+
// before they run) ─────────────────────────────────────────────────────
|
|
320
|
+
//
|
|
321
|
+
// `sessionAllowlists` is keyed by the CONVERSATION's root session id (the
|
|
322
|
+
// one the renderer's `send()` mints once per thread and reuses across
|
|
323
|
+
// turns — see rootSessionId below), never by the per-call sessionId a
|
|
324
|
+
// subagent gets, so "allow for this session" reads the way the user sees
|
|
325
|
+
// it: one decision per open conversation, not per nested tool round.
|
|
326
|
+
// In-memory only, on purpose — never persisted, so a restart (or
|
|
327
|
+
// newChat()'s clearSessionApprovals) always starts from a clean gate.
|
|
328
|
+
const sessionAllowlists = new Map(); // rootSessionId -> Set<toolName>
|
|
329
|
+
const pendingApprovals = new Map(); // approvalId -> { resolve }
|
|
330
|
+
|
|
331
|
+
function sessionAllows(rootId, name) {
|
|
332
|
+
const set = sessionAllowlists.get(rootId);
|
|
333
|
+
return Boolean(set && set.has(name));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function allowForSession(rootId, name) {
|
|
337
|
+
if (!sessionAllowlists.has(rootId)) sessionAllowlists.set(rootId, new Set());
|
|
338
|
+
sessionAllowlists.get(rootId).add(name);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** newChat() in the renderer calls this so a fresh conversation never
|
|
342
|
+
* inherits a prior thread's blanket allows. */
|
|
343
|
+
function clearSessionApprovals(rootSessionId) {
|
|
344
|
+
sessionAllowlists.delete(rootSessionId);
|
|
345
|
+
return { ok: true };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The renderer's approval card resolves the pending requestApproval()
|
|
349
|
+
* promise below. An unknown/already-answered id is a no-op — the card
|
|
350
|
+
* can only be clicked once (it disables itself), but a duplicate or
|
|
351
|
+
* late message must never throw. */
|
|
352
|
+
function respondApproval(approvalId, decision) {
|
|
353
|
+
const pending = pendingApprovals.get(approvalId);
|
|
354
|
+
if (!pending) return { ok: false };
|
|
355
|
+
pendingApprovals.delete(approvalId);
|
|
356
|
+
pending.resolve(decision === 'session' || decision === 'once' ? decision : 'deny');
|
|
357
|
+
return { ok: true };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Ask the renderer to approve one mutating tool call. Resolves 'once',
|
|
362
|
+
* 'session' or 'deny'. Sent over `rootOnDelta` (see chat()) as an
|
|
363
|
+
* `{ approval }` chunk so it rides the exact same streaming channel as
|
|
364
|
+
* tool-activity chunks — no new IPC surface needed on the push side, only
|
|
365
|
+
* on the reply side (respondApproval). Fails safe: no listener able to
|
|
366
|
+
* ever answer (no onDelta, or the turn was aborted) resolves 'deny'
|
|
367
|
+
* instead of hanging the tool round forever.
|
|
368
|
+
*/
|
|
369
|
+
function requestApproval(rootSessionId, rootOnDelta, signal, info) {
|
|
370
|
+
return new Promise((resolve) => {
|
|
371
|
+
if (signal && signal.aborted) {
|
|
372
|
+
resolve('deny');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const id = randomUUID();
|
|
376
|
+
let settled = false;
|
|
377
|
+
const onAbort = () => finish('deny');
|
|
378
|
+
const finish = (decision) => {
|
|
379
|
+
if (settled) return;
|
|
380
|
+
settled = true;
|
|
381
|
+
pendingApprovals.delete(id);
|
|
382
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
383
|
+
resolve(decision);
|
|
384
|
+
};
|
|
385
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
386
|
+
pendingApprovals.set(id, { resolve: finish });
|
|
387
|
+
if (typeof rootOnDelta !== 'function') {
|
|
388
|
+
finish('deny');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
rootOnDelta({
|
|
392
|
+
delta: '',
|
|
393
|
+
approval: {
|
|
394
|
+
id,
|
|
395
|
+
sessionId: rootSessionId,
|
|
396
|
+
tool: info.tool,
|
|
397
|
+
args: info.args,
|
|
398
|
+
diff: info.diff || null,
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The gate itself: read-only tools and already-session-allowed mutating
|
|
406
|
+
* tools run exactly like executeTool always did. A first-time mutating
|
|
407
|
+
* call previews writeFile/editFile (a preview failure — e.g. old_string
|
|
408
|
+
* not found — is returned as the ordinary tool error, no approval prompt
|
|
409
|
+
* needed for a call that couldn't succeed anyway), asks the renderer, and
|
|
410
|
+
* on approval either applies with a fresh hash check (writeFile/editFile)
|
|
411
|
+
* or runs normally (exec — nothing to hash-check).
|
|
412
|
+
*
|
|
413
|
+
* Confirm mode off (Settings → "Confirm before running tools") short-circuits
|
|
414
|
+
* ALL of that: no preview, no approval card, no requestApproval() — the call
|
|
415
|
+
* runs straight through exactly like a session-allowed one, so "don't ask"
|
|
416
|
+
* is one switch rather than a per-tool blanket allow in every conversation.
|
|
417
|
+
*/
|
|
418
|
+
async function gatedExecuteTool(call, { toolCtx, rootSessionId, rootOnDelta, signal }) {
|
|
419
|
+
const { name, args } = call;
|
|
420
|
+
if (!T.MUTATING_TOOLS.has(name)) return T.executeTool(name, args, toolCtx);
|
|
421
|
+
if (!confirmModeEnabled()) return T.executeTool(name, args, toolCtx);
|
|
422
|
+
if (sessionAllows(rootSessionId, name)) return T.executeTool(name, args, toolCtx);
|
|
423
|
+
|
|
424
|
+
let preview = null;
|
|
425
|
+
if (name === 'writeFile' || name === 'editFile') {
|
|
426
|
+
preview = T.previewMutation(name, args);
|
|
427
|
+
if (!preview.ok) return { ok: false, error: preview.error };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const decision = await requestApproval(rootSessionId, rootOnDelta, signal, {
|
|
431
|
+
tool: name,
|
|
432
|
+
args,
|
|
433
|
+
diff: preview && preview.diff,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
if (decision === 'deny') {
|
|
437
|
+
return { ok: false, error: `${name} was not executed — the user denied the request.` };
|
|
438
|
+
}
|
|
439
|
+
if (decision === 'session') allowForSession(rootSessionId, name);
|
|
440
|
+
|
|
441
|
+
if (preview) return T.applyChecked(name, args, preview);
|
|
442
|
+
return T.executeTool(name, args, toolCtx);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Custom endpoints are only usable when they are actually configured:
|
|
447
|
+
* a base URL is mandatory for both, and Anthropic additionally needs its own
|
|
448
|
+
* key (the wire format authenticates with x-api-key). Reporting them as
|
|
449
|
+
* always-ready made chat() POST to `${undefined}/v1/…` (defect #2).
|
|
450
|
+
*/
|
|
451
|
+
function customStatus(cls) {
|
|
452
|
+
const cfg = settings.get(cls) || {};
|
|
453
|
+
const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
|
|
454
|
+
const hasBase = Boolean(baseURL);
|
|
455
|
+
const hasKey = Boolean(cfg.configured);
|
|
456
|
+
return {
|
|
457
|
+
configured: cls === 'anthropic' ? hasBase && hasKey : hasBase,
|
|
458
|
+
baseURL,
|
|
459
|
+
keyMask: cfg.keyMask || null,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function listClasses() {
|
|
464
|
+
const status = await ollama.probe().catch(() => ({ running: false }));
|
|
465
|
+
return CLASSES.map((c) => {
|
|
466
|
+
if (c.class === 'ollama') return { ...c, configured: Boolean(status.running) };
|
|
467
|
+
if (c.class === 'aegis') {
|
|
468
|
+
return { ...c, configured: Boolean(aegis.apiKey) };
|
|
469
|
+
}
|
|
470
|
+
return { ...c, ...customStatus(c.class) };
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function listModels(cls) {
|
|
475
|
+
if (cls === 'aegis') {
|
|
476
|
+
const data = await aegis.listModels();
|
|
477
|
+
return { class: cls, models: filterAegisCatalog(normalizeCatalog(data && data.models)) };
|
|
478
|
+
}
|
|
479
|
+
if (cls === 'ollama') {
|
|
480
|
+
const tags = await ollama.listTags();
|
|
481
|
+
return { class: cls, models: tags.map((t) => ({ id: t.id })) };
|
|
482
|
+
}
|
|
483
|
+
// Custom endpoints: the model id is the *user's* choice — a provider model
|
|
484
|
+
// name, never a URL. Offering the configured base URL as an `id` meant that
|
|
485
|
+
// leaving the default selection POSTed `model: "https://api.openai.com/v1"`,
|
|
486
|
+
// an upstream 400 invalid-model on every call (defect B). There is nothing
|
|
487
|
+
// to enumerate, so the list stays empty and `needsModelId` tells the
|
|
488
|
+
// renderer to prompt for a typed id instead. The base URL still travels
|
|
489
|
+
// along for display only.
|
|
490
|
+
const cfg = settings.get(cls) || {};
|
|
491
|
+
const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
|
|
492
|
+
return { class: cls, models: [], needsModelId: true, baseURL };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* The environment facts the model needs to stop asking which OS it is on.
|
|
497
|
+
* Everything is best-effort: a missing field is simply omitted.
|
|
498
|
+
*/
|
|
499
|
+
function envFor(payload) {
|
|
500
|
+
const supplied = (payload && payload.env) || {};
|
|
501
|
+
const base = env || {};
|
|
502
|
+
const pick = (key, value) => (supplied[key] != null ? supplied[key] : value);
|
|
503
|
+
let homedir = base.homedir;
|
|
504
|
+
let cwd = base.cwd;
|
|
505
|
+
try {
|
|
506
|
+
if (!homedir) homedir = os.homedir();
|
|
507
|
+
if (!cwd) cwd = process.cwd();
|
|
508
|
+
} catch {
|
|
509
|
+
/* keep whatever we have */
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
platform: pick('platform', base.platform || process.platform),
|
|
513
|
+
arch: pick('arch', base.arch || process.arch),
|
|
514
|
+
homedir: pick('homedir', homedir),
|
|
515
|
+
cwd: pick('cwd', cwd),
|
|
516
|
+
roots: pick('roots', base.roots),
|
|
517
|
+
appVersion: pick('appVersion', base.appVersion),
|
|
518
|
+
model: pick('model', payload && payload.model),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** One transport round for the chosen class. */
|
|
523
|
+
async function dispatch(cls, opts) {
|
|
524
|
+
if (cls === 'aegis') {
|
|
525
|
+
// `undefined` = "leave the model id's own default alone" (a real user
|
|
526
|
+
// turn on the pooled class: the selected Nexus id is what decides, so
|
|
527
|
+
// today's behaviour is unchanged). `false` = an explicit single provider
|
|
528
|
+
// call, for a pass that is a continuation rather than a new
|
|
529
|
+
// investigation. `true` = the autonomous fan-out.
|
|
530
|
+
const brainFlag = opts.singlePass ? false : opts.autonomous ? true : undefined;
|
|
531
|
+
return aegis.chatCompletion({
|
|
532
|
+
prompt: opts.prompt,
|
|
533
|
+
system: opts.system,
|
|
534
|
+
messages: opts.messages,
|
|
535
|
+
model: opts.model,
|
|
536
|
+
mode: opts.mode,
|
|
537
|
+
maxTokens: opts.maxTokens,
|
|
538
|
+
stream: opts.stream !== false,
|
|
539
|
+
// The pooled (Nexus) brain is streamed, and an OpenAI-compatible SSE
|
|
540
|
+
// stream reports no token usage unless asked. Without this the Aegis
|
|
541
|
+
// Cloud class — the desktop's default — was the one class that answered
|
|
542
|
+
// with text but never a token count, so a pooled turn's spend was
|
|
543
|
+
// invisible here while the same model through the MCP path reported it.
|
|
544
|
+
includeUsage: true,
|
|
545
|
+
onStream: opts.onDelta,
|
|
546
|
+
// Extended-reasoning trace (the fan-out's worker findings). Its own
|
|
547
|
+
// channel so it never counts as answer text — see vendor/aegis.js.
|
|
548
|
+
onReasoning: opts.onReasoning,
|
|
549
|
+
// A brain fan-out is silent between passes; give it room (see
|
|
550
|
+
// AUTONOMOUS_IDLE_TIMEOUT_MS). Undefined elsewhere -> 60s default.
|
|
551
|
+
idleTimeoutMs: opts.idleTimeoutMs,
|
|
552
|
+
signal: opts.signal,
|
|
553
|
+
// aegis_memory: automatic, no button — the server both reads prior
|
|
554
|
+
// synced memory into context AND writes this turn back to it, the
|
|
555
|
+
// same flag aegis-online sets. Matches aegiscodex-dev's own
|
|
556
|
+
// cross-session memory (auto-indexed, no manual tagging).
|
|
557
|
+
extra: {
|
|
558
|
+
aegis_memory: true,
|
|
559
|
+
session: opts.sessionId,
|
|
560
|
+
// The fan-out is opt-in per dispatch. `brain` is sent EXPLICITLY
|
|
561
|
+
// whenever this dispatch is not the autonomous one, because the
|
|
562
|
+
// model id this class sends (``nexus-brain``) enables the pooled
|
|
563
|
+
// brain on its own: without the flag a continuation pass — the
|
|
564
|
+
// doubled-budget retry, or the "write up what you already found"
|
|
565
|
+
// re-dispatch — silently re-ran the whole workers+1 fan-out for a
|
|
566
|
+
// pass whose documented cost is a single request. aegis1
|
|
567
|
+
// services/pool_brain.py parse_brain_request honours the opt-out.
|
|
568
|
+
...(brainFlag === undefined ? {} : { brain: brainFlag }),
|
|
569
|
+
// An opted-out pass is also told WHICH band to run on. A single call
|
|
570
|
+
// on a brain model id infers its band from the id and lands on
|
|
571
|
+
// "fast" (the cheapest id in the pool); "brain" is the band the
|
|
572
|
+
// workers themselves run on (cheapest model that can think). Same
|
|
573
|
+
// model as the fan-out, one sample instead of four — otherwise
|
|
574
|
+
// dropping the fan-out would have quietly changed the model too.
|
|
575
|
+
...(brainFlag === false ? { mode: 'brain' } : {}),
|
|
576
|
+
// Only meaningful (and only sent) alongside a running fan-out — aegis1
|
|
577
|
+
// services/pool_brain.py parse_brain_request reads `effort`/
|
|
578
|
+
// `workers` straight off the body and clamps them itself
|
|
579
|
+
// (EFFORT_LEVELS / MAX_WORKERS), so no client-side validation here.
|
|
580
|
+
// Keyed on the effective brain flag, not on `autonomous`: an opted-out
|
|
581
|
+
// single pass carries no fan-out tuning it cannot use.
|
|
582
|
+
...(brainFlag === true && opts.effort ? { effort: opts.effort } : {}),
|
|
583
|
+
...(brainFlag === true && opts.workers ? { workers: opts.workers } : {}),
|
|
584
|
+
// The pool forwards `tools` to the provider and returns tool_calls
|
|
585
|
+
// (aegis1 app.py:7765 → provider, pool_brain synthesis keeps them).
|
|
586
|
+
...(opts.tools.length ? { tools: opts.tools } : {}),
|
|
587
|
+
...(opts.toolChoice ? { tool_choice: opts.toolChoice } : {}),
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (cls === 'ollama') {
|
|
593
|
+
return ollama.chat({
|
|
594
|
+
model: opts.model,
|
|
595
|
+
prompt: opts.prompt,
|
|
596
|
+
system: opts.system,
|
|
597
|
+
messages: opts.messages,
|
|
598
|
+
maxTokens: opts.maxTokens,
|
|
599
|
+
signal: opts.signal,
|
|
600
|
+
onDelta: opts.onDelta,
|
|
601
|
+
...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const common = {
|
|
606
|
+
baseURL: opts.cfg.baseURL,
|
|
607
|
+
apiKey: opts.apiKey,
|
|
608
|
+
model: opts.model,
|
|
609
|
+
prompt: opts.prompt,
|
|
610
|
+
system: opts.system,
|
|
611
|
+
messages: opts.messages,
|
|
612
|
+
maxTokens: opts.maxTokens,
|
|
613
|
+
signal: opts.signal,
|
|
614
|
+
onDelta: opts.onDelta,
|
|
615
|
+
...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
if (cls === 'anthropic') return providers.anthropicMessages(common);
|
|
619
|
+
return providers.openaiCompatible(common);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function chat(payload, onDelta) {
|
|
623
|
+
const cls = payload && payload.class;
|
|
624
|
+
const model = payload && payload.model;
|
|
625
|
+
const maxTokens = deepseekReasoningFloor(model, payload && payload.maxTokens, payload && payload.effort);
|
|
626
|
+
// "Work autonomously" — routes this call through aegis1's pool_brain
|
|
627
|
+
// worker fan-out (services/pool_brain.py: N reasoning workers + a
|
|
628
|
+
// synthesis pass) instead of a single provider call. UI-gated to the
|
|
629
|
+
// 'aegis' class only (see AUTONOMOUS_CLASS in app.js).
|
|
630
|
+
const autonomous = cls === 'aegis' && Boolean(payload && payload.autonomous);
|
|
631
|
+
const sessionId = (payload && payload.sessionId) || randomUUID();
|
|
632
|
+
// Recursion depth for the task tool: 0 for a real user turn, N+1 for a
|
|
633
|
+
// subagent spawned by depth N. Never set by an IPC caller — only by
|
|
634
|
+
// runSubagent's own recursive chat() call below.
|
|
635
|
+
const depth = Number.isInteger(payload && payload.depth) ? payload.depth : 0;
|
|
636
|
+
// The approval gate's identity for this whole conversation, regardless of
|
|
637
|
+
// depth: a real user turn defines it (defaults to its own sessionId); a
|
|
638
|
+
// subagent's nested chat() call always receives it explicitly from
|
|
639
|
+
// runSubagent below, so "allow for this session" means the same thing
|
|
640
|
+
// whether the call came from the top-level turn or three subagents deep.
|
|
641
|
+
const rootSessionId = (payload && payload.rootSessionId) || sessionId;
|
|
642
|
+
// Likewise, approval requests must always reach the ORIGINAL caller's
|
|
643
|
+
// stream — a subagent's own chat() call is invoked with a no-op onDelta
|
|
644
|
+
// (its tool activity/text is not streamed to the renderer), so without
|
|
645
|
+
// this a nested approval request would call that no-op and hang forever
|
|
646
|
+
// waiting for a response nobody can ever send.
|
|
647
|
+
const rootOnDelta = (payload && payload.rootOnDelta) || onDelta;
|
|
648
|
+
|
|
649
|
+
// A pooled brain call streams each worker's finding as extended reasoning
|
|
650
|
+
// before the synthesis pass writes the visible answer. Forward it on its
|
|
651
|
+
// own channel so the renderer can show the fan-out working instead of an
|
|
652
|
+
// apparently idle bubble for the whole worker phase. Uses `onDelta` (not
|
|
653
|
+
// rootOnDelta) on purpose: a subagent's reasoning should be suppressed
|
|
654
|
+
// exactly as its text already is.
|
|
655
|
+
const onReasoning =
|
|
656
|
+
typeof onDelta === 'function' ? (text) => text && onDelta({ reasoning: text }) : undefined;
|
|
657
|
+
|
|
658
|
+
const controller = new AbortController();
|
|
659
|
+
controllers.set(sessionId, controller);
|
|
660
|
+
const signal = controller.signal;
|
|
661
|
+
|
|
662
|
+
// A caller can opt out of the agent loop entirely (`tools: false`) and get
|
|
663
|
+
// the old single-shot turn back.
|
|
664
|
+
const toolsEnabled = !(payload && payload.tools === false);
|
|
665
|
+
const wire = cls === 'anthropic' ? 'anthropic' : 'openai';
|
|
666
|
+
const toolSchemas = toolsEnabled ? T.toolsFor(wire, { includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
|
|
667
|
+
const toolChoice = (payload && payload.toolChoice) || null;
|
|
668
|
+
|
|
669
|
+
const system = (payload && payload.system) || buildSystemPrompt(envFor(payload));
|
|
670
|
+
const history = Array.isArray(payload && payload.messages) ? payload.messages.filter(Boolean).slice() : [];
|
|
671
|
+
let prompt = (payload && payload.prompt) || '';
|
|
672
|
+
|
|
673
|
+
// Lazily start ONE shell session for this turn; the exec tool shares it so
|
|
674
|
+
// cd/env/state persist across calls. Only spawned if exec actually runs,
|
|
675
|
+
// and always disposed when the turn ends.
|
|
676
|
+
let shell = null;
|
|
677
|
+
const getShell = () => shell || (shell = new ShellSession({ cwd: envFor(payload).cwd }));
|
|
678
|
+
const toolCtx = { getShell, signal };
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
const cfg = cls === 'aegis' || cls === 'ollama' ? {} : settings.get(cls) || {};
|
|
682
|
+
const apiKey = cls === 'aegis' || cls === 'ollama' ? null : settings.rawKey(cls);
|
|
683
|
+
|
|
684
|
+
// Custom classes carry no enumerable model list (see listModels), so a
|
|
685
|
+
// blank id here means the user never typed one. Fail loudly in-process
|
|
686
|
+
// instead of shipping `model: undefined` upstream (defect B).
|
|
687
|
+
if (CUSTOM_CLASSES.includes(cls) && (typeof model !== 'string' || !model.trim())) {
|
|
688
|
+
const err = new Error(
|
|
689
|
+
`${cls}: a model id is required — type the provider's model name ` +
|
|
690
|
+
'(the base URL is not a model).'
|
|
691
|
+
);
|
|
692
|
+
err.status = 400;
|
|
693
|
+
throw err;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const base = {
|
|
697
|
+
cls, model, mode: payload && payload.mode, maxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
|
|
698
|
+
effort: payload && payload.effort,
|
|
699
|
+
workers: payload && payload.workers,
|
|
700
|
+
onReasoning,
|
|
701
|
+
idleTimeoutMs: autonomous ? AUTONOMOUS_IDLE_TIMEOUT_MS : undefined,
|
|
702
|
+
// A caller with no live streaming surface (a `--no-stream` CLI flag, a
|
|
703
|
+
// one-shot script) can ask for the buffered non-stream wire form
|
|
704
|
+
// instead. Undefined/anything but `false` keeps every existing caller
|
|
705
|
+
// (the desktop renderer never sets this) on the streamed path.
|
|
706
|
+
stream: payload && payload.stream === false ? false : true,
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// No round cap: a model that keeps calling tools keeps going for as
|
|
710
|
+
// long as it wants to. The old fixed cap (12 rounds) cut off genuinely
|
|
711
|
+
// long research/exploration turns mid-investigation. A turn ends when
|
|
712
|
+
// the model answers with text, or the user cancels.
|
|
713
|
+
//
|
|
714
|
+
// Both of those exit paths need a guard, because a provider's
|
|
715
|
+
// "I'm finished" signal can arrive with no answer attached — which is
|
|
716
|
+
// exactly how the renderer came to paint "(empty response)" over a turn
|
|
717
|
+
// that had actually done real work:
|
|
718
|
+
// - truncated (finish_reason 'length'): the budget was consumed
|
|
719
|
+
// before any visible text (DeepSeek's hidden reasoning bills
|
|
720
|
+
// against max_tokens), or mid tool-call JSON. One doubled retry.
|
|
721
|
+
// - empty (no tool call AND no text): re-dispatch once with no tools
|
|
722
|
+
// and a nudge, so the model has to write up what it already found.
|
|
723
|
+
// Each guard fires at most once per turn, so a provider that is simply
|
|
724
|
+
// broken still terminates instead of looping.
|
|
725
|
+
let truncationRetried = false;
|
|
726
|
+
let synthesisDone = false;
|
|
727
|
+
|
|
728
|
+
// Token accounting for the whole TURN, not just its last round. An
|
|
729
|
+
// agentic turn makes one provider call per tool round, and returning only
|
|
730
|
+
// the final round's `usage` (what this did) reported a fraction of what
|
|
731
|
+
// was actually spent — the tool phase's tokens simply disappeared. Every
|
|
732
|
+
// dispatch is summed, including the truncation retry and the synthesis
|
|
733
|
+
// re-dispatch: both are real, separately billed provider calls.
|
|
734
|
+
const turnUsage = { calls: 0 };
|
|
735
|
+
const USAGE_FIELDS = ['input_tokens', 'output_tokens', 'prompt_tokens', 'completion_tokens', 'total_tokens'];
|
|
736
|
+
const addUsage = (res) => {
|
|
737
|
+
const u = res && res.usage;
|
|
738
|
+
if (!u || typeof u !== 'object') return;
|
|
739
|
+
turnUsage.calls += 1;
|
|
740
|
+
for (const key of USAGE_FIELDS) {
|
|
741
|
+
if (typeof u[key] === 'number' && Number.isFinite(u[key])) {
|
|
742
|
+
turnUsage[key] = (turnUsage[key] || 0) + u[key];
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// A provider that reports only the split (Anthropic-compatible) has no
|
|
746
|
+
// total to sum, so derive one or the renderer has nothing to print.
|
|
747
|
+
if (typeof u.total_tokens !== 'number') {
|
|
748
|
+
const derived =
|
|
749
|
+
(u.prompt_tokens ?? u.input_tokens ?? 0) + (u.completion_tokens ?? u.output_tokens ?? 0);
|
|
750
|
+
if (derived) turnUsage.total_tokens = (turnUsage.total_tokens || 0) + derived;
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
// The turn's totals win over any single round's, so the attached usage is
|
|
754
|
+
// never the last round wearing the whole turn's label.
|
|
755
|
+
const withTurnUsage = (res) => {
|
|
756
|
+
if (!res || typeof res !== 'object' || !turnUsage.calls) return res;
|
|
757
|
+
// Built from the accumulator alone: every field already present in a
|
|
758
|
+
// round's usage was summed into it, so merging the last round back in
|
|
759
|
+
// could only reintroduce a partial number under a whole-turn label.
|
|
760
|
+
return { ...res, usage: { ...turnUsage } };
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// Round 1's shorthand prompt was sent as `prompt`, not as a message, so
|
|
764
|
+
// any follow-up dispatch in this turn must fold it into the history
|
|
765
|
+
// first or the model would be shown a nudge with no question above it.
|
|
766
|
+
const foldPromptIntoHistory = () => {
|
|
767
|
+
if (prompt === '') return;
|
|
768
|
+
const last = history[history.length - 1];
|
|
769
|
+
if (!(last && last.role === 'user' && last.content === prompt)) {
|
|
770
|
+
history.push({ role: 'user', content: prompt });
|
|
771
|
+
}
|
|
772
|
+
prompt = '';
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
for (;;) {
|
|
776
|
+
const opts = { ...base, system, messages: history, prompt, tools: toolSchemas };
|
|
777
|
+
let res;
|
|
778
|
+
try {
|
|
779
|
+
res = await dispatch(cls, opts);
|
|
780
|
+
} catch (e) {
|
|
781
|
+
// Ollama's OpenAI shim rejects `tools` on older builds. Retrying once
|
|
782
|
+
// without them keeps local chat working instead of turning an
|
|
783
|
+
// unadvertised capability into a hard failure.
|
|
784
|
+
const retriable = cls === 'ollama' && toolSchemas.length && e && (e.status === 400 || /tool/i.test(e.message || ''));
|
|
785
|
+
if (!retriable) throw e;
|
|
786
|
+
res = await dispatch(cls, { ...opts, tools: [] });
|
|
787
|
+
}
|
|
788
|
+
addUsage(res);
|
|
789
|
+
|
|
790
|
+
// A cancelled turn is over. Both recoveries below exist for "the model
|
|
791
|
+
// said nothing", and an aborted request resolves with exactly that —
|
|
792
|
+
// no text — so without this guard a user pressing Esc to stop a slow
|
|
793
|
+
// turn immediately issued ANOTHER billed provider call (and, because
|
|
794
|
+
// every transport concatenates the deltas it forwards, streamed the
|
|
795
|
+
// partial answer a second time onto the same bubble). Measured before
|
|
796
|
+
// the guard: one interrupted turn == two dispatches.
|
|
797
|
+
if (signal.aborted) return withTurnUsage(res);
|
|
798
|
+
|
|
799
|
+
// Budget exhausted before the answer was written. Doubling it costs
|
|
800
|
+
// one request and converts a dead turn into a real one; a second
|
|
801
|
+
// 'length' result is accepted as-is so a hard-capped model can't
|
|
802
|
+
// spin here forever.
|
|
803
|
+
//
|
|
804
|
+
// Gated on empty text on purpose. Every transport builds `content`
|
|
805
|
+
// by concatenating the deltas it already forwarded to onDelta, so
|
|
806
|
+
// empty content means nothing was streamed and re-dispatching cannot
|
|
807
|
+
// double up in the renderer's live view. When text *has* arrived the
|
|
808
|
+
// turn is not empty — the answer is merely truncated — and a retry
|
|
809
|
+
// would stream it a second time onto the same bubble.
|
|
810
|
+
if (!truncationRetried && !assistantText(res) && isTruncated(res)) {
|
|
811
|
+
truncationRetried = true;
|
|
812
|
+
// singlePass: this retry buys *budget*, not a second investigation.
|
|
813
|
+
// The fan-out's workers would re-run the whole task from scratch for
|
|
814
|
+
// it — 3 extra reasoning passes + a synthesis — which is the opposite
|
|
815
|
+
// of what "one doubled request" means (and what the note above
|
|
816
|
+
// promises). The pass itself is unchanged apart from that.
|
|
817
|
+
res = await dispatch(cls, {
|
|
818
|
+
...opts,
|
|
819
|
+
singlePass: true,
|
|
820
|
+
maxTokens: doubledBudget(opts.maxTokens),
|
|
821
|
+
});
|
|
822
|
+
addUsage(res);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const calls = toolSchemas.length ? extractToolCalls(res) : [];
|
|
826
|
+
if (!calls.length) {
|
|
827
|
+
if (assistantText(res) || synthesisDone || !toolsEnabled) return withTurnUsage(res);
|
|
828
|
+
// The model stopped without calling a tool and without saying
|
|
829
|
+
// anything. Force the summary out of the context it already holds
|
|
830
|
+
// instead of handing the renderer a blank completion. Skipped when
|
|
831
|
+
// the caller opted out of the agent loop (`tools: false`): there is
|
|
832
|
+
// no gathered context to rescue, so a bare completion is just that.
|
|
833
|
+
synthesisDone = true;
|
|
834
|
+
foldPromptIntoHistory();
|
|
835
|
+
history.push({ role: 'user', content: EMPTY_TURN_NUDGE });
|
|
836
|
+
// singlePass: same reasoning as the truncation retry above, and it is
|
|
837
|
+
// the same comment's literal promise ("force the summary out of the
|
|
838
|
+
// context it already holds"). Escalating a write-up back into the
|
|
839
|
+
// worker fan-out asked three fresh workers to redo an investigation
|
|
840
|
+
// whose findings are already in `history`, at 4x the cost, to produce
|
|
841
|
+
// a paragraph the model had all the material for.
|
|
842
|
+
res = await dispatch(cls, {
|
|
843
|
+
...opts,
|
|
844
|
+
singlePass: true,
|
|
845
|
+
messages: history,
|
|
846
|
+
prompt: '',
|
|
847
|
+
tools: [],
|
|
848
|
+
});
|
|
849
|
+
addUsage(res);
|
|
850
|
+
if (!assistantText(res)) {
|
|
851
|
+
throw emptyTurnError({
|
|
852
|
+
cls,
|
|
853
|
+
model,
|
|
854
|
+
maxTokens: opts.maxTokens,
|
|
855
|
+
finishReason: finishReasonOf(res),
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
return withTurnUsage(res);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
foldPromptIntoHistory();
|
|
862
|
+
|
|
863
|
+
// Thread the assistant turn (its tool_calls) and each result back in
|
|
864
|
+
// the shapes both wire formats accept (providers.js normalises them).
|
|
865
|
+
history.push({
|
|
866
|
+
role: 'assistant',
|
|
867
|
+
content: assistantText(res),
|
|
868
|
+
tool_calls: calls.map((c) => ({
|
|
869
|
+
id: c.id,
|
|
870
|
+
type: 'function',
|
|
871
|
+
function: { name: c.name, arguments: JSON.stringify(c.args) },
|
|
872
|
+
})),
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
for (const call of calls) {
|
|
876
|
+
const result = call.name === T.SUBAGENT_TOOL
|
|
877
|
+
? await runSubagent(call.args, {
|
|
878
|
+
cls, model, maxTokens, mode: payload && payload.mode, parentSignal: signal, depth, rootSessionId, rootOnDelta,
|
|
879
|
+
})
|
|
880
|
+
: await gatedExecuteTool(call, { toolCtx, rootSessionId, rootOnDelta, signal });
|
|
881
|
+
// A subagent's spend rides back on its tool result (see runSubagent).
|
|
882
|
+
if (result && result.usage) addUsage({ usage: result.usage });
|
|
883
|
+
if (onDelta) onDelta({ delta: '', tool: { name: call.name, args: call.args, ok: result.ok } });
|
|
884
|
+
history.push({
|
|
885
|
+
role: 'tool',
|
|
886
|
+
tool_call_id: call.id,
|
|
887
|
+
name: call.name,
|
|
888
|
+
content: T.toolResultText(result),
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
} finally {
|
|
893
|
+
if (shell) shell.dispose();
|
|
894
|
+
controllers.delete(sessionId);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Run a `task` tool call as a subagent: a nested chat() turn on the same
|
|
900
|
+
* class/model, primed with the chosen specialist's system prompt (agents.js)
|
|
901
|
+
* and its own tool access (including task, until MAX_SUBAGENT_DEPTH cuts
|
|
902
|
+
* it off), returning the subagent's final text as the tool result. Never
|
|
903
|
+
* throws — resolves { ok, output } or { ok:false, error }, matching
|
|
904
|
+
* tools.js's executor contract so the caller treats it identically.
|
|
905
|
+
*/
|
|
906
|
+
async function runSubagent(
|
|
907
|
+
{ description, subagent_type, prompt: subPrompt } = {},
|
|
908
|
+
{ cls, model, maxTokens, mode, parentSignal, depth, rootSessionId, rootOnDelta } = {}
|
|
909
|
+
) {
|
|
910
|
+
const task = String(subPrompt || description || '').trim();
|
|
911
|
+
if (!task) return { ok: false, error: 'task requires a prompt' };
|
|
912
|
+
const label = subagent_type && subagent_type !== 'general' ? agentRoleLabel(subagent_type) : 'general';
|
|
913
|
+
const system = agentSystemPrompt(subagent_type);
|
|
914
|
+
const subSessionId = randomUUID();
|
|
915
|
+
|
|
916
|
+
// Aborting the parent turn must also stop a running subagent instead of
|
|
917
|
+
// leaving it to finish on its own (or sit out the whole turn timeout).
|
|
918
|
+
let onParentAbort;
|
|
919
|
+
if (parentSignal) {
|
|
920
|
+
if (parentSignal.aborted) return { ok: false, error: 'aborted' };
|
|
921
|
+
onParentAbort = () => cancel(subSessionId);
|
|
922
|
+
parentSignal.addEventListener('abort', onParentAbort, { once: true });
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
try {
|
|
926
|
+
// rootSessionId/rootOnDelta ride along explicitly (see chat()) so the
|
|
927
|
+
// subagent's own mutating tool calls still gate through the SAME
|
|
928
|
+
// approval card the user sees for the top-level turn, instead of
|
|
929
|
+
// silently hanging behind this call's no-op onDelta below.
|
|
930
|
+
const res = await chat(
|
|
931
|
+
{
|
|
932
|
+
class: cls, model, maxTokens, mode, system, prompt: task, sessionId: subSessionId, depth: (depth || 0) + 1,
|
|
933
|
+
rootSessionId, rootOnDelta,
|
|
934
|
+
},
|
|
935
|
+
() => {}
|
|
936
|
+
);
|
|
937
|
+
const text = assistantText(res);
|
|
938
|
+
// The subagent's tokens were billed to the same account as the parent
|
|
939
|
+
// turn, so its usage travels back on the tool result and is summed into
|
|
940
|
+
// the parent's total. Attached even on the no-output branch: a subagent
|
|
941
|
+
// that burned a full context and said nothing is precisely the spend the
|
|
942
|
+
// user most needs to see.
|
|
943
|
+
const spent = res && res.usage ? { usage: res.usage } : {};
|
|
944
|
+
return text
|
|
945
|
+
? { ok: true, output: text, ...spent }
|
|
946
|
+
: { ok: false, error: `subagent (${label}) produced no output`, ...spent };
|
|
947
|
+
} catch (e) {
|
|
948
|
+
return { ok: false, error: `subagent (${label}) failed: ${e && e.message ? e.message : e}` };
|
|
949
|
+
} finally {
|
|
950
|
+
if (parentSignal && onParentAbort) parentSignal.removeEventListener('abort', onParentAbort);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function cancel(sessionId) {
|
|
955
|
+
const controller = controllers.get(sessionId);
|
|
956
|
+
if (controller) controller.abort();
|
|
957
|
+
return { ok: Boolean(controller) };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
return {
|
|
961
|
+
CLASSES,
|
|
962
|
+
listClasses,
|
|
963
|
+
listModels,
|
|
964
|
+
chat,
|
|
965
|
+
cancel,
|
|
966
|
+
respondApproval,
|
|
967
|
+
clearSessionApprovals,
|
|
968
|
+
settings,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
module.exports = { CLASSES, createLocalEngine, extractToolCalls, parseArgs };
|