@graphorin/agent 0.6.0 → 0.7.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 (110) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +31 -11
  3. package/dist/errors/index.d.ts +17 -4
  4. package/dist/errors/index.d.ts.map +1 -1
  5. package/dist/errors/index.js +19 -3
  6. package/dist/errors/index.js.map +1 -1
  7. package/dist/factory.d.ts.map +1 -1
  8. package/dist/factory.js +153 -1930
  9. package/dist/factory.js.map +1 -1
  10. package/dist/fanout/index.d.ts +13 -1
  11. package/dist/fanout/index.d.ts.map +1 -1
  12. package/dist/fanout/index.js +13 -4
  13. package/dist/fanout/index.js.map +1 -1
  14. package/dist/index.d.ts +7 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +11 -9
  17. package/dist/index.js.map +1 -1
  18. package/dist/lateral-leak/index.js +1 -1
  19. package/dist/package.js +6 -0
  20. package/dist/package.js.map +1 -0
  21. package/dist/run-state/index.d.ts +32 -6
  22. package/dist/run-state/index.d.ts.map +1 -1
  23. package/dist/run-state/index.js +46 -22
  24. package/dist/run-state/index.js.map +1 -1
  25. package/dist/runtime/agent-surface.js +122 -0
  26. package/dist/runtime/agent-surface.js.map +1 -0
  27. package/dist/runtime/agent-to-tool.d.ts +51 -0
  28. package/dist/runtime/agent-to-tool.d.ts.map +1 -0
  29. package/dist/runtime/agent-to-tool.js +145 -0
  30. package/dist/runtime/agent-to-tool.js.map +1 -0
  31. package/dist/runtime/approvals.js +0 -0
  32. package/dist/runtime/approvals.js.map +1 -0
  33. package/dist/runtime/dispatch.js +108 -0
  34. package/dist/runtime/dispatch.js.map +1 -0
  35. package/dist/runtime/executor-wiring.js +128 -0
  36. package/dist/runtime/executor-wiring.js.map +1 -0
  37. package/dist/runtime/fallback-chain.js +139 -0
  38. package/dist/runtime/fallback-chain.js.map +1 -0
  39. package/dist/runtime/handoff.js +307 -0
  40. package/dist/runtime/handoff.js.map +1 -0
  41. package/dist/runtime/messages.d.ts +22 -0
  42. package/dist/runtime/messages.d.ts.map +1 -0
  43. package/dist/runtime/messages.js +204 -0
  44. package/dist/runtime/messages.js.map +1 -0
  45. package/dist/runtime/provider-events.js +117 -0
  46. package/dist/runtime/provider-events.js.map +1 -0
  47. package/dist/runtime/run-compaction.js +210 -0
  48. package/dist/runtime/run-compaction.js.map +1 -0
  49. package/dist/runtime/run-finish.js +48 -0
  50. package/dist/runtime/run-finish.js.map +1 -0
  51. package/dist/runtime/run-gates.js +336 -0
  52. package/dist/runtime/run-gates.js.map +1 -0
  53. package/dist/runtime/run-init.js +81 -0
  54. package/dist/runtime/run-init.js.map +1 -0
  55. package/dist/runtime/run-input.js +46 -0
  56. package/dist/runtime/run-input.js.map +1 -0
  57. package/dist/runtime/step-catalogue.js +173 -0
  58. package/dist/runtime/step-catalogue.js.map +1 -0
  59. package/dist/runtime/tool-call-walk.js +189 -0
  60. package/dist/runtime/tool-call-walk.js.map +1 -0
  61. package/dist/runtime/tool-wiring.js +159 -0
  62. package/dist/runtime/tool-wiring.js.map +1 -0
  63. package/dist/tooling/adapters.js +1 -1
  64. package/dist/tooling/dataflow.js +1 -1
  65. package/dist/tooling/policy.js +2 -0
  66. package/dist/tooling/policy.js.map +1 -1
  67. package/dist/types.d.ts +63 -13
  68. package/dist/types.d.ts.map +1 -1
  69. package/package.json +20 -20
  70. package/src/errors/index.ts +320 -0
  71. package/src/evaluator-optimizer/index.ts +212 -0
  72. package/src/factory.ts +957 -0
  73. package/src/fallback/index.ts +108 -0
  74. package/src/fanout/index.ts +523 -0
  75. package/src/filters/index.ts +347 -0
  76. package/src/index.ts +180 -0
  77. package/src/internal/ids.ts +46 -0
  78. package/src/internal/usage-accumulator.ts +90 -0
  79. package/src/lateral-leak/causality-monitor.ts +221 -0
  80. package/src/lateral-leak/index.ts +35 -0
  81. package/src/lateral-leak/merge-guard.ts +151 -0
  82. package/src/lateral-leak/protocol-guard.ts +222 -0
  83. package/src/preferred-model/index.ts +210 -0
  84. package/src/progress/index.ts +238 -0
  85. package/src/run-state/index.ts +607 -0
  86. package/src/runtime/agent-surface.ts +218 -0
  87. package/src/runtime/agent-to-tool.ts +323 -0
  88. package/src/runtime/approvals.ts +0 -0
  89. package/src/runtime/dispatch.ts +183 -0
  90. package/src/runtime/executor-wiring.ts +331 -0
  91. package/src/runtime/fallback-chain.ts +250 -0
  92. package/src/runtime/handoff.ts +428 -0
  93. package/src/runtime/messages.ts +309 -0
  94. package/src/runtime/provider-events.ts +175 -0
  95. package/src/runtime/run-compaction.ts +288 -0
  96. package/src/runtime/run-finish.ts +93 -0
  97. package/src/runtime/run-gates.ts +419 -0
  98. package/src/runtime/run-init.ts +169 -0
  99. package/src/runtime/run-input.ts +102 -0
  100. package/src/runtime/step-catalogue.ts +338 -0
  101. package/src/runtime/tool-call-walk.ts +301 -0
  102. package/src/runtime/tool-wiring.ts +218 -0
  103. package/src/testing/replay-provider.ts +121 -0
  104. package/src/tooling/adapters.ts +403 -0
  105. package/src/tooling/catalogue.ts +36 -0
  106. package/src/tooling/dataflow.ts +171 -0
  107. package/src/tooling/plan.ts +123 -0
  108. package/src/tooling/policy.ts +67 -0
  109. package/src/tooling/registry-build.ts +191 -0
  110. package/src/types.ts +696 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Agent-level model fallback chain primitives.
3
+ *
4
+ * The agent runtime walks `[primaryModel, ...Agent.fallbackModels]`
5
+ * on fallback-eligible errors during the per-step provider call.
6
+ * `isAgentFallbackEligible(...)` is the pure decision function the
7
+ * runtime calls once per `ProviderError`.
8
+ *
9
+ * Layering: this module is the **agent-step-level** fallback
10
+ * (re-tries the whole step against a different model on rate-limit
11
+ * / capacity / context-length / transient errors). The
12
+ * **request-level** `withFallback` provider middleware
13
+ * (`@graphorin/provider`) is a separate concern - it retries against
14
+ * an alternate provider serving the **same** model concept on
15
+ * transient errors inside one `provider.stream(...)` call.
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ import type { ProviderError } from '@graphorin/core';
21
+
22
+ /**
23
+ * Operator-supplied policy that lets the consumer toggle which
24
+ * `ProviderError` kinds the agent runtime should consider eligible
25
+ * for whole-step retries against the next model in the chain.
26
+ *
27
+ * @stable
28
+ */
29
+ export interface AgentFallbackPolicy {
30
+ /** Default `true`. */
31
+ readonly rateLimitEligible?: boolean;
32
+ /** Default `true`. */
33
+ readonly capacityEligible?: boolean;
34
+ /** Default `true`. */
35
+ readonly contextLengthEligible?: boolean;
36
+ /** Default `false` - `withRetry` already covers transient errors. */
37
+ readonly transientEligible?: boolean;
38
+ }
39
+
40
+ /**
41
+ * Stable taxonomy returned by {@link isAgentFallbackEligible} on
42
+ * eligible errors.
43
+ *
44
+ * @stable
45
+ */
46
+ export type AgentFallbackReason = 'rate-limit' | 'capacity' | 'context-length' | 'transient';
47
+
48
+ /**
49
+ * Outcome of {@link isAgentFallbackEligible}.
50
+ *
51
+ * @stable
52
+ */
53
+ export interface AgentFallbackEligibility {
54
+ readonly eligible: boolean;
55
+ readonly reason?: AgentFallbackReason;
56
+ }
57
+
58
+ const DEFAULT_POLICY: Required<AgentFallbackPolicy> = {
59
+ rateLimitEligible: true,
60
+ capacityEligible: true,
61
+ contextLengthEligible: true,
62
+ transientEligible: false,
63
+ };
64
+
65
+ /**
66
+ * Pure dispatcher that maps a {@link ProviderError} to one of four
67
+ * eligible reasons or to `eligible: false` if the error is on the
68
+ * bypass list (`auth | invalid-input | content-filter | cancelled`).
69
+ *
70
+ * The function is intentionally allocation-free on the hot path so
71
+ * the agent runtime can call it once per provider error per step
72
+ * without budget concerns.
73
+ *
74
+ * @stable
75
+ */
76
+ export function isAgentFallbackEligible(
77
+ error: ProviderError,
78
+ policy: AgentFallbackPolicy = {},
79
+ ): AgentFallbackEligibility {
80
+ const merged: Required<AgentFallbackPolicy> = { ...DEFAULT_POLICY, ...policy };
81
+ switch (error.kind) {
82
+ case 'rate-limit':
83
+ return merged.rateLimitEligible
84
+ ? { eligible: true, reason: 'rate-limit' }
85
+ : { eligible: false };
86
+ case 'capacity':
87
+ return merged.capacityEligible ? { eligible: true, reason: 'capacity' } : { eligible: false };
88
+ case 'context-length':
89
+ return merged.contextLengthEligible
90
+ ? { eligible: true, reason: 'context-length' }
91
+ : { eligible: false };
92
+ case 'transient':
93
+ return merged.transientEligible
94
+ ? { eligible: true, reason: 'transient' }
95
+ : { eligible: false };
96
+ case 'invalid-request':
97
+ case 'unauthorized':
98
+ case 'content-filter':
99
+ case 'unknown':
100
+ return { eligible: false };
101
+ default: {
102
+ // Defensive: unknown error kind treated as ineligible.
103
+ const _exhaustive: never = error.kind;
104
+ void _exhaustive;
105
+ return { eligible: false };
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Agent-step-level fan-out - `Agent.fanOut(...)` convenience that
3
+ * spawns N sub-agents in parallel under a bounded-fanout cap with
4
+ * per-child budgets and four built-in merge strategies.
5
+ *
6
+ * Boundary discipline against the workflow `Dispatch(...)`
7
+ * primitive: fan-out is **agent-step-level inline result** (children
8
+ * share parent `RunContext` lineage; result consumed by parent's
9
+ * continuing loop within one or few `agent.run(...)` calls).
10
+ * `Dispatch(...)` is workflow-step-level checkpointed durable graph.
11
+ * The two compose orthogonally.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import { createHash } from 'node:crypto';
17
+ import { type AgentEvent, type FanOutChildMetadata, type Usage, zeroUsage } from '@graphorin/core';
18
+ import { MergeBlockedError } from '../errors/index.js';
19
+ import {
20
+ type ContentOriginKind,
21
+ computeSourceTrust,
22
+ evaluateMerge,
23
+ type MergeGuardConfig,
24
+ type TrustClass,
25
+ } from '../lateral-leak/merge-guard.js';
26
+ import { accumulateUsage } from '../runtime/messages.js';
27
+
28
+ /**
29
+ * Per-child budget. Defaults derived from the canonical 2026
30
+ * scaling-rule table for agent fan-out workloads.
31
+ *
32
+ * @stable
33
+ */
34
+ export interface PerChildBudget {
35
+ /**
36
+ * Max `usage.totalTokens` per child. Enforced **post-hoc** and only
37
+ * for usage-reporting children (an `invoke` that resolves to a full
38
+ * `AgentResult` - e.g. `() => child.run(input)`); a child returning a
39
+ * plain value reports `tokensUsed: 0` and this cap cannot fire.
40
+ */
41
+ readonly tokens?: number;
42
+ /**
43
+ * Max tool calls per child. Same usage-reporting contract as
44
+ * {@link PerChildBudget.tokens} (counted from `state.steps`).
45
+ */
46
+ readonly toolCalls?: number;
47
+ /** Wall-clock cap, enforced for every child via a race timer. */
48
+ readonly durationMs?: number;
49
+ }
50
+
51
+ /**
52
+ * Built-in merge-strategy taxonomy.
53
+ *
54
+ * @stable
55
+ */
56
+ export type MergeStrategy<TOutput = unknown> =
57
+ | { readonly kind: 'concat'; readonly separator?: string }
58
+ | { readonly kind: 'first-success' }
59
+ | {
60
+ readonly kind: 'judge-merge';
61
+ readonly judge: (children: ReadonlyArray<ChildResult<TOutput>>) => Promise<TOutput>;
62
+ }
63
+ | {
64
+ readonly kind: 'custom';
65
+ readonly merge: (children: ReadonlyArray<ChildResult<TOutput>>) => Promise<TOutput>;
66
+ };
67
+
68
+ /**
69
+ * Per-child outcome surfaced on
70
+ * {@link FanOutResult.children}. Failed-child isolation: a child
71
+ * that throws produces a `ChildResult` with `status: 'failed'` -
72
+ * never an exception thrown from the fan-out call itself.
73
+ *
74
+ * @stable
75
+ */
76
+ export interface ChildResult<TOutput = unknown> {
77
+ readonly agentId: string;
78
+ readonly status: 'completed' | 'failed' | 'budget-exceeded' | 'cancelled';
79
+ readonly output?: TOutput;
80
+ readonly error?: { readonly message: string; readonly code: string };
81
+ readonly tokensUsed: number;
82
+ readonly toolCallCount: number;
83
+ readonly durationMs: number;
84
+ /**
85
+ * Full usage breakdown, present only for usage-reporting children
86
+ * (an `invoke` resolving to a full `AgentResult`, W-033).
87
+ */
88
+ readonly usage?: Usage;
89
+ }
90
+
91
+ /**
92
+ * Aggregate result returned by `Agent.fanOut(...)`.
93
+ *
94
+ * @stable
95
+ */
96
+ export interface FanOutResult<TOutput = unknown> {
97
+ readonly fanOutId: string;
98
+ readonly output: TOutput;
99
+ readonly children: ReadonlyArray<ChildResult<TOutput>>;
100
+ readonly mergeDurationMs: number;
101
+ /**
102
+ * Sum of every usage-reporting child's usage (W-033); zero when no
103
+ * child reported. The fan-out helper never mutates the parent run's
104
+ * live state (it runs outside the loop and would race it) - folding
105
+ * this into the parent run's accounting is the caller's decision.
106
+ */
107
+ readonly usage: Usage;
108
+ }
109
+
110
+ /**
111
+ * Per-call options accepted by `Agent.fanOut(...)`.
112
+ *
113
+ * @stable
114
+ */
115
+ export interface FanOutOptions<TOutput = unknown> {
116
+ /**
117
+ * The sub-agents to invoke. Each entry is invoked as a function
118
+ * returning a `Promise<TOutput>` - the fan-out helper does not
119
+ * impose an `Agent` shape on the children so the runtime can
120
+ * adapt any callable surface.
121
+ */
122
+ readonly children: ReadonlyArray<{
123
+ readonly agentId: string;
124
+ /**
125
+ * Child callable. Resolve to a plain `TOutput`, or to a full
126
+ * `AgentResult` (e.g. `() => childAgent.run(input)`) - the fan-out
127
+ * detects the result envelope structurally (`output` + numeric
128
+ * `usage.totalTokens` + `state`), unwraps `output`, and harvests
129
+ * `tokensUsed` / `toolCallCount` so per-child budgets can enforce.
130
+ */
131
+ readonly invoke: () => Promise<TOutput>;
132
+ /** Trust-class for the merge guard (default `'loopback'`). */
133
+ readonly trustClass?: TrustClass;
134
+ /** Content-origin for the merge guard (default `'built-in'`). */
135
+ readonly origin?: ContentOriginKind;
136
+ /** Rolling trust adjustment in `[0,1]` (default `1`). */
137
+ readonly historyAdjustment?: number;
138
+ }>;
139
+ /** Default `4` per the canonical 2026 production lesson. */
140
+ readonly maxConcurrentChildren?: number;
141
+ /** Per-child budget; default unset. */
142
+ readonly perBudget?: PerChildBudget;
143
+ /** Default `{ kind: 'concat' }`. */
144
+ readonly mergeStrategy?: MergeStrategy<TOutput>;
145
+ readonly signal?: AbortSignal;
146
+ /** Optional callback for per-child completion observability. */
147
+ readonly onChildResult?: (result: ChildResult<TOutput>) => void;
148
+ /** Optional event emitter for `agent.fanout.spawned / merged`. */
149
+ readonly emit?: (event: AgentEvent) => void;
150
+ /**
151
+ * Sideways-injection merge guard (AG-7): on `'judge-merge'` the
152
+ * fan-out scores each child's source trust and contribution weight
153
+ * against the judge's merged output; a biased merge emits
154
+ * `agent.lateral-leak.detected` (vector `sideways-injection`) and -
155
+ * under `strictness: 'detect-and-block'` - throws
156
+ * {@link MergeBlockedError}.
157
+ */
158
+ readonly mergeGuard?: MergeGuardConfig;
159
+ /** Identifiers required to populate the events. */
160
+ readonly runId: string;
161
+ readonly sessionId: string;
162
+ readonly agentId: string;
163
+ /** Default - generated from `runId + Date.now()`. */
164
+ readonly fanOutId?: string;
165
+ }
166
+
167
+ const DEFAULT_MAX_CONCURRENT_CHILDREN = 4;
168
+
169
+ /**
170
+ * Structurally detect a full `AgentResult` returned by a child
171
+ * `invoke` (AG-16): an object carrying `output`, a numeric
172
+ * `usage.totalTokens`, and a `state` with string `id`/`status`. The
173
+ * three-field match makes accidental collision with a user `TOutput`
174
+ * negligible; children whose genuine output looks like this should
175
+ * resolve a plain value instead.
176
+ */
177
+ function harvestAgentResult(value: unknown):
178
+ | {
179
+ readonly output: unknown;
180
+ readonly tokensUsed: number;
181
+ readonly toolCallCount: number;
182
+ readonly usage?: Usage;
183
+ }
184
+ | undefined {
185
+ if (typeof value !== 'object' || value === null) return undefined;
186
+ const v = value as {
187
+ readonly output?: unknown;
188
+ readonly usage?: {
189
+ readonly totalTokens?: unknown;
190
+ readonly promptTokens?: unknown;
191
+ readonly completionTokens?: unknown;
192
+ };
193
+ readonly state?: { readonly id?: unknown; readonly status?: unknown; readonly steps?: unknown };
194
+ };
195
+ if (!('output' in v)) return undefined;
196
+ if (typeof v.usage?.totalTokens !== 'number') return undefined;
197
+ if (typeof v.state?.id !== 'string' || typeof v.state.status !== 'string') return undefined;
198
+ let toolCallCount = 0;
199
+ if (Array.isArray(v.state.steps)) {
200
+ for (const step of v.state.steps) {
201
+ const calls = (step as { readonly toolCalls?: unknown }).toolCalls;
202
+ if (Array.isArray(calls)) toolCallCount += calls.length;
203
+ }
204
+ }
205
+ const usage =
206
+ typeof v.usage.promptTokens === 'number' && typeof v.usage.completionTokens === 'number'
207
+ ? (v.usage as Usage)
208
+ : undefined;
209
+ return {
210
+ output: v.output,
211
+ tokensUsed: v.usage.totalTokens,
212
+ toolCallCount,
213
+ ...(usage !== undefined ? { usage } : {}),
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Whitespace-token overlap of a child's output against the merged
219
+ * output, in `[0,1]` - the contribution-weight estimate the merge
220
+ * guard's docstring contracts (each child scored independently; a
221
+ * judge parroting one child verbatim yields ~1.0 for that child).
222
+ */
223
+ function contributionWeight(childText: string, mergedText: string): number {
224
+ if (childText.length === 0 || mergedText.length === 0) return 0;
225
+ const mergedTokens = mergedText.split(/\s+/).filter((t) => t.length > 0);
226
+ if (mergedTokens.length === 0) return 0;
227
+ const childTokens = new Set(childText.split(/\s+/).filter((t) => t.length > 0));
228
+ let hits = 0;
229
+ for (const token of mergedTokens) {
230
+ if (childTokens.has(token)) hits += 1;
231
+ }
232
+ return hits / mergedTokens.length;
233
+ }
234
+
235
+ async function runWithSemaphore<TOutput>(
236
+ children: ReadonlyArray<FanOutOptions<TOutput>['children'][number]>,
237
+ cap: number,
238
+ signal: AbortSignal | undefined,
239
+ perBudget: PerChildBudget | undefined,
240
+ onChildResult?: (r: ChildResult<TOutput>) => void,
241
+ ): Promise<ReadonlyArray<ChildResult<TOutput>>> {
242
+ const results: ChildResult<TOutput>[] = new Array(children.length);
243
+ let cursor = 0;
244
+ const running: Set<Promise<void>> = new Set();
245
+ const limit = Math.max(1, Math.min(cap, children.length));
246
+
247
+ const launchOne = (index: number): Promise<void> => {
248
+ const child = children[index];
249
+ if (child === undefined) return Promise.resolve();
250
+ const start = Date.now();
251
+ const exec = async (): Promise<ChildResult<TOutput>> => {
252
+ if (signal?.aborted) {
253
+ return {
254
+ agentId: child.agentId,
255
+ status: 'cancelled',
256
+ tokensUsed: 0,
257
+ toolCallCount: 0,
258
+ durationMs: 0,
259
+ };
260
+ }
261
+ let timer: ReturnType<typeof setTimeout> | undefined;
262
+ try {
263
+ const timedPromise =
264
+ perBudget?.durationMs !== undefined
265
+ ? new Promise<TOutput>((_, reject) => {
266
+ timer = setTimeout(
267
+ () => reject(new Error('budget-exceeded:durationMs')),
268
+ perBudget.durationMs,
269
+ );
270
+ })
271
+ : undefined;
272
+ const racePromise = timedPromise
273
+ ? Promise.race([child.invoke(), timedPromise])
274
+ : child.invoke();
275
+ const raw = await racePromise;
276
+ // AG-16: a child resolving to a full AgentResult reports its
277
+ // real usage - unwrap the output and harvest the counters.
278
+ const report = harvestAgentResult(raw);
279
+ const output = (report === undefined ? raw : report.output) as TOutput;
280
+ const tokensUsed = report?.tokensUsed ?? 0;
281
+ const toolCallCount = report?.toolCallCount ?? 0;
282
+ // Post-hoc budget enforcement (only fires for usage-reporting
283
+ // children): the over-budget output is withheld from the merge.
284
+ const exceeded =
285
+ (perBudget?.tokens !== undefined && tokensUsed > perBudget.tokens
286
+ ? `tokens ${tokensUsed} > ${perBudget.tokens}`
287
+ : undefined) ??
288
+ (perBudget?.toolCalls !== undefined && toolCallCount > perBudget.toolCalls
289
+ ? `toolCalls ${toolCallCount} > ${perBudget.toolCalls}`
290
+ : undefined);
291
+ if (exceeded !== undefined) {
292
+ return {
293
+ agentId: child.agentId,
294
+ status: 'budget-exceeded',
295
+ error: { message: `budget-exceeded: ${exceeded}`, code: 'budget-exceeded' },
296
+ tokensUsed,
297
+ toolCallCount,
298
+ durationMs: Date.now() - start,
299
+ ...(report?.usage !== undefined ? { usage: report.usage } : {}),
300
+ };
301
+ }
302
+ return {
303
+ agentId: child.agentId,
304
+ status: 'completed',
305
+ output,
306
+ tokensUsed,
307
+ toolCallCount,
308
+ durationMs: Date.now() - start,
309
+ ...(report?.usage !== undefined ? { usage: report.usage } : {}),
310
+ };
311
+ } catch (cause) {
312
+ const message = cause instanceof Error ? cause.message : String(cause);
313
+ const aborted = signal?.aborted;
314
+ const status: ChildResult<TOutput>['status'] = message.startsWith('budget-exceeded')
315
+ ? 'budget-exceeded'
316
+ : aborted
317
+ ? 'cancelled'
318
+ : 'failed';
319
+ return {
320
+ agentId: child.agentId,
321
+ status,
322
+ error: { message, code: status },
323
+ tokensUsed: 0,
324
+ toolCallCount: 0,
325
+ durationMs: Date.now() - start,
326
+ };
327
+ } finally {
328
+ // AG-16: the duration timer must die on EVERY path - leaving it
329
+ // armed on rejection held the event loop open.
330
+ if (timer !== undefined) clearTimeout(timer);
331
+ }
332
+ };
333
+ const promise = exec().then((r) => {
334
+ results[index] = r;
335
+ onChildResult?.(r);
336
+ });
337
+ running.add(promise);
338
+ promise.finally(() => running.delete(promise));
339
+ return promise;
340
+ };
341
+
342
+ while (cursor < children.length || running.size > 0) {
343
+ while (running.size < limit && cursor < children.length) {
344
+ void launchOne(cursor);
345
+ cursor += 1;
346
+ }
347
+ if (running.size > 0) {
348
+ await Promise.race(running);
349
+ }
350
+ }
351
+ return results;
352
+ }
353
+
354
+ /**
355
+ * Run a fan-out and produce the aggregate {@link FanOutResult}.
356
+ * Pure with respect to side effects - the runtime emits events /
357
+ * audit rows / counter increments via the supplied `emit` callback.
358
+ *
359
+ * @stable
360
+ */
361
+ export async function runFanOut<TOutput>(
362
+ opts: FanOutOptions<TOutput>,
363
+ ): Promise<FanOutResult<TOutput>> {
364
+ const fanOutId = opts.fanOutId ?? `fanout-${opts.runId.slice(-8)}-${Date.now().toString(36)}`;
365
+ const merge: MergeStrategy<TOutput> = opts.mergeStrategy ?? {
366
+ kind: 'concat',
367
+ };
368
+ const cap = opts.maxConcurrentChildren ?? DEFAULT_MAX_CONCURRENT_CHILDREN;
369
+
370
+ opts.emit?.({
371
+ type: 'agent.fanout.spawned',
372
+ runId: opts.runId,
373
+ sessionId: opts.sessionId,
374
+ agentId: opts.agentId,
375
+ fanOutId,
376
+ childCount: opts.children.length,
377
+ mergeStrategyKind: merge.kind,
378
+ spawnedAtIso: new Date().toISOString(),
379
+ });
380
+
381
+ const results = await runWithSemaphore<TOutput>(
382
+ opts.children,
383
+ cap,
384
+ opts.signal,
385
+ opts.perBudget,
386
+ opts.onChildResult,
387
+ );
388
+
389
+ const mergeStart = Date.now();
390
+ let merged: TOutput;
391
+ switch (merge.kind) {
392
+ case 'concat': {
393
+ const sep = merge.separator ?? '\n\n---\n\n';
394
+ const parts: string[] = [];
395
+ for (const r of results) {
396
+ if (r.status === 'completed' && r.output !== undefined) {
397
+ parts.push(typeof r.output === 'string' ? r.output : JSON.stringify(r.output));
398
+ } else if (
399
+ r.status === 'failed' ||
400
+ r.status === 'budget-exceeded' ||
401
+ r.status === 'cancelled'
402
+ ) {
403
+ parts.push(`[${r.status}: ${r.agentId}]`);
404
+ }
405
+ }
406
+ merged = parts.join(sep) as unknown as TOutput;
407
+ break;
408
+ }
409
+ case 'first-success': {
410
+ const first = results.find((r) => r.status === 'completed');
411
+ merged = first?.output ?? ('' as unknown as TOutput);
412
+ break;
413
+ }
414
+ case 'judge-merge': {
415
+ merged = await merge.judge(results);
416
+ // AG-7: the sideways-injection merge guard scores each child's
417
+ // source trust × contribution weight against the judge's merged
418
+ // output. A biased merge emits `agent.lateral-leak.detected`;
419
+ // 'detect-and-block' refuses the merge entirely.
420
+ if (opts.mergeGuard !== undefined && opts.mergeGuard.strictness !== 'off') {
421
+ const mergedText = typeof merged === 'string' ? merged : JSON.stringify(merged);
422
+ const overrides = opts.mergeGuard.sourceTrustOverrides ?? {};
423
+ const perChild = results.map((r, i) => {
424
+ const c = opts.children[i];
425
+ const childText =
426
+ r.output === undefined
427
+ ? ''
428
+ : typeof r.output === 'string'
429
+ ? r.output
430
+ : JSON.stringify(r.output);
431
+ return {
432
+ agentId: r.agentId,
433
+ sourceTrust: computeSourceTrust(
434
+ {
435
+ agentId: r.agentId,
436
+ trustClass: c?.trustClass ?? 'loopback',
437
+ origin: c?.origin ?? 'built-in',
438
+ ...(c?.historyAdjustment !== undefined
439
+ ? { historyAdjustment: c.historyAdjustment }
440
+ : {}),
441
+ },
442
+ overrides,
443
+ ),
444
+ contributionWeight: contributionWeight(childText, mergedText),
445
+ };
446
+ });
447
+ const verdict = evaluateMerge(perChild, opts.mergeGuard);
448
+ if (verdict.biased) {
449
+ opts.emit?.({
450
+ type: 'agent.lateral-leak.detected',
451
+ runId: opts.runId,
452
+ sessionId: opts.sessionId,
453
+ agentId: opts.agentId,
454
+ vector: 'sideways-injection',
455
+ severity: verdict.decision === 'block' ? 'block' : 'warn',
456
+ causalityChain: verdict.offendingChild === undefined ? [] : [verdict.offendingChild],
457
+ messageContentSha256: createHash('sha256').update(mergedText, 'utf8').digest('hex'),
458
+ decision:
459
+ verdict.decision === 'block'
460
+ ? 'block'
461
+ : verdict.decision === 'flag'
462
+ ? 'flag'
463
+ : 'detect',
464
+ detectedAtIso: new Date().toISOString(),
465
+ });
466
+ if (verdict.decision === 'block') {
467
+ throw new MergeBlockedError(
468
+ fanOutId,
469
+ `low-trust child '${verdict.offendingChild}' (sourceTrust ${verdict.sourceTrust?.toFixed(2)}) contributes ${(
470
+ (verdict.contributionWeight ?? 0) * 100
471
+ ).toFixed(0)}% of the merged output`,
472
+ );
473
+ }
474
+ }
475
+ }
476
+ break;
477
+ }
478
+ case 'custom':
479
+ merged = await merge.merge(results);
480
+ break;
481
+ default: {
482
+ const _exhaustive: never = merge;
483
+ void _exhaustive;
484
+ merged = '' as unknown as TOutput;
485
+ }
486
+ }
487
+ const mergeDurationMs = Date.now() - mergeStart;
488
+
489
+ const childMetadata: FanOutChildMetadata[] = results.map((r) => ({
490
+ agentId: r.agentId,
491
+ status: r.status,
492
+ tokensUsed: r.tokensUsed,
493
+ toolCallCount: r.toolCallCount,
494
+ durationMs: r.durationMs,
495
+ }));
496
+ const successfulChildCount = results.filter((r) => r.status === 'completed').length;
497
+
498
+ opts.emit?.({
499
+ type: 'agent.fanout.merged',
500
+ runId: opts.runId,
501
+ sessionId: opts.sessionId,
502
+ agentId: opts.agentId,
503
+ fanOutId,
504
+ childCount: opts.children.length,
505
+ successfulChildCount,
506
+ mergeStrategyKind: merge.kind,
507
+ mergeDurationMs,
508
+ childMetadata,
509
+ });
510
+
511
+ const usage = zeroUsage();
512
+ for (const r of results) {
513
+ if (r.usage !== undefined) accumulateUsage(usage, r.usage);
514
+ }
515
+
516
+ return {
517
+ fanOutId,
518
+ output: merged,
519
+ children: results,
520
+ mergeDurationMs,
521
+ usage,
522
+ };
523
+ }