@jackchen_me/open-multi-agent 0.1.0 → 1.0.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 (140) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  3. package/.github/pull_request_template.md +14 -0
  4. package/.github/workflows/ci.yml +23 -0
  5. package/CLAUDE.md +80 -0
  6. package/CODE_OF_CONDUCT.md +48 -0
  7. package/CONTRIBUTING.md +72 -0
  8. package/DECISIONS.md +43 -0
  9. package/README.md +144 -144
  10. package/README_zh.md +277 -0
  11. package/SECURITY.md +17 -0
  12. package/dist/agent/agent.d.ts +20 -1
  13. package/dist/agent/agent.d.ts.map +1 -1
  14. package/dist/agent/agent.js +233 -12
  15. package/dist/agent/agent.js.map +1 -1
  16. package/dist/agent/loop-detector.d.ts +39 -0
  17. package/dist/agent/loop-detector.d.ts.map +1 -0
  18. package/dist/agent/loop-detector.js +122 -0
  19. package/dist/agent/loop-detector.js.map +1 -0
  20. package/dist/agent/pool.d.ts +2 -1
  21. package/dist/agent/pool.d.ts.map +1 -1
  22. package/dist/agent/pool.js +4 -2
  23. package/dist/agent/pool.js.map +1 -1
  24. package/dist/agent/runner.d.ts +23 -1
  25. package/dist/agent/runner.d.ts.map +1 -1
  26. package/dist/agent/runner.js +113 -12
  27. package/dist/agent/runner.js.map +1 -1
  28. package/dist/agent/structured-output.d.ts +33 -0
  29. package/dist/agent/structured-output.d.ts.map +1 -0
  30. package/dist/agent/structured-output.js +116 -0
  31. package/dist/agent/structured-output.js.map +1 -0
  32. package/dist/index.d.ts +5 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/llm/adapter.d.ts +12 -4
  37. package/dist/llm/adapter.d.ts.map +1 -1
  38. package/dist/llm/adapter.js +28 -5
  39. package/dist/llm/adapter.js.map +1 -1
  40. package/dist/llm/anthropic.d.ts +1 -1
  41. package/dist/llm/anthropic.d.ts.map +1 -1
  42. package/dist/llm/anthropic.js +2 -1
  43. package/dist/llm/anthropic.js.map +1 -1
  44. package/dist/llm/copilot.d.ts +92 -0
  45. package/dist/llm/copilot.d.ts.map +1 -0
  46. package/dist/llm/copilot.js +427 -0
  47. package/dist/llm/copilot.js.map +1 -0
  48. package/dist/llm/gemini.d.ts +65 -0
  49. package/dist/llm/gemini.d.ts.map +1 -0
  50. package/dist/llm/gemini.js +317 -0
  51. package/dist/llm/gemini.js.map +1 -0
  52. package/dist/llm/grok.d.ts +21 -0
  53. package/dist/llm/grok.d.ts.map +1 -0
  54. package/dist/llm/grok.js +24 -0
  55. package/dist/llm/grok.js.map +1 -0
  56. package/dist/llm/openai-common.d.ts +54 -0
  57. package/dist/llm/openai-common.d.ts.map +1 -0
  58. package/dist/llm/openai-common.js +242 -0
  59. package/dist/llm/openai-common.js.map +1 -0
  60. package/dist/llm/openai.d.ts +2 -2
  61. package/dist/llm/openai.d.ts.map +1 -1
  62. package/dist/llm/openai.js +23 -226
  63. package/dist/llm/openai.js.map +1 -1
  64. package/dist/orchestrator/orchestrator.d.ts +25 -1
  65. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  66. package/dist/orchestrator/orchestrator.js +214 -41
  67. package/dist/orchestrator/orchestrator.js.map +1 -1
  68. package/dist/task/queue.d.ts +31 -2
  69. package/dist/task/queue.d.ts.map +1 -1
  70. package/dist/task/queue.js +70 -3
  71. package/dist/task/queue.js.map +1 -1
  72. package/dist/task/task.d.ts +3 -0
  73. package/dist/task/task.d.ts.map +1 -1
  74. package/dist/task/task.js +5 -1
  75. package/dist/task/task.js.map +1 -1
  76. package/dist/team/messaging.d.ts.map +1 -1
  77. package/dist/team/messaging.js +2 -1
  78. package/dist/team/messaging.js.map +1 -1
  79. package/dist/tool/text-tool-extractor.d.ts +32 -0
  80. package/dist/tool/text-tool-extractor.d.ts.map +1 -0
  81. package/dist/tool/text-tool-extractor.js +187 -0
  82. package/dist/tool/text-tool-extractor.js.map +1 -0
  83. package/dist/types.d.ts +167 -7
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/utils/trace.d.ts +12 -0
  86. package/dist/utils/trace.d.ts.map +1 -0
  87. package/dist/utils/trace.js +30 -0
  88. package/dist/utils/trace.js.map +1 -0
  89. package/examples/05-copilot-test.ts +49 -0
  90. package/examples/06-local-model.ts +200 -0
  91. package/examples/07-fan-out-aggregate.ts +209 -0
  92. package/examples/08-gemma4-local.ts +192 -0
  93. package/examples/09-structured-output.ts +73 -0
  94. package/examples/10-task-retry.ts +132 -0
  95. package/examples/11-trace-observability.ts +133 -0
  96. package/examples/12-grok.ts +154 -0
  97. package/examples/13-gemini.ts +48 -0
  98. package/package.json +14 -3
  99. package/src/agent/agent.ts +273 -15
  100. package/src/agent/loop-detector.ts +137 -0
  101. package/src/agent/pool.ts +9 -2
  102. package/src/agent/runner.ts +148 -19
  103. package/src/agent/structured-output.ts +126 -0
  104. package/src/index.ts +17 -1
  105. package/src/llm/adapter.ts +29 -5
  106. package/src/llm/anthropic.ts +2 -1
  107. package/src/llm/copilot.ts +552 -0
  108. package/src/llm/gemini.ts +378 -0
  109. package/src/llm/grok.ts +29 -0
  110. package/src/llm/openai-common.ts +294 -0
  111. package/src/llm/openai.ts +31 -261
  112. package/src/orchestrator/orchestrator.ts +260 -40
  113. package/src/task/queue.ts +74 -4
  114. package/src/task/task.ts +8 -1
  115. package/src/team/messaging.ts +3 -1
  116. package/src/tool/text-tool-extractor.ts +219 -0
  117. package/src/types.ts +186 -6
  118. package/src/utils/trace.ts +34 -0
  119. package/tests/agent-hooks.test.ts +473 -0
  120. package/tests/agent-pool.test.ts +212 -0
  121. package/tests/approval.test.ts +464 -0
  122. package/tests/built-in-tools.test.ts +393 -0
  123. package/tests/gemini-adapter.test.ts +97 -0
  124. package/tests/grok-adapter.test.ts +74 -0
  125. package/tests/llm-adapters.test.ts +357 -0
  126. package/tests/loop-detection.test.ts +456 -0
  127. package/tests/openai-fallback.test.ts +159 -0
  128. package/tests/orchestrator.test.ts +281 -0
  129. package/tests/scheduler.test.ts +221 -0
  130. package/tests/semaphore.test.ts +57 -0
  131. package/tests/shared-memory.test.ts +122 -0
  132. package/tests/structured-output.test.ts +331 -0
  133. package/tests/task-queue.test.ts +244 -0
  134. package/tests/task-retry.test.ts +368 -0
  135. package/tests/task-utils.test.ts +155 -0
  136. package/tests/team-messaging.test.ts +329 -0
  137. package/tests/text-tool-extractor.test.ts +170 -0
  138. package/tests/tool-executor.test.ts +193 -0
  139. package/tests/trace.test.ts +453 -0
  140. package/vitest.config.ts +9 -0
@@ -52,8 +52,10 @@ import type {
52
52
  TeamRunResult,
53
53
  TokenUsage,
54
54
  } from '../types.js'
55
+ import type { RunOptions } from '../agent/runner.js'
55
56
  import { Agent } from '../agent/agent.js'
56
57
  import { AgentPool } from '../agent/pool.js'
58
+ import { emitTrace, generateRunId } from '../utils/trace.js'
57
59
  import { ToolRegistry } from '../tool/framework.js'
58
60
  import { ToolExecutor } from '../tool/executor.js'
59
61
  import { registerBuiltInTools } from '../tool/built-in/index.js'
@@ -92,6 +94,105 @@ function buildAgent(config: AgentConfig): Agent {
92
94
  return new Agent(config, registry, executor)
93
95
  }
94
96
 
97
+ /** Promise-based delay. */
98
+ function sleep(ms: number): Promise<void> {
99
+ return new Promise((resolve) => setTimeout(resolve, ms))
100
+ }
101
+
102
+ /** Maximum delay cap to prevent runaway exponential backoff (30 seconds). */
103
+ const MAX_RETRY_DELAY_MS = 30_000
104
+
105
+ /**
106
+ * Compute the retry delay for a given attempt, capped at {@link MAX_RETRY_DELAY_MS}.
107
+ */
108
+ export function computeRetryDelay(
109
+ baseDelay: number,
110
+ backoff: number,
111
+ attempt: number,
112
+ ): number {
113
+ return Math.min(baseDelay * backoff ** (attempt - 1), MAX_RETRY_DELAY_MS)
114
+ }
115
+
116
+ /**
117
+ * Execute an agent task with optional retry and exponential backoff.
118
+ *
119
+ * Exported for testability — called internally by {@link executeQueue}.
120
+ *
121
+ * @param run - The function that executes the task (typically `pool.run`).
122
+ * @param task - The task to execute (retry config read from its fields).
123
+ * @param onRetry - Called before each retry sleep with event data.
124
+ * @param delayFn - Injectable delay function (defaults to real `sleep`).
125
+ * @returns The final {@link AgentRunResult} from the last attempt.
126
+ */
127
+ export async function executeWithRetry(
128
+ run: () => Promise<AgentRunResult>,
129
+ task: Task,
130
+ onRetry?: (data: { attempt: number; maxAttempts: number; error: string; nextDelayMs: number }) => void,
131
+ delayFn: (ms: number) => Promise<void> = sleep,
132
+ ): Promise<AgentRunResult> {
133
+ const rawRetries = Number.isFinite(task.maxRetries) ? task.maxRetries! : 0
134
+ const maxAttempts = Math.max(0, rawRetries) + 1
135
+ const baseDelay = Math.max(0, Number.isFinite(task.retryDelayMs) ? task.retryDelayMs! : 1000)
136
+ const backoff = Math.max(1, Number.isFinite(task.retryBackoff) ? task.retryBackoff! : 2)
137
+
138
+ let lastError: string = ''
139
+ // Accumulate token usage across all attempts so billing/observability
140
+ // reflects the true cost of retries.
141
+ let totalUsage: TokenUsage = { input_tokens: 0, output_tokens: 0 }
142
+
143
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
144
+ try {
145
+ const result = await run()
146
+ totalUsage = {
147
+ input_tokens: totalUsage.input_tokens + result.tokenUsage.input_tokens,
148
+ output_tokens: totalUsage.output_tokens + result.tokenUsage.output_tokens,
149
+ }
150
+
151
+ if (result.success) {
152
+ return { ...result, tokenUsage: totalUsage }
153
+ }
154
+ lastError = result.output
155
+
156
+ // Failure — retry or give up
157
+ if (attempt < maxAttempts) {
158
+ const delay = computeRetryDelay(baseDelay, backoff, attempt)
159
+ onRetry?.({ attempt, maxAttempts, error: lastError, nextDelayMs: delay })
160
+ await delayFn(delay)
161
+ continue
162
+ }
163
+
164
+ return { ...result, tokenUsage: totalUsage }
165
+ } catch (err) {
166
+ lastError = err instanceof Error ? err.message : String(err)
167
+
168
+ if (attempt < maxAttempts) {
169
+ const delay = computeRetryDelay(baseDelay, backoff, attempt)
170
+ onRetry?.({ attempt, maxAttempts, error: lastError, nextDelayMs: delay })
171
+ await delayFn(delay)
172
+ continue
173
+ }
174
+
175
+ // All retries exhausted — return a failure result
176
+ return {
177
+ success: false,
178
+ output: lastError,
179
+ messages: [],
180
+ tokenUsage: totalUsage,
181
+ toolCalls: [],
182
+ }
183
+ }
184
+ }
185
+
186
+ // Should not be reached, but TypeScript needs a return
187
+ return {
188
+ success: false,
189
+ output: lastError,
190
+ messages: [],
191
+ tokenUsage: totalUsage,
192
+ toolCalls: [],
193
+ }
194
+ }
195
+
95
196
  // ---------------------------------------------------------------------------
96
197
  // Parsed task spec (result of coordinator decomposition)
97
198
  // ---------------------------------------------------------------------------
@@ -161,6 +262,8 @@ interface RunContext {
161
262
  readonly scheduler: Scheduler
162
263
  readonly agentResults: Map<string, AgentRunResult>
163
264
  readonly config: OrchestratorConfig
265
+ /** Trace run ID, present when `onTrace` is configured. */
266
+ readonly runId?: string
164
267
  }
165
268
 
166
269
  /**
@@ -180,6 +283,17 @@ async function executeQueue(
180
283
  ): Promise<void> {
181
284
  const { team, pool, scheduler, config } = ctx
182
285
 
286
+ // Relay queue-level skip events to the orchestrator's onProgress callback.
287
+ const unsubSkipped = config.onProgress
288
+ ? queue.on('task:skipped', (task) => {
289
+ config.onProgress!({
290
+ type: 'task_skipped',
291
+ task: task.id,
292
+ data: task,
293
+ } satisfies OrchestratorEvent)
294
+ })
295
+ : undefined
296
+
183
297
  while (true) {
184
298
  // Re-run auto-assignment each iteration so tasks that were unblocked since
185
299
  // the last round (and thus have no assignee yet) get assigned before dispatch.
@@ -191,6 +305,11 @@ async function executeQueue(
191
305
  break
192
306
  }
193
307
 
308
+ // Track tasks that complete successfully in this round for the approval gate.
309
+ // Safe to push from concurrent promises: JS is single-threaded, so
310
+ // Array.push calls from resolved microtasks never interleave.
311
+ const completedThisRound: Task[] = []
312
+
194
313
  // Dispatch all currently-pending tasks as a parallel batch.
195
314
  const dispatchPromises = pending.map(async (task): Promise<void> => {
196
315
  // Mark in-progress
@@ -239,56 +358,109 @@ async function executeQueue(
239
358
  // Build the prompt: inject shared memory context + task description
240
359
  const prompt = await buildTaskPrompt(task, team)
241
360
 
242
- try {
243
- const result = await pool.run(assignee, prompt)
244
- ctx.agentResults.set(`${assignee}:${task.id}`, result)
361
+ // Build trace context for this task's agent run
362
+ const traceOptions: Partial<RunOptions> | undefined = config.onTrace
363
+ ? { onTrace: config.onTrace, runId: ctx.runId ?? '', taskId: task.id, traceAgent: assignee }
364
+ : undefined
245
365
 
246
- if (result.success) {
247
- // Persist result into shared memory so other agents can read it
248
- const sharedMem = team.getSharedMemoryInstance()
249
- if (sharedMem) {
250
- await sharedMem.write(assignee, `task:${task.id}:result`, result.output)
251
- }
252
-
253
- queue.complete(task.id, result.output)
366
+ const taskStartMs = config.onTrace ? Date.now() : 0
367
+ let retryCount = 0
254
368
 
369
+ const result = await executeWithRetry(
370
+ () => pool.run(assignee, prompt, traceOptions),
371
+ task,
372
+ (retryData) => {
373
+ retryCount++
255
374
  config.onProgress?.({
256
- type: 'task_complete',
375
+ type: 'task_retry',
257
376
  task: task.id,
258
377
  agent: assignee,
259
- data: result,
378
+ data: retryData,
260
379
  } satisfies OrchestratorEvent)
380
+ },
381
+ )
261
382
 
262
- config.onProgress?.({
263
- type: 'agent_complete',
264
- agent: assignee,
265
- task: task.id,
266
- data: result,
267
- } satisfies OrchestratorEvent)
268
- } else {
269
- queue.fail(task.id, result.output)
270
- config.onProgress?.({
271
- type: 'error',
272
- task: task.id,
273
- agent: assignee,
274
- data: result,
275
- } satisfies OrchestratorEvent)
383
+ // Emit task trace
384
+ if (config.onTrace) {
385
+ const taskEndMs = Date.now()
386
+ emitTrace(config.onTrace, {
387
+ type: 'task',
388
+ runId: ctx.runId ?? '',
389
+ taskId: task.id,
390
+ taskTitle: task.title,
391
+ agent: assignee,
392
+ success: result.success,
393
+ retries: retryCount,
394
+ startMs: taskStartMs,
395
+ endMs: taskEndMs,
396
+ durationMs: taskEndMs - taskStartMs,
397
+ })
398
+ }
399
+
400
+ ctx.agentResults.set(`${assignee}:${task.id}`, result)
401
+
402
+ if (result.success) {
403
+ // Persist result into shared memory so other agents can read it
404
+ const sharedMem = team.getSharedMemoryInstance()
405
+ if (sharedMem) {
406
+ await sharedMem.write(assignee, `task:${task.id}:result`, result.output)
276
407
  }
277
- } catch (err) {
278
- const message = err instanceof Error ? err.message : String(err)
279
- queue.fail(task.id, message)
408
+
409
+ const completedTask = queue.complete(task.id, result.output)
410
+ completedThisRound.push(completedTask)
411
+
412
+ config.onProgress?.({
413
+ type: 'task_complete',
414
+ task: task.id,
415
+ agent: assignee,
416
+ data: result,
417
+ } satisfies OrchestratorEvent)
418
+
419
+ config.onProgress?.({
420
+ type: 'agent_complete',
421
+ agent: assignee,
422
+ task: task.id,
423
+ data: result,
424
+ } satisfies OrchestratorEvent)
425
+ } else {
426
+ queue.fail(task.id, result.output)
280
427
  config.onProgress?.({
281
428
  type: 'error',
282
429
  task: task.id,
283
430
  agent: assignee,
284
- data: err,
431
+ data: result,
285
432
  } satisfies OrchestratorEvent)
286
433
  }
287
434
  })
288
435
 
289
436
  // Wait for the entire parallel batch before checking for newly-unblocked tasks.
290
437
  await Promise.all(dispatchPromises)
438
+
439
+ // --- Approval gate ---
440
+ // After the batch completes, check if the caller wants to approve
441
+ // the next round before it starts.
442
+ if (config.onApproval && completedThisRound.length > 0) {
443
+ scheduler.autoAssign(queue, team.getAgents())
444
+ const nextPending = queue.getByStatus('pending')
445
+
446
+ if (nextPending.length > 0) {
447
+ let approved: boolean
448
+ try {
449
+ approved = await config.onApproval(completedThisRound, nextPending)
450
+ } catch (err) {
451
+ const reason = `Skipped: approval callback error — ${err instanceof Error ? err.message : String(err)}`
452
+ queue.skipRemaining(reason)
453
+ break
454
+ }
455
+ if (!approved) {
456
+ queue.skipRemaining('Skipped: approval rejected.')
457
+ break
458
+ }
459
+ }
460
+ }
291
461
  }
462
+
463
+ unsubSkipped?.()
292
464
  }
293
465
 
294
466
  /**
@@ -341,8 +513,8 @@ async function buildTaskPrompt(task: Task, team: Team): Promise<string> {
341
513
  */
342
514
  export class OpenMultiAgent {
343
515
  private readonly config: Required<
344
- Omit<OrchestratorConfig, 'onProgress'>
345
- > & Pick<OrchestratorConfig, 'onProgress'>
516
+ Omit<OrchestratorConfig, 'onApproval' | 'onProgress' | 'onTrace' | 'defaultBaseURL' | 'defaultApiKey'>
517
+ > & Pick<OrchestratorConfig, 'onApproval' | 'onProgress' | 'onTrace' | 'defaultBaseURL' | 'defaultApiKey'>
346
518
 
347
519
  private readonly teams: Map<string, Team> = new Map()
348
520
  private completedTaskCount = 0
@@ -360,7 +532,11 @@ export class OpenMultiAgent {
360
532
  maxConcurrency: config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
361
533
  defaultModel: config.defaultModel ?? DEFAULT_MODEL,
362
534
  defaultProvider: config.defaultProvider ?? 'anthropic',
535
+ defaultBaseURL: config.defaultBaseURL,
536
+ defaultApiKey: config.defaultApiKey,
537
+ onApproval: config.onApproval,
363
538
  onProgress: config.onProgress,
539
+ onTrace: config.onTrace,
364
540
  }
365
541
  }
366
542
 
@@ -405,14 +581,24 @@ export class OpenMultiAgent {
405
581
  * @param prompt - The user prompt to send.
406
582
  */
407
583
  async runAgent(config: AgentConfig, prompt: string): Promise<AgentRunResult> {
408
- const agent = buildAgent(config)
584
+ const effective: AgentConfig = {
585
+ ...config,
586
+ provider: config.provider ?? this.config.defaultProvider,
587
+ baseURL: config.baseURL ?? this.config.defaultBaseURL,
588
+ apiKey: config.apiKey ?? this.config.defaultApiKey,
589
+ }
590
+ const agent = buildAgent(effective)
409
591
  this.config.onProgress?.({
410
592
  type: 'agent_start',
411
593
  agent: config.name,
412
594
  data: { prompt },
413
595
  })
414
596
 
415
- const result = await agent.run(prompt)
597
+ const traceOptions: Partial<RunOptions> | undefined = this.config.onTrace
598
+ ? { onTrace: this.config.onTrace, runId: generateRunId(), traceAgent: config.name }
599
+ : undefined
600
+
601
+ const result = await agent.run(prompt, traceOptions)
416
602
 
417
603
  this.config.onProgress?.({
418
604
  type: 'agent_complete',
@@ -462,12 +648,15 @@ export class OpenMultiAgent {
462
648
  name: 'coordinator',
463
649
  model: this.config.defaultModel,
464
650
  provider: this.config.defaultProvider,
651
+ baseURL: this.config.defaultBaseURL,
652
+ apiKey: this.config.defaultApiKey,
465
653
  systemPrompt: this.buildCoordinatorSystemPrompt(agentConfigs),
466
654
  maxTurns: 3,
467
655
  }
468
656
 
469
657
  const decompositionPrompt = this.buildDecompositionPrompt(goal, agentConfigs)
470
658
  const coordinatorAgent = buildAgent(coordinatorConfig)
659
+ const runId = this.config.onTrace ? generateRunId() : undefined
471
660
 
472
661
  this.config.onProgress?.({
473
662
  type: 'agent_start',
@@ -475,7 +664,10 @@ export class OpenMultiAgent {
475
664
  data: { phase: 'decomposition', goal },
476
665
  })
477
666
 
478
- const decompositionResult = await coordinatorAgent.run(decompositionPrompt)
667
+ const decompTraceOptions: Partial<RunOptions> | undefined = this.config.onTrace
668
+ ? { onTrace: this.config.onTrace, runId: runId ?? '', traceAgent: 'coordinator' }
669
+ : undefined
670
+ const decompositionResult = await coordinatorAgent.run(decompositionPrompt, decompTraceOptions)
479
671
  const agentResults = new Map<string, AgentRunResult>()
480
672
  agentResults.set('coordinator:decompose', decompositionResult)
481
673
 
@@ -519,6 +711,7 @@ export class OpenMultiAgent {
519
711
  scheduler,
520
712
  agentResults,
521
713
  config: this.config,
714
+ runId,
522
715
  }
523
716
 
524
717
  await executeQueue(queue, ctx)
@@ -527,7 +720,10 @@ export class OpenMultiAgent {
527
720
  // Step 5: Coordinator synthesises final result
528
721
  // ------------------------------------------------------------------
529
722
  const synthesisPrompt = await this.buildSynthesisPrompt(goal, queue.list(), team)
530
- const synthesisResult = await coordinatorAgent.run(synthesisPrompt)
723
+ const synthTraceOptions: Partial<RunOptions> | undefined = this.config.onTrace
724
+ ? { onTrace: this.config.onTrace, runId: runId ?? '', traceAgent: 'coordinator' }
725
+ : undefined
726
+ const synthesisResult = await coordinatorAgent.run(synthesisPrompt, synthTraceOptions)
531
727
  agentResults.set('coordinator', synthesisResult)
532
728
 
533
729
  this.config.onProgress?.({
@@ -564,6 +760,9 @@ export class OpenMultiAgent {
564
760
  description: string
565
761
  assignee?: string
566
762
  dependsOn?: string[]
763
+ maxRetries?: number
764
+ retryDelayMs?: number
765
+ retryBackoff?: number
567
766
  }>,
568
767
  ): Promise<TeamRunResult> {
569
768
  const agentConfigs = team.getAgents()
@@ -576,6 +775,9 @@ export class OpenMultiAgent {
576
775
  description: t.description,
577
776
  assignee: t.assignee,
578
777
  dependsOn: t.dependsOn,
778
+ maxRetries: t.maxRetries,
779
+ retryDelayMs: t.retryDelayMs,
780
+ retryBackoff: t.retryBackoff,
579
781
  })),
580
782
  agentConfigs,
581
783
  queue,
@@ -591,6 +793,7 @@ export class OpenMultiAgent {
591
793
  scheduler,
592
794
  agentResults,
593
795
  config: this.config,
796
+ runId: this.config.onTrace ? generateRunId() : undefined,
594
797
  }
595
798
 
596
799
  await executeQueue(queue, ctx)
@@ -694,6 +897,7 @@ export class OpenMultiAgent {
694
897
  ): Promise<string> {
695
898
  const completedTasks = tasks.filter((t) => t.status === 'completed')
696
899
  const failedTasks = tasks.filter((t) => t.status === 'failed')
900
+ const skippedTasks = tasks.filter((t) => t.status === 'skipped')
697
901
 
698
902
  const resultSections = completedTasks.map((t) => {
699
903
  const assignee = t.assignee ?? 'unknown'
@@ -704,6 +908,10 @@ export class OpenMultiAgent {
704
908
  (t) => `### ${t.title} (FAILED)\nError: ${t.result ?? 'unknown error'}`,
705
909
  )
706
910
 
911
+ const skippedSections = skippedTasks.map(
912
+ (t) => `### ${t.title} (SKIPPED)\nReason: ${t.result ?? 'approval rejected'}`,
913
+ )
914
+
707
915
  // Also include shared memory summary for additional context
708
916
  let memorySummary = ''
709
917
  const sharedMem = team.getSharedMemoryInstance()
@@ -718,11 +926,12 @@ export class OpenMultiAgent {
718
926
  `## Task Results`,
719
927
  ...resultSections,
720
928
  ...(failureSections.length > 0 ? ['', '## Failed Tasks', ...failureSections] : []),
929
+ ...(skippedSections.length > 0 ? ['', '## Skipped Tasks', ...skippedSections] : []),
721
930
  ...(memorySummary ? ['', memorySummary] : []),
722
931
  '',
723
932
  '## Your Task',
724
933
  'Synthesise the above results into a comprehensive final answer that addresses the original goal.',
725
- 'If some tasks failed, note any gaps in the result.',
934
+ 'If some tasks failed or were skipped, note any gaps in the result.',
726
935
  ].join('\n')
727
936
  }
728
937
 
@@ -733,7 +942,11 @@ export class OpenMultiAgent {
733
942
  * then resolving them to real IDs before adding tasks to the queue.
734
943
  */
735
944
  private loadSpecsIntoQueue(
736
- specs: ReadonlyArray<ParsedTaskSpec>,
945
+ specs: ReadonlyArray<ParsedTaskSpec & {
946
+ maxRetries?: number
947
+ retryDelayMs?: number
948
+ retryBackoff?: number
949
+ }>,
737
950
  agentConfigs: AgentConfig[],
738
951
  queue: TaskQueue,
739
952
  ): void {
@@ -750,6 +963,9 @@ export class OpenMultiAgent {
750
963
  assignee: spec.assignee && agentNames.has(spec.assignee)
751
964
  ? spec.assignee
752
965
  : undefined,
966
+ maxRetries: spec.maxRetries,
967
+ retryDelayMs: spec.retryDelayMs,
968
+ retryBackoff: spec.retryBackoff,
753
969
  })
754
970
  titleToId.set(spec.title.toLowerCase().trim(), task.id)
755
971
  createdTasks.push(task)
@@ -792,6 +1008,8 @@ export class OpenMultiAgent {
792
1008
  ...config,
793
1009
  model: config.model,
794
1010
  provider: config.provider ?? this.config.defaultProvider,
1011
+ baseURL: config.baseURL ?? this.config.defaultBaseURL,
1012
+ apiKey: config.apiKey ?? this.config.defaultApiKey,
795
1013
  }
796
1014
  pool.add(buildAgent(effective))
797
1015
  }
@@ -825,13 +1043,15 @@ export class OpenMultiAgent {
825
1043
  if (!existing) {
826
1044
  collapsed.set(agentName, result)
827
1045
  } else {
828
- // Merge multiple results for the same agent (multi-task case)
1046
+ // Merge multiple results for the same agent (multi-task case).
1047
+ // Keep the latest `structured` value (last completed task wins).
829
1048
  collapsed.set(agentName, {
830
1049
  success: existing.success && result.success,
831
1050
  output: [existing.output, result.output].filter(Boolean).join('\n\n---\n\n'),
832
1051
  messages: [...existing.messages, ...result.messages],
833
1052
  tokenUsage: addUsage(existing.tokenUsage, result.tokenUsage),
834
1053
  toolCalls: [...existing.toolCalls, ...result.toolCalls],
1054
+ structured: result.structured !== undefined ? result.structured : existing.structured,
835
1055
  })
836
1056
  }
837
1057
 
package/src/task/queue.ts CHANGED
@@ -18,6 +18,7 @@ export type TaskQueueEvent =
18
18
  | 'task:ready'
19
19
  | 'task:complete'
20
20
  | 'task:failed'
21
+ | 'task:skipped'
21
22
  | 'all:complete'
22
23
 
23
24
  /** Handler for `'task:ready' | 'task:complete' | 'task:failed'` events. */
@@ -156,6 +157,51 @@ export class TaskQueue {
156
157
  return failed
157
158
  }
158
159
 
160
+ /**
161
+ * Marks `taskId` as `'skipped'` and records `reason` in the `result` field.
162
+ *
163
+ * Fires `'task:skipped'` for the skipped task and cascades to every
164
+ * downstream task that transitively depended on it — even if the dependent
165
+ * has other dependencies that are still pending or completed. A skipped
166
+ * upstream is treated as permanently unsatisfiable, mirroring `fail()`.
167
+ *
168
+ * @throws {Error} when `taskId` is not found.
169
+ */
170
+ skip(taskId: string, reason: string): Task {
171
+ const skipped = this.update(taskId, { status: 'skipped', result: reason })
172
+ this.emit('task:skipped', skipped)
173
+ this.cascadeSkip(taskId)
174
+ if (this.isComplete()) {
175
+ this.emitAllComplete()
176
+ }
177
+ return skipped
178
+ }
179
+
180
+ /**
181
+ * Marks all non-terminal tasks as `'skipped'`.
182
+ *
183
+ * Used when an approval gate rejects continuation — every pending, blocked,
184
+ * or in-progress task is skipped with the given reason.
185
+ *
186
+ * **Important:** Call only when no tasks are actively executing. The
187
+ * orchestrator invokes this after `await Promise.all()`, so no tasks are
188
+ * in-flight. Calling while agents are running may mark an in-progress task
189
+ * as skipped while its agent continues executing.
190
+ */
191
+ skipRemaining(reason = 'Skipped: approval rejected.'): void {
192
+ // Snapshot first — update() mutates the live map, which is unsafe to
193
+ // iterate over during modification.
194
+ const snapshot = Array.from(this.tasks.values())
195
+ for (const task of snapshot) {
196
+ if (task.status === 'completed' || task.status === 'failed' || task.status === 'skipped') continue
197
+ const skipped = this.update(task.id, { status: 'skipped', result: reason })
198
+ this.emit('task:skipped', skipped)
199
+ }
200
+ if (this.isComplete()) {
201
+ this.emitAllComplete()
202
+ }
203
+ }
204
+
159
205
  /**
160
206
  * Recursively marks all tasks that (transitively) depend on `failedTaskId`
161
207
  * as `'failed'` with an informative message, firing `'task:failed'` for each.
@@ -178,6 +224,24 @@ export class TaskQueue {
178
224
  }
179
225
  }
180
226
 
227
+ /**
228
+ * Recursively marks all tasks that (transitively) depend on `skippedTaskId`
229
+ * as `'skipped'`, firing `'task:skipped'` for each.
230
+ */
231
+ private cascadeSkip(skippedTaskId: string): void {
232
+ for (const task of this.tasks.values()) {
233
+ if (task.status !== 'blocked' && task.status !== 'pending') continue
234
+ if (!task.dependsOn?.includes(skippedTaskId)) continue
235
+
236
+ const cascaded = this.update(task.id, {
237
+ status: 'skipped',
238
+ result: `Skipped: dependency "${skippedTaskId}" was skipped.`,
239
+ })
240
+ this.emit('task:skipped', cascaded)
241
+ this.cascadeSkip(task.id)
242
+ }
243
+ }
244
+
181
245
  // ---------------------------------------------------------------------------
182
246
  // Queries
183
247
  // ---------------------------------------------------------------------------
@@ -227,11 +291,11 @@ export class TaskQueue {
227
291
 
228
292
  /**
229
293
  * Returns `true` when every task in the queue has reached a terminal state
230
- * (`'completed'` or `'failed'`), **or** the queue is empty.
294
+ * (`'completed'`, `'failed'`, or `'skipped'`), **or** the queue is empty.
231
295
  */
232
296
  isComplete(): boolean {
233
297
  for (const task of this.tasks.values()) {
234
- if (task.status !== 'completed' && task.status !== 'failed') return false
298
+ if (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'skipped') return false
235
299
  }
236
300
  return true
237
301
  }
@@ -249,12 +313,14 @@ export class TaskQueue {
249
313
  total: number
250
314
  completed: number
251
315
  failed: number
316
+ skipped: number
252
317
  inProgress: number
253
318
  pending: number
254
319
  blocked: number
255
320
  } {
256
321
  let completed = 0
257
322
  let failed = 0
323
+ let skipped = 0
258
324
  let inProgress = 0
259
325
  let pending = 0
260
326
  let blocked = 0
@@ -267,6 +333,9 @@ export class TaskQueue {
267
333
  case 'failed':
268
334
  failed++
269
335
  break
336
+ case 'skipped':
337
+ skipped++
338
+ break
270
339
  case 'in_progress':
271
340
  inProgress++
272
341
  break
@@ -283,6 +352,7 @@ export class TaskQueue {
283
352
  total: this.tasks.size,
284
353
  completed,
285
354
  failed,
355
+ skipped,
286
356
  inProgress,
287
357
  pending,
288
358
  blocked,
@@ -356,7 +426,7 @@ export class TaskQueue {
356
426
 
357
427
  // Re-check against the current state of the whole task set.
358
428
  // Pass the pre-built map to avoid rebuilding it for every candidate task.
359
- if (isTaskReady(task, allTasks, taskById)) {
429
+ if (isTaskReady({ ...task, status: 'pending' }, allTasks, taskById)) {
360
430
  const unblocked: Task = {
361
431
  ...task,
362
432
  status: 'pending',
@@ -370,7 +440,7 @@ export class TaskQueue {
370
440
  }
371
441
  }
372
442
 
373
- private emit(event: 'task:ready' | 'task:complete' | 'task:failed', task: Task): void {
443
+ private emit(event: 'task:ready' | 'task:complete' | 'task:failed' | 'task:skipped', task: Task): void {
374
444
  const map = this.listeners.get(event)
375
445
  if (!map) return
376
446
  for (const handler of map.values()) {
package/src/task/task.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * Stateful orchestration belongs in {@link TaskQueue}.
7
7
  */
8
8
 
9
+ import { randomUUID } from 'node:crypto'
9
10
  import type { Task, TaskStatus } from '../types.js'
10
11
 
11
12
  // ---------------------------------------------------------------------------
@@ -30,10 +31,13 @@ export function createTask(input: {
30
31
  description: string
31
32
  assignee?: string
32
33
  dependsOn?: string[]
34
+ maxRetries?: number
35
+ retryDelayMs?: number
36
+ retryBackoff?: number
33
37
  }): Task {
34
38
  const now = new Date()
35
39
  return {
36
- id: crypto.randomUUID(),
40
+ id: randomUUID(),
37
41
  title: input.title,
38
42
  description: input.description,
39
43
  status: 'pending' as TaskStatus,
@@ -42,6 +46,9 @@ export function createTask(input: {
42
46
  result: undefined,
43
47
  createdAt: now,
44
48
  updatedAt: now,
49
+ maxRetries: input.maxRetries,
50
+ retryDelayMs: input.retryDelayMs,
51
+ retryBackoff: input.retryBackoff,
45
52
  }
46
53
  }
47
54
 
@@ -6,6 +6,8 @@
6
6
  * for replay and audit; read-state is tracked per recipient.
7
7
  */
8
8
 
9
+ import { randomUUID } from 'node:crypto'
10
+
9
11
  // ---------------------------------------------------------------------------
10
12
  // Message type
11
13
  // ---------------------------------------------------------------------------
@@ -93,7 +95,7 @@ export class MessageBus {
93
95
  */
94
96
  send(from: string, to: string, content: string): Message {
95
97
  const message: Message = {
96
- id: crypto.randomUUID(),
98
+ id: randomUUID(),
97
99
  from,
98
100
  to,
99
101
  content,