@yemi33/minions 0.1.1587 → 0.1.1589
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/CHANGELOG.md +10 -0
- package/bin/minions.js +5 -3
- package/dashboard/js/settings.js +216 -22
- package/dashboard.js +136 -8
- package/docs/copilot-cli-schema.md +637 -0
- package/docs/copilot-output-sample-claude.jsonl +72 -0
- package/docs/copilot-output-sample-default.jsonl +26 -0
- package/docs/copilot-output-sample-gpt4o.jsonl +23 -0
- package/engine/cli.js +250 -18
- package/engine/lifecycle.js +14 -9
- package/engine/llm.js +346 -94
- package/engine/model-discovery.js +167 -0
- package/engine/preflight.js +247 -19
- package/engine/runtimes/claude.js +413 -0
- package/engine/runtimes/copilot.js +566 -0
- package/engine/runtimes/index.js +61 -0
- package/engine/shared.js +299 -63
- package/engine/spawn-agent.js +265 -181
- package/engine.js +118 -31
- package/package.json +1 -1
package/engine/lifecycle.js
CHANGED
|
@@ -1484,8 +1484,8 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
|
|
|
1484
1484
|
|
|
1485
1485
|
// ─── Agent Output Parsing ────────────────────────────────────────────────────
|
|
1486
1486
|
|
|
1487
|
-
function parseAgentOutput(stdout) {
|
|
1488
|
-
const { text, usage, sessionId, model } = shared.parseStreamJsonOutput(stdout, { maxTextLength: 2000 });
|
|
1487
|
+
function parseAgentOutput(stdout, runtimeName) {
|
|
1488
|
+
const { text, usage, sessionId, model } = shared.parseStreamJsonOutput(stdout, runtimeName, { maxTextLength: 2000 });
|
|
1489
1489
|
return { resultSummary: text, taskUsage: usage, sessionId, model };
|
|
1490
1490
|
}
|
|
1491
1491
|
|
|
@@ -1495,14 +1495,14 @@ function parseAgentOutput(stdout) {
|
|
|
1495
1495
|
* Returns parsed object or null if not found / malformed.
|
|
1496
1496
|
* If multiple blocks exist, the last one wins (agent may retry).
|
|
1497
1497
|
*/
|
|
1498
|
-
function parseStructuredCompletion(stdout) {
|
|
1498
|
+
function parseStructuredCompletion(stdout, runtimeName) {
|
|
1499
1499
|
if (!stdout || typeof stdout !== 'string') return null;
|
|
1500
1500
|
|
|
1501
1501
|
// Extract text from stream-json output if needed
|
|
1502
1502
|
let text = stdout;
|
|
1503
1503
|
if (stdout.includes('"type":')) {
|
|
1504
1504
|
try {
|
|
1505
|
-
const parsed = shared.parseStreamJsonOutput(stdout);
|
|
1505
|
+
const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
|
|
1506
1506
|
if (parsed.text) text = parsed.text;
|
|
1507
1507
|
} catch {}
|
|
1508
1508
|
}
|
|
@@ -1536,13 +1536,13 @@ function parseStructuredCompletion(stdout) {
|
|
|
1536
1536
|
* Handle decomposition result — parse sub-items from agent output and create child work items.
|
|
1537
1537
|
* Called from runPostCompletionHooks when type === 'decompose'.
|
|
1538
1538
|
*/
|
|
1539
|
-
function handleDecompositionResult(stdout, meta, config) {
|
|
1539
|
+
function handleDecompositionResult(stdout, meta, config, runtimeName) {
|
|
1540
1540
|
|
|
1541
1541
|
const parentId = meta?.item?.id;
|
|
1542
1542
|
if (!parentId) return 0;
|
|
1543
1543
|
|
|
1544
1544
|
// Parse sub-items JSON from agent output
|
|
1545
|
-
const { text } = shared.parseStreamJsonOutput(stdout);
|
|
1545
|
+
const { text } = shared.parseStreamJsonOutput(stdout, runtimeName);
|
|
1546
1546
|
const jsonMatch = text.match(/```json\s*\n([\s\S]*?)```/);
|
|
1547
1547
|
if (!jsonMatch) {
|
|
1548
1548
|
log('warn', `Decomposition for ${parentId}: no JSON block found in output`);
|
|
@@ -1628,10 +1628,15 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1628
1628
|
const meta = dispatchItem.meta;
|
|
1629
1629
|
const isSuccess = code === 0;
|
|
1630
1630
|
const result = isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
|
|
1631
|
-
|
|
1631
|
+
// Runtime name comes from the dispatch entry (set when the agent was spawned).
|
|
1632
|
+
// Defaults to 'claude' when missing — preserves behavior for existing dispatches
|
|
1633
|
+
// and for the foundation-only state of this plan item; downstream items
|
|
1634
|
+
// (P-2a6d9c4f, P-9c4f2d6a) populate dispatchItem.meta.runtimeName at spawn time.
|
|
1635
|
+
const runtimeName = dispatchItem.meta?.runtimeName || dispatchItem.runtimeName || 'claude';
|
|
1636
|
+
const { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout, runtimeName);
|
|
1632
1637
|
|
|
1633
1638
|
// Try structured completion protocol first (```completion block from agent output)
|
|
1634
|
-
const structuredCompletion = parseStructuredCompletion(stdout);
|
|
1639
|
+
const structuredCompletion = parseStructuredCompletion(stdout, runtimeName);
|
|
1635
1640
|
if (structuredCompletion) {
|
|
1636
1641
|
log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}`);
|
|
1637
1642
|
}
|
|
@@ -1669,7 +1674,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1669
1674
|
// Handle decomposition results — create sub-items from decompose agent output
|
|
1670
1675
|
let skipDoneStatus = false;
|
|
1671
1676
|
if (type === WORK_TYPE.DECOMPOSE && effectiveSuccess && meta?.item?.id) {
|
|
1672
|
-
const subCount = handleDecompositionResult(stdout, meta, config);
|
|
1677
|
+
const subCount = handleDecompositionResult(stdout, meta, config, runtimeName);
|
|
1673
1678
|
if (subCount > 0) skipDoneStatus = true; // parent already marked 'decomposed' by handler
|
|
1674
1679
|
// If decomposition produced nothing, fall through to mark parent as done
|
|
1675
1680
|
}
|
package/engine/llm.js
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* engine/llm.js — Shared LLM utilities for Minions engine + dashboard
|
|
3
|
-
*
|
|
2
|
+
* engine/llm.js — Shared LLM utilities for Minions engine + dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Provides callLLM() / callLLMStreaming() (with optional session resume) and
|
|
5
|
+
* trackEngineUsage(). As of P-5e1b7a3c the CC / doc-chat direct-spawn path
|
|
6
|
+
* goes through the runtime adapter registry — same model used by the agent
|
|
7
|
+
* dispatch path (P-2a6d9c4f). This file holds zero `runtime.name === ...`
|
|
8
|
+
* branches; conditional behavior gates exclusively on `runtime.capabilities.*`
|
|
9
|
+
* flags or on event-shape inspection inside the streaming accumulator.
|
|
4
10
|
*/
|
|
5
11
|
|
|
12
|
+
const fs = require('fs');
|
|
6
13
|
const path = require('path');
|
|
7
14
|
const shared = require('./shared');
|
|
8
|
-
const {
|
|
15
|
+
const {
|
|
16
|
+
safeWrite, safeUnlink, uid, ts, runFile, cleanChildEnv,
|
|
17
|
+
parseStreamJsonOutput, mutateJsonFileLocked, appendTextTail,
|
|
18
|
+
ENGINE_DEFAULTS,
|
|
19
|
+
resolveCcCli, resolveCcModel,
|
|
20
|
+
} = shared;
|
|
21
|
+
const { resolveRuntime } = require('./runtimes');
|
|
9
22
|
|
|
10
23
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
11
24
|
const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
|
|
12
25
|
|
|
26
|
+
// ─── Engine-Usage Metrics ────────────────────────────────────────────────────
|
|
27
|
+
|
|
13
28
|
function trackEngineUsage(category, usage) {
|
|
14
29
|
if (!usage) return;
|
|
15
30
|
if (category && (category.startsWith('_test') || category.startsWith('test-'))) return;
|
|
@@ -46,88 +61,198 @@ function trackEngineUsage(category, usage) {
|
|
|
46
61
|
} catch (e) { console.error('metrics update:', e.message); }
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
// ─── Runtime Binary Resolution (TTL-cached) ──────────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// Replaces the legacy `_resolveClaudeBin()`. Each adapter's `resolveBinary()`
|
|
67
|
+
// already encapsulates its own disk-cache + PATH probe + npm probe (Claude) or
|
|
68
|
+
// PATH probe + gh-extension fallback (Copilot). We layer a per-process,
|
|
69
|
+
// per-runtime in-memory TTL cache on top so a busy CC session doesn't pay
|
|
70
|
+
// the tiny disk-read cost on every call.
|
|
71
|
+
//
|
|
72
|
+
// `runtime.capsFile` (an adapter-exported absolute path) is the on-disk cache
|
|
73
|
+
// path the adapter owns. We don't read it directly here — the adapter does
|
|
74
|
+
// that inside resolveBinary() — but the test surface inspects `runtime.capsFile`
|
|
75
|
+
// to verify each adapter has its own file.
|
|
76
|
+
|
|
77
|
+
const _binCache = new Map(); // runtime.name → { bin, native, leadingArgs, ts }
|
|
78
|
+
const _BIN_TTL = 1800000; // 30 min
|
|
79
|
+
|
|
80
|
+
function _resolveBin(runtime) {
|
|
81
|
+
if (!runtime) return null;
|
|
82
|
+
const key = runtime.name;
|
|
83
|
+
const cached = _binCache.get(key);
|
|
84
|
+
if (cached && Date.now() - cached.ts < _BIN_TTL && fs.existsSync(cached.bin)) {
|
|
85
|
+
return { bin: cached.bin, native: cached.native, leadingArgs: cached.leadingArgs };
|
|
62
86
|
}
|
|
63
|
-
|
|
87
|
+
let resolved = null;
|
|
88
|
+
try { resolved = runtime.resolveBinary({ env: cleanChildEnv() }); }
|
|
89
|
+
catch { return null; }
|
|
90
|
+
if (!resolved) return null;
|
|
91
|
+
const leadingArgs = Array.isArray(resolved.leadingArgs) ? resolved.leadingArgs : [];
|
|
92
|
+
_binCache.set(key, { bin: resolved.bin, native: !!resolved.native, leadingArgs, ts: Date.now() });
|
|
93
|
+
return { bin: resolved.bin, native: !!resolved.native, leadingArgs };
|
|
64
94
|
}
|
|
65
95
|
|
|
66
|
-
|
|
96
|
+
function _resetBinCache() { _binCache.clear(); }
|
|
97
|
+
|
|
98
|
+
// ─── Spawn Helpers ───────────────────────────────────────────────────────────
|
|
67
99
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Translate the unified opts bag into the named CLI flags consumed by
|
|
102
|
+
* `engine/spawn-agent.js`. spawn-agent.js parses these back into an opts
|
|
103
|
+
* object and calls `runtime.buildArgs(opts)` once — keeping the adapter as
|
|
104
|
+
* the single source of truth and avoiding double-flag emission.
|
|
105
|
+
*
|
|
106
|
+
* Capability gating (matches engine.js _buildAgentSpawnFlags from P-2a6d9c4f):
|
|
107
|
+
* - effort/sessionId/maxBudget/bare/fallbackModel are dropped when the
|
|
108
|
+
* runtime's matching capability is false.
|
|
109
|
+
* - Copilot-specific opts (stream, disableBuiltinMcps, suppressAgentsMd,
|
|
110
|
+
* reasoningSummaries) are emitted unconditionally; the Claude adapter
|
|
111
|
+
* ignores them via the "tolerate unknown opts" rule.
|
|
112
|
+
*/
|
|
113
|
+
function _buildSpawnAgentFlags(runtime, opts = {}) {
|
|
114
|
+
const caps = (runtime && runtime.capabilities) || {};
|
|
115
|
+
const flags = ['--runtime', String(runtime?.name || 'claude')];
|
|
116
|
+
|
|
117
|
+
if (opts.maxTurns != null) flags.push('--max-turns', String(opts.maxTurns));
|
|
118
|
+
if (opts.model) flags.push('--model', String(opts.model));
|
|
119
|
+
if (opts.allowedTools) flags.push('--allowedTools', String(opts.allowedTools));
|
|
120
|
+
|
|
121
|
+
if (caps.effortLevels && opts.effort) flags.push('--effort', String(opts.effort));
|
|
122
|
+
if (caps.sessionResume && opts.sessionId) flags.push('--resume', String(opts.sessionId));
|
|
123
|
+
if (caps.budgetCap && opts.maxBudget != null) flags.push('--max-budget-usd', String(opts.maxBudget));
|
|
124
|
+
if (caps.bareMode && opts.bare === true) flags.push('--bare');
|
|
125
|
+
if (caps.fallbackModel && opts.fallbackModel) flags.push('--fallback-model', String(opts.fallbackModel));
|
|
126
|
+
|
|
127
|
+
if (opts.stream === 'on' || opts.stream === 'off') flags.push('--stream', opts.stream);
|
|
128
|
+
if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
|
|
129
|
+
if (opts.suppressAgentsMd === true) flags.push('--no-custom-instructions');
|
|
130
|
+
if (opts.reasoningSummaries === true) flags.push('--enable-reasoning-summaries');
|
|
131
|
+
|
|
132
|
+
return flags;
|
|
76
133
|
}
|
|
77
134
|
|
|
78
135
|
/**
|
|
79
|
-
* Spawn a
|
|
80
|
-
*
|
|
136
|
+
* Spawn a runtime CLI process. Returns `{ proc, cleanupFiles }` or null when
|
|
137
|
+
* the runtime can't even be resolved.
|
|
138
|
+
*
|
|
139
|
+
* Direct path (`direct: true`): bypasses spawn-agent.js, spawns the runtime
|
|
140
|
+
* binary directly. Fewer file syscalls. Used by CC and doc-chat.
|
|
141
|
+
*
|
|
142
|
+
* Indirect path: uses engine/spawn-agent.js — mostly a fallback when the
|
|
143
|
+
* direct path can't resolve the binary cache. spawn-agent.js handles
|
|
144
|
+
* adapter resolution itself; we just hand it `--runtime <name>` plus the
|
|
145
|
+
* named flags it knows how to parse.
|
|
81
146
|
*/
|
|
82
|
-
function _spawnProcess(promptText, sysPromptText,
|
|
83
|
-
const
|
|
147
|
+
function _spawnProcess(promptText, sysPromptText, callOpts) {
|
|
148
|
+
const {
|
|
149
|
+
direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
|
|
150
|
+
maxBudget, bare, fallbackModel,
|
|
151
|
+
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
152
|
+
} = callOpts;
|
|
153
|
+
|
|
84
154
|
const id = uid();
|
|
85
155
|
const tmpDir = path.join(ENGINE_DIR, 'tmp');
|
|
86
156
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
87
157
|
|
|
88
158
|
const cleanupFiles = [];
|
|
89
|
-
const
|
|
90
|
-
|
|
159
|
+
const caps = (runtime && runtime.capabilities) || {};
|
|
160
|
+
const adapterOpts = {
|
|
161
|
+
model, maxTurns, allowedTools, effort, sessionId,
|
|
162
|
+
maxBudget, bare, fallbackModel,
|
|
163
|
+
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
164
|
+
};
|
|
165
|
+
const finalPrompt = runtime.buildPrompt(promptText, sysPromptText);
|
|
166
|
+
|
|
167
|
+
// ── Direct path ──
|
|
168
|
+
const resolved = direct ? _resolveBin(runtime) : null;
|
|
91
169
|
if (resolved) {
|
|
92
170
|
let sysTmpPath = null;
|
|
93
|
-
|
|
171
|
+
// Only write a sys-prompt tmp file when the runtime actually consumes one
|
|
172
|
+
// via --system-prompt-file (Claude) AND we're not resuming (resumed sessions
|
|
173
|
+
// already have the sys prompt baked in).
|
|
174
|
+
if (!sessionId && sysPromptText && caps.systemPromptFile) {
|
|
94
175
|
sysTmpPath = path.join(tmpDir, `direct-sys-${id}.md`);
|
|
95
176
|
fs.writeFileSync(sysTmpPath, sysPromptText);
|
|
96
177
|
cleanupFiles.push(sysTmpPath);
|
|
178
|
+
adapterOpts.sysPromptFile = sysTmpPath;
|
|
179
|
+
}
|
|
180
|
+
// Capability-gate per-flag opts so the Claude path keeps emitting its
|
|
181
|
+
// historical flag set while Copilot only sees what it understands.
|
|
182
|
+
if (!caps.effortLevels) adapterOpts.effort = undefined;
|
|
183
|
+
if (!caps.sessionResume) adapterOpts.sessionId = undefined;
|
|
184
|
+
if (!caps.budgetCap) adapterOpts.maxBudget = undefined;
|
|
185
|
+
if (!caps.bareMode) adapterOpts.bare = undefined;
|
|
186
|
+
if (!caps.fallbackModel) adapterOpts.fallbackModel = undefined;
|
|
187
|
+
// promptViaArg=true: the adapter splices `--prompt <text>` into args itself.
|
|
188
|
+
if (caps.promptViaArg) adapterOpts.prompt = finalPrompt;
|
|
189
|
+
|
|
190
|
+
const cliArgs = runtime.buildArgs(adapterOpts);
|
|
191
|
+
const execArgs = resolved.native
|
|
192
|
+
? [...resolved.leadingArgs, ...cliArgs]
|
|
193
|
+
: [resolved.bin, ...resolved.leadingArgs, ...cliArgs];
|
|
194
|
+
const execBin = resolved.native ? resolved.bin : process.execPath;
|
|
195
|
+
const proc = runFile(execBin, execArgs, {
|
|
196
|
+
cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv(),
|
|
197
|
+
});
|
|
198
|
+
if (caps.promptViaArg) {
|
|
199
|
+
// Adapter has already spliced the prompt into argv; close stdin so the
|
|
200
|
+
// child doesn't wait on it indefinitely.
|
|
201
|
+
try { proc.stdin.end(); } catch { /* may already be closed */ }
|
|
202
|
+
} else {
|
|
203
|
+
try { proc.stdin.write(finalPrompt); proc.stdin.end(); } catch { /* broken pipe */ }
|
|
97
204
|
}
|
|
98
|
-
const cliArgs = _buildCliArgs({ model, maxTurns, allowedTools, effort, sessionId, sysPromptFile: sysTmpPath });
|
|
99
|
-
const proc = resolved.native
|
|
100
|
-
? runFile(resolved.bin, cliArgs, { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() })
|
|
101
|
-
: runFile(process.execPath, [resolved.bin, ...cliArgs], { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() });
|
|
102
|
-
try { proc.stdin.write(promptText); proc.stdin.end(); } catch { /* broken pipe */ }
|
|
103
205
|
return { proc, cleanupFiles };
|
|
104
206
|
}
|
|
105
207
|
|
|
106
|
-
// Indirect: use spawn-agent.js
|
|
208
|
+
// Indirect: use spawn-agent.js (when direct=false or binary cache miss)
|
|
107
209
|
const promptPath = path.join(tmpDir, `${label}-prompt-${id}.md`);
|
|
108
210
|
const sysPath = path.join(tmpDir, `${label}-sys-${id}.md`);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
211
|
+
// The wrapper merges sys prompt into the user prompt for runtimes without
|
|
212
|
+
// --system-prompt-file (Copilot) — write the user prompt as `finalPrompt`
|
|
213
|
+
// (system block already prepended by buildPrompt) for those, and just the
|
|
214
|
+
// raw user text for runtimes that take sys via a separate file (Claude).
|
|
215
|
+
if (caps.systemPromptFile) {
|
|
216
|
+
safeWrite(promptPath, promptText == null ? '' : String(promptText));
|
|
217
|
+
safeWrite(sysPath, sysPromptText || '');
|
|
218
|
+
} else {
|
|
219
|
+
safeWrite(promptPath, finalPrompt);
|
|
220
|
+
safeWrite(sysPath, '');
|
|
221
|
+
}
|
|
222
|
+
// spawn-agent.js derives a PID file from prompt path — include it in cleanup
|
|
223
|
+
// to prevent leaks even if the spawned process never writes one.
|
|
112
224
|
const pidPath = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
|
|
113
225
|
cleanupFiles.push(promptPath, sysPath, pidPath);
|
|
114
226
|
|
|
115
227
|
const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const proc = runFile(process.execPath, args, { cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv() });
|
|
228
|
+
const adapterFlags = _buildSpawnAgentFlags(runtime, {
|
|
229
|
+
model, maxTurns, allowedTools, effort, sessionId,
|
|
230
|
+
maxBudget, bare, fallbackModel,
|
|
231
|
+
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
232
|
+
});
|
|
233
|
+
const args = [spawnScript, promptPath, sysPath, ...adapterFlags];
|
|
234
|
+
|
|
235
|
+
const proc = runFile(process.execPath, args, {
|
|
236
|
+
cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv(),
|
|
237
|
+
});
|
|
127
238
|
return { proc, cleanupFiles };
|
|
128
239
|
}
|
|
129
240
|
|
|
241
|
+
// ─── Streaming Accumulator ───────────────────────────────────────────────────
|
|
242
|
+
//
|
|
243
|
+
// Reads JSONL events as they stream in. JSON parsing is delegated to
|
|
244
|
+
// `runtime.parseStreamChunk()` — that gives us the runtime's defensive
|
|
245
|
+
// guarantees (e.g. Copilot rewrapping unknown event types as type:'ignore').
|
|
246
|
+
//
|
|
247
|
+
// Text / tool extraction branches on event SHAPE rather than runtime identity.
|
|
248
|
+
// Both Claude and Copilot events flow through here; for any given object only
|
|
249
|
+
// one branch matches because the event type strings don't collide.
|
|
250
|
+
// Final reconciliation calls `runtime.parseOutput(stdout)` so per-runtime
|
|
251
|
+
// finalization quirks (Copilot's premiumRequests, Claude's session_id) stay
|
|
252
|
+
// inside the adapter.
|
|
253
|
+
|
|
130
254
|
function _createStreamAccumulator({
|
|
255
|
+
runtime,
|
|
131
256
|
maxRawBytes,
|
|
132
257
|
maxStderrBytes,
|
|
133
258
|
maxLineBufferBytes,
|
|
@@ -144,15 +269,20 @@ function _createStreamAccumulator({
|
|
|
144
269
|
let lastTextSent = '';
|
|
145
270
|
const toolUses = [];
|
|
146
271
|
|
|
147
|
-
|
|
272
|
+
// Copilot streams `assistant.message_delta` with `data.deltaContent` chunks
|
|
273
|
+
// before emitting the final `assistant.message`. We accumulate the deltas
|
|
274
|
+
// for the live onChunk feed; the final `assistant.message.data.content`
|
|
275
|
+
// value is the authoritative text.
|
|
276
|
+
let copilotMessageBuffer = '';
|
|
277
|
+
|
|
278
|
+
function captureEvent(obj) {
|
|
148
279
|
if (!obj || typeof obj !== 'object') return;
|
|
280
|
+
|
|
281
|
+
// ── Claude shape ────────────────────────────────────────────────────────
|
|
149
282
|
if (obj.session_id) sessionId = obj.session_id;
|
|
150
|
-
if (obj.type === 'result') {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// of agent output. Head-slicing dropped them (#1234).
|
|
154
|
-
text = maxTextLength ? obj.result.slice(-maxTextLength) : obj.result;
|
|
155
|
-
}
|
|
283
|
+
if (obj.type === 'result' && typeof obj.result === 'string') {
|
|
284
|
+
// Claude result event: terminal text + usage.
|
|
285
|
+
text = maxTextLength ? obj.result.slice(-maxTextLength) : obj.result;
|
|
156
286
|
if (obj.total_cost_usd || obj.usage) {
|
|
157
287
|
usage = {
|
|
158
288
|
costUsd: obj.total_cost_usd || 0,
|
|
@@ -166,9 +296,9 @@ function _createStreamAccumulator({
|
|
|
166
296
|
}
|
|
167
297
|
}
|
|
168
298
|
if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
|
|
299
|
+
// Claude assistant turn: content blocks (text + tool_use).
|
|
169
300
|
for (const block of obj.message.content) {
|
|
170
301
|
if (block?.type === 'text' && block.text) {
|
|
171
|
-
// Tail-slice for consistency with the result branch (see #1234).
|
|
172
302
|
text = maxTextLength ? block.text.slice(-maxTextLength) : block.text;
|
|
173
303
|
if (onChunk && block.text !== lastTextSent) {
|
|
174
304
|
lastTextSent = block.text;
|
|
@@ -181,6 +311,43 @@ function _createStreamAccumulator({
|
|
|
181
311
|
}
|
|
182
312
|
}
|
|
183
313
|
}
|
|
314
|
+
|
|
315
|
+
// ── Copilot shape ───────────────────────────────────────────────────────
|
|
316
|
+
if (obj.type === 'result' && typeof obj.sessionId === 'string') sessionId = obj.sessionId;
|
|
317
|
+
if (obj.type === 'assistant.message_delta' && typeof obj.data?.deltaContent === 'string') {
|
|
318
|
+
copilotMessageBuffer += obj.data.deltaContent;
|
|
319
|
+
if (onChunk && copilotMessageBuffer !== lastTextSent) {
|
|
320
|
+
lastTextSent = copilotMessageBuffer;
|
|
321
|
+
onChunk(copilotMessageBuffer);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (obj.type === 'assistant.message' && typeof obj.data?.content === 'string') {
|
|
325
|
+
// Authoritative final assistant text for this turn.
|
|
326
|
+
const content = obj.data.content;
|
|
327
|
+
if (content) {
|
|
328
|
+
text = maxTextLength ? content.slice(-maxTextLength) : content;
|
|
329
|
+
copilotMessageBuffer = '';
|
|
330
|
+
}
|
|
331
|
+
if (Array.isArray(obj.data.toolRequests)) {
|
|
332
|
+
for (const tr of obj.data.toolRequests) {
|
|
333
|
+
if (tr && tr.name) {
|
|
334
|
+
const toolUse = { name: tr.name, input: tr.arguments || {} };
|
|
335
|
+
toolUses.push(toolUse);
|
|
336
|
+
if (onToolUse) onToolUse(toolUse.name, toolUse.input);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (obj.type === 'tool.execution_start' && obj.data?.toolName) {
|
|
342
|
+
const toolUse = { name: obj.data.toolName, input: obj.data.arguments || {} };
|
|
343
|
+
// Dedup: assistant.message.toolRequests already adds this — only push if
|
|
344
|
+
// we haven't seen it yet (toolCallId would be the unique key, but we
|
|
345
|
+
// compare by name+input shape since not every consumer cares).
|
|
346
|
+
if (!toolUses.some(t => t.name === toolUse.name && JSON.stringify(t.input) === JSON.stringify(toolUse.input))) {
|
|
347
|
+
toolUses.push(toolUse);
|
|
348
|
+
if (onToolUse) onToolUse(toolUse.name, toolUse.input);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
184
351
|
}
|
|
185
352
|
|
|
186
353
|
function ingestStdout(chunk) {
|
|
@@ -190,9 +357,8 @@ function _createStreamAccumulator({
|
|
|
190
357
|
const lines = lineBuf.split('\n');
|
|
191
358
|
lineBuf = lines.pop() || '';
|
|
192
359
|
for (const line of lines) {
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
195
|
-
try { captureResult(JSON.parse(trimmed)); } catch { /* incomplete JSON or non-JSON line */ }
|
|
360
|
+
const ev = runtime.parseStreamChunk(line);
|
|
361
|
+
if (ev) captureEvent(ev);
|
|
196
362
|
}
|
|
197
363
|
}
|
|
198
364
|
|
|
@@ -202,11 +368,15 @@ function _createStreamAccumulator({
|
|
|
202
368
|
|
|
203
369
|
function finalize() {
|
|
204
370
|
const trimmed = lineBuf.trim();
|
|
205
|
-
if (trimmed
|
|
206
|
-
|
|
371
|
+
if (trimmed) {
|
|
372
|
+
const ev = runtime.parseStreamChunk(trimmed);
|
|
373
|
+
if (ev) captureEvent(ev);
|
|
207
374
|
}
|
|
375
|
+
// Reconciliation: if any field is still missing, ask the runtime adapter
|
|
376
|
+
// to re-parse the whole stdout. parseOutput() may catch a result event
|
|
377
|
+
// that was malformed when streamed in chunks.
|
|
208
378
|
if (!text || !usage || !sessionId) {
|
|
209
|
-
const parsedTail =
|
|
379
|
+
const parsedTail = runtime.parseOutput(stdout, maxTextLength ? { maxTextLength } : {});
|
|
210
380
|
if (!text && parsedTail.text) text = parsedTail.text;
|
|
211
381
|
if (!usage && parsedTail.usage) usage = parsedTail.usage;
|
|
212
382
|
if (!sessionId && parsedTail.sessionId) sessionId = parsedTail.sessionId;
|
|
@@ -217,14 +387,54 @@ function _createStreamAccumulator({
|
|
|
217
387
|
return { ingestStdout, ingestStderr, finalize };
|
|
218
388
|
}
|
|
219
389
|
|
|
220
|
-
//
|
|
390
|
+
// ─── Resolution Helpers (local, kept private) ───────────────────────────────
|
|
391
|
+
|
|
392
|
+
function _resolveRuntimeFor(callOpts) {
|
|
393
|
+
// Explicit `cli` opt wins; otherwise fall to `engineConfig` resolution;
|
|
394
|
+
// otherwise default to claude (the historical behavior).
|
|
395
|
+
let runtimeName = callOpts.cli;
|
|
396
|
+
if (!runtimeName && callOpts.engineConfig) runtimeName = resolveCcCli(callOpts.engineConfig);
|
|
397
|
+
if (!runtimeName) runtimeName = 'claude';
|
|
398
|
+
return resolveRuntime(runtimeName);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function _resolveModelFor(callOpts) {
|
|
402
|
+
// Explicit `model` opt wins (current behavior of every internal caller —
|
|
403
|
+
// kb-sweep, pipeline.js, dashboard CC paths). When unset and engineConfig is
|
|
404
|
+
// provided, resolve via shared.resolveCcModel — that's the new fleet path.
|
|
405
|
+
if (callOpts.model) return callOpts.model;
|
|
406
|
+
if (callOpts.engineConfig) return resolveCcModel(callOpts.engineConfig);
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ─── Core LLM Call ───────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
function callLLM(promptText, sysPromptText, opts = {}) {
|
|
413
|
+
const {
|
|
414
|
+
timeout = 120000, label = 'llm', maxTurns = 1, allowedTools = '',
|
|
415
|
+
sessionId = null, effort = null, direct = false,
|
|
416
|
+
// Backward-compat opt (overrides resolution):
|
|
417
|
+
model: modelOverride,
|
|
418
|
+
cli: cliOverride,
|
|
419
|
+
engineConfig,
|
|
420
|
+
// Cross-runtime + Copilot opts:
|
|
421
|
+
maxBudget, bare, fallbackModel,
|
|
422
|
+
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
423
|
+
} = opts;
|
|
424
|
+
|
|
425
|
+
const runtime = _resolveRuntimeFor({ cli: cliOverride, engineConfig });
|
|
426
|
+
const model = _resolveModelFor({ model: modelOverride, engineConfig });
|
|
221
427
|
|
|
222
|
-
function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', model = 'sonnet', maxTurns = 1, allowedTools = '', sessionId = null, effort = null, direct = false } = {}) {
|
|
223
428
|
let _abort = null;
|
|
224
429
|
const promise = new Promise((resolve) => {
|
|
225
430
|
const _startMs = Date.now();
|
|
226
|
-
const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
|
|
431
|
+
const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
|
|
432
|
+
direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
|
|
433
|
+
maxBudget, bare, fallbackModel,
|
|
434
|
+
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
435
|
+
});
|
|
227
436
|
const acc = _createStreamAccumulator({
|
|
437
|
+
runtime,
|
|
228
438
|
maxRawBytes: ENGINE_DEFAULTS.maxLlmRawBytes,
|
|
229
439
|
maxStderrBytes: ENGINE_DEFAULTS.maxLlmStderrBytes,
|
|
230
440
|
maxLineBufferBytes: ENGINE_DEFAULTS.maxLlmLineBufferBytes,
|
|
@@ -243,48 +453,68 @@ function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', m
|
|
|
243
453
|
const parsed = acc.finalize();
|
|
244
454
|
const durationMs = Date.now() - _startMs;
|
|
245
455
|
const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
|
|
246
|
-
|
|
456
|
+
// parseError lets the adapter classify obvious failure modes (auth /
|
|
457
|
+
// context-limit / rate-limit / crash). Callers can ignore the field
|
|
458
|
+
// when they don't need it.
|
|
459
|
+
const errInfo = code !== 0
|
|
460
|
+
? runtime.parseError([parsed.raw, parsed.stderr].filter(Boolean).join('\n'))
|
|
461
|
+
: { message: '', code: null, retriable: true };
|
|
462
|
+
resolve({
|
|
463
|
+
text: parsed.text || '',
|
|
464
|
+
usage,
|
|
465
|
+
sessionId: parsed.sessionId || null,
|
|
466
|
+
code,
|
|
467
|
+
stderr: parsed.stderr,
|
|
468
|
+
raw: parsed.raw,
|
|
469
|
+
toolUses: parsed.toolUses,
|
|
470
|
+
runtime: runtime.name,
|
|
471
|
+
errorClass: errInfo.code,
|
|
472
|
+
});
|
|
247
473
|
});
|
|
248
474
|
|
|
249
475
|
proc.on('error', (err) => {
|
|
250
476
|
clearTimeout(timer);
|
|
251
477
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
252
478
|
shared.log('error', `LLM spawn error (${label}): ${err.message}`);
|
|
253
|
-
resolve({
|
|
479
|
+
resolve({
|
|
480
|
+
text: '', usage: null, sessionId: null, code: 1,
|
|
481
|
+
stderr: err.message, raw: '', toolUses: [],
|
|
482
|
+
runtime: runtime.name, errorClass: null,
|
|
483
|
+
});
|
|
254
484
|
});
|
|
255
485
|
});
|
|
256
486
|
promise.abort = () => { if (_abort) _abort(); };
|
|
257
487
|
return promise;
|
|
258
488
|
}
|
|
259
489
|
|
|
260
|
-
/**
|
|
261
|
-
* After a --resume call fails (non-zero exit or empty text), determine whether
|
|
262
|
-
* the underlying session still exists (e.g. a tool timeout mid-turn) vs the
|
|
263
|
-
* session is truly dead (expired, invalid ID, etc.).
|
|
264
|
-
*
|
|
265
|
-
* When the session still exists we should preserve it so the user can retry
|
|
266
|
-
* with "try again" and resume into the same conversation.
|
|
267
|
-
*/
|
|
268
|
-
function isResumeSessionStillValid(result) {
|
|
269
|
-
if (!result) return false;
|
|
270
|
-
// If the CLI returned a session_id in the parsed output or raw stream,
|
|
271
|
-
// the session is alive — the call just failed mid-execution.
|
|
272
|
-
if (result.sessionId) return true;
|
|
273
|
-
if (result.raw && result.raw.includes('"session_id"')) return true;
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
490
|
/**
|
|
278
491
|
* Streaming variant of callLLM — emits text chunks via onChunk callback.
|
|
279
492
|
* Returns the same result object as callLLM when the process completes.
|
|
280
493
|
* onChunk(text) is called for each assistant text block as it arrives.
|
|
281
494
|
*/
|
|
282
|
-
function callLLMStreaming(promptText, sysPromptText,
|
|
495
|
+
function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
496
|
+
const {
|
|
497
|
+
timeout = 120000, label = 'llm', maxTurns = 1, allowedTools = '',
|
|
498
|
+
sessionId = null, onChunk = () => {}, onToolUse = null,
|
|
499
|
+
effort = null, direct = false,
|
|
500
|
+
model: modelOverride, cli: cliOverride, engineConfig,
|
|
501
|
+
maxBudget, bare, fallbackModel,
|
|
502
|
+
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
503
|
+
} = opts;
|
|
504
|
+
|
|
505
|
+
const runtime = _resolveRuntimeFor({ cli: cliOverride, engineConfig });
|
|
506
|
+
const model = _resolveModelFor({ model: modelOverride, engineConfig });
|
|
507
|
+
|
|
283
508
|
let _abort = null;
|
|
284
509
|
const promise = new Promise((resolve) => {
|
|
285
510
|
const _startMs = Date.now();
|
|
286
|
-
const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
|
|
511
|
+
const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
|
|
512
|
+
direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
|
|
513
|
+
maxBudget, bare, fallbackModel,
|
|
514
|
+
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
515
|
+
});
|
|
287
516
|
const acc = _createStreamAccumulator({
|
|
517
|
+
runtime,
|
|
288
518
|
maxRawBytes: ENGINE_DEFAULTS.maxLlmRawBytes,
|
|
289
519
|
maxStderrBytes: ENGINE_DEFAULTS.maxLlmStderrBytes,
|
|
290
520
|
maxLineBufferBytes: ENGINE_DEFAULTS.maxLlmLineBufferBytes,
|
|
@@ -305,14 +535,31 @@ function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label =
|
|
|
305
535
|
const parsed = acc.finalize();
|
|
306
536
|
const durationMs = Date.now() - _startMs;
|
|
307
537
|
const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
|
|
308
|
-
|
|
538
|
+
const errInfo = code !== 0
|
|
539
|
+
? runtime.parseError([parsed.raw, parsed.stderr].filter(Boolean).join('\n'))
|
|
540
|
+
: { message: '', code: null, retriable: true };
|
|
541
|
+
resolve({
|
|
542
|
+
text: parsed.text || '',
|
|
543
|
+
usage,
|
|
544
|
+
sessionId: parsed.sessionId || null,
|
|
545
|
+
code,
|
|
546
|
+
stderr: parsed.stderr,
|
|
547
|
+
raw: parsed.raw,
|
|
548
|
+
toolUses: parsed.toolUses,
|
|
549
|
+
runtime: runtime.name,
|
|
550
|
+
errorClass: errInfo.code,
|
|
551
|
+
});
|
|
309
552
|
});
|
|
310
553
|
|
|
311
554
|
proc.on('error', (err) => {
|
|
312
555
|
clearTimeout(timer);
|
|
313
556
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
314
557
|
shared.log('error', `LLM-stream spawn error (${label}): ${err.message}`);
|
|
315
|
-
resolve({
|
|
558
|
+
resolve({
|
|
559
|
+
text: '', usage: null, sessionId: null, code: 1,
|
|
560
|
+
stderr: err.message, raw: '', toolUses: [],
|
|
561
|
+
runtime: runtime.name, errorClass: null,
|
|
562
|
+
});
|
|
316
563
|
});
|
|
317
564
|
});
|
|
318
565
|
promise.abort = () => { if (_abort) _abort(); };
|
|
@@ -323,5 +570,10 @@ module.exports = {
|
|
|
323
570
|
callLLM,
|
|
324
571
|
callLLMStreaming,
|
|
325
572
|
trackEngineUsage,
|
|
326
|
-
|
|
573
|
+
// Exposed for unit tests — engine code MUST use the runtime adapter contract.
|
|
574
|
+
_buildSpawnAgentFlags,
|
|
575
|
+
_resolveBin,
|
|
576
|
+
_resetBinCache,
|
|
577
|
+
_resolveRuntimeFor,
|
|
578
|
+
_resolveModelFor,
|
|
327
579
|
};
|