@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
|
@@ -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
|
+
})
|