@dogpile/sdk 0.5.0 → 0.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 (67) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/browser/index.js +3992 -4997
  3. package/dist/browser/index.js.map +1 -1
  4. package/dist/providers/openai-compatible.d.ts.map +1 -1
  5. package/dist/providers/openai-compatible.js +5 -1
  6. package/dist/providers/openai-compatible.js.map +1 -1
  7. package/dist/runtime/broadcast.d.ts +1 -0
  8. package/dist/runtime/broadcast.d.ts.map +1 -1
  9. package/dist/runtime/broadcast.js +132 -69
  10. package/dist/runtime/broadcast.js.map +1 -1
  11. package/dist/runtime/coordinator.d.ts +4 -2
  12. package/dist/runtime/coordinator.d.ts.map +1 -1
  13. package/dist/runtime/coordinator.js +114 -39
  14. package/dist/runtime/coordinator.js.map +1 -1
  15. package/dist/runtime/defaults.d.ts.map +1 -1
  16. package/dist/runtime/defaults.js +2 -1
  17. package/dist/runtime/defaults.js.map +1 -1
  18. package/dist/runtime/engine.d.ts.map +1 -1
  19. package/dist/runtime/engine.js +54 -34
  20. package/dist/runtime/engine.js.map +1 -1
  21. package/dist/runtime/model.d.ts.map +1 -1
  22. package/dist/runtime/model.js +6 -3
  23. package/dist/runtime/model.js.map +1 -1
  24. package/dist/runtime/redaction.d.ts +13 -0
  25. package/dist/runtime/redaction.d.ts.map +1 -0
  26. package/dist/runtime/redaction.js +278 -0
  27. package/dist/runtime/redaction.js.map +1 -0
  28. package/dist/runtime/sanitization.d.ts +4 -0
  29. package/dist/runtime/sanitization.d.ts.map +1 -0
  30. package/dist/runtime/sanitization.js +63 -0
  31. package/dist/runtime/sanitization.js.map +1 -0
  32. package/dist/runtime/shared.d.ts +1 -0
  33. package/dist/runtime/shared.d.ts.map +1 -1
  34. package/dist/runtime/shared.js +128 -65
  35. package/dist/runtime/shared.js.map +1 -1
  36. package/dist/runtime/tools/built-in.d.ts +2 -0
  37. package/dist/runtime/tools/built-in.d.ts.map +1 -1
  38. package/dist/runtime/tools/built-in.js +153 -15
  39. package/dist/runtime/tools/built-in.js.map +1 -1
  40. package/dist/runtime/tools.d.ts.map +1 -1
  41. package/dist/runtime/tools.js +29 -7
  42. package/dist/runtime/tools.js.map +1 -1
  43. package/dist/runtime/validation.d.ts.map +1 -1
  44. package/dist/runtime/validation.js +3 -0
  45. package/dist/runtime/validation.js.map +1 -1
  46. package/dist/types/events.d.ts +3 -3
  47. package/dist/types/events.d.ts.map +1 -1
  48. package/dist/types/replay.d.ts +3 -1
  49. package/dist/types/replay.d.ts.map +1 -1
  50. package/dist/types.d.ts +20 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +8 -1
  53. package/src/providers/openai-compatible.ts +5 -1
  54. package/src/runtime/broadcast.ts +156 -72
  55. package/src/runtime/coordinator.ts +143 -47
  56. package/src/runtime/defaults.ts +2 -1
  57. package/src/runtime/engine.ts +77 -40
  58. package/src/runtime/model.ts +6 -3
  59. package/src/runtime/redaction.ts +355 -0
  60. package/src/runtime/sanitization.ts +81 -0
  61. package/src/runtime/shared.ts +152 -68
  62. package/src/runtime/tools/built-in.ts +168 -15
  63. package/src/runtime/tools.ts +39 -8
  64. package/src/runtime/validation.ts +3 -0
  65. package/src/types/events.ts +3 -3
  66. package/src/types/replay.ts +3 -1
  67. package/src/types.ts +20 -0
@@ -87,6 +87,7 @@ export type RunProtocolFn = (input: {
87
87
  readonly currentDepth?: number;
88
88
  readonly effectiveMaxDepth?: number;
89
89
  readonly effectiveMaxConcurrentChildren?: number;
90
+ readonly effectiveMaxConcurrentAgentTurns?: number;
90
91
  readonly onChildFailure?: DogpileOptions["onChildFailure"];
91
92
  /**
92
93
  * Root-run deadline (epoch ms). Children inherit `parentDeadlineMs - now()`
@@ -126,11 +127,12 @@ interface CoordinatorRunOptions {
126
127
  */
127
128
  readonly currentDepth?: number;
128
129
  /**
129
- * Effective max recursion depth resolved at run start. Plan 04 enforces;
130
- * Plan 03 only plumbs the value.
130
+ * Effective max recursion depth resolved at run start and enforced before
131
+ * delegate dispatch.
131
132
  */
132
133
  readonly effectiveMaxDepth?: number;
133
134
  readonly effectiveMaxConcurrentChildren?: number;
135
+ readonly effectiveMaxConcurrentAgentTurns?: number;
134
136
  readonly onChildFailure?: DogpileOptions["onChildFailure"];
135
137
  /**
136
138
  * Engine `runProtocol` callback used by the delegate dispatch loop to
@@ -162,6 +164,7 @@ interface CoordinatorRunOptions {
162
164
  */
163
165
  const MAX_DISPATCH_PER_TURN = 8;
164
166
  const DEFAULT_MAX_CONCURRENT_CHILDREN = 4;
167
+ const DEFAULT_MAX_CONCURRENT_AGENT_TURNS = 4;
165
168
 
166
169
  type DispatchWaveFailure = {
167
170
  readonly childRunId: string;
@@ -183,7 +186,8 @@ interface Semaphore {
183
186
 
184
187
  function createSemaphore(maxConcurrent: number): Semaphore {
185
188
  let inFlight = 0;
186
- const waiters: Array<() => void> = [];
189
+ const waiters: Array<(() => void) | undefined> = [];
190
+ let waiterHead = 0;
187
191
  return {
188
192
  acquire(): Promise<void> {
189
193
  if (inFlight < maxConcurrent) {
@@ -199,8 +203,14 @@ function createSemaphore(maxConcurrent: number): Semaphore {
199
203
  },
200
204
  release(): void {
201
205
  inFlight -= 1;
202
- const next = waiters.shift();
206
+ const next = waiters[waiterHead];
203
207
  if (next !== undefined) {
208
+ waiters[waiterHead] = undefined;
209
+ waiterHead += 1;
210
+ if (waiterHead > 32 && waiterHead * 2 > waiters.length) {
211
+ waiters.splice(0, waiterHead);
212
+ waiterHead = 0;
213
+ }
204
214
  next();
205
215
  }
206
216
  },
@@ -208,7 +218,7 @@ function createSemaphore(maxConcurrent: number): Semaphore {
208
218
  return inFlight;
209
219
  },
210
220
  get queued() {
211
- return waiters.length;
221
+ return waiters.length - waiterHead;
212
222
  }
213
223
  };
214
224
  }
@@ -218,15 +228,16 @@ function createSemaphore(maxConcurrent: number): Semaphore {
218
228
  * whose metadata.locality === "local", or undefined if none found.
219
229
  *
220
230
  * Walk order (forward-compat): options.model first, then options.agents in
221
- * declaration order. AgentSpec has no `model` field today (Phase 3 D-11
222
- * forward-compat scaffolding); the agent walk uses optional chaining and
223
- * effectively no-ops until a future phase adds AgentSpec.model.
231
+ * declaration order. AgentSpec has no `model` field today; the agent walk
232
+ * uses optional chaining and effectively no-ops until a future caller-defined
233
+ * tree surface adds AgentSpec.model.
224
234
  */
225
235
  function findFirstLocalProvider(options: CoordinatorRunOptions): ConfiguredModelProvider | undefined {
226
236
  if (options.model.metadata?.locality === "local") {
227
237
  return options.model;
228
238
  }
229
- // Forward-compat: AgentSpec.model not yet declared (Phase 3 D-11). Walk no-ops today; ready for caller-defined trees in a future milestone.
239
+ // Forward-compat: AgentSpec.model is not yet declared; this no-ops today and
240
+ // is ready for a future caller-defined tree surface.
230
241
  for (const agent of options.agents) {
231
242
  const agentModel = (agent as { readonly model?: ConfiguredModelProvider }).model;
232
243
  if (agentModel?.metadata?.locality === "local") {
@@ -363,13 +374,12 @@ export async function runCoordinator(options: CoordinatorRunOptions): Promise<Ru
363
374
 
364
375
  if (coordinator) {
365
376
  if (!stopIfNeeded()) {
366
- // Delegate dispatch loop (D-11/D-16/D-17/D-18). Phase 1 limits delegation
367
- // to the coordinator's plan turn; workers cannot delegate. The loop
368
- // re-issues the coordinator plan turn after each successful sub-run with
369
- // the projected D-17 result tagged into the next prompt and a synthetic
370
- // D-18 transcript entry already appended. `partialTrace` for failed
371
- // sub-runs is captured via a tee'd emit buffer locally — `runProtocol`'s
372
- // error contract is unchanged.
377
+ // Delegate dispatch is restricted to the coordinator's plan turn.
378
+ // Workers and final synthesis cannot delegate. The loop re-issues the
379
+ // coordinator plan turn after each successful sub-run with the tagged
380
+ // child result in the next prompt and a synthetic transcript entry
381
+ // already appended. Failed children use the local tee buffer for
382
+ // partialTrace capture; runProtocol's error contract stays unchanged.
373
383
  let dispatchInput = buildCoordinatorPlanInput(options.intent, coordinator);
374
384
  let dispatchCount = 0;
375
385
  while (true) {
@@ -644,29 +654,39 @@ export async function runCoordinator(options: CoordinatorRunOptions): Promise<Ru
644
654
  const workers = activeAgents.slice(1);
645
655
  const providerCallSlots: ReplayTraceProviderCall[] = [];
646
656
  const planTranscript = [...transcript];
647
- const workerResults = await Promise.all(
648
- workers.map((agent, index) =>
649
- runCoordinatorWorkerTurn({
650
- agent,
651
- coordinator,
652
- input: buildWorkerInput(options.intent, planTranscript, coordinator),
653
- options,
654
- runId,
655
- turn: transcript.length + index + 1,
656
- providerCallId: providerCallIdFor(runId, providerCalls.length + index + 1),
657
- providerCallIndex: index,
658
- providerCallSlots,
659
- toolExecutor,
660
- toolAvailability,
661
- totalCost,
662
- events,
663
- transcript: planTranscript,
664
- startedAtMs,
665
- wrapUpHint,
666
- emit
667
- })
668
- )
669
- );
657
+ const fanout = createFanoutAbortController(options.signal);
658
+ const workerResults = await (async () => {
659
+ try {
660
+ return await mapWithConcurrency(
661
+ workers,
662
+ options.effectiveMaxConcurrentAgentTurns ?? DEFAULT_MAX_CONCURRENT_AGENT_TURNS,
663
+ fanout,
664
+ (agent, index) =>
665
+ runCoordinatorWorkerTurn({
666
+ agent,
667
+ coordinator,
668
+ input: buildWorkerInput(options.intent, planTranscript, coordinator),
669
+ options,
670
+ runId,
671
+ turn: transcript.length + index + 1,
672
+ providerCallId: providerCallIdFor(runId, providerCalls.length + index + 1),
673
+ providerCallIndex: index,
674
+ providerCallSlots,
675
+ toolExecutor,
676
+ toolAvailability,
677
+ totalCost,
678
+ events,
679
+ transcript: planTranscript,
680
+ startedAtMs,
681
+ wrapUpHint,
682
+ emit,
683
+ fanoutSignal: fanout.signal
684
+ })
685
+ );
686
+ } finally {
687
+ fanout.cleanup();
688
+ }
689
+ })();
670
690
  providerCalls.push(...providerCallSlots.filter((call): call is ReplayTraceProviderCall => call !== undefined));
671
691
 
672
692
  for (const result of workerResults) {
@@ -721,11 +741,11 @@ export async function runCoordinator(options: CoordinatorRunOptions): Promise<Ru
721
741
  recordProtocolDecision
722
742
  });
723
743
  totalCost = synthesisOutcome.totalCost;
724
- // Phase 1: final-synthesis turn cannot delegate.
744
+ // Final synthesis is terminal and cannot dispatch children.
725
745
  if (Array.isArray(synthesisOutcome.decision) || synthesisOutcome.decision?.type === "delegate") {
726
746
  throw new DogpileError({
727
747
  code: "invalid-configuration",
728
- message: "Coordinator final-synthesis turn cannot emit a delegate decision in Phase 1",
748
+ message: "Coordinator final-synthesis turn cannot emit a delegate decision.",
729
749
  retryable: false,
730
750
  detail: {
731
751
  kind: "delegate-validation",
@@ -1016,6 +1036,7 @@ interface CoordinatorWorkerTurnOptions {
1016
1036
  readonly startedAtMs: number;
1017
1037
  readonly wrapUpHint: ReturnType<typeof createWrapUpHintController>;
1018
1038
  readonly emit: (event: RunEvent) => void;
1039
+ readonly fanoutSignal?: AbortSignal;
1019
1040
  }
1020
1041
 
1021
1042
  interface CoordinatorWorkerTurnResult {
@@ -1029,10 +1050,11 @@ interface CoordinatorWorkerTurnResult {
1029
1050
 
1030
1051
  async function runCoordinatorWorkerTurn(turn: CoordinatorWorkerTurnOptions): Promise<CoordinatorWorkerTurnResult> {
1031
1052
  throwIfAborted(turn.options.signal, turn.options.model.id);
1053
+ throwIfAborted(turn.fanoutSignal, turn.options.model.id);
1032
1054
 
1033
1055
  const request: ModelRequest = {
1034
1056
  temperature: turn.options.temperature,
1035
- ...(turn.options.signal !== undefined ? { signal: turn.options.signal } : {}),
1057
+ ...(turn.fanoutSignal !== undefined ? { signal: turn.fanoutSignal } : turn.options.signal !== undefined ? { signal: turn.options.signal } : {}),
1036
1058
  metadata: {
1037
1059
  runId: turn.runId,
1038
1060
  protocol: "coordinator",
@@ -1085,7 +1107,7 @@ async function runCoordinatorWorkerTurn(turn: CoordinatorWorkerTurnOptions): Pro
1085
1107
  if (Array.isArray(decision) || decision?.type === "delegate") {
1086
1108
  throw new DogpileError({
1087
1109
  code: "invalid-configuration",
1088
- message: "Workers cannot emit delegate decisions in Phase 1",
1110
+ message: "Workers cannot emit delegate decisions.",
1089
1111
  retryable: false,
1090
1112
  detail: {
1091
1113
  kind: "delegate-validation",
@@ -1105,6 +1127,7 @@ async function runCoordinatorWorkerTurn(turn: CoordinatorWorkerTurnOptions): Pro
1105
1127
  }
1106
1128
  });
1107
1129
  throwIfAborted(turn.options.signal, turn.options.model.id);
1130
+ throwIfAborted(turn.fanoutSignal, turn.options.model.id);
1108
1131
 
1109
1132
  return {
1110
1133
  agent: turn.agent,
@@ -1193,6 +1216,76 @@ function responseCost(response: ModelResponse): CostSummary {
1193
1216
  };
1194
1217
  }
1195
1218
 
1219
+ interface FanoutAbortController {
1220
+ readonly signal: AbortSignal;
1221
+ abort(reason: unknown): void;
1222
+ cleanup(): void;
1223
+ }
1224
+
1225
+ function createFanoutAbortController(parentSignal: AbortSignal | undefined): FanoutAbortController {
1226
+ const controller = new AbortController();
1227
+ let removeParentListener = (): void => {};
1228
+
1229
+ if (parentSignal?.aborted) {
1230
+ controller.abort(parentSignal.reason);
1231
+ } else if (parentSignal !== undefined) {
1232
+ const abortFromParent = (): void => {
1233
+ controller.abort(parentSignal.reason);
1234
+ };
1235
+ parentSignal.addEventListener("abort", abortFromParent, { once: true });
1236
+ removeParentListener = (): void => {
1237
+ parentSignal.removeEventListener("abort", abortFromParent);
1238
+ };
1239
+ }
1240
+
1241
+ return {
1242
+ signal: controller.signal,
1243
+ abort(reason: unknown): void {
1244
+ if (!controller.signal.aborted) {
1245
+ controller.abort(reason);
1246
+ }
1247
+ },
1248
+ cleanup(): void {
1249
+ removeParentListener();
1250
+ }
1251
+ };
1252
+ }
1253
+
1254
+ async function mapWithConcurrency<T, R>(
1255
+ items: readonly T[],
1256
+ maxConcurrent: number,
1257
+ fanout: FanoutAbortController,
1258
+ mapper: (item: T, index: number) => Promise<R>
1259
+ ): Promise<R[]> {
1260
+ if (items.length === 0) {
1261
+ return [];
1262
+ }
1263
+
1264
+ const results: R[] = new Array(items.length);
1265
+ let nextIndex = 0;
1266
+ let firstError: unknown;
1267
+ const workerCount = Math.min(maxConcurrent, items.length);
1268
+
1269
+ await Promise.all(Array.from({ length: workerCount }, async () => {
1270
+ while (nextIndex < items.length && firstError === undefined) {
1271
+ const index = nextIndex;
1272
+ nextIndex += 1;
1273
+ try {
1274
+ results[index] = await mapper(items[index]!, index);
1275
+ } catch (error) {
1276
+ firstError ??= error;
1277
+ fanout.abort(error);
1278
+ }
1279
+ }
1280
+ }));
1281
+
1282
+ if (firstError !== undefined) {
1283
+ throw firstError;
1284
+ }
1285
+
1286
+ return results;
1287
+ }
1288
+
1196
1289
  interface DispatchDelegateOptions {
1197
1290
  readonly decision: DelegateAgentDecision;
1198
1291
  readonly childRunId?: string;
@@ -1238,7 +1331,7 @@ interface DispatchedChild {
1238
1331
  startedAtMs: number;
1239
1332
  childTimeoutMs: number | undefined;
1240
1333
  failure: DispatchWaveFailure | undefined;
1241
- /** STREAM-03 hook (Phase 4). Reserved; do not use. */
1334
+ /** Reserved child stream handle slot; do not use. */
1242
1335
  readonly streamHandle?: never;
1243
1336
  }
1244
1337
 
@@ -1324,7 +1417,7 @@ async function dispatchDelegate(input: DispatchDelegateOptions): Promise<Dispatc
1324
1417
  });
1325
1418
  }
1326
1419
 
1327
- // Buffered tee for partialTrace capture see Plan 03 step 8.
1420
+ // Buffered tee captures the child event prefix used for partialTrace.
1328
1421
  const childEvents = input.dispatchedChild.childEvents;
1329
1422
  const parentEmit = input.emit;
1330
1423
  const teedEmit = (event: RunEvent): void => {
@@ -1381,7 +1474,7 @@ async function dispatchDelegate(input: DispatchDelegateOptions): Promise<Dispatc
1381
1474
  // BUDGET-01 / D-07: derive a per-child AbortController so child engines see
1382
1475
  // their own signal. Listener forwards parent.signal.reason verbatim, so
1383
1476
  // detail.reason classification (parent-aborted vs timeout) is preserved.
1384
- // Phase 4 STREAM-03 hook: per-child cancel handle attaches here.
1477
+ // Reserved per-child stream cancel hook attaches here if that surface ships.
1385
1478
  const parentSignal = options.signal;
1386
1479
  let removeParentAbortListener: (() => void) | undefined;
1387
1480
  if (parentSignal !== undefined) {
@@ -1429,6 +1522,9 @@ async function dispatchDelegate(input: DispatchDelegateOptions): Promise<Dispatc
1429
1522
  ...(options.effectiveMaxConcurrentChildren !== undefined
1430
1523
  ? { effectiveMaxConcurrentChildren: options.effectiveMaxConcurrentChildren }
1431
1524
  : {}),
1525
+ ...(options.effectiveMaxConcurrentAgentTurns !== undefined
1526
+ ? { effectiveMaxConcurrentAgentTurns: options.effectiveMaxConcurrentAgentTurns }
1527
+ : {}),
1432
1528
  ...(options.onChildFailure !== undefined ? { onChildFailure: options.onChildFailure } : {}),
1433
1529
  // BUDGET-02 / D-12: forward the ROOT deadline so depth-N grandchildren
1434
1530
  // see the same `parentDeadlineMs` rather than a fresh per-level snapshot.
@@ -1644,7 +1740,7 @@ function eventTimestamp(event: RunEvent | undefined): string | undefined {
1644
1740
  /**
1645
1741
  * Build a JSON-serializable {@link Trace} for `sub-run-failed.partialTrace`
1646
1742
  * from a buffered tee of child emits. Keeps `runProtocol`'s error contract
1647
- * unchanged — Plan 03 step 8.
1743
+ * unchanged.
1648
1744
  */
1649
1745
  function buildPartialTrace(input: {
1650
1746
  readonly childRunId: string;
@@ -404,7 +404,8 @@ export function createReplayTraceProtocolDecision(
404
404
  agentId: event.agentId,
405
405
  role: event.role,
406
406
  input: event.input,
407
- output: event.output
407
+ output: event.text,
408
+ outputLength: event.outputLength
408
409
  };
409
410
  case "tool-call":
410
411
  return {
@@ -69,6 +69,7 @@ import type { MetricsHook, RunMetricsSnapshot } from "./metrics.js";
69
69
 
70
70
  const DEFAULT_MAX_DEPTH = 4;
71
71
  const DEFAULT_MAX_CONCURRENT_CHILDREN = 4;
72
+ const DEFAULT_MAX_CONCURRENT_AGENT_TURNS = 4;
72
73
 
73
74
  const defaultHighLevelProtocol = "sequential";
74
75
  const defaultHighLevelTier = "balanced";
@@ -99,6 +100,7 @@ export function createEngine(options: EngineOptions): Engine {
99
100
  const terminate = options.terminate ?? (options.budget ? conditionFromBudget(options.budget) : undefined);
100
101
  const engineMaxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
101
102
  const engineMaxConcurrentChildren = options.maxConcurrentChildren ?? DEFAULT_MAX_CONCURRENT_CHILDREN;
103
+ const engineMaxConcurrentAgentTurns = options.maxConcurrentAgentTurns ?? DEFAULT_MAX_CONCURRENT_AGENT_TURNS;
102
104
  const engineOnChildFailure = options.onChildFailure;
103
105
 
104
106
  return {
@@ -120,6 +122,15 @@ export function createEngine(options: EngineOptions): Engine {
120
122
  engineMaxConcurrentChildren,
121
123
  runOptions?.maxConcurrentChildren ?? Number.POSITIVE_INFINITY
122
124
  );
125
+ assertRunDoesNotRaiseEngineMax(
126
+ "maxConcurrentAgentTurns",
127
+ runOptions?.maxConcurrentAgentTurns,
128
+ engineMaxConcurrentAgentTurns
129
+ );
130
+ const effectiveMaxConcurrentAgentTurns = Math.min(
131
+ engineMaxConcurrentAgentTurns,
132
+ runOptions?.maxConcurrentAgentTurns ?? Number.POSITIVE_INFINITY
133
+ );
123
134
  const onChildFailure = resolveOnChildFailure(runOptions?.onChildFailure, engineOnChildFailure);
124
135
 
125
136
  const startedAtMs = Date.now();
@@ -146,6 +157,7 @@ export function createEngine(options: EngineOptions): Engine {
146
157
  currentDepth: 0,
147
158
  effectiveMaxDepth,
148
159
  effectiveMaxConcurrentChildren,
160
+ effectiveMaxConcurrentAgentTurns,
149
161
  onChildFailure,
150
162
  ...(parentDeadlineMs !== undefined ? { parentDeadlineMs } : {}),
151
163
  ...(options.defaultSubRunTimeoutMs !== undefined
@@ -172,10 +184,18 @@ export function createEngine(options: EngineOptions): Engine {
172
184
  engineMaxConcurrentChildren,
173
185
  runOptions?.maxConcurrentChildren ?? Number.POSITIVE_INFINITY
174
186
  );
187
+ assertRunDoesNotRaiseEngineMax(
188
+ "maxConcurrentAgentTurns",
189
+ runOptions?.maxConcurrentAgentTurns,
190
+ engineMaxConcurrentAgentTurns
191
+ );
192
+ const effectiveMaxConcurrentAgentTurns = Math.min(
193
+ engineMaxConcurrentAgentTurns,
194
+ runOptions?.maxConcurrentAgentTurns ?? Number.POSITIVE_INFINITY
195
+ );
175
196
  const onChildFailure = resolveOnChildFailure(runOptions?.onChildFailure, engineOnChildFailure);
176
197
 
177
- const pendingEvents: StreamEvent[] = [];
178
- const pendingResolvers: Array<(value: IteratorResult<StreamEvent>) => void> = [];
198
+ const pendingIteratorResolvers: Array<() => void> = [];
179
199
  const emittedEvents: StreamEvent[] = [];
180
200
  const subscribers = new Set<StreamEventSubscriber>();
181
201
  const abortController = new AbortController();
@@ -212,12 +232,20 @@ export function createEngine(options: EngineOptions): Engine {
212
232
  cancelRun();
213
233
  },
214
234
  subscribe(subscriber: StreamEventSubscriber) {
215
- subscribers.add(subscriber);
216
-
217
235
  for (const event of emittedEvents) {
218
236
  subscriber(event);
219
237
  }
220
238
 
239
+ if (complete) {
240
+ return {
241
+ unsubscribe(): void {
242
+ // Completed streams replay synchronously and have no live source.
243
+ }
244
+ };
245
+ }
246
+
247
+ subscribers.add(subscriber);
248
+
221
249
  return {
222
250
  unsubscribe(): void {
223
251
  subscribers.delete(subscriber);
@@ -225,20 +253,29 @@ export function createEngine(options: EngineOptions): Engine {
225
253
  };
226
254
  },
227
255
  [Symbol.asyncIterator](): AsyncIterator<StreamEvent> {
256
+ let cursor = 0;
257
+
228
258
  return {
229
259
  next(): Promise<IteratorResult<StreamEvent>> {
230
- const event = pendingEvents.shift();
231
- if (event) {
232
- return Promise.resolve({ done: false, value: event });
233
- }
234
- if (complete) {
235
- return Promise.resolve({ done: true, value: undefined });
236
- }
237
- return new Promise<IteratorResult<StreamEvent>>((resolve) => {
238
- pendingResolvers.push(resolve);
239
- });
260
+ return readNext();
240
261
  }
241
262
  };
263
+
264
+ function readNext(): Promise<IteratorResult<StreamEvent>> {
265
+ const event = emittedEvents[cursor];
266
+ if (event !== undefined) {
267
+ cursor += 1;
268
+ return Promise.resolve({ done: false, value: event });
269
+ }
270
+ if (complete) {
271
+ return Promise.resolve({ done: true, value: undefined });
272
+ }
273
+ return new Promise<IteratorResult<StreamEvent>>((resolve) => {
274
+ pendingIteratorResolvers.push(() => {
275
+ void readNext().then(resolve);
276
+ });
277
+ });
278
+ }
242
279
  }
243
280
  };
244
281
 
@@ -266,6 +303,7 @@ export function createEngine(options: EngineOptions): Engine {
266
303
  currentDepth: 0,
267
304
  effectiveMaxDepth,
268
305
  effectiveMaxConcurrentChildren,
306
+ effectiveMaxConcurrentAgentTurns,
269
307
  onChildFailure,
270
308
  ...(streamParentDeadlineMs !== undefined ? { parentDeadlineMs: streamParentDeadlineMs } : {}),
271
309
  ...(options.defaultSubRunTimeoutMs !== undefined
@@ -362,8 +400,8 @@ export function createEngine(options: EngineOptions): Engine {
362
400
  timeoutLifecycle.cleanup();
363
401
  abortRace.cleanup();
364
402
  subscribers.clear();
365
- for (const resolver of pendingResolvers.splice(0)) {
366
- resolver({ done: true, value: undefined });
403
+ for (const resolvePending of pendingIteratorResolvers.splice(0)) {
404
+ resolvePending();
367
405
  }
368
406
  }
369
407
 
@@ -383,12 +421,9 @@ export function createEngine(options: EngineOptions): Engine {
383
421
  }
384
422
  }
385
423
 
386
- const resolver = pendingResolvers.shift();
387
- if (resolver) {
388
- resolver({ done: false, value: canonicalEvent });
389
- return;
424
+ for (const resolvePending of pendingIteratorResolvers.splice(0)) {
425
+ resolvePending();
390
426
  }
391
- pendingEvents.push(canonicalEvent);
392
427
  }
393
428
  }
394
429
  };
@@ -675,15 +710,17 @@ interface RunProtocolOptions {
675
710
  /**
676
711
  * Current recursion depth. Top-level runs use 0; the coordinator dispatch
677
712
  * loop increments before invoking {@link runProtocol} for a child run.
678
- * Plan 04 will wire `effectiveMaxDepth` validation around this value.
713
+ * Depth validation is enforced before delegate dispatch.
679
714
  */
680
715
  readonly currentDepth?: number;
681
716
  /**
682
- * Effective max recursion depth. Plan 04 enforces; Plan 03 plumbs the param.
717
+ * Effective max recursion depth after engine and per-run ceilings are merged.
683
718
  */
684
719
  readonly effectiveMaxDepth?: number;
685
720
  /** Effective max delegated child concurrency resolved at run start. */
686
721
  readonly effectiveMaxConcurrentChildren?: number;
722
+ /** Effective max agent-turn fan-out resolved at run start. */
723
+ readonly effectiveMaxConcurrentAgentTurns?: number;
687
724
  readonly onChildFailure?: EngineOptions["onChildFailure"];
688
725
  /**
689
726
  * Root-run deadline (epoch ms) threaded through every recursive coordinator
@@ -1363,6 +1400,9 @@ function runProtocolInner(
1363
1400
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
1364
1401
  ...(options.terminate ? { terminate: options.terminate } : {}),
1365
1402
  ...(options.wrapUpHint ? { wrapUpHint: options.wrapUpHint } : {}),
1403
+ ...(options.effectiveMaxConcurrentAgentTurns !== undefined
1404
+ ? { maxConcurrentAgentTurns: options.effectiveMaxConcurrentAgentTurns }
1405
+ : {}),
1366
1406
  ...(emitForProtocol ? { emit: emitForProtocol } : {})
1367
1407
  });
1368
1408
  case "coordinator":
@@ -1379,11 +1419,16 @@ function runProtocolInner(
1379
1419
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
1380
1420
  ...(options.terminate ? { terminate: options.terminate } : {}),
1381
1421
  ...(options.wrapUpHint ? { wrapUpHint: options.wrapUpHint } : {}),
1422
+ ...(options.effectiveMaxConcurrentAgentTurns !== undefined
1423
+ ? { maxConcurrentAgentTurns: options.effectiveMaxConcurrentAgentTurns }
1424
+ : {}),
1382
1425
  ...(emitForProtocol ? { emit: emitForProtocol } : {}),
1383
1426
  ...(options.streamEvents !== undefined ? { streamEvents: options.streamEvents } : {}),
1384
1427
  currentDepth: options.currentDepth ?? 0,
1385
1428
  effectiveMaxDepth: options.effectiveMaxDepth ?? Infinity,
1386
1429
  effectiveMaxConcurrentChildren: options.effectiveMaxConcurrentChildren ?? DEFAULT_MAX_CONCURRENT_CHILDREN,
1430
+ effectiveMaxConcurrentAgentTurns:
1431
+ options.effectiveMaxConcurrentAgentTurns ?? DEFAULT_MAX_CONCURRENT_AGENT_TURNS,
1387
1432
  onChildFailure: options.onChildFailure ?? "continue",
1388
1433
  ...(options.parentDeadlineMs !== undefined ? { parentDeadlineMs: options.parentDeadlineMs } : {}),
1389
1434
  ...(options.defaultSubRunTimeoutMs !== undefined
@@ -1419,6 +1464,9 @@ function runProtocolInner(
1419
1464
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
1420
1465
  ...(options.terminate ? { terminate: options.terminate } : {}),
1421
1466
  ...(options.wrapUpHint ? { wrapUpHint: options.wrapUpHint } : {}),
1467
+ ...(options.effectiveMaxConcurrentAgentTurns !== undefined
1468
+ ? { maxConcurrentAgentTurns: options.effectiveMaxConcurrentAgentTurns }
1469
+ : {}),
1422
1470
  ...(emitForProtocol ? { emit: emitForProtocol } : {})
1423
1471
  });
1424
1472
  }
@@ -1697,7 +1745,6 @@ function dogpileErrorFromSerializedPayload(input: {
1697
1745
  // Tracing/metrics-free: replayStream never uses EngineOptions tracer or metricsHook.
1698
1746
  export function replayStream(trace: Trace): StreamHandle {
1699
1747
  const result = Promise.resolve(replay(trace));
1700
- const replayEvents = replayStreamEvents(trace);
1701
1748
 
1702
1749
  return {
1703
1750
  get status(): StreamHandleStatus {
@@ -1708,7 +1755,7 @@ export function replayStream(trace: Trace): StreamHandle {
1708
1755
  // Replay streams are already completed snapshots, so cancellation is a no-op.
1709
1756
  },
1710
1757
  subscribe(subscriber: StreamEventSubscriber) {
1711
- for (const event of replayEvents) {
1758
+ for (const event of replayStreamEvents(trace)) {
1712
1759
  subscriber(event);
1713
1760
  }
1714
1761
 
@@ -1719,34 +1766,24 @@ export function replayStream(trace: Trace): StreamHandle {
1719
1766
  };
1720
1767
  },
1721
1768
  [Symbol.asyncIterator](): AsyncIterator<StreamEvent> {
1722
- let index = 0;
1769
+ const iterator = replayStreamEvents(trace)[Symbol.iterator]();
1723
1770
 
1724
1771
  return {
1725
1772
  next(): Promise<IteratorResult<StreamEvent>> {
1726
- const event = replayEvents[index];
1727
- if (event) {
1728
- index += 1;
1729
- return Promise.resolve({ done: false, value: event });
1730
- }
1731
-
1732
- return Promise.resolve({ done: true, value: undefined });
1773
+ return Promise.resolve(iterator.next());
1733
1774
  }
1734
1775
  };
1735
1776
  }
1736
1777
  };
1737
1778
  }
1738
1779
 
1739
- function replayStreamEvents(trace: Trace, parentRunIds: readonly string[] = []): StreamEvent[] {
1740
- const events: StreamEvent[] = [];
1741
-
1780
+ function* replayStreamEvents(trace: Trace, parentRunIds: readonly string[] = []): IterableIterator<StreamEvent> {
1742
1781
  for (const event of synthesizeProviderEvents(trace, trace.providerCalls)) {
1743
1782
  if (event.type === "sub-run-completed") {
1744
- events.push(...replayStreamEvents(event.subResult.trace, [...parentRunIds, trace.runId]));
1783
+ yield* replayStreamEvents(event.subResult.trace, [...parentRunIds, trace.runId]);
1745
1784
  }
1746
- events.push(wrapReplayStreamEvent(event, parentRunIds));
1785
+ yield wrapReplayStreamEvent(event, parentRunIds);
1747
1786
  }
1748
-
1749
- return events;
1750
1787
  }
1751
1788
 
1752
1789
  function wrapReplayStreamEvent(event: RunEvent, parentRunIds: readonly string[]): StreamEvent {
@@ -49,7 +49,8 @@ export async function generateModelTurn(options: GenerateModelTurnOptions): Prom
49
49
  return response;
50
50
  }
51
51
 
52
- let text = "";
52
+ const chunks: string[] = [];
53
+ let outputLength = 0;
53
54
  let chunkIndex = 0;
54
55
  let usage: ModelUsage | undefined;
55
56
  let costUsd: number | undefined;
@@ -59,7 +60,8 @@ export async function generateModelTurn(options: GenerateModelTurnOptions): Prom
59
60
 
60
61
  for await (const chunk of options.model.stream(options.request)) {
61
62
  throwIfAborted(options.request.signal, options.model.id);
62
- text += chunk.text;
63
+ chunks.push(chunk.text);
64
+ outputLength += chunk.text.length;
63
65
 
64
66
  options.emit({
65
67
  type: "model-output-chunk",
@@ -70,7 +72,7 @@ export async function generateModelTurn(options: GenerateModelTurnOptions): Prom
70
72
  input: options.input,
71
73
  chunkIndex,
72
74
  text: chunk.text,
73
- output: text
75
+ outputLength
74
76
  });
75
77
  chunkIndex += 1;
76
78
 
@@ -91,6 +93,7 @@ export async function generateModelTurn(options: GenerateModelTurnOptions): Prom
91
93
  }
92
94
  }
93
95
 
96
+ const text = chunks.join("");
94
97
  response = {
95
98
  text,
96
99
  ...(finishReason !== undefined ? { finishReason } : {}),