@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,473 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { z } from 'zod'
3
- import { Agent } from '../src/agent/agent.js'
4
- import { AgentRunner } from '../src/agent/runner.js'
5
- import { ToolRegistry } from '../src/tool/framework.js'
6
- import { ToolExecutor } from '../src/tool/executor.js'
7
- import type { AgentConfig, AgentRunResult, LLMAdapter, LLMMessage, LLMResponse } from '../src/types.js'
8
-
9
- // ---------------------------------------------------------------------------
10
- // Mock helpers
11
- // ---------------------------------------------------------------------------
12
-
13
- /**
14
- * Create a mock adapter that records every `chat()` call's messages
15
- * and returns a fixed text response.
16
- */
17
- function mockAdapter(responseText: string) {
18
- const calls: LLMMessage[][] = []
19
- const adapter: LLMAdapter = {
20
- name: 'mock',
21
- async chat(messages) {
22
- calls.push([...messages])
23
- return {
24
- id: 'mock-1',
25
- content: [{ type: 'text' as const, text: responseText }],
26
- model: 'mock-model',
27
- stop_reason: 'end_turn',
28
- usage: { input_tokens: 10, output_tokens: 20 },
29
- } satisfies LLMResponse
30
- },
31
- async *stream() {
32
- /* unused */
33
- },
34
- }
35
- return { adapter, calls }
36
- }
37
-
38
- /** Build an Agent with a mocked LLM, bypassing createAdapter. */
39
- function buildMockAgent(config: AgentConfig, responseText: string) {
40
- const { adapter, calls } = mockAdapter(responseText)
41
- const registry = new ToolRegistry()
42
- const executor = new ToolExecutor(registry)
43
- const agent = new Agent(config, registry, executor)
44
-
45
- const runner = new AgentRunner(adapter, registry, executor, {
46
- model: config.model,
47
- systemPrompt: config.systemPrompt,
48
- maxTurns: config.maxTurns,
49
- maxTokens: config.maxTokens,
50
- temperature: config.temperature,
51
- agentName: config.name,
52
- })
53
- ;(agent as any).runner = runner
54
-
55
- return { agent, calls }
56
- }
57
-
58
- const baseConfig: AgentConfig = {
59
- name: 'test-agent',
60
- model: 'mock-model',
61
- systemPrompt: 'You are a test agent.',
62
- }
63
-
64
- // ---------------------------------------------------------------------------
65
- // Tests
66
- // ---------------------------------------------------------------------------
67
-
68
- describe('Agent hooks — beforeRun / afterRun', () => {
69
- // -----------------------------------------------------------------------
70
- // Baseline — no hooks
71
- // -----------------------------------------------------------------------
72
-
73
- it('works normally without hooks', async () => {
74
- const { agent } = buildMockAgent(baseConfig, 'hello')
75
- const result = await agent.run('ping')
76
-
77
- expect(result.success).toBe(true)
78
- expect(result.output).toBe('hello')
79
- })
80
-
81
- // -----------------------------------------------------------------------
82
- // beforeRun
83
- // -----------------------------------------------------------------------
84
-
85
- it('beforeRun can modify the prompt', async () => {
86
- const config: AgentConfig = {
87
- ...baseConfig,
88
- beforeRun: (ctx) => ({ ...ctx, prompt: 'modified prompt' }),
89
- }
90
- const { agent, calls } = buildMockAgent(config, 'response')
91
- await agent.run('original prompt')
92
-
93
- // The adapter should have received the modified prompt.
94
- const lastUserMsg = calls[0]!.find(m => m.role === 'user')
95
- const textBlock = lastUserMsg!.content.find(b => b.type === 'text')
96
- expect((textBlock as any).text).toBe('modified prompt')
97
- })
98
-
99
- it('beforeRun that returns context unchanged does not alter prompt', async () => {
100
- const config: AgentConfig = {
101
- ...baseConfig,
102
- beforeRun: (ctx) => ctx,
103
- }
104
- const { agent, calls } = buildMockAgent(config, 'response')
105
- await agent.run('keep this')
106
-
107
- const lastUserMsg = calls[0]!.find(m => m.role === 'user')
108
- const textBlock = lastUserMsg!.content.find(b => b.type === 'text')
109
- expect((textBlock as any).text).toBe('keep this')
110
- })
111
-
112
- it('beforeRun throwing aborts the run with failure', async () => {
113
- const config: AgentConfig = {
114
- ...baseConfig,
115
- beforeRun: () => { throw new Error('budget exceeded') },
116
- }
117
- const { agent, calls } = buildMockAgent(config, 'should not reach')
118
- const result = await agent.run('hi')
119
-
120
- expect(result.success).toBe(false)
121
- expect(result.output).toContain('budget exceeded')
122
- // No LLM call should have been made.
123
- expect(calls).toHaveLength(0)
124
- })
125
-
126
- it('async beforeRun works', async () => {
127
- const config: AgentConfig = {
128
- ...baseConfig,
129
- beforeRun: async (ctx) => {
130
- await Promise.resolve()
131
- return { ...ctx, prompt: 'async modified' }
132
- },
133
- }
134
- const { agent, calls } = buildMockAgent(config, 'ok')
135
- await agent.run('original')
136
-
137
- const lastUserMsg = calls[0]!.find(m => m.role === 'user')
138
- const textBlock = lastUserMsg!.content.find(b => b.type === 'text')
139
- expect((textBlock as any).text).toBe('async modified')
140
- })
141
-
142
- // -----------------------------------------------------------------------
143
- // afterRun
144
- // -----------------------------------------------------------------------
145
-
146
- it('afterRun can modify the result', async () => {
147
- const config: AgentConfig = {
148
- ...baseConfig,
149
- afterRun: (result) => ({ ...result, output: 'modified output' }),
150
- }
151
- const { agent } = buildMockAgent(config, 'original output')
152
- const result = await agent.run('hi')
153
-
154
- expect(result.success).toBe(true)
155
- expect(result.output).toBe('modified output')
156
- })
157
-
158
- it('afterRun throwing marks run as failed', async () => {
159
- const config: AgentConfig = {
160
- ...baseConfig,
161
- afterRun: () => { throw new Error('content violation') },
162
- }
163
- const { agent } = buildMockAgent(config, 'bad content')
164
- const result = await agent.run('hi')
165
-
166
- expect(result.success).toBe(false)
167
- expect(result.output).toContain('content violation')
168
- })
169
-
170
- it('async afterRun works', async () => {
171
- const config: AgentConfig = {
172
- ...baseConfig,
173
- afterRun: async (result) => {
174
- await Promise.resolve()
175
- return { ...result, output: result.output.toUpperCase() }
176
- },
177
- }
178
- const { agent } = buildMockAgent(config, 'hello')
179
- const result = await agent.run('hi')
180
-
181
- expect(result.output).toBe('HELLO')
182
- })
183
-
184
- // -----------------------------------------------------------------------
185
- // Both hooks together
186
- // -----------------------------------------------------------------------
187
-
188
- it('beforeRun and afterRun compose correctly', async () => {
189
- const hookOrder: string[] = []
190
-
191
- const config: AgentConfig = {
192
- ...baseConfig,
193
- beforeRun: (ctx) => {
194
- hookOrder.push('before')
195
- return { ...ctx, prompt: 'injected prompt' }
196
- },
197
- afterRun: (result) => {
198
- hookOrder.push('after')
199
- return { ...result, output: `[processed] ${result.output}` }
200
- },
201
- }
202
- const { agent, calls } = buildMockAgent(config, 'raw output')
203
- const result = await agent.run('original')
204
-
205
- expect(hookOrder).toEqual(['before', 'after'])
206
-
207
- const lastUserMsg = calls[0]!.find(m => m.role === 'user')
208
- const textBlock = lastUserMsg!.content.find(b => b.type === 'text')
209
- expect((textBlock as any).text).toBe('injected prompt')
210
-
211
- expect(result.output).toBe('[processed] raw output')
212
- })
213
-
214
- // -----------------------------------------------------------------------
215
- // prompt() multi-turn mode
216
- // -----------------------------------------------------------------------
217
-
218
- it('hooks fire on prompt() calls', async () => {
219
- const beforeSpy = vi.fn((ctx) => ctx)
220
- const afterSpy = vi.fn((result) => result)
221
-
222
- const config: AgentConfig = {
223
- ...baseConfig,
224
- beforeRun: beforeSpy,
225
- afterRun: afterSpy,
226
- }
227
- const { agent } = buildMockAgent(config, 'reply')
228
- await agent.prompt('hello')
229
-
230
- expect(beforeSpy).toHaveBeenCalledOnce()
231
- expect(afterSpy).toHaveBeenCalledOnce()
232
- expect(beforeSpy.mock.calls[0]![0].prompt).toBe('hello')
233
- })
234
-
235
- // -----------------------------------------------------------------------
236
- // stream() mode
237
- // -----------------------------------------------------------------------
238
-
239
- it('beforeRun fires in stream mode', async () => {
240
- const config: AgentConfig = {
241
- ...baseConfig,
242
- beforeRun: (ctx) => ({ ...ctx, prompt: 'stream modified' }),
243
- }
244
- const { agent, calls } = buildMockAgent(config, 'streamed')
245
-
246
- const events = []
247
- for await (const event of agent.stream('original')) {
248
- events.push(event)
249
- }
250
-
251
- const lastUserMsg = calls[0]!.find(m => m.role === 'user')
252
- const textBlock = lastUserMsg!.content.find(b => b.type === 'text')
253
- expect((textBlock as any).text).toBe('stream modified')
254
-
255
- // Should have at least a text event and a done event.
256
- expect(events.some(e => e.type === 'done')).toBe(true)
257
- })
258
-
259
- it('afterRun fires in stream mode and modifies done event', async () => {
260
- const config: AgentConfig = {
261
- ...baseConfig,
262
- afterRun: (result) => ({ ...result, output: 'stream modified output' }),
263
- }
264
- const { agent } = buildMockAgent(config, 'original')
265
-
266
- const events = []
267
- for await (const event of agent.stream('hi')) {
268
- events.push(event)
269
- }
270
-
271
- const doneEvent = events.find(e => e.type === 'done')
272
- expect(doneEvent).toBeDefined()
273
- expect((doneEvent!.data as AgentRunResult).output).toBe('stream modified output')
274
- })
275
-
276
- it('beforeRun throwing in stream mode yields error event', async () => {
277
- const config: AgentConfig = {
278
- ...baseConfig,
279
- beforeRun: () => { throw new Error('stream abort') },
280
- }
281
- const { agent } = buildMockAgent(config, 'unreachable')
282
-
283
- const events = []
284
- for await (const event of agent.stream('hi')) {
285
- events.push(event)
286
- }
287
-
288
- const errorEvent = events.find(e => e.type === 'error')
289
- expect(errorEvent).toBeDefined()
290
- expect((errorEvent!.data as Error).message).toContain('stream abort')
291
- })
292
-
293
- it('afterRun throwing in stream mode yields error event', async () => {
294
- const config: AgentConfig = {
295
- ...baseConfig,
296
- afterRun: () => { throw new Error('stream content violation') },
297
- }
298
- const { agent } = buildMockAgent(config, 'streamed output')
299
-
300
- const events = []
301
- for await (const event of agent.stream('hi')) {
302
- events.push(event)
303
- }
304
-
305
- // Text events may have been yielded before the error.
306
- const errorEvent = events.find(e => e.type === 'error')
307
- expect(errorEvent).toBeDefined()
308
- expect((errorEvent!.data as Error).message).toContain('stream content violation')
309
- // No done event should be present since afterRun rejected it.
310
- expect(events.find(e => e.type === 'done')).toBeUndefined()
311
- })
312
-
313
- // -----------------------------------------------------------------------
314
- // prompt() history integrity
315
- // -----------------------------------------------------------------------
316
-
317
- it('beforeRun modifying prompt preserves non-text content blocks', async () => {
318
- // Simulate a multi-turn message where the last user message has mixed content
319
- // (text + tool_result). beforeRun should only replace text, not strip other blocks.
320
- const config: AgentConfig = {
321
- ...baseConfig,
322
- beforeRun: (ctx) => ({ ...ctx, prompt: 'modified' }),
323
- }
324
- const { adapter, calls } = mockAdapter('ok')
325
- const registry = new ToolRegistry()
326
- const executor = new ToolExecutor(registry)
327
- const agent = new Agent(config, registry, executor)
328
-
329
- const runner = new AgentRunner(adapter, registry, executor, {
330
- model: config.model,
331
- agentName: config.name,
332
- })
333
- ;(agent as any).runner = runner
334
-
335
- // Directly call run which creates a single text-only user message.
336
- // To test mixed content, we need to go through the private executeRun.
337
- // Instead, we test via prompt() after injecting history with mixed content.
338
- ;(agent as any).messageHistory = [
339
- {
340
- role: 'user' as const,
341
- content: [
342
- { type: 'text' as const, text: 'original' },
343
- { type: 'image' as const, source: { type: 'base64' as const, media_type: 'image/png', data: 'abc' } },
344
- ],
345
- },
346
- ]
347
-
348
- // prompt() appends a new user message then calls executeRun with full history
349
- await agent.prompt('follow up')
350
-
351
- // The last user message sent to the LLM should have modified text
352
- const sentMessages = calls[0]!
353
- const lastUser = [...sentMessages].reverse().find(m => m.role === 'user')!
354
- const textBlock = lastUser.content.find(b => b.type === 'text')
355
- expect((textBlock as any).text).toBe('modified')
356
-
357
- // The earlier user message (with the image) should be untouched
358
- const firstUser = sentMessages.find(m => m.role === 'user')!
359
- const imageBlock = firstUser.content.find(b => b.type === 'image')
360
- expect(imageBlock).toBeDefined()
361
- })
362
-
363
- it('beforeRun modifying prompt does not corrupt messageHistory', async () => {
364
- const config: AgentConfig = {
365
- ...baseConfig,
366
- beforeRun: (ctx) => ({ ...ctx, prompt: 'hook-modified' }),
367
- }
368
- const { agent, calls } = buildMockAgent(config, 'reply')
369
-
370
- await agent.prompt('original message')
371
-
372
- // The LLM should have received the modified prompt.
373
- const lastUserMsg = calls[0]!.find(m => m.role === 'user')
374
- expect((lastUserMsg!.content[0] as any).text).toBe('hook-modified')
375
-
376
- // But the persistent history should retain the original message.
377
- const history = agent.getHistory()
378
- const firstUserInHistory = history.find(m => m.role === 'user')
379
- expect((firstUserInHistory!.content[0] as any).text).toBe('original message')
380
- })
381
-
382
- // -----------------------------------------------------------------------
383
- // afterRun NOT called on error
384
- // -----------------------------------------------------------------------
385
-
386
- it('afterRun is not called when executeRun throws', async () => {
387
- const afterSpy = vi.fn((result) => result)
388
-
389
- const config: AgentConfig = {
390
- ...baseConfig,
391
- // Use beforeRun to trigger an error inside executeRun's try block,
392
- // before afterRun would normally run.
393
- beforeRun: () => { throw new Error('rejected by policy') },
394
- afterRun: afterSpy,
395
- }
396
- const { agent } = buildMockAgent(config, 'should not reach')
397
- const result = await agent.run('hi')
398
-
399
- expect(result.success).toBe(false)
400
- expect(result.output).toContain('rejected by policy')
401
- expect(afterSpy).not.toHaveBeenCalled()
402
- })
403
-
404
- // -----------------------------------------------------------------------
405
- // outputSchema + afterRun
406
- // -----------------------------------------------------------------------
407
-
408
- it('afterRun fires after structured output validation', async () => {
409
- const schema = z.object({ answer: z.string() })
410
-
411
- const config: AgentConfig = {
412
- ...baseConfig,
413
- outputSchema: schema,
414
- afterRun: (result) => ({ ...result, output: '[post-processed] ' + result.output }),
415
- }
416
- // Return valid JSON matching the schema
417
- const { agent } = buildMockAgent(config, '{"answer":"42"}')
418
- const result = await agent.run('what is the answer?')
419
-
420
- expect(result.success).toBe(true)
421
- expect(result.output).toBe('[post-processed] {"answer":"42"}')
422
- expect(result.structured).toEqual({ answer: '42' })
423
- })
424
-
425
- // -----------------------------------------------------------------------
426
- // ctx.agent does not contain hook self-references
427
- // -----------------------------------------------------------------------
428
-
429
- it('beforeRun context.agent has correct config without hook self-references', async () => {
430
- let receivedAgent: AgentConfig | undefined
431
-
432
- const config: AgentConfig = {
433
- ...baseConfig,
434
- beforeRun: (ctx) => {
435
- receivedAgent = ctx.agent
436
- return ctx
437
- },
438
- }
439
- const { agent } = buildMockAgent(config, 'ok')
440
- await agent.run('test')
441
-
442
- expect(receivedAgent).toBeDefined()
443
- expect(receivedAgent!.name).toBe('test-agent')
444
- expect(receivedAgent!.model).toBe('mock-model')
445
- // Hook functions should be stripped to avoid circular references
446
- expect(receivedAgent!.beforeRun).toBeUndefined()
447
- expect(receivedAgent!.afterRun).toBeUndefined()
448
- })
449
-
450
- // -----------------------------------------------------------------------
451
- // Multiple prompt() turns fire hooks each time
452
- // -----------------------------------------------------------------------
453
-
454
- it('hooks fire on every prompt() call', async () => {
455
- const beforeSpy = vi.fn((ctx) => ctx)
456
- const afterSpy = vi.fn((result) => result)
457
-
458
- const config: AgentConfig = {
459
- ...baseConfig,
460
- beforeRun: beforeSpy,
461
- afterRun: afterSpy,
462
- }
463
- const { agent } = buildMockAgent(config, 'reply')
464
-
465
- await agent.prompt('turn 1')
466
- await agent.prompt('turn 2')
467
-
468
- expect(beforeSpy).toHaveBeenCalledTimes(2)
469
- expect(afterSpy).toHaveBeenCalledTimes(2)
470
- expect(beforeSpy.mock.calls[0]![0].prompt).toBe('turn 1')
471
- expect(beforeSpy.mock.calls[1]![0].prompt).toBe('turn 2')
472
- })
473
- })
@@ -1,212 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { AgentPool } from '../src/agent/pool.js'
3
- import type { Agent } from '../src/agent/agent.js'
4
- import type { AgentRunResult, AgentState } from '../src/types.js'
5
-
6
- // ---------------------------------------------------------------------------
7
- // Mock Agent factory
8
- // ---------------------------------------------------------------------------
9
-
10
- const SUCCESS_RESULT: AgentRunResult = {
11
- success: true,
12
- output: 'done',
13
- messages: [],
14
- tokenUsage: { input_tokens: 10, output_tokens: 20 },
15
- toolCalls: [],
16
- }
17
-
18
- function createMockAgent(
19
- name: string,
20
- opts?: { runResult?: AgentRunResult; state?: AgentState['status'] },
21
- ): Agent {
22
- const state: AgentState = {
23
- status: opts?.state ?? 'idle',
24
- messages: [],
25
- tokenUsage: { input_tokens: 0, output_tokens: 0 },
26
- }
27
-
28
- return {
29
- name,
30
- config: { name, model: 'test' },
31
- run: vi.fn().mockResolvedValue(opts?.runResult ?? SUCCESS_RESULT),
32
- getState: vi.fn().mockReturnValue(state),
33
- reset: vi.fn(),
34
- } as unknown as Agent
35
- }
36
-
37
- // ---------------------------------------------------------------------------
38
- // Tests
39
- // ---------------------------------------------------------------------------
40
-
41
- describe('AgentPool', () => {
42
- describe('registry: add / remove / get / list', () => {
43
- it('adds and retrieves an agent', () => {
44
- const pool = new AgentPool()
45
- const agent = createMockAgent('alice')
46
- pool.add(agent)
47
-
48
- expect(pool.get('alice')).toBe(agent)
49
- expect(pool.list()).toHaveLength(1)
50
- })
51
-
52
- it('throws on duplicate add', () => {
53
- const pool = new AgentPool()
54
- pool.add(createMockAgent('alice'))
55
- expect(() => pool.add(createMockAgent('alice'))).toThrow('already registered')
56
- })
57
-
58
- it('removes an agent', () => {
59
- const pool = new AgentPool()
60
- pool.add(createMockAgent('alice'))
61
- pool.remove('alice')
62
- expect(pool.get('alice')).toBeUndefined()
63
- expect(pool.list()).toHaveLength(0)
64
- })
65
-
66
- it('throws on remove of unknown agent', () => {
67
- const pool = new AgentPool()
68
- expect(() => pool.remove('unknown')).toThrow('not registered')
69
- })
70
-
71
- it('get returns undefined for unknown agent', () => {
72
- const pool = new AgentPool()
73
- expect(pool.get('unknown')).toBeUndefined()
74
- })
75
- })
76
-
77
- describe('run', () => {
78
- it('runs a prompt on a named agent', async () => {
79
- const pool = new AgentPool()
80
- const agent = createMockAgent('alice')
81
- pool.add(agent)
82
-
83
- const result = await pool.run('alice', 'hello')
84
-
85
- expect(result.success).toBe(true)
86
- expect(agent.run).toHaveBeenCalledWith('hello', undefined)
87
- })
88
-
89
- it('throws on unknown agent name', async () => {
90
- const pool = new AgentPool()
91
- await expect(pool.run('unknown', 'hello')).rejects.toThrow('not registered')
92
- })
93
- })
94
-
95
- describe('runParallel', () => {
96
- it('runs multiple agents in parallel', async () => {
97
- const pool = new AgentPool(5)
98
- pool.add(createMockAgent('a'))
99
- pool.add(createMockAgent('b'))
100
-
101
- const results = await pool.runParallel([
102
- { agent: 'a', prompt: 'task a' },
103
- { agent: 'b', prompt: 'task b' },
104
- ])
105
-
106
- expect(results.size).toBe(2)
107
- expect(results.get('a')!.success).toBe(true)
108
- expect(results.get('b')!.success).toBe(true)
109
- })
110
-
111
- it('handles agent failures gracefully', async () => {
112
- const pool = new AgentPool()
113
- const failAgent = createMockAgent('fail')
114
- ;(failAgent.run as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'))
115
- pool.add(failAgent)
116
-
117
- const results = await pool.runParallel([
118
- { agent: 'fail', prompt: 'will fail' },
119
- ])
120
-
121
- expect(results.get('fail')!.success).toBe(false)
122
- expect(results.get('fail')!.output).toContain('boom')
123
- })
124
- })
125
-
126
- describe('runAny', () => {
127
- it('round-robins across agents', async () => {
128
- const pool = new AgentPool()
129
- const a = createMockAgent('a')
130
- const b = createMockAgent('b')
131
- pool.add(a)
132
- pool.add(b)
133
-
134
- await pool.runAny('first')
135
- await pool.runAny('second')
136
-
137
- expect(a.run).toHaveBeenCalledTimes(1)
138
- expect(b.run).toHaveBeenCalledTimes(1)
139
- })
140
-
141
- it('throws on empty pool', async () => {
142
- const pool = new AgentPool()
143
- await expect(pool.runAny('hello')).rejects.toThrow('empty pool')
144
- })
145
- })
146
-
147
- describe('getStatus', () => {
148
- it('reports agent states', () => {
149
- const pool = new AgentPool()
150
- pool.add(createMockAgent('idle1', { state: 'idle' }))
151
- pool.add(createMockAgent('idle2', { state: 'idle' }))
152
- pool.add(createMockAgent('running', { state: 'running' }))
153
- pool.add(createMockAgent('done', { state: 'completed' }))
154
- pool.add(createMockAgent('err', { state: 'error' }))
155
-
156
- const status = pool.getStatus()
157
-
158
- expect(status.total).toBe(5)
159
- expect(status.idle).toBe(2)
160
- expect(status.running).toBe(1)
161
- expect(status.completed).toBe(1)
162
- expect(status.error).toBe(1)
163
- })
164
- })
165
-
166
- describe('shutdown', () => {
167
- it('resets all agents', async () => {
168
- const pool = new AgentPool()
169
- const a = createMockAgent('a')
170
- const b = createMockAgent('b')
171
- pool.add(a)
172
- pool.add(b)
173
-
174
- await pool.shutdown()
175
-
176
- expect(a.reset).toHaveBeenCalled()
177
- expect(b.reset).toHaveBeenCalled()
178
- })
179
- })
180
-
181
- describe('concurrency', () => {
182
- it('respects maxConcurrency limit', async () => {
183
- let concurrent = 0
184
- let maxConcurrent = 0
185
-
186
- const makeAgent = (name: string): Agent => {
187
- const agent = createMockAgent(name)
188
- ;(agent.run as ReturnType<typeof vi.fn>).mockImplementation(async () => {
189
- concurrent++
190
- maxConcurrent = Math.max(maxConcurrent, concurrent)
191
- await new Promise(r => setTimeout(r, 50))
192
- concurrent--
193
- return SUCCESS_RESULT
194
- })
195
- return agent
196
- }
197
-
198
- const pool = new AgentPool(2) // max 2 concurrent
199
- pool.add(makeAgent('a'))
200
- pool.add(makeAgent('b'))
201
- pool.add(makeAgent('c'))
202
-
203
- await pool.runParallel([
204
- { agent: 'a', prompt: 'x' },
205
- { agent: 'b', prompt: 'y' },
206
- { agent: 'c', prompt: 'z' },
207
- ])
208
-
209
- expect(maxConcurrent).toBeLessThanOrEqual(2)
210
- })
211
- })
212
- })