@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
- package/.github/pull_request_template.md +14 -0
- package/.github/workflows/ci.yml +23 -0
- package/CLAUDE.md +80 -0
- package/CODE_OF_CONDUCT.md +48 -0
- package/CONTRIBUTING.md +72 -0
- package/DECISIONS.md +43 -0
- package/README.md +144 -144
- package/README_zh.md +277 -0
- package/SECURITY.md +17 -0
- package/dist/agent/agent.d.ts +20 -1
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +233 -12
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/loop-detector.d.ts +39 -0
- package/dist/agent/loop-detector.d.ts.map +1 -0
- package/dist/agent/loop-detector.js +122 -0
- package/dist/agent/loop-detector.js.map +1 -0
- package/dist/agent/pool.d.ts +2 -1
- package/dist/agent/pool.d.ts.map +1 -1
- package/dist/agent/pool.js +4 -2
- package/dist/agent/pool.js.map +1 -1
- package/dist/agent/runner.d.ts +23 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +113 -12
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/structured-output.d.ts +33 -0
- package/dist/agent/structured-output.d.ts.map +1 -0
- package/dist/agent/structured-output.js +116 -0
- package/dist/agent/structured-output.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/adapter.d.ts +12 -4
- package/dist/llm/adapter.d.ts.map +1 -1
- package/dist/llm/adapter.js +28 -5
- package/dist/llm/adapter.js.map +1 -1
- package/dist/llm/anthropic.d.ts +1 -1
- package/dist/llm/anthropic.d.ts.map +1 -1
- package/dist/llm/anthropic.js +2 -1
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/copilot.d.ts +92 -0
- package/dist/llm/copilot.d.ts.map +1 -0
- package/dist/llm/copilot.js +427 -0
- package/dist/llm/copilot.js.map +1 -0
- package/dist/llm/gemini.d.ts +65 -0
- package/dist/llm/gemini.d.ts.map +1 -0
- package/dist/llm/gemini.js +317 -0
- package/dist/llm/gemini.js.map +1 -0
- package/dist/llm/grok.d.ts +21 -0
- package/dist/llm/grok.d.ts.map +1 -0
- package/dist/llm/grok.js +24 -0
- package/dist/llm/grok.js.map +1 -0
- package/dist/llm/openai-common.d.ts +54 -0
- package/dist/llm/openai-common.d.ts.map +1 -0
- package/dist/llm/openai-common.js +242 -0
- package/dist/llm/openai-common.js.map +1 -0
- package/dist/llm/openai.d.ts +2 -2
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +23 -226
- package/dist/llm/openai.js.map +1 -1
- package/dist/orchestrator/orchestrator.d.ts +25 -1
- package/dist/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.js +214 -41
- package/dist/orchestrator/orchestrator.js.map +1 -1
- package/dist/task/queue.d.ts +31 -2
- package/dist/task/queue.d.ts.map +1 -1
- package/dist/task/queue.js +70 -3
- package/dist/task/queue.js.map +1 -1
- package/dist/task/task.d.ts +3 -0
- package/dist/task/task.d.ts.map +1 -1
- package/dist/task/task.js +5 -1
- package/dist/task/task.js.map +1 -1
- package/dist/team/messaging.d.ts.map +1 -1
- package/dist/team/messaging.js +2 -1
- package/dist/team/messaging.js.map +1 -1
- package/dist/tool/text-tool-extractor.d.ts +32 -0
- package/dist/tool/text-tool-extractor.d.ts.map +1 -0
- package/dist/tool/text-tool-extractor.js +187 -0
- package/dist/tool/text-tool-extractor.js.map +1 -0
- package/dist/types.d.ts +167 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/trace.d.ts +12 -0
- package/dist/utils/trace.d.ts.map +1 -0
- package/dist/utils/trace.js +30 -0
- package/dist/utils/trace.js.map +1 -0
- package/examples/05-copilot-test.ts +49 -0
- package/examples/06-local-model.ts +200 -0
- package/examples/07-fan-out-aggregate.ts +209 -0
- package/examples/08-gemma4-local.ts +192 -0
- package/examples/09-structured-output.ts +73 -0
- package/examples/10-task-retry.ts +132 -0
- package/examples/11-trace-observability.ts +133 -0
- package/examples/12-grok.ts +154 -0
- package/examples/13-gemini.ts +48 -0
- package/package.json +14 -3
- package/src/agent/agent.ts +273 -15
- package/src/agent/loop-detector.ts +137 -0
- package/src/agent/pool.ts +9 -2
- package/src/agent/runner.ts +148 -19
- package/src/agent/structured-output.ts +126 -0
- package/src/index.ts +17 -1
- package/src/llm/adapter.ts +29 -5
- package/src/llm/anthropic.ts +2 -1
- package/src/llm/copilot.ts +552 -0
- package/src/llm/gemini.ts +378 -0
- package/src/llm/grok.ts +29 -0
- package/src/llm/openai-common.ts +294 -0
- package/src/llm/openai.ts +31 -261
- package/src/orchestrator/orchestrator.ts +260 -40
- package/src/task/queue.ts +74 -4
- package/src/task/task.ts +8 -1
- package/src/team/messaging.ts +3 -1
- package/src/tool/text-tool-extractor.ts +219 -0
- package/src/types.ts +186 -6
- package/src/utils/trace.ts +34 -0
- package/tests/agent-hooks.test.ts +473 -0
- package/tests/agent-pool.test.ts +212 -0
- package/tests/approval.test.ts +464 -0
- package/tests/built-in-tools.test.ts +393 -0
- package/tests/gemini-adapter.test.ts +97 -0
- package/tests/grok-adapter.test.ts +74 -0
- package/tests/llm-adapters.test.ts +357 -0
- package/tests/loop-detection.test.ts +456 -0
- package/tests/openai-fallback.test.ts +159 -0
- package/tests/orchestrator.test.ts +281 -0
- package/tests/scheduler.test.ts +221 -0
- package/tests/semaphore.test.ts +57 -0
- package/tests/shared-memory.test.ts +122 -0
- package/tests/structured-output.test.ts +331 -0
- package/tests/task-queue.test.ts +244 -0
- package/tests/task-retry.test.ts +368 -0
- package/tests/task-utils.test.ts +155 -0
- package/tests/team-messaging.test.ts +329 -0
- package/tests/text-tool-extractor.test.ts +170 -0
- package/tests/tool-executor.test.ts +193 -0
- package/tests/trace.test.ts +453 -0
- 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
|
-
|
|
243
|
-
|
|
244
|
-
ctx.
|
|
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
|
-
|
|
247
|
-
|
|
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: '
|
|
375
|
+
type: 'task_retry',
|
|
257
376
|
task: task.id,
|
|
258
377
|
agent: assignee,
|
|
259
|
-
data:
|
|
378
|
+
data: retryData,
|
|
260
379
|
} satisfies OrchestratorEvent)
|
|
380
|
+
},
|
|
381
|
+
)
|
|
261
382
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 `'
|
|
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:
|
|
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
|
|
package/src/team/messaging.ts
CHANGED
|
@@ -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:
|
|
98
|
+
id: randomUUID(),
|
|
97
99
|
from,
|
|
98
100
|
to,
|
|
99
101
|
content,
|