@jackchen_me/open-multi-agent 0.1.0 → 0.2.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 +72 -0
- package/CODE_OF_CONDUCT.md +48 -0
- package/CONTRIBUTING.md +72 -0
- package/DECISIONS.md +43 -0
- package/README.md +73 -140
- package/README_zh.md +217 -0
- package/SECURITY.md +17 -0
- package/dist/agent/agent.d.ts +5 -0
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +90 -3
- package/dist/agent/agent.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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/adapter.d.ts +9 -4
- package/dist/llm/adapter.d.ts.map +1 -1
- package/dist/llm/adapter.js +17 -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 +426 -0
- package/dist/llm/copilot.js.map +1 -0
- package/dist/llm/openai-common.d.ts +47 -0
- package/dist/llm/openai-common.d.ts.map +1 -0
- package/dist/llm/openai-common.js +209 -0
- package/dist/llm/openai-common.js.map +1 -0
- package/dist/llm/openai.d.ts +1 -1
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +3 -224
- 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 +130 -37
- package/dist/orchestrator/orchestrator.js.map +1 -1
- package/dist/task/queue.js +1 -1
- 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/types.d.ts +31 -3
- package/dist/types.d.ts.map +1 -1
- package/examples/05-copilot-test.ts +49 -0
- package/examples/06-local-model.ts +199 -0
- package/examples/07-fan-out-aggregate.ts +209 -0
- package/examples/08-gemma4-local.ts +203 -0
- package/examples/09-gemma4-auto-orchestration.ts +162 -0
- package/package.json +4 -3
- package/src/agent/agent.ts +115 -6
- package/src/agent/structured-output.ts +126 -0
- package/src/index.ts +2 -1
- package/src/llm/adapter.ts +18 -5
- package/src/llm/anthropic.ts +2 -1
- package/src/llm/copilot.ts +551 -0
- package/src/llm/openai-common.ts +255 -0
- package/src/llm/openai.ts +8 -258
- package/src/orchestrator/orchestrator.ts +164 -38
- package/src/task/queue.ts +1 -1
- package/src/task/task.ts +8 -1
- package/src/team/messaging.ts +3 -1
- package/src/types.ts +31 -2
- 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/tool-executor.test.ts +193 -0
|
@@ -92,6 +92,104 @@ function buildAgent(config: AgentConfig): Agent {
|
|
|
92
92
|
return new Agent(config, registry, executor)
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/** Promise-based delay. */
|
|
96
|
+
function sleep(ms: number): Promise<void> {
|
|
97
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Maximum delay cap to prevent runaway exponential backoff (30 seconds). */
|
|
101
|
+
const MAX_RETRY_DELAY_MS = 30_000
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compute the retry delay for a given attempt, capped at {@link MAX_RETRY_DELAY_MS}.
|
|
105
|
+
*/
|
|
106
|
+
export function computeRetryDelay(
|
|
107
|
+
baseDelay: number,
|
|
108
|
+
backoff: number,
|
|
109
|
+
attempt: number,
|
|
110
|
+
): number {
|
|
111
|
+
return Math.min(baseDelay * backoff ** (attempt - 1), MAX_RETRY_DELAY_MS)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Execute an agent task with optional retry and exponential backoff.
|
|
116
|
+
*
|
|
117
|
+
* Exported for testability — called internally by {@link executeQueue}.
|
|
118
|
+
*
|
|
119
|
+
* @param run - The function that executes the task (typically `pool.run`).
|
|
120
|
+
* @param task - The task to execute (retry config read from its fields).
|
|
121
|
+
* @param onRetry - Called before each retry sleep with event data.
|
|
122
|
+
* @param delayFn - Injectable delay function (defaults to real `sleep`).
|
|
123
|
+
* @returns The final {@link AgentRunResult} from the last attempt.
|
|
124
|
+
*/
|
|
125
|
+
export async function executeWithRetry(
|
|
126
|
+
run: () => Promise<AgentRunResult>,
|
|
127
|
+
task: Task,
|
|
128
|
+
onRetry?: (data: { attempt: number; maxAttempts: number; error: string; nextDelayMs: number }) => void,
|
|
129
|
+
delayFn: (ms: number) => Promise<void> = sleep,
|
|
130
|
+
): Promise<AgentRunResult> {
|
|
131
|
+
const maxAttempts = Math.max(0, task.maxRetries ?? 0) + 1
|
|
132
|
+
const baseDelay = Math.max(0, task.retryDelayMs ?? 1000)
|
|
133
|
+
const backoff = Math.max(1, task.retryBackoff ?? 2)
|
|
134
|
+
|
|
135
|
+
let lastError: string = ''
|
|
136
|
+
// Accumulate token usage across all attempts so billing/observability
|
|
137
|
+
// reflects the true cost of retries.
|
|
138
|
+
let totalUsage: TokenUsage = { input_tokens: 0, output_tokens: 0 }
|
|
139
|
+
|
|
140
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
141
|
+
try {
|
|
142
|
+
const result = await run()
|
|
143
|
+
totalUsage = {
|
|
144
|
+
input_tokens: totalUsage.input_tokens + result.tokenUsage.input_tokens,
|
|
145
|
+
output_tokens: totalUsage.output_tokens + result.tokenUsage.output_tokens,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (result.success) {
|
|
149
|
+
return { ...result, tokenUsage: totalUsage }
|
|
150
|
+
}
|
|
151
|
+
lastError = result.output
|
|
152
|
+
|
|
153
|
+
// Failure — retry or give up
|
|
154
|
+
if (attempt < maxAttempts) {
|
|
155
|
+
const delay = computeRetryDelay(baseDelay, backoff, attempt)
|
|
156
|
+
onRetry?.({ attempt, maxAttempts, error: lastError, nextDelayMs: delay })
|
|
157
|
+
await delayFn(delay)
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { ...result, tokenUsage: totalUsage }
|
|
162
|
+
} catch (err) {
|
|
163
|
+
lastError = err instanceof Error ? err.message : String(err)
|
|
164
|
+
|
|
165
|
+
if (attempt < maxAttempts) {
|
|
166
|
+
const delay = computeRetryDelay(baseDelay, backoff, attempt)
|
|
167
|
+
onRetry?.({ attempt, maxAttempts, error: lastError, nextDelayMs: delay })
|
|
168
|
+
await delayFn(delay)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// All retries exhausted — return a failure result
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
output: lastError,
|
|
176
|
+
messages: [],
|
|
177
|
+
tokenUsage: totalUsage,
|
|
178
|
+
toolCalls: [],
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Should not be reached, but TypeScript needs a return
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
output: lastError,
|
|
187
|
+
messages: [],
|
|
188
|
+
tokenUsage: totalUsage,
|
|
189
|
+
toolCalls: [],
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
95
193
|
// ---------------------------------------------------------------------------
|
|
96
194
|
// Parsed task spec (result of coordinator decomposition)
|
|
97
195
|
// ---------------------------------------------------------------------------
|
|
@@ -239,49 +337,50 @@ async function executeQueue(
|
|
|
239
337
|
// Build the prompt: inject shared memory context + task description
|
|
240
338
|
const prompt = await buildTaskPrompt(task, team)
|
|
241
339
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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)
|
|
254
|
-
|
|
340
|
+
const result = await executeWithRetry(
|
|
341
|
+
() => pool.run(assignee, prompt),
|
|
342
|
+
task,
|
|
343
|
+
(retryData) => {
|
|
255
344
|
config.onProgress?.({
|
|
256
|
-
type: '
|
|
345
|
+
type: 'task_retry',
|
|
257
346
|
task: task.id,
|
|
258
347
|
agent: assignee,
|
|
259
|
-
data:
|
|
348
|
+
data: retryData,
|
|
260
349
|
} satisfies OrchestratorEvent)
|
|
350
|
+
},
|
|
351
|
+
)
|
|
261
352
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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)
|
|
353
|
+
ctx.agentResults.set(`${assignee}:${task.id}`, result)
|
|
354
|
+
|
|
355
|
+
if (result.success) {
|
|
356
|
+
// Persist result into shared memory so other agents can read it
|
|
357
|
+
const sharedMem = team.getSharedMemoryInstance()
|
|
358
|
+
if (sharedMem) {
|
|
359
|
+
await sharedMem.write(assignee, `task:${task.id}:result`, result.output)
|
|
276
360
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
361
|
+
|
|
362
|
+
queue.complete(task.id, result.output)
|
|
363
|
+
|
|
364
|
+
config.onProgress?.({
|
|
365
|
+
type: 'task_complete',
|
|
366
|
+
task: task.id,
|
|
367
|
+
agent: assignee,
|
|
368
|
+
data: result,
|
|
369
|
+
} satisfies OrchestratorEvent)
|
|
370
|
+
|
|
371
|
+
config.onProgress?.({
|
|
372
|
+
type: 'agent_complete',
|
|
373
|
+
agent: assignee,
|
|
374
|
+
task: task.id,
|
|
375
|
+
data: result,
|
|
376
|
+
} satisfies OrchestratorEvent)
|
|
377
|
+
} else {
|
|
378
|
+
queue.fail(task.id, result.output)
|
|
280
379
|
config.onProgress?.({
|
|
281
380
|
type: 'error',
|
|
282
381
|
task: task.id,
|
|
283
382
|
agent: assignee,
|
|
284
|
-
data:
|
|
383
|
+
data: result,
|
|
285
384
|
} satisfies OrchestratorEvent)
|
|
286
385
|
}
|
|
287
386
|
})
|
|
@@ -341,8 +440,8 @@ async function buildTaskPrompt(task: Task, team: Team): Promise<string> {
|
|
|
341
440
|
*/
|
|
342
441
|
export class OpenMultiAgent {
|
|
343
442
|
private readonly config: Required<
|
|
344
|
-
Omit<OrchestratorConfig, 'onProgress'>
|
|
345
|
-
> & Pick<OrchestratorConfig, 'onProgress'>
|
|
443
|
+
Omit<OrchestratorConfig, 'onProgress' | 'defaultBaseURL' | 'defaultApiKey'>
|
|
444
|
+
> & Pick<OrchestratorConfig, 'onProgress' | 'defaultBaseURL' | 'defaultApiKey'>
|
|
346
445
|
|
|
347
446
|
private readonly teams: Map<string, Team> = new Map()
|
|
348
447
|
private completedTaskCount = 0
|
|
@@ -360,6 +459,8 @@ export class OpenMultiAgent {
|
|
|
360
459
|
maxConcurrency: config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
|
361
460
|
defaultModel: config.defaultModel ?? DEFAULT_MODEL,
|
|
362
461
|
defaultProvider: config.defaultProvider ?? 'anthropic',
|
|
462
|
+
defaultBaseURL: config.defaultBaseURL,
|
|
463
|
+
defaultApiKey: config.defaultApiKey,
|
|
363
464
|
onProgress: config.onProgress,
|
|
364
465
|
}
|
|
365
466
|
}
|
|
@@ -405,7 +506,13 @@ export class OpenMultiAgent {
|
|
|
405
506
|
* @param prompt - The user prompt to send.
|
|
406
507
|
*/
|
|
407
508
|
async runAgent(config: AgentConfig, prompt: string): Promise<AgentRunResult> {
|
|
408
|
-
const
|
|
509
|
+
const effective: AgentConfig = {
|
|
510
|
+
...config,
|
|
511
|
+
provider: config.provider ?? this.config.defaultProvider,
|
|
512
|
+
baseURL: config.baseURL ?? this.config.defaultBaseURL,
|
|
513
|
+
apiKey: config.apiKey ?? this.config.defaultApiKey,
|
|
514
|
+
}
|
|
515
|
+
const agent = buildAgent(effective)
|
|
409
516
|
this.config.onProgress?.({
|
|
410
517
|
type: 'agent_start',
|
|
411
518
|
agent: config.name,
|
|
@@ -462,6 +569,8 @@ export class OpenMultiAgent {
|
|
|
462
569
|
name: 'coordinator',
|
|
463
570
|
model: this.config.defaultModel,
|
|
464
571
|
provider: this.config.defaultProvider,
|
|
572
|
+
baseURL: this.config.defaultBaseURL,
|
|
573
|
+
apiKey: this.config.defaultApiKey,
|
|
465
574
|
systemPrompt: this.buildCoordinatorSystemPrompt(agentConfigs),
|
|
466
575
|
maxTurns: 3,
|
|
467
576
|
}
|
|
@@ -564,6 +673,9 @@ export class OpenMultiAgent {
|
|
|
564
673
|
description: string
|
|
565
674
|
assignee?: string
|
|
566
675
|
dependsOn?: string[]
|
|
676
|
+
maxRetries?: number
|
|
677
|
+
retryDelayMs?: number
|
|
678
|
+
retryBackoff?: number
|
|
567
679
|
}>,
|
|
568
680
|
): Promise<TeamRunResult> {
|
|
569
681
|
const agentConfigs = team.getAgents()
|
|
@@ -576,6 +688,9 @@ export class OpenMultiAgent {
|
|
|
576
688
|
description: t.description,
|
|
577
689
|
assignee: t.assignee,
|
|
578
690
|
dependsOn: t.dependsOn,
|
|
691
|
+
maxRetries: t.maxRetries,
|
|
692
|
+
retryDelayMs: t.retryDelayMs,
|
|
693
|
+
retryBackoff: t.retryBackoff,
|
|
579
694
|
})),
|
|
580
695
|
agentConfigs,
|
|
581
696
|
queue,
|
|
@@ -733,7 +848,11 @@ export class OpenMultiAgent {
|
|
|
733
848
|
* then resolving them to real IDs before adding tasks to the queue.
|
|
734
849
|
*/
|
|
735
850
|
private loadSpecsIntoQueue(
|
|
736
|
-
specs: ReadonlyArray<ParsedTaskSpec
|
|
851
|
+
specs: ReadonlyArray<ParsedTaskSpec & {
|
|
852
|
+
maxRetries?: number
|
|
853
|
+
retryDelayMs?: number
|
|
854
|
+
retryBackoff?: number
|
|
855
|
+
}>,
|
|
737
856
|
agentConfigs: AgentConfig[],
|
|
738
857
|
queue: TaskQueue,
|
|
739
858
|
): void {
|
|
@@ -750,6 +869,9 @@ export class OpenMultiAgent {
|
|
|
750
869
|
assignee: spec.assignee && agentNames.has(spec.assignee)
|
|
751
870
|
? spec.assignee
|
|
752
871
|
: undefined,
|
|
872
|
+
maxRetries: spec.maxRetries,
|
|
873
|
+
retryDelayMs: spec.retryDelayMs,
|
|
874
|
+
retryBackoff: spec.retryBackoff,
|
|
753
875
|
})
|
|
754
876
|
titleToId.set(spec.title.toLowerCase().trim(), task.id)
|
|
755
877
|
createdTasks.push(task)
|
|
@@ -792,6 +914,8 @@ export class OpenMultiAgent {
|
|
|
792
914
|
...config,
|
|
793
915
|
model: config.model,
|
|
794
916
|
provider: config.provider ?? this.config.defaultProvider,
|
|
917
|
+
baseURL: config.baseURL ?? this.config.defaultBaseURL,
|
|
918
|
+
apiKey: config.apiKey ?? this.config.defaultApiKey,
|
|
795
919
|
}
|
|
796
920
|
pool.add(buildAgent(effective))
|
|
797
921
|
}
|
|
@@ -825,13 +949,15 @@ export class OpenMultiAgent {
|
|
|
825
949
|
if (!existing) {
|
|
826
950
|
collapsed.set(agentName, result)
|
|
827
951
|
} else {
|
|
828
|
-
// Merge multiple results for the same agent (multi-task case)
|
|
952
|
+
// Merge multiple results for the same agent (multi-task case).
|
|
953
|
+
// Keep the latest `structured` value (last completed task wins).
|
|
829
954
|
collapsed.set(agentName, {
|
|
830
955
|
success: existing.success && result.success,
|
|
831
956
|
output: [existing.output, result.output].filter(Boolean).join('\n\n---\n\n'),
|
|
832
957
|
messages: [...existing.messages, ...result.messages],
|
|
833
958
|
tokenUsage: addUsage(existing.tokenUsage, result.tokenUsage),
|
|
834
959
|
toolCalls: [...existing.toolCalls, ...result.toolCalls],
|
|
960
|
+
structured: result.structured !== undefined ? result.structured : existing.structured,
|
|
835
961
|
})
|
|
836
962
|
}
|
|
837
963
|
|
package/src/task/queue.ts
CHANGED
|
@@ -356,7 +356,7 @@ export class TaskQueue {
|
|
|
356
356
|
|
|
357
357
|
// Re-check against the current state of the whole task set.
|
|
358
358
|
// Pass the pre-built map to avoid rebuilding it for every candidate task.
|
|
359
|
-
if (isTaskReady(task, allTasks, taskById)) {
|
|
359
|
+
if (isTaskReady({ ...task, status: 'pending' }, allTasks, taskById)) {
|
|
360
360
|
const unblocked: Task = {
|
|
361
361
|
...task,
|
|
362
362
|
status: 'pending',
|
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,
|
package/src/types.ts
CHANGED
|
@@ -186,13 +186,27 @@ export interface ToolDefinition<TInput = Record<string, unknown>> {
|
|
|
186
186
|
export interface AgentConfig {
|
|
187
187
|
readonly name: string
|
|
188
188
|
readonly model: string
|
|
189
|
-
readonly provider?: 'anthropic' | 'openai'
|
|
189
|
+
readonly provider?: 'anthropic' | 'copilot' | 'openai'
|
|
190
|
+
/**
|
|
191
|
+
* Custom base URL for OpenAI-compatible APIs (Ollama, vLLM, LM Studio, etc.).
|
|
192
|
+
* Note: local servers that don't require auth still need `apiKey` set to a
|
|
193
|
+
* non-empty placeholder (e.g. `'ollama'`) because the OpenAI SDK validates it.
|
|
194
|
+
*/
|
|
195
|
+
readonly baseURL?: string
|
|
196
|
+
/** API key override; falls back to the provider's standard env var. */
|
|
197
|
+
readonly apiKey?: string
|
|
190
198
|
readonly systemPrompt?: string
|
|
191
199
|
/** Names of tools (from the tool registry) available to this agent. */
|
|
192
200
|
readonly tools?: readonly string[]
|
|
193
201
|
readonly maxTurns?: number
|
|
194
202
|
readonly maxTokens?: number
|
|
195
203
|
readonly temperature?: number
|
|
204
|
+
/**
|
|
205
|
+
* Optional Zod schema for structured output. When set, the agent's final
|
|
206
|
+
* output is parsed as JSON and validated against this schema. A single
|
|
207
|
+
* retry with error feedback is attempted on validation failure.
|
|
208
|
+
*/
|
|
209
|
+
readonly outputSchema?: ZodSchema
|
|
196
210
|
}
|
|
197
211
|
|
|
198
212
|
/** Lifecycle state tracked during an agent run. */
|
|
@@ -219,6 +233,12 @@ export interface AgentRunResult {
|
|
|
219
233
|
readonly messages: LLMMessage[]
|
|
220
234
|
readonly tokenUsage: TokenUsage
|
|
221
235
|
readonly toolCalls: ToolCallRecord[]
|
|
236
|
+
/**
|
|
237
|
+
* Parsed and validated structured output when `outputSchema` is set on the
|
|
238
|
+
* agent config. `undefined` when no schema is configured or validation
|
|
239
|
+
* failed after retry.
|
|
240
|
+
*/
|
|
241
|
+
readonly structured?: unknown
|
|
222
242
|
}
|
|
223
243
|
|
|
224
244
|
// ---------------------------------------------------------------------------
|
|
@@ -261,6 +281,12 @@ export interface Task {
|
|
|
261
281
|
result?: string
|
|
262
282
|
readonly createdAt: Date
|
|
263
283
|
updatedAt: Date
|
|
284
|
+
/** Maximum number of retry attempts on failure (default: 0 — no retry). */
|
|
285
|
+
readonly maxRetries?: number
|
|
286
|
+
/** Base delay in ms before the first retry (default: 1000). */
|
|
287
|
+
readonly retryDelayMs?: number
|
|
288
|
+
/** Exponential backoff multiplier (default: 2). */
|
|
289
|
+
readonly retryBackoff?: number
|
|
264
290
|
}
|
|
265
291
|
|
|
266
292
|
// ---------------------------------------------------------------------------
|
|
@@ -274,6 +300,7 @@ export interface OrchestratorEvent {
|
|
|
274
300
|
| 'agent_complete'
|
|
275
301
|
| 'task_start'
|
|
276
302
|
| 'task_complete'
|
|
303
|
+
| 'task_retry'
|
|
277
304
|
| 'message'
|
|
278
305
|
| 'error'
|
|
279
306
|
readonly agent?: string
|
|
@@ -285,7 +312,9 @@ export interface OrchestratorEvent {
|
|
|
285
312
|
export interface OrchestratorConfig {
|
|
286
313
|
readonly maxConcurrency?: number
|
|
287
314
|
readonly defaultModel?: string
|
|
288
|
-
readonly defaultProvider?: 'anthropic' | 'openai'
|
|
315
|
+
readonly defaultProvider?: 'anthropic' | 'copilot' | 'openai'
|
|
316
|
+
readonly defaultBaseURL?: string
|
|
317
|
+
readonly defaultApiKey?: string
|
|
289
318
|
onProgress?: (event: OrchestratorEvent) => void
|
|
290
319
|
}
|
|
291
320
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { Semaphore } from '../src/utils/semaphore.js'
|
|
3
|
+
|
|
4
|
+
describe('Semaphore', () => {
|
|
5
|
+
it('throws on max < 1', () => {
|
|
6
|
+
expect(() => new Semaphore(0)).toThrow()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('allows up to max concurrent holders', async () => {
|
|
10
|
+
const sem = new Semaphore(2)
|
|
11
|
+
let running = 0
|
|
12
|
+
let peak = 0
|
|
13
|
+
|
|
14
|
+
const work = async () => {
|
|
15
|
+
await sem.acquire()
|
|
16
|
+
running++
|
|
17
|
+
peak = Math.max(peak, running)
|
|
18
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
19
|
+
running--
|
|
20
|
+
sem.release()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await Promise.all([work(), work(), work(), work()])
|
|
24
|
+
expect(peak).toBeLessThanOrEqual(2)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('run() auto-releases on success', async () => {
|
|
28
|
+
const sem = new Semaphore(1)
|
|
29
|
+
const result = await sem.run(async () => 42)
|
|
30
|
+
expect(result).toBe(42)
|
|
31
|
+
expect(sem.active).toBe(0)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('run() auto-releases on error', async () => {
|
|
35
|
+
const sem = new Semaphore(1)
|
|
36
|
+
await expect(sem.run(async () => { throw new Error('oops') })).rejects.toThrow('oops')
|
|
37
|
+
expect(sem.active).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('tracks active and pending counts', async () => {
|
|
41
|
+
const sem = new Semaphore(1)
|
|
42
|
+
await sem.acquire()
|
|
43
|
+
expect(sem.active).toBe(1)
|
|
44
|
+
|
|
45
|
+
// This will queue
|
|
46
|
+
const p = sem.acquire()
|
|
47
|
+
expect(sem.pending).toBe(1)
|
|
48
|
+
|
|
49
|
+
sem.release()
|
|
50
|
+
await p
|
|
51
|
+
expect(sem.active).toBe(1)
|
|
52
|
+
expect(sem.pending).toBe(0)
|
|
53
|
+
|
|
54
|
+
sem.release()
|
|
55
|
+
expect(sem.active).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { SharedMemory } from '../src/memory/shared.js'
|
|
3
|
+
|
|
4
|
+
describe('SharedMemory', () => {
|
|
5
|
+
// -------------------------------------------------------------------------
|
|
6
|
+
// Write & read
|
|
7
|
+
// -------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
it('writes and reads a value under a namespaced key', async () => {
|
|
10
|
+
const mem = new SharedMemory()
|
|
11
|
+
await mem.write('researcher', 'findings', 'TS 5.5 ships const type params')
|
|
12
|
+
|
|
13
|
+
const entry = await mem.read('researcher/findings')
|
|
14
|
+
expect(entry).not.toBeNull()
|
|
15
|
+
expect(entry!.value).toBe('TS 5.5 ships const type params')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns null for a non-existent key', async () => {
|
|
19
|
+
const mem = new SharedMemory()
|
|
20
|
+
expect(await mem.read('nope/nothing')).toBeNull()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// -------------------------------------------------------------------------
|
|
24
|
+
// Namespace isolation
|
|
25
|
+
// -------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
it('isolates writes between agents', async () => {
|
|
28
|
+
const mem = new SharedMemory()
|
|
29
|
+
await mem.write('alice', 'plan', 'plan A')
|
|
30
|
+
await mem.write('bob', 'plan', 'plan B')
|
|
31
|
+
|
|
32
|
+
const alice = await mem.read('alice/plan')
|
|
33
|
+
const bob = await mem.read('bob/plan')
|
|
34
|
+
expect(alice!.value).toBe('plan A')
|
|
35
|
+
expect(bob!.value).toBe('plan B')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('listByAgent returns only that agent\'s entries', async () => {
|
|
39
|
+
const mem = new SharedMemory()
|
|
40
|
+
await mem.write('alice', 'a1', 'v1')
|
|
41
|
+
await mem.write('alice', 'a2', 'v2')
|
|
42
|
+
await mem.write('bob', 'b1', 'v3')
|
|
43
|
+
|
|
44
|
+
const aliceEntries = await mem.listByAgent('alice')
|
|
45
|
+
expect(aliceEntries).toHaveLength(2)
|
|
46
|
+
expect(aliceEntries.every((e) => e.key.startsWith('alice/'))).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// -------------------------------------------------------------------------
|
|
50
|
+
// Overwrite
|
|
51
|
+
// -------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
it('overwrites a value and preserves createdAt', async () => {
|
|
54
|
+
const mem = new SharedMemory()
|
|
55
|
+
await mem.write('agent', 'key', 'first')
|
|
56
|
+
const first = await mem.read('agent/key')
|
|
57
|
+
|
|
58
|
+
await mem.write('agent', 'key', 'second')
|
|
59
|
+
const second = await mem.read('agent/key')
|
|
60
|
+
|
|
61
|
+
expect(second!.value).toBe('second')
|
|
62
|
+
expect(second!.createdAt.getTime()).toBe(first!.createdAt.getTime())
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
// Metadata
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
it('stores metadata alongside the value', async () => {
|
|
70
|
+
const mem = new SharedMemory()
|
|
71
|
+
await mem.write('agent', 'key', 'val', { priority: 'high' })
|
|
72
|
+
|
|
73
|
+
const entry = await mem.read('agent/key')
|
|
74
|
+
expect(entry!.metadata).toMatchObject({ priority: 'high', agent: 'agent' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// -------------------------------------------------------------------------
|
|
78
|
+
// Summary
|
|
79
|
+
// -------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
it('returns empty string for an empty store', async () => {
|
|
82
|
+
const mem = new SharedMemory()
|
|
83
|
+
expect(await mem.getSummary()).toBe('')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('produces a markdown summary grouped by agent', async () => {
|
|
87
|
+
const mem = new SharedMemory()
|
|
88
|
+
await mem.write('researcher', 'findings', 'result A')
|
|
89
|
+
await mem.write('coder', 'plan', 'implement X')
|
|
90
|
+
|
|
91
|
+
const summary = await mem.getSummary()
|
|
92
|
+
expect(summary).toContain('## Shared Team Memory')
|
|
93
|
+
expect(summary).toContain('### researcher')
|
|
94
|
+
expect(summary).toContain('### coder')
|
|
95
|
+
expect(summary).toContain('findings: result A')
|
|
96
|
+
expect(summary).toContain('plan: implement X')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('truncates long values in the summary', async () => {
|
|
100
|
+
const mem = new SharedMemory()
|
|
101
|
+
const longValue = 'x'.repeat(300)
|
|
102
|
+
await mem.write('agent', 'big', longValue)
|
|
103
|
+
|
|
104
|
+
const summary = await mem.getSummary()
|
|
105
|
+
// Summary truncates at 200 chars → 197 + '…'
|
|
106
|
+
expect(summary.length).toBeLessThan(longValue.length)
|
|
107
|
+
expect(summary).toContain('…')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// listAll
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
it('listAll returns entries from all agents', async () => {
|
|
115
|
+
const mem = new SharedMemory()
|
|
116
|
+
await mem.write('a', 'k1', 'v1')
|
|
117
|
+
await mem.write('b', 'k2', 'v2')
|
|
118
|
+
|
|
119
|
+
const all = await mem.listAll()
|
|
120
|
+
expect(all).toHaveLength(2)
|
|
121
|
+
})
|
|
122
|
+
})
|