@jackchen_me/open-multi-agent 1.0.0 → 1.0.1

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 (80) hide show
  1. package/package.json +8 -2
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
  4. package/.github/pull_request_template.md +0 -14
  5. package/.github/workflows/ci.yml +0 -23
  6. package/CLAUDE.md +0 -80
  7. package/CODE_OF_CONDUCT.md +0 -48
  8. package/CONTRIBUTING.md +0 -72
  9. package/DECISIONS.md +0 -43
  10. package/README_zh.md +0 -277
  11. package/SECURITY.md +0 -17
  12. package/examples/01-single-agent.ts +0 -131
  13. package/examples/02-team-collaboration.ts +0 -167
  14. package/examples/03-task-pipeline.ts +0 -201
  15. package/examples/04-multi-model-team.ts +0 -261
  16. package/examples/05-copilot-test.ts +0 -49
  17. package/examples/06-local-model.ts +0 -200
  18. package/examples/07-fan-out-aggregate.ts +0 -209
  19. package/examples/08-gemma4-local.ts +0 -192
  20. package/examples/09-structured-output.ts +0 -73
  21. package/examples/10-task-retry.ts +0 -132
  22. package/examples/11-trace-observability.ts +0 -133
  23. package/examples/12-grok.ts +0 -154
  24. package/examples/13-gemini.ts +0 -48
  25. package/src/agent/agent.ts +0 -622
  26. package/src/agent/loop-detector.ts +0 -137
  27. package/src/agent/pool.ts +0 -285
  28. package/src/agent/runner.ts +0 -542
  29. package/src/agent/structured-output.ts +0 -126
  30. package/src/index.ts +0 -182
  31. package/src/llm/adapter.ts +0 -98
  32. package/src/llm/anthropic.ts +0 -389
  33. package/src/llm/copilot.ts +0 -552
  34. package/src/llm/gemini.ts +0 -378
  35. package/src/llm/grok.ts +0 -29
  36. package/src/llm/openai-common.ts +0 -294
  37. package/src/llm/openai.ts +0 -292
  38. package/src/memory/shared.ts +0 -181
  39. package/src/memory/store.ts +0 -124
  40. package/src/orchestrator/orchestrator.ts +0 -1071
  41. package/src/orchestrator/scheduler.ts +0 -352
  42. package/src/task/queue.ts +0 -464
  43. package/src/task/task.ts +0 -239
  44. package/src/team/messaging.ts +0 -232
  45. package/src/team/team.ts +0 -334
  46. package/src/tool/built-in/bash.ts +0 -187
  47. package/src/tool/built-in/file-edit.ts +0 -154
  48. package/src/tool/built-in/file-read.ts +0 -105
  49. package/src/tool/built-in/file-write.ts +0 -81
  50. package/src/tool/built-in/grep.ts +0 -362
  51. package/src/tool/built-in/index.ts +0 -50
  52. package/src/tool/executor.ts +0 -178
  53. package/src/tool/framework.ts +0 -557
  54. package/src/tool/text-tool-extractor.ts +0 -219
  55. package/src/types.ts +0 -542
  56. package/src/utils/semaphore.ts +0 -89
  57. package/src/utils/trace.ts +0 -34
  58. package/tests/agent-hooks.test.ts +0 -473
  59. package/tests/agent-pool.test.ts +0 -212
  60. package/tests/approval.test.ts +0 -464
  61. package/tests/built-in-tools.test.ts +0 -393
  62. package/tests/gemini-adapter.test.ts +0 -97
  63. package/tests/grok-adapter.test.ts +0 -74
  64. package/tests/llm-adapters.test.ts +0 -357
  65. package/tests/loop-detection.test.ts +0 -456
  66. package/tests/openai-fallback.test.ts +0 -159
  67. package/tests/orchestrator.test.ts +0 -281
  68. package/tests/scheduler.test.ts +0 -221
  69. package/tests/semaphore.test.ts +0 -57
  70. package/tests/shared-memory.test.ts +0 -122
  71. package/tests/structured-output.test.ts +0 -331
  72. package/tests/task-queue.test.ts +0 -244
  73. package/tests/task-retry.test.ts +0 -368
  74. package/tests/task-utils.test.ts +0 -155
  75. package/tests/team-messaging.test.ts +0 -329
  76. package/tests/text-tool-extractor.test.ts +0 -170
  77. package/tests/tool-executor.test.ts +0 -193
  78. package/tests/trace.test.ts +0 -453
  79. package/tsconfig.json +0 -25
  80. package/vitest.config.ts +0 -9
@@ -1,1071 +0,0 @@
1
- /**
2
- * @fileoverview OpenMultiAgent — the top-level multi-agent orchestration class.
3
- *
4
- * {@link OpenMultiAgent} is the primary public API of the open-multi-agent framework.
5
- * It ties together every subsystem:
6
- *
7
- * - {@link Team} — Agent roster, shared memory, inter-agent messaging
8
- * - {@link TaskQueue} — Dependency-aware work queue
9
- * - {@link Scheduler} — Task-to-agent assignment strategies
10
- * - {@link AgentPool} — Concurrency-controlled execution pool
11
- * - {@link Agent} — Conversation + tool-execution loop
12
- *
13
- * ## Quick start
14
- *
15
- * ```ts
16
- * const orchestrator = new OpenMultiAgent({ defaultModel: 'claude-opus-4-6' })
17
- *
18
- * const team = orchestrator.createTeam('research', {
19
- * name: 'research',
20
- * agents: [
21
- * { name: 'researcher', model: 'claude-opus-4-6', systemPrompt: 'You are a researcher.' },
22
- * { name: 'writer', model: 'claude-opus-4-6', systemPrompt: 'You are a technical writer.' },
23
- * ],
24
- * sharedMemory: true,
25
- * })
26
- *
27
- * const result = await orchestrator.runTeam(team, 'Produce a report on TypeScript 5.5.')
28
- * console.log(result.agentResults.get('coordinator')?.output)
29
- * ```
30
- *
31
- * ## Key design decisions
32
- *
33
- * - **Coordinator pattern** — `runTeam()` spins up a temporary "coordinator" agent
34
- * that breaks the high-level goal into tasks, assigns them, and synthesises the
35
- * final answer. This is the framework's killer feature.
36
- * - **Parallel-by-default** — Independent tasks (no shared dependency) run in
37
- * parallel up to `maxConcurrency`.
38
- * - **Graceful failure** — A failed task marks itself `'failed'` and its direct
39
- * dependents remain `'blocked'` indefinitely; all non-dependent tasks continue.
40
- * - **Progress callbacks** — Callers can pass `onProgress` in the config to receive
41
- * structured {@link OrchestratorEvent}s without polling.
42
- */
43
-
44
- import type {
45
- AgentConfig,
46
- AgentRunResult,
47
- OrchestratorConfig,
48
- OrchestratorEvent,
49
- Task,
50
- TaskStatus,
51
- TeamConfig,
52
- TeamRunResult,
53
- TokenUsage,
54
- } from '../types.js'
55
- import type { RunOptions } from '../agent/runner.js'
56
- import { Agent } from '../agent/agent.js'
57
- import { AgentPool } from '../agent/pool.js'
58
- import { emitTrace, generateRunId } from '../utils/trace.js'
59
- import { ToolRegistry } from '../tool/framework.js'
60
- import { ToolExecutor } from '../tool/executor.js'
61
- import { registerBuiltInTools } from '../tool/built-in/index.js'
62
- import { Team } from '../team/team.js'
63
- import { TaskQueue } from '../task/queue.js'
64
- import { createTask } from '../task/task.js'
65
- import { Scheduler } from './scheduler.js'
66
-
67
- // ---------------------------------------------------------------------------
68
- // Internal constants
69
- // ---------------------------------------------------------------------------
70
-
71
- const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
72
- const DEFAULT_MAX_CONCURRENCY = 5
73
- const DEFAULT_MODEL = 'claude-opus-4-6'
74
-
75
- // ---------------------------------------------------------------------------
76
- // Internal helpers
77
- // ---------------------------------------------------------------------------
78
-
79
- function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
80
- return {
81
- input_tokens: a.input_tokens + b.input_tokens,
82
- output_tokens: a.output_tokens + b.output_tokens,
83
- }
84
- }
85
-
86
- /**
87
- * Build a minimal {@link Agent} with its own fresh registry/executor.
88
- * Registers all built-in tools so coordinator/worker agents can use them.
89
- */
90
- function buildAgent(config: AgentConfig): Agent {
91
- const registry = new ToolRegistry()
92
- registerBuiltInTools(registry)
93
- const executor = new ToolExecutor(registry)
94
- return new Agent(config, registry, executor)
95
- }
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
-
196
- // ---------------------------------------------------------------------------
197
- // Parsed task spec (result of coordinator decomposition)
198
- // ---------------------------------------------------------------------------
199
-
200
- interface ParsedTaskSpec {
201
- title: string
202
- description: string
203
- assignee?: string
204
- dependsOn?: string[]
205
- }
206
-
207
- /**
208
- * Attempt to extract a JSON array of task specs from the coordinator's raw
209
- * output. The coordinator is prompted to emit JSON inside a ```json … ``` fence
210
- * or as a bare array. Returns `null` when no valid array can be extracted.
211
- */
212
- function parseTaskSpecs(raw: string): ParsedTaskSpec[] | null {
213
- // Strategy 1: look for a fenced JSON block
214
- const fenceMatch = raw.match(/```json\s*([\s\S]*?)```/)
215
- const candidate = fenceMatch ? fenceMatch[1]! : raw
216
-
217
- // Strategy 2: find the first '[' and last ']'
218
- const arrayStart = candidate.indexOf('[')
219
- const arrayEnd = candidate.lastIndexOf(']')
220
- if (arrayStart === -1 || arrayEnd === -1 || arrayEnd <= arrayStart) {
221
- return null
222
- }
223
-
224
- const jsonSlice = candidate.slice(arrayStart, arrayEnd + 1)
225
- try {
226
- const parsed: unknown = JSON.parse(jsonSlice)
227
- if (!Array.isArray(parsed)) return null
228
-
229
- const specs: ParsedTaskSpec[] = []
230
- for (const item of parsed) {
231
- if (typeof item !== 'object' || item === null) continue
232
- const obj = item as Record<string, unknown>
233
- if (typeof obj['title'] !== 'string') continue
234
- if (typeof obj['description'] !== 'string') continue
235
-
236
- specs.push({
237
- title: obj['title'],
238
- description: obj['description'],
239
- assignee: typeof obj['assignee'] === 'string' ? obj['assignee'] : undefined,
240
- dependsOn: Array.isArray(obj['dependsOn'])
241
- ? (obj['dependsOn'] as unknown[]).filter((x): x is string => typeof x === 'string')
242
- : undefined,
243
- })
244
- }
245
-
246
- return specs.length > 0 ? specs : null
247
- } catch {
248
- return null
249
- }
250
- }
251
-
252
- // ---------------------------------------------------------------------------
253
- // Orchestration loop
254
- // ---------------------------------------------------------------------------
255
-
256
- /**
257
- * Internal execution context assembled once per `runTeam` / `runTasks` call.
258
- */
259
- interface RunContext {
260
- readonly team: Team
261
- readonly pool: AgentPool
262
- readonly scheduler: Scheduler
263
- readonly agentResults: Map<string, AgentRunResult>
264
- readonly config: OrchestratorConfig
265
- /** Trace run ID, present when `onTrace` is configured. */
266
- readonly runId?: string
267
- }
268
-
269
- /**
270
- * Execute all tasks in `queue` using agents in `pool`, respecting dependencies
271
- * and running independent tasks in parallel.
272
- *
273
- * The orchestration loop works in rounds:
274
- * 1. Find all `'pending'` tasks (dependencies satisfied).
275
- * 2. Dispatch them in parallel via the pool.
276
- * 3. On completion, the queue automatically unblocks dependents.
277
- * 4. Repeat until no more pending tasks exist or all remaining tasks are
278
- * `'failed'`/`'blocked'` (stuck).
279
- */
280
- async function executeQueue(
281
- queue: TaskQueue,
282
- ctx: RunContext,
283
- ): Promise<void> {
284
- const { team, pool, scheduler, config } = ctx
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
-
297
- while (true) {
298
- // Re-run auto-assignment each iteration so tasks that were unblocked since
299
- // the last round (and thus have no assignee yet) get assigned before dispatch.
300
- scheduler.autoAssign(queue, team.getAgents())
301
-
302
- const pending = queue.getByStatus('pending')
303
- if (pending.length === 0) {
304
- // Either all done, or everything remaining is blocked/failed.
305
- break
306
- }
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
-
313
- // Dispatch all currently-pending tasks as a parallel batch.
314
- const dispatchPromises = pending.map(async (task): Promise<void> => {
315
- // Mark in-progress
316
- queue.update(task.id, { status: 'in_progress' as TaskStatus })
317
-
318
- const assignee = task.assignee
319
- if (!assignee) {
320
- // No assignee — mark failed and continue
321
- const msg = `Task "${task.title}" has no assignee.`
322
- queue.fail(task.id, msg)
323
- config.onProgress?.({
324
- type: 'error',
325
- task: task.id,
326
- data: msg,
327
- } satisfies OrchestratorEvent)
328
- return
329
- }
330
-
331
- const agent = pool.get(assignee)
332
- if (!agent) {
333
- const msg = `Agent "${assignee}" not found in pool for task "${task.title}".`
334
- queue.fail(task.id, msg)
335
- config.onProgress?.({
336
- type: 'error',
337
- task: task.id,
338
- agent: assignee,
339
- data: msg,
340
- } satisfies OrchestratorEvent)
341
- return
342
- }
343
-
344
- config.onProgress?.({
345
- type: 'task_start',
346
- task: task.id,
347
- agent: assignee,
348
- data: task,
349
- } satisfies OrchestratorEvent)
350
-
351
- config.onProgress?.({
352
- type: 'agent_start',
353
- agent: assignee,
354
- task: task.id,
355
- data: task,
356
- } satisfies OrchestratorEvent)
357
-
358
- // Build the prompt: inject shared memory context + task description
359
- const prompt = await buildTaskPrompt(task, team)
360
-
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
365
-
366
- const taskStartMs = config.onTrace ? Date.now() : 0
367
- let retryCount = 0
368
-
369
- const result = await executeWithRetry(
370
- () => pool.run(assignee, prompt, traceOptions),
371
- task,
372
- (retryData) => {
373
- retryCount++
374
- config.onProgress?.({
375
- type: 'task_retry',
376
- task: task.id,
377
- agent: assignee,
378
- data: retryData,
379
- } satisfies OrchestratorEvent)
380
- },
381
- )
382
-
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)
407
- }
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)
427
- config.onProgress?.({
428
- type: 'error',
429
- task: task.id,
430
- agent: assignee,
431
- data: result,
432
- } satisfies OrchestratorEvent)
433
- }
434
- })
435
-
436
- // Wait for the entire parallel batch before checking for newly-unblocked tasks.
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
- }
461
- }
462
-
463
- unsubSkipped?.()
464
- }
465
-
466
- /**
467
- * Build the agent prompt for a specific task.
468
- *
469
- * Injects:
470
- * - Task title and description
471
- * - Dependency results from shared memory (if available)
472
- * - Any messages addressed to this agent from the team bus
473
- */
474
- async function buildTaskPrompt(task: Task, team: Team): Promise<string> {
475
- const lines: string[] = [
476
- `# Task: ${task.title}`,
477
- '',
478
- task.description,
479
- ]
480
-
481
- // Inject shared memory summary so the agent sees its teammates' work
482
- const sharedMem = team.getSharedMemoryInstance()
483
- if (sharedMem) {
484
- const summary = await sharedMem.getSummary()
485
- if (summary) {
486
- lines.push('', summary)
487
- }
488
- }
489
-
490
- // Inject messages from other agents addressed to this assignee
491
- if (task.assignee) {
492
- const messages = team.getMessages(task.assignee)
493
- if (messages.length > 0) {
494
- lines.push('', '## Messages from team members')
495
- for (const msg of messages) {
496
- lines.push(`- **${msg.from}**: ${msg.content}`)
497
- }
498
- }
499
- }
500
-
501
- return lines.join('\n')
502
- }
503
-
504
- // ---------------------------------------------------------------------------
505
- // OpenMultiAgent
506
- // ---------------------------------------------------------------------------
507
-
508
- /**
509
- * Top-level orchestrator for the open-multi-agent framework.
510
- *
511
- * Manages teams, coordinates task execution, and surfaces progress events.
512
- * Most users will interact with this class exclusively.
513
- */
514
- export class OpenMultiAgent {
515
- private readonly config: Required<
516
- Omit<OrchestratorConfig, 'onApproval' | 'onProgress' | 'onTrace' | 'defaultBaseURL' | 'defaultApiKey'>
517
- > & Pick<OrchestratorConfig, 'onApproval' | 'onProgress' | 'onTrace' | 'defaultBaseURL' | 'defaultApiKey'>
518
-
519
- private readonly teams: Map<string, Team> = new Map()
520
- private completedTaskCount = 0
521
-
522
- /**
523
- * @param config - Optional top-level configuration.
524
- *
525
- * Sensible defaults:
526
- * - `maxConcurrency`: 5
527
- * - `defaultModel`: `'claude-opus-4-6'`
528
- * - `defaultProvider`: `'anthropic'`
529
- */
530
- constructor(config: OrchestratorConfig = {}) {
531
- this.config = {
532
- maxConcurrency: config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
533
- defaultModel: config.defaultModel ?? DEFAULT_MODEL,
534
- defaultProvider: config.defaultProvider ?? 'anthropic',
535
- defaultBaseURL: config.defaultBaseURL,
536
- defaultApiKey: config.defaultApiKey,
537
- onApproval: config.onApproval,
538
- onProgress: config.onProgress,
539
- onTrace: config.onTrace,
540
- }
541
- }
542
-
543
- // -------------------------------------------------------------------------
544
- // Team management
545
- // -------------------------------------------------------------------------
546
-
547
- /**
548
- * Create and register a {@link Team} with the orchestrator.
549
- *
550
- * The team is stored internally so {@link getStatus} can report aggregate
551
- * agent counts. Returns the new {@link Team} for further configuration.
552
- *
553
- * @param name - Unique team identifier. Throws if already registered.
554
- * @param config - Team configuration (agents, shared memory, concurrency).
555
- */
556
- createTeam(name: string, config: TeamConfig): Team {
557
- if (this.teams.has(name)) {
558
- throw new Error(
559
- `OpenMultiAgent: a team named "${name}" already exists. ` +
560
- `Use a unique name or call shutdown() to clear all teams.`,
561
- )
562
- }
563
- const team = new Team(config)
564
- this.teams.set(name, team)
565
- return team
566
- }
567
-
568
- // -------------------------------------------------------------------------
569
- // Single-agent convenience
570
- // -------------------------------------------------------------------------
571
-
572
- /**
573
- * Run a single prompt with a one-off agent.
574
- *
575
- * Constructs a fresh agent from `config`, runs `prompt` in a single turn,
576
- * and returns the result. The agent is not registered with any pool or team.
577
- *
578
- * Useful for simple one-shot queries that do not need team orchestration.
579
- *
580
- * @param config - Agent configuration.
581
- * @param prompt - The user prompt to send.
582
- */
583
- async runAgent(config: AgentConfig, prompt: string): Promise<AgentRunResult> {
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)
591
- this.config.onProgress?.({
592
- type: 'agent_start',
593
- agent: config.name,
594
- data: { prompt },
595
- })
596
-
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)
602
-
603
- this.config.onProgress?.({
604
- type: 'agent_complete',
605
- agent: config.name,
606
- data: result,
607
- })
608
-
609
- if (result.success) {
610
- this.completedTaskCount++
611
- }
612
-
613
- return result
614
- }
615
-
616
- // -------------------------------------------------------------------------
617
- // Auto-orchestrated team run (KILLER FEATURE)
618
- // -------------------------------------------------------------------------
619
-
620
- /**
621
- * Run a team on a high-level goal with full automatic orchestration.
622
- *
623
- * This is the flagship method of the framework. It works as follows:
624
- *
625
- * 1. A temporary "coordinator" agent receives the goal and the team's agent
626
- * roster, and is asked to decompose it into an ordered list of tasks with
627
- * JSON output.
628
- * 2. The tasks are loaded into a {@link TaskQueue}. Title-based dependency
629
- * tokens in the coordinator's output are resolved to task IDs.
630
- * 3. The {@link Scheduler} assigns unassigned tasks to team agents.
631
- * 4. Tasks are executed in dependency order, with independent tasks running
632
- * in parallel up to `maxConcurrency`.
633
- * 5. Results are persisted to shared memory after each task so subsequent
634
- * agents can read them.
635
- * 6. The coordinator synthesises a final answer from all task outputs.
636
- * 7. A {@link TeamRunResult} is returned.
637
- *
638
- * @param team - A team created via {@link createTeam} (or `new Team(...)`).
639
- * @param goal - High-level natural-language goal for the team.
640
- */
641
- async runTeam(team: Team, goal: string): Promise<TeamRunResult> {
642
- const agentConfigs = team.getAgents()
643
-
644
- // ------------------------------------------------------------------
645
- // Step 1: Coordinator decomposes goal into tasks
646
- // ------------------------------------------------------------------
647
- const coordinatorConfig: AgentConfig = {
648
- name: 'coordinator',
649
- model: this.config.defaultModel,
650
- provider: this.config.defaultProvider,
651
- baseURL: this.config.defaultBaseURL,
652
- apiKey: this.config.defaultApiKey,
653
- systemPrompt: this.buildCoordinatorSystemPrompt(agentConfigs),
654
- maxTurns: 3,
655
- }
656
-
657
- const decompositionPrompt = this.buildDecompositionPrompt(goal, agentConfigs)
658
- const coordinatorAgent = buildAgent(coordinatorConfig)
659
- const runId = this.config.onTrace ? generateRunId() : undefined
660
-
661
- this.config.onProgress?.({
662
- type: 'agent_start',
663
- agent: 'coordinator',
664
- data: { phase: 'decomposition', goal },
665
- })
666
-
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)
671
- const agentResults = new Map<string, AgentRunResult>()
672
- agentResults.set('coordinator:decompose', decompositionResult)
673
-
674
- // ------------------------------------------------------------------
675
- // Step 2: Parse tasks from coordinator output
676
- // ------------------------------------------------------------------
677
- const taskSpecs = parseTaskSpecs(decompositionResult.output)
678
-
679
- const queue = new TaskQueue()
680
- const scheduler = new Scheduler('dependency-first')
681
-
682
- if (taskSpecs && taskSpecs.length > 0) {
683
- // Map title-based dependsOn references to real task IDs so we can
684
- // build the dependency graph before adding tasks to the queue.
685
- this.loadSpecsIntoQueue(taskSpecs, agentConfigs, queue)
686
- } else {
687
- // Coordinator failed to produce structured output — fall back to
688
- // one task per agent using the goal as the description.
689
- for (const agentConfig of agentConfigs) {
690
- const task = createTask({
691
- title: `${agentConfig.name}: ${goal.slice(0, 80)}`,
692
- description: goal,
693
- assignee: agentConfig.name,
694
- })
695
- queue.add(task)
696
- }
697
- }
698
-
699
- // ------------------------------------------------------------------
700
- // Step 3: Auto-assign any unassigned tasks
701
- // ------------------------------------------------------------------
702
- scheduler.autoAssign(queue, agentConfigs)
703
-
704
- // ------------------------------------------------------------------
705
- // Step 4: Build pool and execute
706
- // ------------------------------------------------------------------
707
- const pool = this.buildPool(agentConfigs)
708
- const ctx: RunContext = {
709
- team,
710
- pool,
711
- scheduler,
712
- agentResults,
713
- config: this.config,
714
- runId,
715
- }
716
-
717
- await executeQueue(queue, ctx)
718
-
719
- // ------------------------------------------------------------------
720
- // Step 5: Coordinator synthesises final result
721
- // ------------------------------------------------------------------
722
- const synthesisPrompt = await this.buildSynthesisPrompt(goal, queue.list(), team)
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)
727
- agentResults.set('coordinator', synthesisResult)
728
-
729
- this.config.onProgress?.({
730
- type: 'agent_complete',
731
- agent: 'coordinator',
732
- data: synthesisResult,
733
- })
734
-
735
- // Note: coordinator decompose and synthesis are internal meta-steps.
736
- // Only actual user tasks (non-coordinator keys) are counted in
737
- // buildTeamRunResult, so we do not increment completedTaskCount here.
738
-
739
- return this.buildTeamRunResult(agentResults)
740
- }
741
-
742
- // -------------------------------------------------------------------------
743
- // Explicit-task team run
744
- // -------------------------------------------------------------------------
745
-
746
- /**
747
- * Run a team with an explicitly provided task list.
748
- *
749
- * Simpler than {@link runTeam}: no coordinator agent is involved. Tasks are
750
- * loaded directly into the queue, unassigned tasks are auto-assigned via the
751
- * {@link Scheduler}, and execution proceeds in dependency order.
752
- *
753
- * @param team - A team created via {@link createTeam}.
754
- * @param tasks - Array of task descriptors.
755
- */
756
- async runTasks(
757
- team: Team,
758
- tasks: ReadonlyArray<{
759
- title: string
760
- description: string
761
- assignee?: string
762
- dependsOn?: string[]
763
- maxRetries?: number
764
- retryDelayMs?: number
765
- retryBackoff?: number
766
- }>,
767
- ): Promise<TeamRunResult> {
768
- const agentConfigs = team.getAgents()
769
- const queue = new TaskQueue()
770
- const scheduler = new Scheduler('dependency-first')
771
-
772
- this.loadSpecsIntoQueue(
773
- tasks.map((t) => ({
774
- title: t.title,
775
- description: t.description,
776
- assignee: t.assignee,
777
- dependsOn: t.dependsOn,
778
- maxRetries: t.maxRetries,
779
- retryDelayMs: t.retryDelayMs,
780
- retryBackoff: t.retryBackoff,
781
- })),
782
- agentConfigs,
783
- queue,
784
- )
785
-
786
- scheduler.autoAssign(queue, agentConfigs)
787
-
788
- const pool = this.buildPool(agentConfigs)
789
- const agentResults = new Map<string, AgentRunResult>()
790
- const ctx: RunContext = {
791
- team,
792
- pool,
793
- scheduler,
794
- agentResults,
795
- config: this.config,
796
- runId: this.config.onTrace ? generateRunId() : undefined,
797
- }
798
-
799
- await executeQueue(queue, ctx)
800
-
801
- return this.buildTeamRunResult(agentResults)
802
- }
803
-
804
- // -------------------------------------------------------------------------
805
- // Observability
806
- // -------------------------------------------------------------------------
807
-
808
- /**
809
- * Returns a lightweight status snapshot.
810
- *
811
- * - `teams` — Number of teams registered with this orchestrator.
812
- * - `activeAgents` — Total agents currently in `running` state.
813
- * - `completedTasks` — Cumulative count of successfully completed tasks
814
- * (coordinator meta-steps excluded).
815
- */
816
- getStatus(): { teams: number; activeAgents: number; completedTasks: number } {
817
- return {
818
- teams: this.teams.size,
819
- activeAgents: 0, // Pools are ephemeral per-run; no cross-run state to inspect.
820
- completedTasks: this.completedTaskCount,
821
- }
822
- }
823
-
824
- // -------------------------------------------------------------------------
825
- // Lifecycle
826
- // -------------------------------------------------------------------------
827
-
828
- /**
829
- * Deregister all teams and reset internal counters.
830
- *
831
- * Does not cancel in-flight runs. Call this when you want to reuse the
832
- * orchestrator instance for a fresh set of teams.
833
- *
834
- * Async for forward compatibility — shutdown may need to perform async
835
- * cleanup (e.g. graceful agent drain) in future versions.
836
- */
837
- async shutdown(): Promise<void> {
838
- this.teams.clear()
839
- this.completedTaskCount = 0
840
- }
841
-
842
- // -------------------------------------------------------------------------
843
- // Private helpers
844
- // -------------------------------------------------------------------------
845
-
846
- /** Build the system prompt given to the coordinator agent. */
847
- private buildCoordinatorSystemPrompt(agents: AgentConfig[]): string {
848
- const roster = agents
849
- .map(
850
- (a) =>
851
- `- **${a.name}** (${a.model}): ${a.systemPrompt?.slice(0, 120) ?? 'general purpose agent'}`,
852
- )
853
- .join('\n')
854
-
855
- return [
856
- 'You are a task coordinator responsible for decomposing high-level goals',
857
- 'into concrete, actionable tasks and assigning them to the right team members.',
858
- '',
859
- '## Team Roster',
860
- roster,
861
- '',
862
- '## Output Format',
863
- 'When asked to decompose a goal, respond ONLY with a JSON array of task objects.',
864
- 'Each task must have:',
865
- ' - "title": Short descriptive title (string)',
866
- ' - "description": Full task description with context and expected output (string)',
867
- ' - "assignee": One of the agent names listed in the roster (string)',
868
- ' - "dependsOn": Array of titles of tasks this task depends on (string[], may be empty)',
869
- '',
870
- 'Wrap the JSON in a ```json code fence.',
871
- 'Do not include any text outside the code fence.',
872
- '',
873
- '## When synthesising results',
874
- 'You will be given completed task outputs and asked to synthesise a final answer.',
875
- 'Write a clear, comprehensive response that addresses the original goal.',
876
- ].join('\n')
877
- }
878
-
879
- /** Build the decomposition prompt for the coordinator. */
880
- private buildDecompositionPrompt(goal: string, agents: AgentConfig[]): string {
881
- const names = agents.map((a) => a.name).join(', ')
882
- return [
883
- `Decompose the following goal into tasks for your team (${names}).`,
884
- '',
885
- `## Goal`,
886
- goal,
887
- '',
888
- 'Return ONLY the JSON task array in a ```json code fence.',
889
- ].join('\n')
890
- }
891
-
892
- /** Build the synthesis prompt shown to the coordinator after all tasks complete. */
893
- private async buildSynthesisPrompt(
894
- goal: string,
895
- tasks: Task[],
896
- team: Team,
897
- ): Promise<string> {
898
- const completedTasks = tasks.filter((t) => t.status === 'completed')
899
- const failedTasks = tasks.filter((t) => t.status === 'failed')
900
- const skippedTasks = tasks.filter((t) => t.status === 'skipped')
901
-
902
- const resultSections = completedTasks.map((t) => {
903
- const assignee = t.assignee ?? 'unknown'
904
- return `### ${t.title} (completed by ${assignee})\n${t.result ?? '(no output)'}`
905
- })
906
-
907
- const failureSections = failedTasks.map(
908
- (t) => `### ${t.title} (FAILED)\nError: ${t.result ?? 'unknown error'}`,
909
- )
910
-
911
- const skippedSections = skippedTasks.map(
912
- (t) => `### ${t.title} (SKIPPED)\nReason: ${t.result ?? 'approval rejected'}`,
913
- )
914
-
915
- // Also include shared memory summary for additional context
916
- let memorySummary = ''
917
- const sharedMem = team.getSharedMemoryInstance()
918
- if (sharedMem) {
919
- memorySummary = await sharedMem.getSummary()
920
- }
921
-
922
- return [
923
- `## Original Goal`,
924
- goal,
925
- '',
926
- `## Task Results`,
927
- ...resultSections,
928
- ...(failureSections.length > 0 ? ['', '## Failed Tasks', ...failureSections] : []),
929
- ...(skippedSections.length > 0 ? ['', '## Skipped Tasks', ...skippedSections] : []),
930
- ...(memorySummary ? ['', memorySummary] : []),
931
- '',
932
- '## Your Task',
933
- 'Synthesise the above results into a comprehensive final answer that addresses the original goal.',
934
- 'If some tasks failed or were skipped, note any gaps in the result.',
935
- ].join('\n')
936
- }
937
-
938
- /**
939
- * Load a list of task specs into a queue.
940
- *
941
- * Handles title-based `dependsOn` references by building a title→id map first,
942
- * then resolving them to real IDs before adding tasks to the queue.
943
- */
944
- private loadSpecsIntoQueue(
945
- specs: ReadonlyArray<ParsedTaskSpec & {
946
- maxRetries?: number
947
- retryDelayMs?: number
948
- retryBackoff?: number
949
- }>,
950
- agentConfigs: AgentConfig[],
951
- queue: TaskQueue,
952
- ): void {
953
- const agentNames = new Set(agentConfigs.map((a) => a.name))
954
-
955
- // First pass: create tasks (without dependencies) to get stable IDs.
956
- const titleToId = new Map<string, string>()
957
- const createdTasks: Task[] = []
958
-
959
- for (const spec of specs) {
960
- const task = createTask({
961
- title: spec.title,
962
- description: spec.description,
963
- assignee: spec.assignee && agentNames.has(spec.assignee)
964
- ? spec.assignee
965
- : undefined,
966
- maxRetries: spec.maxRetries,
967
- retryDelayMs: spec.retryDelayMs,
968
- retryBackoff: spec.retryBackoff,
969
- })
970
- titleToId.set(spec.title.toLowerCase().trim(), task.id)
971
- createdTasks.push(task)
972
- }
973
-
974
- // Second pass: resolve title-based dependsOn to IDs.
975
- for (let i = 0; i < createdTasks.length; i++) {
976
- const spec = specs[i]!
977
- const task = createdTasks[i]!
978
-
979
- if (!spec.dependsOn || spec.dependsOn.length === 0) {
980
- queue.add(task)
981
- continue
982
- }
983
-
984
- const resolvedDeps: string[] = []
985
- for (const depRef of spec.dependsOn) {
986
- // Accept both raw IDs and title strings
987
- const byId = createdTasks.find((t) => t.id === depRef)
988
- const byTitle = titleToId.get(depRef.toLowerCase().trim())
989
- const resolvedId = byId?.id ?? byTitle
990
- if (resolvedId) {
991
- resolvedDeps.push(resolvedId)
992
- }
993
- }
994
-
995
- const taskWithDeps: Task = {
996
- ...task,
997
- dependsOn: resolvedDeps.length > 0 ? resolvedDeps : undefined,
998
- }
999
- queue.add(taskWithDeps)
1000
- }
1001
- }
1002
-
1003
- /** Build an {@link AgentPool} from a list of agent configurations. */
1004
- private buildPool(agentConfigs: AgentConfig[]): AgentPool {
1005
- const pool = new AgentPool(this.config.maxConcurrency)
1006
- for (const config of agentConfigs) {
1007
- const effective: AgentConfig = {
1008
- ...config,
1009
- model: config.model,
1010
- provider: config.provider ?? this.config.defaultProvider,
1011
- baseURL: config.baseURL ?? this.config.defaultBaseURL,
1012
- apiKey: config.apiKey ?? this.config.defaultApiKey,
1013
- }
1014
- pool.add(buildAgent(effective))
1015
- }
1016
- return pool
1017
- }
1018
-
1019
- /**
1020
- * Aggregate the per-run `agentResults` map into a {@link TeamRunResult}.
1021
- *
1022
- * Merges results keyed as `agentName:taskId` back into a per-agent map
1023
- * by agent name for the public result surface.
1024
- *
1025
- * Only non-coordinator entries are counted toward `completedTaskCount` to
1026
- * avoid double-counting the coordinator's internal decompose/synthesis steps.
1027
- */
1028
- private buildTeamRunResult(
1029
- agentResults: Map<string, AgentRunResult>,
1030
- ): TeamRunResult {
1031
- let totalUsage: TokenUsage = ZERO_USAGE
1032
- let overallSuccess = true
1033
- const collapsed = new Map<string, AgentRunResult>()
1034
-
1035
- for (const [key, result] of agentResults) {
1036
- // Strip the `:taskId` suffix to get the agent name
1037
- const agentName = key.includes(':') ? key.split(':')[0]! : key
1038
-
1039
- totalUsage = addUsage(totalUsage, result.tokenUsage)
1040
- if (!result.success) overallSuccess = false
1041
-
1042
- const existing = collapsed.get(agentName)
1043
- if (!existing) {
1044
- collapsed.set(agentName, result)
1045
- } else {
1046
- // Merge multiple results for the same agent (multi-task case).
1047
- // Keep the latest `structured` value (last completed task wins).
1048
- collapsed.set(agentName, {
1049
- success: existing.success && result.success,
1050
- output: [existing.output, result.output].filter(Boolean).join('\n\n---\n\n'),
1051
- messages: [...existing.messages, ...result.messages],
1052
- tokenUsage: addUsage(existing.tokenUsage, result.tokenUsage),
1053
- toolCalls: [...existing.toolCalls, ...result.toolCalls],
1054
- structured: result.structured !== undefined ? result.structured : existing.structured,
1055
- })
1056
- }
1057
-
1058
- // Only count actual user tasks — skip coordinator meta-entries
1059
- // (keys that start with 'coordinator') to avoid double-counting.
1060
- if (result.success && !key.startsWith('coordinator')) {
1061
- this.completedTaskCount++
1062
- }
1063
- }
1064
-
1065
- return {
1066
- success: overallSuccess,
1067
- agentResults: collapsed,
1068
- totalTokenUsage: totalUsage,
1069
- }
1070
- }
1071
- }