@jackchen_me/open-multi-agent 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  3. package/.github/pull_request_template.md +14 -0
  4. package/.github/workflows/ci.yml +23 -0
  5. package/CLAUDE.md +80 -0
  6. package/CODE_OF_CONDUCT.md +48 -0
  7. package/CONTRIBUTING.md +72 -0
  8. package/DECISIONS.md +43 -0
  9. package/README.md +144 -144
  10. package/README_zh.md +277 -0
  11. package/SECURITY.md +17 -0
  12. package/dist/agent/agent.d.ts +20 -1
  13. package/dist/agent/agent.d.ts.map +1 -1
  14. package/dist/agent/agent.js +233 -12
  15. package/dist/agent/agent.js.map +1 -1
  16. package/dist/agent/loop-detector.d.ts +39 -0
  17. package/dist/agent/loop-detector.d.ts.map +1 -0
  18. package/dist/agent/loop-detector.js +122 -0
  19. package/dist/agent/loop-detector.js.map +1 -0
  20. package/dist/agent/pool.d.ts +2 -1
  21. package/dist/agent/pool.d.ts.map +1 -1
  22. package/dist/agent/pool.js +4 -2
  23. package/dist/agent/pool.js.map +1 -1
  24. package/dist/agent/runner.d.ts +23 -1
  25. package/dist/agent/runner.d.ts.map +1 -1
  26. package/dist/agent/runner.js +113 -12
  27. package/dist/agent/runner.js.map +1 -1
  28. package/dist/agent/structured-output.d.ts +33 -0
  29. package/dist/agent/structured-output.d.ts.map +1 -0
  30. package/dist/agent/structured-output.js +116 -0
  31. package/dist/agent/structured-output.js.map +1 -0
  32. package/dist/index.d.ts +5 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/llm/adapter.d.ts +12 -4
  37. package/dist/llm/adapter.d.ts.map +1 -1
  38. package/dist/llm/adapter.js +28 -5
  39. package/dist/llm/adapter.js.map +1 -1
  40. package/dist/llm/anthropic.d.ts +1 -1
  41. package/dist/llm/anthropic.d.ts.map +1 -1
  42. package/dist/llm/anthropic.js +2 -1
  43. package/dist/llm/anthropic.js.map +1 -1
  44. package/dist/llm/copilot.d.ts +92 -0
  45. package/dist/llm/copilot.d.ts.map +1 -0
  46. package/dist/llm/copilot.js +427 -0
  47. package/dist/llm/copilot.js.map +1 -0
  48. package/dist/llm/gemini.d.ts +65 -0
  49. package/dist/llm/gemini.d.ts.map +1 -0
  50. package/dist/llm/gemini.js +317 -0
  51. package/dist/llm/gemini.js.map +1 -0
  52. package/dist/llm/grok.d.ts +21 -0
  53. package/dist/llm/grok.d.ts.map +1 -0
  54. package/dist/llm/grok.js +24 -0
  55. package/dist/llm/grok.js.map +1 -0
  56. package/dist/llm/openai-common.d.ts +54 -0
  57. package/dist/llm/openai-common.d.ts.map +1 -0
  58. package/dist/llm/openai-common.js +242 -0
  59. package/dist/llm/openai-common.js.map +1 -0
  60. package/dist/llm/openai.d.ts +2 -2
  61. package/dist/llm/openai.d.ts.map +1 -1
  62. package/dist/llm/openai.js +23 -226
  63. package/dist/llm/openai.js.map +1 -1
  64. package/dist/orchestrator/orchestrator.d.ts +25 -1
  65. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  66. package/dist/orchestrator/orchestrator.js +214 -41
  67. package/dist/orchestrator/orchestrator.js.map +1 -1
  68. package/dist/task/queue.d.ts +31 -2
  69. package/dist/task/queue.d.ts.map +1 -1
  70. package/dist/task/queue.js +70 -3
  71. package/dist/task/queue.js.map +1 -1
  72. package/dist/task/task.d.ts +3 -0
  73. package/dist/task/task.d.ts.map +1 -1
  74. package/dist/task/task.js +5 -1
  75. package/dist/task/task.js.map +1 -1
  76. package/dist/team/messaging.d.ts.map +1 -1
  77. package/dist/team/messaging.js +2 -1
  78. package/dist/team/messaging.js.map +1 -1
  79. package/dist/tool/text-tool-extractor.d.ts +32 -0
  80. package/dist/tool/text-tool-extractor.d.ts.map +1 -0
  81. package/dist/tool/text-tool-extractor.js +187 -0
  82. package/dist/tool/text-tool-extractor.js.map +1 -0
  83. package/dist/types.d.ts +167 -7
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/utils/trace.d.ts +12 -0
  86. package/dist/utils/trace.d.ts.map +1 -0
  87. package/dist/utils/trace.js +30 -0
  88. package/dist/utils/trace.js.map +1 -0
  89. package/examples/05-copilot-test.ts +49 -0
  90. package/examples/06-local-model.ts +200 -0
  91. package/examples/07-fan-out-aggregate.ts +209 -0
  92. package/examples/08-gemma4-local.ts +192 -0
  93. package/examples/09-structured-output.ts +73 -0
  94. package/examples/10-task-retry.ts +132 -0
  95. package/examples/11-trace-observability.ts +133 -0
  96. package/examples/12-grok.ts +154 -0
  97. package/examples/13-gemini.ts +48 -0
  98. package/package.json +14 -3
  99. package/src/agent/agent.ts +273 -15
  100. package/src/agent/loop-detector.ts +137 -0
  101. package/src/agent/pool.ts +9 -2
  102. package/src/agent/runner.ts +148 -19
  103. package/src/agent/structured-output.ts +126 -0
  104. package/src/index.ts +17 -1
  105. package/src/llm/adapter.ts +29 -5
  106. package/src/llm/anthropic.ts +2 -1
  107. package/src/llm/copilot.ts +552 -0
  108. package/src/llm/gemini.ts +378 -0
  109. package/src/llm/grok.ts +29 -0
  110. package/src/llm/openai-common.ts +294 -0
  111. package/src/llm/openai.ts +31 -261
  112. package/src/orchestrator/orchestrator.ts +260 -40
  113. package/src/task/queue.ts +74 -4
  114. package/src/task/task.ts +8 -1
  115. package/src/team/messaging.ts +3 -1
  116. package/src/tool/text-tool-extractor.ts +219 -0
  117. package/src/types.ts +186 -6
  118. package/src/utils/trace.ts +34 -0
  119. package/tests/agent-hooks.test.ts +473 -0
  120. package/tests/agent-pool.test.ts +212 -0
  121. package/tests/approval.test.ts +464 -0
  122. package/tests/built-in-tools.test.ts +393 -0
  123. package/tests/gemini-adapter.test.ts +97 -0
  124. package/tests/grok-adapter.test.ts +74 -0
  125. package/tests/llm-adapters.test.ts +357 -0
  126. package/tests/loop-detection.test.ts +456 -0
  127. package/tests/openai-fallback.test.ts +159 -0
  128. package/tests/orchestrator.test.ts +281 -0
  129. package/tests/scheduler.test.ts +221 -0
  130. package/tests/semaphore.test.ts +57 -0
  131. package/tests/shared-memory.test.ts +122 -0
  132. package/tests/structured-output.test.ts +331 -0
  133. package/tests/task-queue.test.ts +244 -0
  134. package/tests/task-retry.test.ts +368 -0
  135. package/tests/task-utils.test.ts +155 -0
  136. package/tests/team-messaging.test.ts +329 -0
  137. package/tests/text-tool-extractor.test.ts +170 -0
  138. package/tests/tool-executor.test.ts +193 -0
  139. package/tests/trace.test.ts +453 -0
  140. package/vitest.config.ts +9 -0
@@ -0,0 +1,473 @@
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
+ })
@@ -0,0 +1,212 @@
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
+ })