aiden-runtime 4.5.0 → 4.6.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.
Files changed (38) hide show
  1. package/README.md +17 -2
  2. package/dist/cli/v4/aidenCLI.js +185 -99
  3. package/dist/cli/v4/chatSession.js +107 -0
  4. package/dist/cli/v4/commands/_runtimeToggleHelpers.js +2 -0
  5. package/dist/cli/v4/commands/fanout.js +42 -59
  6. package/dist/cli/v4/commands/help.js +6 -0
  7. package/dist/cli/v4/commands/index.js +16 -1
  8. package/dist/cli/v4/commands/mcp.js +80 -54
  9. package/dist/cli/v4/commands/plannerGuard.js +53 -0
  10. package/dist/cli/v4/commands/recovery.js +122 -0
  11. package/dist/cli/v4/commands/runs.js +22 -2
  12. package/dist/cli/v4/commands/spawnPause.js +93 -0
  13. package/dist/cli/v4/daemonAgentBuilder.js +4 -1
  14. package/dist/cli/v4/defaultSoul.js +1 -1
  15. package/dist/core/v4/aidenAgent.js +219 -1
  16. package/dist/core/v4/daemon/bootstrap.js +47 -0
  17. package/dist/core/v4/daemon/db/migrations.js +66 -0
  18. package/dist/core/v4/daemon/runStore.js +33 -3
  19. package/dist/core/v4/providerFallback.js +35 -2
  20. package/dist/core/v4/runtimeToggles.js +30 -3
  21. package/dist/core/v4/selfimprovement/recoveryStore.js +307 -0
  22. package/dist/core/v4/selfimprovement/signatureBuilder.js +158 -0
  23. package/dist/core/v4/subagent/childBuilder.js +391 -0
  24. package/dist/core/v4/subagent/fanout.js +75 -51
  25. package/dist/core/v4/subagent/spawnPause.js +191 -0
  26. package/dist/core/v4/subagent/spawnSubAgent.js +310 -0
  27. package/dist/core/v4/toolRegistry.js +19 -3
  28. package/dist/core/version.js +1 -1
  29. package/dist/moat/plannerGuard.js +29 -0
  30. package/dist/providers/v4/anthropicAdapter.js +31 -3
  31. package/dist/providers/v4/chatCompletionsAdapter.js +26 -3
  32. package/dist/providers/v4/codexResponsesAdapter.js +25 -2
  33. package/dist/providers/v4/ollamaPromptToolsAdapter.js +57 -2
  34. package/dist/tools/v4/index.js +17 -3
  35. package/dist/tools/v4/skills/lookupToolSchema.js +6 -1
  36. package/dist/tools/v4/subagent/spawnSubAgentTool.js +334 -0
  37. package/dist/tools/v4/subagent/subagentFanout.js +53 -1
  38. package/package.json +7 -3
@@ -0,0 +1,391 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/subagent/childBuilder.ts — v4.6 Phase 1.
10
+ *
11
+ * Constructs the child `AidenAgent` for one `spawn_sub_agent` call.
12
+ * Mirrors the closure-capture shape of `cli/v4/daemonAgentBuilder.ts`
13
+ * — shared deps captured at REPL bootstrap, fresh per-spawn state
14
+ * built inline.
15
+ *
16
+ * Per the design doc §5 state-isolation matrix:
17
+ * - Conversation history, system prompt, TCE, file-op cache:
18
+ * ISOLATED (child gets fresh).
19
+ * - Toolset: intersection of (parent's enabled toolsets) ∩
20
+ * (spec.toolsets or parent's full set) MINUS the hard blocklist.
21
+ * - Provider + model + credentials: INHERITED (same adapter).
22
+ * - FallbackAdapter rate-limit state: CLONED per child (when the
23
+ * adapter exposes `clone()`) so a child's 429 doesn't poison
24
+ * the parent's quota tracking.
25
+ * - ApprovalEngine: fresh instance with auto-deny callbacks
26
+ * (child cannot prompt the user).
27
+ * - plannerGuard / honestyEnforcement / skillTeacher / skillMiner:
28
+ * OMITTED (focused worker config, matching daemon agent shape).
29
+ * - Working directory / sandbox / runtimeToggles: shared via the
30
+ * process-level singletons read on each tool dispatch.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.ProviderNotFoundError = exports.SUBAGENT_BLOCKED_TOOL_NAMES = void 0;
34
+ exports.buildChildAgent = buildChildAgent;
35
+ const approvalEngine_1 = require("../../../moat/approvalEngine");
36
+ const aidenAgent_1 = require("../aidenAgent");
37
+ const providerFallback_1 = require("../providerFallback");
38
+ // ── Hard-coded blocklist (Q5 from design doc §2) ────────────────────────────
39
+ /**
40
+ * Tools children must NEVER receive, even if the parent's enabled toolsets
41
+ * cover them and the spec explicitly requests them. Filtered post-intersection.
42
+ *
43
+ * Each entry's rationale, in order:
44
+ * - `spawn_sub_agent` — no recursive spawning (depth cap = 1 in Phase 1)
45
+ * - `clarify` — child cannot prompt the user
46
+ * - `memory` — no writes to shared MEMORY.md / USER.md
47
+ * - `execute_code` — children reason step-by-step, not write scripts
48
+ * - `send_message` — no cross-platform side effects from a child
49
+ */
50
+ exports.SUBAGENT_BLOCKED_TOOL_NAMES = new Set([
51
+ 'spawn_sub_agent',
52
+ 'clarify',
53
+ 'memory',
54
+ 'execute_code',
55
+ 'send_message',
56
+ ]);
57
+ /**
58
+ * v4.6 Phase 2P — error thrown by `buildChildAgent` when
59
+ * `input.providerOverride` doesn't match any provider in the
60
+ * parent's pool. Caught by `spawnSubAgent` and converted to a
61
+ * `status: 'failed', exitReason: 'provider_not_found'` envelope
62
+ * with the failing name + the list of valid alternatives.
63
+ */
64
+ class ProviderNotFoundError extends Error {
65
+ constructor(requested, available, hint) {
66
+ const base = `spawn_sub_agent: provider "${requested}" not in parent's pool.`;
67
+ const list = available.length > 0
68
+ ? ` Available: ${available.join(', ')}.`
69
+ : ' Parent has no FallbackAdapter pool (single-provider configuration).';
70
+ super(`${base}${list}${hint ? ' ' + hint : ' Omit the provider field to inherit the parent\'s provider.'}`);
71
+ this.name = 'ProviderNotFoundError';
72
+ this.requested = requested;
73
+ this.available = available;
74
+ }
75
+ }
76
+ exports.ProviderNotFoundError = ProviderNotFoundError;
77
+ // ── Implementation ──────────────────────────────────────────────────────────
78
+ /**
79
+ * Build the child agent + initial history. Pure factory — no side
80
+ * effects beyond constructing in-memory objects. The caller is
81
+ * responsible for running `agent.runConversation(...)` and writing
82
+ * the `runs` row.
83
+ */
84
+ function buildChildAgent(deps, input) {
85
+ // ── 1. ApprovalEngine: fresh, auto-deny callbacks ────────────────────────
86
+ // 'smart' mode: safe auto-allows, dangerous auto-denies, caution
87
+ // calls promptUser which we wire to a synchronous deny — children
88
+ // cannot interact with a TUI.
89
+ const autoDenyCallbacks = {
90
+ promptUser: async () => 'deny',
91
+ };
92
+ const childApprovalEngine = new approvalEngine_1.ApprovalEngine('smart', autoDenyCallbacks);
93
+ // ── 2. ToolContext: parent's services + child approval engine + session ──
94
+ const childToolContext = {
95
+ ...deps.parentToolContext,
96
+ sessionId: input.sessionId,
97
+ approvalEngine: childApprovalEngine,
98
+ };
99
+ // ── 3. Build the child's toolExecutor from the parent's registry ─────────
100
+ // Same registry, different context. The registry stays read-only.
101
+ const childToolExecutor = deps.toolRegistry.buildExecutor(childToolContext);
102
+ // ── 4. Tool array: intersection + blocklist filter ───────────────────────
103
+ // Step 4a — pick the parent's toolsets we care about.
104
+ // If the spec named toolsets, intersect with the parent's known set.
105
+ // Otherwise the child gets the parent's full enabled set (which on
106
+ // REPL means every toolset the registry knows).
107
+ const allHandlers = deps.toolRegistry.list();
108
+ const parentToolsetNames = new Set();
109
+ for (const name of allHandlers) {
110
+ const handler = deps.toolRegistry.get(name);
111
+ if (handler?.toolset)
112
+ parentToolsetNames.add(handler.toolset);
113
+ }
114
+ let chosenToolsets = input.requestedToolsets && input.requestedToolsets.length > 0
115
+ ? input.requestedToolsets.filter((t) => parentToolsetNames.has(t))
116
+ : [...parentToolsetNames];
117
+ // v4.6 Phase 1 (Dispatch 2L) — zero-tools-bug fallback. When the
118
+ // model passes `toolsets: [...]` with values that DON'T match any
119
+ // real registry toolset (e.g. `["functions"]`, a name fabricated
120
+ // from the OpenAI tool-use vocabulary, or `["file_operations"]`, a
121
+ // skill name confused for a toolset), the strict filter strips all
122
+ // entries → `chosenToolsets` is `[]` → child gets ZERO tools → it
123
+ // hallucinates an answer rather than admit it can't do the work.
124
+ //
125
+ // Recover by inheriting the full parent set when the requested
126
+ // names ALL miss. Logs a warning so the operator sees what the
127
+ // model asked for and what real names exist. Partial intersections
128
+ // (some valid, some invalid) keep the valid subset — that's the
129
+ // user's explicit narrowing intent, not a bug.
130
+ if (input.requestedToolsets && input.requestedToolsets.length > 0 &&
131
+ chosenToolsets.length === 0) {
132
+ deps.logger?.warn?.('spawn_sub_agent: requested toolsets stripped to empty, falling back to full parent set', {
133
+ requested: input.requestedToolsets,
134
+ validParentToolsets: [...parentToolsetNames],
135
+ });
136
+ chosenToolsets = [...parentToolsetNames];
137
+ }
138
+ // Step 4b — pull the schemas for those toolsets.
139
+ // v4.6 Phase 1 — pass 'repl' context: in Phase 1 spawn_sub_agent
140
+ // is REPL-only (Q6), so children always spawn from a REPL parent.
141
+ // Phase 3+ may extend the child builder to receive the parent's
142
+ // context dynamically; for now, 'repl' is the only path.
143
+ const candidateSchemas = chosenToolsets.length > 0
144
+ ? deps.toolRegistry.getSchemas(chosenToolsets, 'repl')
145
+ : []; // No matching toolsets means an empty child toolset.
146
+ // Step 4c — strip the hard blocklist (with v4.6 Phase 2P legacy
147
+ // env-flag escape hatch per design doc §12.4). Default: full
148
+ // 5-name blocklist. When AIDEN_SUBAGENT_ALLOW_DESTRUCTIVE=1 is
149
+ // set in the environment, `execute_code` is removed from the
150
+ // blocklist for THIS spawn — backward-compat preservation of any
151
+ // user's existing v4.1.0 .env workflow. The other 4 names
152
+ // (spawn_sub_agent, clarify, memory, send_message) remain
153
+ // blocked regardless of the flag.
154
+ const blocked = resolveBlocklist(deps.logger);
155
+ const childTools = candidateSchemas.filter((t) => !blocked.has(t.name));
156
+ // ── 5. Provider: clone FallbackAdapter rate-limit state if supported ─────
157
+ // Per Q11 (verbatim mirror of providerFallback.ts:578 clone pattern).
158
+ // Best-effort — if the adapter doesn't expose `clone()`, fall back to
159
+ // sharing the parent's adapter. Phase 1 accepts that fallback case
160
+ // means a child's 429 affects the parent's quota tracking; that's
161
+ // explicit in the design-doc §5 row.
162
+ //
163
+ // v4.6 Phase 2P — when `input.providerOverride` is supplied, resolve
164
+ // it against the parent's FallbackAdapter slot pool. Fail-loud on
165
+ // unknown names (`ProviderNotFoundError`) so fanout's diversity
166
+ // invariant is preserved (silent fallback would collapse the
167
+ // rotation). Single-provider parents (non-FallbackAdapter) reject
168
+ // any override, since there's no pool to select from.
169
+ const childProvider = resolveChildProvider(deps.parentProvider, input.providerOverride);
170
+ // ── 6. Observability — onToolCall → run_events + log ─────────────────────
171
+ // When deps.runStore + deps.childRunId are present (the production
172
+ // path from spawnSubAgent.ts), wire an `onToolCall` callback that
173
+ // mirrors the daemon dispatcher's audit shape (see
174
+ // `core/v4/daemon/dispatcher/realAgentRunner.ts:367-382`). Per-call
175
+ // start time is tracked in a Map keyed by tool_call_id so the
176
+ // `durationMs` on completed events is per-tool, not per-turn.
177
+ // Pure no-op when runStore is absent (unit tests of buildChildAgent).
178
+ const onToolCall = buildOnToolCall(deps);
179
+ // ── 7. Build the child agent ─────────────────────────────────────────────
180
+ // Focused worker config: omit plannerGuard, honestyEnforcement,
181
+ // skillTeacher, skillMiner, contextCompressor, promptCaching,
182
+ // promptBuilder. Match the daemon agent's "act on the task, don't
183
+ // self-improve" shape.
184
+ const agent = new aidenAgent_1.AidenAgent({
185
+ provider: childProvider,
186
+ tools: childTools,
187
+ toolExecutor: childToolExecutor,
188
+ sessionId: input.sessionId,
189
+ maxTurns: input.maxIterations,
190
+ providerId: deps.parentProviderId,
191
+ modelId: deps.parentModelId,
192
+ resolveVerifiedFlag: deps.resolveVerifiedFlag,
193
+ resolveToolset: deps.resolveToolset,
194
+ resolveMutates: deps.resolveMutates,
195
+ onToolCall,
196
+ // iterationBudgetInjection inherits the default (true) — child
197
+ // sees its own remaining-budget hint near the end of the run.
198
+ });
199
+ // ── 7. Initial history: fresh system prompt + the user-shaped goal ───────
200
+ const systemContent = buildChildSystemPrompt(input.goal, input.context);
201
+ const userContent = composeUserMessage(input.goal, input.context);
202
+ const history = [
203
+ { role: 'system', content: systemContent },
204
+ { role: 'user', content: userContent },
205
+ ];
206
+ return { agent, history };
207
+ }
208
+ // ── Helpers ─────────────────────────────────────────────────────────────────
209
+ /**
210
+ * v4.6 Phase 2P — resolve the child's provider adapter.
211
+ *
212
+ * No override (Phase 1 behavior unchanged):
213
+ * - FallbackAdapter → clone with fresh mutable state, all slots.
214
+ * - Any other adapter shape with `clone()` → clone.
215
+ * - Adapter without `clone()` → reuse the parent's instance.
216
+ *
217
+ * Override supplied (Phase 2P):
218
+ * - Parent must be FallbackAdapter (only adapter type with a
219
+ * multi-provider pool). Otherwise throw `ProviderNotFoundError`.
220
+ * - Override must match one of `parent.getProviderIds()`. Otherwise
221
+ * throw `ProviderNotFoundError` listing the available pool.
222
+ * - On match: clone with slot-subset filter restricted to that
223
+ * provider's slots. Child rotates only within the chosen
224
+ * provider's slots; the diversity invariant fanout depends on
225
+ * is preserved.
226
+ */
227
+ function resolveChildProvider(parent, override) {
228
+ if (!override) {
229
+ // ── No override path — Phase 1 clone semantics, unchanged. ────────
230
+ const maybeClone = parent.clone;
231
+ if (typeof maybeClone === 'function') {
232
+ try {
233
+ return maybeClone.call(parent);
234
+ }
235
+ catch {
236
+ return parent;
237
+ }
238
+ }
239
+ return parent;
240
+ }
241
+ // ── Override path — must be FallbackAdapter. ───────────────────────
242
+ if (!(parent instanceof providerFallback_1.FallbackAdapter)) {
243
+ throw new ProviderNotFoundError(override, [], 'Parent agent uses a single-provider adapter; provider override is only available when parent is configured with FallbackAdapter (multi-provider pool).');
244
+ }
245
+ const available = parent.getProviderIds();
246
+ if (!available.includes(override)) {
247
+ throw new ProviderNotFoundError(override, available);
248
+ }
249
+ return parent.clone({ providerId: override });
250
+ }
251
+ /**
252
+ * v4.6 Phase 2P — resolve the effective tool blocklist for THIS
253
+ * child build. Default is the full 5-name set
254
+ * (`SUBAGENT_BLOCKED_TOOL_NAMES`). When
255
+ * `AIDEN_SUBAGENT_ALLOW_DESTRUCTIVE=1` (or any truthy value) is
256
+ * present in `process.env`, drop `execute_code` — preserves the
257
+ * v4.1.0 fanout escape hatch for users with that flag in `.env`.
258
+ * Other 4 names stay blocked regardless.
259
+ *
260
+ * Logs a one-line warning when the escape hatch is active so the
261
+ * relaxation is visible in observability — silently relaxing a
262
+ * security boundary is exactly the failure mode we want to avoid.
263
+ */
264
+ function resolveBlocklist(logger) {
265
+ const raw = process.env.AIDEN_SUBAGENT_ALLOW_DESTRUCTIVE;
266
+ if (!raw)
267
+ return exports.SUBAGENT_BLOCKED_TOOL_NAMES;
268
+ const flag = String(raw).trim().toLowerCase();
269
+ if (flag === '1' || flag === 'true' || flag === 'on' || flag === 'yes') {
270
+ logger?.warn?.('spawn_sub_agent: AIDEN_SUBAGENT_ALLOW_DESTRUCTIVE escape hatch active; ' +
271
+ 'execute_code dropped from child blocklist (other 4 names still blocked)', { source: 'env', flag: raw });
272
+ const relaxed = new Set(exports.SUBAGENT_BLOCKED_TOOL_NAMES);
273
+ relaxed.delete('execute_code');
274
+ return relaxed;
275
+ }
276
+ return exports.SUBAGENT_BLOCKED_TOOL_NAMES;
277
+ }
278
+ /**
279
+ * Compose the child's system prompt. Intentionally minimal — no
280
+ * SOUL.md, no parent identity, no MEMORY.md preamble. Goal-focused.
281
+ */
282
+ function buildChildSystemPrompt(goal, context) {
283
+ const lines = [
284
+ 'You are a focused sub-agent dispatched to handle ONE concrete task.',
285
+ 'You have no memory of any prior conversation — only the goal and',
286
+ 'optional context below. You cannot ask the user follow-up questions',
287
+ '(your `clarify` tool is disabled), you cannot spawn further sub-agents,',
288
+ 'and you cannot write to MEMORY.md.',
289
+ '',
290
+ 'When the task is done, produce a single final assistant message',
291
+ 'summarising what you did and what you found. That summary is the',
292
+ 'ONLY output the parent agent will see — no tool traces, no',
293
+ 'intermediate reasoning. Make it self-contained, factual, and tight.',
294
+ '',
295
+ `## Goal`,
296
+ goal.trim(),
297
+ ];
298
+ if (context && context.trim().length > 0) {
299
+ lines.push('', '## Background context', context.trim());
300
+ }
301
+ return lines.join('\n');
302
+ }
303
+ /**
304
+ * Compose the initial user message. Currently a stub repeat of the goal
305
+ * — kept distinct from the system prompt so providers that prefer the
306
+ * system role for instructions and the user role for the immediate
307
+ * request both get a sensible payload. Context is included only in
308
+ * the system prompt so the user message stays compact.
309
+ */
310
+ function composeUserMessage(goal, _context) {
311
+ return goal.trim();
312
+ }
313
+ /**
314
+ * v4.6 Phase 1 — build the child agent's `onToolCall` callback. Emits
315
+ * `tool_call_started` + `tool_call_completed` events to the child's
316
+ * `runs` row via the supplied runStore, mirroring the daemon
317
+ * dispatcher's audit shape. Also logs each call at info level so
318
+ * users tailing aiden.log see what the child actually did.
319
+ *
320
+ * Returns `undefined` when persistence + logger are both absent —
321
+ * the agent constructor accepts `undefined` for `onToolCall` and
322
+ * dispatches without notifying.
323
+ */
324
+ function buildOnToolCall(deps) {
325
+ const { runStore, childRunId, logger } = deps;
326
+ if (!runStore && !logger && childRunId === undefined)
327
+ return undefined;
328
+ // Per-call start time keyed by tool_call_id so `durationMs` on the
329
+ // completed event reflects per-tool wall-clock, not per-turn.
330
+ const callStarts = new Map();
331
+ return (call, phase, result) => {
332
+ try {
333
+ if (phase === 'before') {
334
+ const startedAt = Date.now();
335
+ callStarts.set(call.id, startedAt);
336
+ const argsSummary = safeShortJson(call.arguments, 200);
337
+ if (runStore && childRunId !== undefined) {
338
+ runStore.emitEvent(childRunId, 'tool_call_started', {
339
+ toolName: call.name,
340
+ args: argsSummary,
341
+ ts: startedAt,
342
+ });
343
+ }
344
+ if (logger) {
345
+ logger.info('sub-agent tool call', {
346
+ childRunId: childRunId ?? null,
347
+ toolName: call.name,
348
+ args: argsSummary,
349
+ });
350
+ }
351
+ return;
352
+ }
353
+ // phase === 'after'
354
+ const startedAt = callStarts.get(call.id) ?? Date.now();
355
+ callStarts.delete(call.id);
356
+ const durationMs = Date.now() - startedAt;
357
+ if (runStore && childRunId !== undefined) {
358
+ runStore.emitEvent(childRunId, 'tool_call_completed', {
359
+ toolName: call.name,
360
+ error: result?.error ?? null,
361
+ hasResult: result?.result !== undefined && result?.result !== null,
362
+ durationMs,
363
+ });
364
+ }
365
+ if (logger) {
366
+ logger.info('sub-agent tool result', {
367
+ childRunId: childRunId ?? null,
368
+ toolName: call.name,
369
+ ok: !result?.error,
370
+ durationMs,
371
+ });
372
+ }
373
+ }
374
+ catch {
375
+ // Observability must never crash the agent loop.
376
+ }
377
+ };
378
+ }
379
+ /** JSON-stringify with a byte cap; returns the truncated string with an
380
+ * ellipsis when the payload exceeds `maxBytes`. */
381
+ function safeShortJson(value, maxBytes) {
382
+ try {
383
+ const s = JSON.stringify(value);
384
+ if (s === undefined)
385
+ return '';
386
+ return s.length > maxBytes ? s.slice(0, maxBytes) + '…' : s;
387
+ }
388
+ catch {
389
+ return String(value).slice(0, maxBytes);
390
+ }
391
+ }
@@ -36,21 +36,22 @@
36
36
  * - destructive tool exposure: caller filters the schemas array
37
37
  * based on `AIDEN_SUBAGENT_ALLOW_DESTRUCTIVE`
38
38
  *
39
- * The orchestrator itself is INTENTIONALLY decoupled from
40
- * AidenAgent it takes a `runChild` callback that knows how to run
41
- * one subagent. The tool wrapper at tools/v4/subagent/subagentFanout
42
- * supplies the production callback (which constructs an AidenAgent);
43
- * tests inject a stub that returns canned strings without any
44
- * provider plumbing. This is what made the offline smoke tractable.
39
+ * v4.6 Phase 2Q-A — the orchestrator was previously decoupled from
40
+ * AidenAgent via a `runChild` callback (one closure per fanout call
41
+ * built the child agent). Post-2Q-A every child flows through the
42
+ * `spawnSubAgent` primitive, so the legacy `runChild` callback is
43
+ * gone (deleted in 2R). Behavioural test fakes inject a stub
44
+ * `SpawnSubAgentDeps` (real `toolRegistry` + mock provider) see
45
+ * `tests/v4/subagent/fanout.behavioral.test.ts` for the pattern.
45
46
  */
46
47
  Object.defineProperty(exports, "__esModule", { value: true });
47
48
  exports.runFanout = runFanout;
48
- const node_crypto_1 = require("node:crypto");
49
49
  const factory_1 = require("../logger/factory");
50
50
  const budget_1 = require("./budget");
51
51
  const providerRotation_1 = require("./providerRotation");
52
52
  const merger_1 = require("./merger");
53
53
  const diagnostics_1 = require("./diagnostics");
54
+ const spawnSubAgent_1 = require("./spawnSubAgent");
54
55
  // ── Orchestrator ─────────────────────────────────────────────────────────
55
56
  async function runFanout(opts) {
56
57
  const logger = (opts.logger ?? (0, factory_1.noopLogger)()).child('subagent');
@@ -103,19 +104,21 @@ async function runFanout(opts) {
103
104
  const children = [];
104
105
  for (let i = 0; i < opts.n; i += 1) {
105
106
  const provider = rotation.assignments[i];
106
- const prompt = opts.mode === 'ensemble'
107
- ? opts.query
108
- : buildPartitionPrompt(opts.tasks[i]);
109
- const role = opts.mode === 'partition' ? opts.tasks[i].role : undefined;
110
- children.push(spawnOne({
107
+ const task = opts.mode === 'partition' ? opts.tasks[i] : null;
108
+ const role = task?.role;
109
+ children.push(spawnViaPrimitive({
111
110
  index: i,
112
- prompt,
111
+ query: opts.mode === 'ensemble' ? opts.query : task.goal,
112
+ context: task?.context,
113
113
  role,
114
114
  provider,
115
+ singleProviderWarning: rotation.singleProviderWarning,
115
116
  maxIterations: budget.maxIterations,
116
117
  perTimeoutMs: budget.perSubagentTimeoutMs,
117
118
  wallSignal: wallController.signal,
118
- runChild: opts.runChild,
119
+ spawnDeps: opts.spawnDeps,
120
+ parentRunId: opts.parentRunId,
121
+ parentSessionId: opts.parentSessionId,
119
122
  logger: logger.child(`#${i}:${provider.providerId}`),
120
123
  now,
121
124
  }));
@@ -157,60 +160,81 @@ async function runFanout(opts) {
157
160
  });
158
161
  return { results, merged: merge.merged, diagnostics };
159
162
  }
160
- async function spawnOne(args) {
163
+ /**
164
+ * Spawn one child via the `spawnSubAgent` primitive and adapt its
165
+ * envelope to the merger's `SubagentResult` shape. Centralises the
166
+ * fanout-layer → primitive-layer conversion in one place — every
167
+ * call site goes through here so a future envelope-shape change
168
+ * has a single edit point.
169
+ *
170
+ * Envelope → SubagentResult mapping:
171
+ * - `envelope.summary` → `output` (empty string on failure;
172
+ * the merger uses `output.length === 0` for the failed test).
173
+ * - `envelope.error` → `error` (undefined when ok).
174
+ * - `envelope.metrics.durationMs` → ignored; we capture wall-clock
175
+ * at this layer for diagnostics consistency with v4.1's shape.
176
+ */
177
+ async function spawnViaPrimitive(args) {
161
178
  const startedAt = args.now();
162
- // Per-child controller, aborted on wall-cap OR per-child timeout.
163
- const childController = new AbortController();
164
- const timer = setTimeout(() => childController.abort(), args.perTimeoutMs);
165
- const wallHandler = () => childController.abort();
166
- if (args.wallSignal.aborted)
167
- childController.abort();
168
- else
169
- args.wallSignal.addEventListener('abort', wallHandler, { once: true });
170
- const id = (0, node_crypto_1.randomUUID)();
171
179
  args.logger.info('child: spawned', {
172
- id,
173
180
  provider: `${args.provider.providerId}:${args.provider.modelId}`,
174
181
  role: args.role,
175
182
  timeoutMs: args.perTimeoutMs,
176
183
  });
177
- let output = '';
178
- let error;
184
+ // v4.6 Phase 2Q-A-FIX — only forward the per-spawn provider
185
+ // override when rotation has real diversity (>= 2 distinct
186
+ // providerIds). For single-provider pools, every child would be
187
+ // assigned the same providerId, so the override path adds nothing
188
+ // — and worse, it trips 2P's `resolveChildProvider` rejection when
189
+ // the parent is a non-FallbackAdapter ("single-provider
190
+ // configuration" branch). Omitting it lets the child inherit the
191
+ // parent's adapter, which is the correct effective behavior.
192
+ const spec = {
193
+ goal: args.role ? `[role: ${args.role}] ${args.query}` : args.query,
194
+ context: args.context,
195
+ maxIterations: args.maxIterations,
196
+ timeoutMs: args.perTimeoutMs,
197
+ provider: args.singleProviderWarning ? undefined : args.provider.providerId,
198
+ };
199
+ let envelope;
179
200
  try {
180
- output = await args.runChild({
181
- index: args.index,
182
- prompt: args.prompt,
183
- role: args.role,
184
- provider: args.provider,
185
- signal: childController.signal,
186
- maxIterations: args.maxIterations,
187
- logger: args.logger,
201
+ envelope = await (0, spawnSubAgent_1.spawnSubAgent)(spec, args.spawnDeps, {
202
+ signal: args.wallSignal,
203
+ parentRunId: args.parentRunId,
204
+ parentSessionId: args.parentSessionId,
188
205
  });
189
206
  }
190
207
  catch (err) {
191
- error = err instanceof Error ? err.message : String(err);
192
- if (childController.signal.aborted) {
193
- error = `aborted (timeout=${args.perTimeoutMs}ms or parent abort): ${error}`;
194
- }
195
- args.logger.warn('child: errored', { error });
196
- }
197
- finally {
198
- clearTimeout(timer);
199
- args.wallSignal.removeEventListener('abort', wallHandler);
208
+ // spawnSubAgent's contract says it never throws but defend in
209
+ // depth: a thrown error from the primitive would otherwise sink
210
+ // the whole Promise.all, which would silently kill sibling
211
+ // children. Surface as a failed SubagentResult instead.
212
+ const error = err instanceof Error ? err.message : String(err);
213
+ args.logger.warn('child: primitive threw', { error });
214
+ return {
215
+ index: args.index,
216
+ providerId: args.provider.providerId,
217
+ modelId: args.provider.modelId,
218
+ output: '',
219
+ error,
220
+ elapsedMs: args.now() - startedAt,
221
+ };
200
222
  }
201
223
  const elapsedMs = args.now() - startedAt;
202
- args.logger.info('child: done', { elapsedMs, ok: !error && output.length > 0 });
224
+ const error = envelope.error ?? undefined;
225
+ args.logger.info('child: done', {
226
+ elapsedMs,
227
+ ok: envelope.ok,
228
+ status: envelope.status,
229
+ exitReason: envelope.exitReason,
230
+ childRunId: envelope.childRunId,
231
+ });
203
232
  return {
204
233
  index: args.index,
205
234
  providerId: args.provider.providerId,
206
235
  modelId: args.provider.modelId,
207
- output: error ? '' : output,
236
+ output: envelope.ok ? (envelope.summary ?? '') : '',
208
237
  error,
209
238
  elapsedMs,
210
239
  };
211
240
  }
212
- function buildPartitionPrompt(task) {
213
- const role = task.role ? `Role: ${task.role}\n` : '';
214
- const context = task.context ? `\nContext:\n${task.context}\n` : '';
215
- return `${role}Goal: ${task.goal}${context}`;
216
- }