aegis-desktop 0.3.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/bin/aegis.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * `aegis` CLI entry point for the `aegis-desktop` npm package: launches the
6
+ * same Electron app as `npm start` in this repo, but from a global install
7
+ * where there is no sibling `client/` dir — main.js's require('../client/
8
+ * aegis.js') fails there and it falls back to the vendored copy staged by
9
+ * predist at publish time (prepublishOnly), which is the intended path.
10
+ */
11
+
12
+ const path = require('node:path');
13
+ const { spawn } = require('node:child_process');
14
+
15
+ const appDir = path.join(__dirname, '..');
16
+
17
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
18
+ console.log(require(path.join(appDir, 'package.json')).version);
19
+ process.exit(0);
20
+ }
21
+
22
+ const electronPath = require('electron');
23
+ const child = spawn(electronPath, [appDir, ...process.argv.slice(2)], {
24
+ stdio: 'inherit',
25
+ });
26
+ child.on('error', (err) => {
27
+ console.error('Failed to launch AEGIS Desktop:', err.message);
28
+ process.exit(1);
29
+ });
30
+ child.on('close', (code) => process.exit(code == null ? 0 : code));
package/build/icon.png ADDED
Binary file
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * agents.js — subagent prompt presets for the desktop `task` tool, ported
5
+ * from aegiscodex-dev's src/agents.js (same roles, same synthesis presets).
6
+ * A preset is a system prompt: `task` runs it through the normal engine.chat
7
+ * loop as a nested turn (its own tool rounds, same model class), not a
8
+ * separate runtime, so the presets are pure prompt composition.
9
+ *
10
+ * Text is adapted to the desktop's own tool vocabulary (readFile/writeFile/
11
+ * editFile/listDir/glob/grep/exec/task — see tools.js) so a subagent's
12
+ * instructions never name a tool the desktop doesn't actually advertise.
13
+ */
14
+
15
+ /** Role → system prompt. */
16
+ const AGENT_PRESETS = {
17
+ synthesizer: `You are a senior technical lead. Given analysis from multiple specialist agents, synthesize their findings into a clear, actionable summary.
18
+ Structure your response as: key findings, recommended approach, top action items.
19
+ Be direct, concrete, and avoid repeating everything the agents said.
20
+ Focus on delivering a decision-ready synthesis.`,
21
+
22
+ architect: `You are a System Architect. Design the new application architecture.
23
+ Define: project structure, tech stack, directory layout, key modules, data flow, API design.
24
+ Consider: scalability, maintainability, testing strategy, deployment.
25
+ Output a concrete file tree and architecture decisions log. Be specific.`,
26
+
27
+ scaffolder: `You are a Project Scaffolder. Build the complete application from scratch.
28
+
29
+ YOUR JOB IS TO CREATE ALL PROJECT FILES - not just describe them.
30
+
31
+ Use writeFile to create: package.json, tsconfig.json, source files, configs, tests.
32
+ Generate COMPLETE, WORKING code - not stubs or placeholders.
33
+ Set up build scripts, lint config, and any necessary tooling.
34
+
35
+ After creating files, use exec to run: npm/pnpm install, then build/compile.
36
+ Fix any errors until the project builds successfully.
37
+
38
+ Be thorough - a real, runnable project is the goal.`,
39
+
40
+ planner: `You are a Refactoring Planner. Given the analyzer findings, create a step-by-step plan.
41
+ Each step: file path, what to change, why, risk level (LOW/MEDIUM/HIGH).
42
+ Include before/after snippets. Order by impact. Be concrete.`,
43
+
44
+ implementer: `You are an Implementation Engineer. Execute the refactoring plan.
45
+ Use editFile and writeFile to make actual code changes.
46
+ After each change, use readFile to verify correctness. Keep existing code style.
47
+ Run build commands with exec to ensure nothing is broken.`,
48
+
49
+ reviewer: `You are a Code Reviewer. Review the approach and code.
50
+ Check: logic errors, type safety, error handling, performance, security.
51
+ Be critical but constructive. Report specific issues with file paths.`,
52
+
53
+ debugger: `You are a Debugging Specialist. Analyze potential issues and edge cases.
54
+ Identify: failure modes, error handling gaps, testing considerations.
55
+ Think about what could go wrong and how to prevent it.`,
56
+
57
+ scanner: `You are a Security Vulnerability Scanner.
58
+ Scan for: hardcoded API keys/secrets, SQL injection, XSS, unsafe eval/exec, path traversal.
59
+ Use grep with targeted patterns. Report every finding with: file path, severity (CRITICAL/HIGH/MEDIUM/LOW), line number.`,
60
+
61
+ analyzer: `You are a Code Analyzer. Find refactoring opportunities.
62
+ Look for: duplicated code, long functions (>20 lines), complex conditionals, unused imports,
63
+ circular dependencies, inconsistent patterns. Report with file paths and line numbers.`,
64
+ };
65
+
66
+ /** System prompt for a `general` (or unrecognized) subagent type. */
67
+ const GENERAL_AGENT_PROMPT =
68
+ 'You are a capable autonomous coding subagent. Complete the assigned task end to end using ' +
69
+ 'the available tools (exec, readFile, writeFile, editFile, listDir, glob, grep). Work in the ' +
70
+ 'current repository, verify your work, and finish with a concise report of what you did and ' +
71
+ 'what you found. You cannot ask follow-up questions — make reasonable assumptions and proceed. ' +
72
+ 'When a large sub-task is better handled by a focused specialist, delegate it with the task tool.';
73
+
74
+ /** Human-readable role label for a preset id (falls back to the id). */
75
+ function agentRoleLabel(role) {
76
+ const labels = {
77
+ synthesizer: 'Technical Lead', architect: 'System Architect',
78
+ scaffolder: 'Project Scaffolder', planner: 'Refactoring Planner',
79
+ implementer: 'Implementation Engineer', reviewer: 'Code Reviewer',
80
+ debugger: 'Debugging Specialist', scanner: 'Vulnerability Scanner',
81
+ analyzer: 'Code Analyzer', general: 'General',
82
+ };
83
+ return labels[role] || role;
84
+ }
85
+
86
+ /** Every preset id, in palette order (excludes the 'general' fallback). */
87
+ function agentRoles() {
88
+ return Object.keys(AGENT_PRESETS);
89
+ }
90
+
91
+ /** Resolve a subagent_type to its system prompt (unknown/absent → general). */
92
+ function agentSystemPrompt(role) {
93
+ return AGENT_PRESETS[role] || GENERAL_AGENT_PROMPT;
94
+ }
95
+
96
+ module.exports = {
97
+ AGENT_PRESETS,
98
+ GENERAL_AGENT_PROMPT,
99
+ agentRoleLabel,
100
+ agentRoles,
101
+ agentSystemPrompt,
102
+ };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * context.js — token-budget trimmer for very large inputs (plan P1 §5.1 /
5
+ * P2 §6.4).
6
+ *
7
+ * Pure Node, no Electron imports. Estimates tokens with a ~4 chars/token
8
+ * heuristic, keeps the system message plus the newest turns, and drops the
9
+ * oldest turns that exceed the budget. The full transcript always stays in
10
+ * the local session record — this only trims what is sent to the wire.
11
+ */
12
+
13
+ const CHARS_PER_TOKEN = 4;
14
+ const MESSAGE_OVERHEAD = 4; // per-message framing + role overhead
15
+
16
+ /** Rough token count for a string (no tokenizer in a zero-dep shell). */
17
+ function estimateTokens(text) {
18
+ if (text == null) return 0;
19
+ const s = typeof text === 'string' ? text : String(text);
20
+ return Math.max(0, Math.ceil(s.length / CHARS_PER_TOKEN));
21
+ }
22
+
23
+ /** Rough token cost of one { role, content } message. */
24
+ function messageTokens(message) {
25
+ if (!message) return 0;
26
+ const content =
27
+ message.content != null
28
+ ? message.content
29
+ : message.text != null
30
+ ? message.text
31
+ : '';
32
+ return MESSAGE_OVERHEAD + estimateTokens(content);
33
+ }
34
+
35
+ /**
36
+ * Trim a message list to fit a token budget, keeping the newest turns.
37
+ *
38
+ * @param {object} opts
39
+ * @param {Array<{role:string,content:string}>} [opts.messages=[]]
40
+ * @param {string} [opts.system='']
41
+ * @param {number} [opts.budgetTokens=8192] total tokens allowed on the wire
42
+ * @param {number} [opts.reserveTokens=0] tokens reserved for the reply
43
+ * @returns {{messages:Array, system:string, dropped:number, estimatedTokens:number}}
44
+ */
45
+ function trimContext({
46
+ messages = [],
47
+ system = '',
48
+ budgetTokens = 8192,
49
+ reserveTokens = 0,
50
+ } = {}) {
51
+ const limit = Math.max(0, budgetTokens - reserveTokens);
52
+ const kept = [];
53
+ let used = 0;
54
+
55
+ if (system) used += MESSAGE_OVERHEAD + estimateTokens(system);
56
+
57
+ // Walk newest -> oldest; keep a turn if it fits, else stop (older turns are
58
+ // even less relevant). Always keep at least the newest turn so a single
59
+ // oversized message is still sent rather than silently emptied.
60
+ for (let i = messages.length - 1; i >= 0; i--) {
61
+ const cost = messageTokens(messages[i]);
62
+ if (used + cost > limit && kept.length > 0) break;
63
+ kept.unshift(messages[i]);
64
+ used += cost;
65
+ }
66
+
67
+ return {
68
+ messages: kept,
69
+ system,
70
+ dropped: messages.length - kept.length,
71
+ estimatedTokens: used,
72
+ };
73
+ }
74
+
75
+ module.exports = {
76
+ CHARS_PER_TOKEN,
77
+ MESSAGE_OVERHEAD,
78
+ estimateTokens,
79
+ messageTokens,
80
+ trimContext,
81
+ };
@@ -0,0 +1,460 @@
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 until the model answers with text or the round cap is
15
+ * hit. The window stays contextIsolated + sandboxed: this module runs in the
16
+ * MAIN process, so the executor never has to be exposed to the renderer.
17
+ *
18
+ * Two turn-scoped resources ride along with the loop, mirroring
19
+ * aegiscodex-dev's runProvider exactly:
20
+ * - a lazily-started ShellSession (shell.js) that the `exec` tool shares,
21
+ * so cd/export state persists across calls within one turn instead of
22
+ * each call spawning a fresh process;
23
+ * - the `task` tool, executed as a nested `chat()` call on the chosen
24
+ * specialist preset (agents.js) rather than a local tool — a subagent
25
+ * turn with its own tool rounds, bounded by MAX_SUBAGENT_DEPTH so a
26
+ * delegation chain can't recurse forever.
27
+ *
28
+ * Pure Node + dependency-injected (aegis client, settings store, ollama,
29
+ * providers, optionally tools/prompt) so it unit-tests without Electron.
30
+ */
31
+
32
+ const { randomUUID } = require('node:crypto');
33
+ const os = require('node:os');
34
+
35
+ const toolsModule = require('./tools.js');
36
+ const promptModule = require('./prompt.js');
37
+ const { ShellSession } = require('./shell.js');
38
+ const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
39
+
40
+ /** Classes whose transport is a user-supplied endpoint + credential. */
41
+ const CUSTOM_CLASSES = Object.freeze(['openai-compat', 'anthropic']);
42
+
43
+ /** Hard cap on tool rounds per turn — mirrors the CLI's bounded loop. */
44
+ const MAX_TOOL_ROUNDS = 12;
45
+
46
+ /**
47
+ * Depth at which the task tool stops being offered. The main chat (depth 0)
48
+ * and subagents down to depth MAX_SUBAGENT_DEPTH - 1 can all delegate, so
49
+ * legitimate hierarchical work (scan -> review -> patch, etc.) has room to
50
+ * nest without hitting a wall. Past that the tool is dropped, hard-cutting a
51
+ * runaway chain instead of letting it recurse unbounded.
52
+ */
53
+ const MAX_SUBAGENT_DEPTH = 4;
54
+
55
+ const CLASSES = [
56
+ { class: 'aegis', label: 'Aegis Cloud', kind: 'cloud' },
57
+ { class: 'ollama', label: 'Ollama (local)', kind: 'local' },
58
+ { class: 'openai-compat', label: 'Custom OpenAI-compatible', kind: 'custom' },
59
+ { class: 'anthropic', label: 'Anthropic-compatible', kind: 'custom' },
60
+ ];
61
+
62
+ /** Relay model entries arrive as ids or objects; keep only real model ids. */
63
+ function normalizeCatalog(models) {
64
+ if (!Array.isArray(models)) return [];
65
+ return models
66
+ .map((m) => (typeof m === 'string' ? { id: m } : m))
67
+ .filter((m) => m && m.id);
68
+ }
69
+
70
+ /**
71
+ * The Aegis Cloud catalog (`/api/v1/models`) also lists internal routing
72
+ * aliases — per-provider variants like `openai-gpt4o-mini`/`anthropic-haiku`,
73
+ * and six pooled-brain tier ids (`{aegis,nexus}-brain[-smart|-neo]`) that all
74
+ * run the same worker pool on the same backend model. None of those are a
75
+ * human's model choice; picking between them put six near-duplicate "brain"
76
+ * entries in the desktop dropdown. Surface only the four platform models the
77
+ * user actually selects between, plus one collapsed "Nexus" entry standing
78
+ * in for whichever brain tier the pool advertises.
79
+ */
80
+ const AEGIS_PLATFORM_MODELS = Object.freeze(['openai', 'anthropic', 'groq', 'gemini']);
81
+ const NEXUS_BRAIN_ID = 'aegis-brain';
82
+ const NEXUS_LABEL = 'Nexus (Aegis brain)';
83
+
84
+ function filterAegisCatalog(models) {
85
+ const platform = models.filter((m) => AEGIS_PLATFORM_MODELS.includes(m.id));
86
+ const nexus = models.find((m) => m.id === NEXUS_BRAIN_ID);
87
+ return nexus ? [...platform, { ...nexus, label: NEXUS_LABEL }] : platform;
88
+ }
89
+
90
+ // ── Agent-loop helpers ──────────────────────────────────────────────────────
91
+
92
+ /** Parse a model-supplied argument blob (string or already-parsed object). */
93
+ function parseArgs(raw) {
94
+ if (raw == null) return {};
95
+ if (typeof raw === 'object') return raw;
96
+ try {
97
+ const v = JSON.parse(String(raw));
98
+ return v && typeof v === 'object' ? v : {};
99
+ } catch {
100
+ return {};
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Pull normalised tool calls out of whichever shape the transport returned:
106
+ * the shared `result.toolCalls` both local parsers emit, or a provider-native
107
+ * `choices[0].message.tool_calls` (the Aegis pool relays the upstream OpenAI
108
+ * shape verbatim).
109
+ */
110
+ function extractToolCalls(res) {
111
+ if (!res) return [];
112
+ if (Array.isArray(res.toolCalls) && res.toolCalls.length) {
113
+ return res.toolCalls
114
+ .map((c) => ({ id: c.id || '', name: c.name || '', args: parseArgs(c.args) }))
115
+ .filter((c) => c.name);
116
+ }
117
+ const msg = res.choices && res.choices[0] && res.choices[0].message;
118
+ const raw = (msg && msg.tool_calls) || [];
119
+ if (!Array.isArray(raw)) return [];
120
+ return raw
121
+ .map((tc) => {
122
+ const fn = (tc && tc.function) || {};
123
+ return { id: (tc && tc.id) || '', name: fn.name || (tc && tc.name) || '', args: parseArgs(fn.arguments) };
124
+ })
125
+ .filter((c) => c.name);
126
+ }
127
+
128
+ /** The textual content of one assistant turn (empty when it only called tools). */
129
+ function assistantText(res) {
130
+ const msg = res && res.choices && res.choices[0] && res.choices[0].message;
131
+ return (msg && typeof msg.content === 'string' && msg.content) || '';
132
+ }
133
+
134
+ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBuilder, env }) {
135
+ const controllers = new Map(); // sessionId -> AbortController
136
+ const T = tools || toolsModule;
137
+ const buildSystemPrompt = (promptBuilder && promptBuilder.buildSystemPrompt) || promptModule.buildSystemPrompt;
138
+
139
+ /**
140
+ * Custom endpoints are only usable when they are actually configured:
141
+ * a base URL is mandatory for both, and Anthropic additionally needs its own
142
+ * key (the wire format authenticates with x-api-key). Reporting them as
143
+ * always-ready made chat() POST to `${undefined}/v1/…` (defect #2).
144
+ */
145
+ function customStatus(cls) {
146
+ const cfg = settings.get(cls) || {};
147
+ const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
148
+ const hasBase = Boolean(baseURL);
149
+ const hasKey = Boolean(cfg.configured);
150
+ return {
151
+ configured: cls === 'anthropic' ? hasBase && hasKey : hasBase,
152
+ baseURL,
153
+ keyMask: cfg.keyMask || null,
154
+ };
155
+ }
156
+
157
+ async function listClasses() {
158
+ const status = await ollama.probe().catch(() => ({ running: false }));
159
+ return CLASSES.map((c) => {
160
+ if (c.class === 'ollama') return { ...c, configured: Boolean(status.running) };
161
+ if (c.class === 'aegis') {
162
+ return { ...c, configured: Boolean(aegis.apiKey) };
163
+ }
164
+ return { ...c, ...customStatus(c.class) };
165
+ });
166
+ }
167
+
168
+ async function listModels(cls) {
169
+ if (cls === 'aegis') {
170
+ const data = await aegis.listModels();
171
+ return { class: cls, models: filterAegisCatalog(normalizeCatalog(data && data.models)) };
172
+ }
173
+ if (cls === 'ollama') {
174
+ const tags = await ollama.listTags();
175
+ return { class: cls, models: tags.map((t) => ({ id: t.id })) };
176
+ }
177
+ // Custom endpoints: the model id is the *user's* choice — a provider model
178
+ // name, never a URL. Offering the configured base URL as an `id` meant that
179
+ // leaving the default selection POSTed `model: "https://api.openai.com/v1"`,
180
+ // an upstream 400 invalid-model on every call (defect B). There is nothing
181
+ // to enumerate, so the list stays empty and `needsModelId` tells the
182
+ // renderer to prompt for a typed id instead. The base URL still travels
183
+ // along for display only.
184
+ const cfg = settings.get(cls) || {};
185
+ const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
186
+ return { class: cls, models: [], needsModelId: true, baseURL };
187
+ }
188
+
189
+ /**
190
+ * The environment facts the model needs to stop asking which OS it is on.
191
+ * Everything is best-effort: a missing field is simply omitted.
192
+ */
193
+ function envFor(payload) {
194
+ const supplied = (payload && payload.env) || {};
195
+ const base = env || {};
196
+ const pick = (key, value) => (supplied[key] != null ? supplied[key] : value);
197
+ let homedir = base.homedir;
198
+ let cwd = base.cwd;
199
+ try {
200
+ if (!homedir) homedir = os.homedir();
201
+ if (!cwd) cwd = process.cwd();
202
+ } catch {
203
+ /* keep whatever we have */
204
+ }
205
+ return {
206
+ platform: pick('platform', base.platform || process.platform),
207
+ arch: pick('arch', base.arch || process.arch),
208
+ homedir: pick('homedir', homedir),
209
+ cwd: pick('cwd', cwd),
210
+ roots: pick('roots', base.roots),
211
+ appVersion: pick('appVersion', base.appVersion),
212
+ model: pick('model', payload && payload.model),
213
+ };
214
+ }
215
+
216
+ /** One transport round for the chosen class. */
217
+ async function dispatch(cls, opts) {
218
+ if (cls === 'aegis') {
219
+ return aegis.chatCompletion({
220
+ prompt: opts.prompt,
221
+ system: opts.system,
222
+ messages: opts.messages,
223
+ model: opts.model,
224
+ mode: opts.mode,
225
+ maxTokens: opts.maxTokens,
226
+ stream: true,
227
+ onStream: opts.onDelta,
228
+ signal: opts.signal,
229
+ // aegis_memory: automatic, no button — the server both reads prior
230
+ // synced memory into context AND writes this turn back to it, the
231
+ // same flag aegis-online sets. Matches aegiscodex-dev's own
232
+ // cross-session memory (auto-indexed, no manual tagging).
233
+ extra: {
234
+ aegis_memory: true,
235
+ session: opts.sessionId,
236
+ ...(opts.autonomous ? { brain: true } : {}),
237
+ // Only meaningful (and only sent) alongside `brain` — aegis1
238
+ // services/pool_brain.py parse_brain_request reads `effort`/
239
+ // `workers` straight off the body and clamps them itself
240
+ // (EFFORT_LEVELS / MAX_WORKERS), so no client-side validation here.
241
+ ...(opts.autonomous && opts.effort ? { effort: opts.effort } : {}),
242
+ ...(opts.autonomous && opts.workers ? { workers: opts.workers } : {}),
243
+ // The pool forwards `tools` to the provider and returns tool_calls
244
+ // (aegis1 app.py:7765 → provider, pool_brain synthesis keeps them).
245
+ ...(opts.tools.length ? { tools: opts.tools } : {}),
246
+ ...(opts.toolChoice ? { tool_choice: opts.toolChoice } : {}),
247
+ },
248
+ });
249
+ }
250
+
251
+ if (cls === 'ollama') {
252
+ return ollama.chat({
253
+ model: opts.model,
254
+ prompt: opts.prompt,
255
+ system: opts.system,
256
+ messages: opts.messages,
257
+ maxTokens: opts.maxTokens,
258
+ signal: opts.signal,
259
+ onDelta: opts.onDelta,
260
+ ...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
261
+ });
262
+ }
263
+
264
+ const common = {
265
+ baseURL: opts.cfg.baseURL,
266
+ apiKey: opts.apiKey,
267
+ model: opts.model,
268
+ prompt: opts.prompt,
269
+ system: opts.system,
270
+ messages: opts.messages,
271
+ maxTokens: opts.maxTokens,
272
+ signal: opts.signal,
273
+ onDelta: opts.onDelta,
274
+ ...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
275
+ };
276
+
277
+ if (cls === 'anthropic') return providers.anthropicMessages(common);
278
+ return providers.openaiCompatible(common);
279
+ }
280
+
281
+ async function chat(payload, onDelta) {
282
+ const cls = payload && payload.class;
283
+ const model = payload && payload.model;
284
+ const maxTokens = payload && payload.maxTokens;
285
+ // "Work autonomously" — routes this call through aegis1's pool_brain
286
+ // worker fan-out (services/pool_brain.py: N reasoning workers + a
287
+ // synthesis pass) instead of a single provider call. UI-gated to the
288
+ // 'aegis' class only (see AUTONOMOUS_CLASS in app.js).
289
+ const autonomous = cls === 'aegis' && Boolean(payload && payload.autonomous);
290
+ const sessionId = (payload && payload.sessionId) || randomUUID();
291
+ // Recursion depth for the task tool: 0 for a real user turn, N+1 for a
292
+ // subagent spawned by depth N. Never set by an IPC caller — only by
293
+ // runSubagent's own recursive chat() call below.
294
+ const depth = Number.isInteger(payload && payload.depth) ? payload.depth : 0;
295
+
296
+ const controller = new AbortController();
297
+ controllers.set(sessionId, controller);
298
+ const signal = controller.signal;
299
+
300
+ // A caller can opt out of the agent loop entirely (`tools: false`) and get
301
+ // the old single-shot turn back.
302
+ const toolsEnabled = !(payload && payload.tools === false);
303
+ const wire = cls === 'anthropic' ? 'anthropic' : 'openai';
304
+ const toolSchemas = toolsEnabled ? T.toolsFor(wire, { includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
305
+ const toolChoice = (payload && payload.toolChoice) || null;
306
+
307
+ const system = (payload && payload.system) || buildSystemPrompt(envFor(payload));
308
+ const history = Array.isArray(payload && payload.messages) ? payload.messages.filter(Boolean).slice() : [];
309
+ let prompt = (payload && payload.prompt) || '';
310
+
311
+ // Lazily start ONE shell session for this turn; the exec tool shares it so
312
+ // cd/env/state persist across calls. Only spawned if exec actually runs,
313
+ // and always disposed when the turn ends.
314
+ let shell = null;
315
+ const getShell = () => shell || (shell = new ShellSession({ cwd: envFor(payload).cwd }));
316
+ const toolCtx = { getShell, signal };
317
+
318
+ try {
319
+ const cfg = cls === 'aegis' || cls === 'ollama' ? {} : settings.get(cls) || {};
320
+ const apiKey = cls === 'aegis' || cls === 'ollama' ? null : settings.rawKey(cls);
321
+
322
+ // Custom classes carry no enumerable model list (see listModels), so a
323
+ // blank id here means the user never typed one. Fail loudly in-process
324
+ // instead of shipping `model: undefined` upstream (defect B).
325
+ if (CUSTOM_CLASSES.includes(cls) && (typeof model !== 'string' || !model.trim())) {
326
+ const err = new Error(
327
+ `${cls}: a model id is required — type the provider's model name ` +
328
+ '(the base URL is not a model).'
329
+ );
330
+ err.status = 400;
331
+ throw err;
332
+ }
333
+
334
+ const base = {
335
+ cls, model, mode: payload && payload.mode, maxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
336
+ effort: payload && payload.effort,
337
+ workers: payload && payload.workers,
338
+ };
339
+
340
+ let last = null;
341
+ for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
342
+ const opts = { ...base, system, messages: history, prompt, tools: toolSchemas };
343
+ let res;
344
+ try {
345
+ res = await dispatch(cls, opts);
346
+ } catch (e) {
347
+ // Ollama's OpenAI shim rejects `tools` on older builds. Retrying once
348
+ // without them keeps local chat working instead of turning an
349
+ // unadvertised capability into a hard failure.
350
+ const retriable = cls === 'ollama' && toolSchemas.length && e && (e.status === 400 || /tool/i.test(e.message || ''));
351
+ if (!retriable) throw e;
352
+ res = await dispatch(cls, { ...opts, tools: [] });
353
+ }
354
+ last = res;
355
+
356
+ const calls = toolSchemas.length ? extractToolCalls(res) : [];
357
+ if (!calls.length) return res;
358
+ if (round === MAX_TOOL_ROUNDS) return res; // round cap: hand back what we have
359
+
360
+ // Continuing the loop means round 1's shorthand prompt has to become
361
+ // part of the history — it was sent as `prompt`, not as a message, so
362
+ // without this the model would see a tool result and no question.
363
+ if (prompt !== '') {
364
+ const last = history[history.length - 1];
365
+ if (!(last && last.role === 'user' && last.content === prompt)) {
366
+ history.push({ role: 'user', content: prompt });
367
+ }
368
+ prompt = '';
369
+ }
370
+
371
+ // Thread the assistant turn (its tool_calls) and each result back in
372
+ // the shapes both wire formats accept (providers.js normalises them).
373
+ history.push({
374
+ role: 'assistant',
375
+ content: assistantText(res),
376
+ tool_calls: calls.map((c) => ({
377
+ id: c.id,
378
+ type: 'function',
379
+ function: { name: c.name, arguments: JSON.stringify(c.args) },
380
+ })),
381
+ });
382
+
383
+ for (const call of calls) {
384
+ const result = call.name === T.SUBAGENT_TOOL
385
+ ? await runSubagent(call.args, { cls, model, maxTokens, mode: payload && payload.mode, parentSignal: signal, depth })
386
+ : await T.executeTool(call.name, call.args, toolCtx);
387
+ if (onDelta) onDelta({ delta: '', tool: { name: call.name, args: call.args, ok: result.ok } });
388
+ history.push({
389
+ role: 'tool',
390
+ tool_call_id: call.id,
391
+ name: call.name,
392
+ content: T.toolResultText(result),
393
+ });
394
+ }
395
+ }
396
+ return last;
397
+ } finally {
398
+ if (shell) shell.dispose();
399
+ controllers.delete(sessionId);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Run a `task` tool call as a subagent: a nested chat() turn on the same
405
+ * class/model, primed with the chosen specialist's system prompt (agents.js)
406
+ * and its own tool access (including task, until MAX_SUBAGENT_DEPTH cuts
407
+ * it off), returning the subagent's final text as the tool result. Never
408
+ * throws — resolves { ok, output } or { ok:false, error }, matching
409
+ * tools.js's executor contract so the caller treats it identically.
410
+ */
411
+ async function runSubagent({ description, subagent_type, prompt: subPrompt } = {}, { cls, model, maxTokens, mode, parentSignal, depth } = {}) {
412
+ const task = String(subPrompt || description || '').trim();
413
+ if (!task) return { ok: false, error: 'task requires a prompt' };
414
+ const label = subagent_type && subagent_type !== 'general' ? agentRoleLabel(subagent_type) : 'general';
415
+ const system = agentSystemPrompt(subagent_type);
416
+ const subSessionId = randomUUID();
417
+
418
+ // Aborting the parent turn must also stop a running subagent instead of
419
+ // leaving it to finish on its own (or sit out the whole turn timeout).
420
+ let onParentAbort;
421
+ if (parentSignal) {
422
+ if (parentSignal.aborted) return { ok: false, error: 'aborted' };
423
+ onParentAbort = () => cancel(subSessionId);
424
+ parentSignal.addEventListener('abort', onParentAbort, { once: true });
425
+ }
426
+
427
+ try {
428
+ const res = await chat(
429
+ { class: cls, model, maxTokens, mode, system, prompt: task, sessionId: subSessionId, depth: (depth || 0) + 1 },
430
+ () => {}
431
+ );
432
+ const text = assistantText(res);
433
+ return text
434
+ ? { ok: true, output: text }
435
+ : { ok: false, error: `subagent (${label}) produced no output` };
436
+ } catch (e) {
437
+ return { ok: false, error: `subagent (${label}) failed: ${e && e.message ? e.message : e}` };
438
+ } finally {
439
+ if (parentSignal && onParentAbort) parentSignal.removeEventListener('abort', onParentAbort);
440
+ }
441
+ }
442
+
443
+ function cancel(sessionId) {
444
+ const controller = controllers.get(sessionId);
445
+ if (controller) controller.abort();
446
+ return { ok: Boolean(controller) };
447
+ }
448
+
449
+ return {
450
+ CLASSES,
451
+ MAX_TOOL_ROUNDS,
452
+ listClasses,
453
+ listModels,
454
+ chat,
455
+ cancel,
456
+ settings,
457
+ };
458
+ }
459
+
460
+ module.exports = { CLASSES, MAX_TOOL_ROUNDS, createLocalEngine, extractToolCalls, parseArgs };