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 +30 -0
- package/build/icon.png +0 -0
- package/lib/local/agents.js +102 -0
- package/lib/local/context.js +81 -0
- package/lib/local/engine.js +460 -0
- package/lib/local/ollama.js +77 -0
- package/lib/local/prompt.js +91 -0
- package/lib/local/providers.js +536 -0
- package/lib/local/shell.js +208 -0
- package/lib/local/tools.js +638 -0
- package/lib/settings.js +225 -0
- package/lib/sync/memory-queue.js +57 -0
- package/lib/sync/sessions.js +199 -0
- package/main.js +715 -0
- package/package.json +46 -0
- package/preload.js +168 -0
- package/renderer/app.js +1990 -0
- package/renderer/index.html +289 -0
- package/renderer/max-tokens.js +18 -0
- package/renderer/style.css +1454 -0
- package/vendor/aegis.js +694 -0
- package/vendor/foreign-memory.js +666 -0
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 };
|