@jackchen_me/open-multi-agent 0.2.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/workflows/ci.yml +1 -1
- package/CLAUDE.md +11 -3
- package/README.md +87 -20
- package/README_zh.md +85 -25
- package/dist/agent/agent.d.ts +15 -1
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +144 -10
- 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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/llm/adapter.d.ts +4 -1
- package/dist/llm/adapter.d.ts.map +1 -1
- package/dist/llm/adapter.js +11 -0
- package/dist/llm/adapter.js.map +1 -1
- package/dist/llm/copilot.d.ts.map +1 -1
- package/dist/llm/copilot.js +2 -1
- package/dist/llm/copilot.js.map +1 -1
- 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 +8 -1
- package/dist/llm/openai-common.d.ts.map +1 -1
- package/dist/llm/openai-common.js +35 -2
- package/dist/llm/openai-common.js.map +1 -1
- package/dist/llm/openai.d.ts +1 -1
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +20 -2
- package/dist/llm/openai.js.map +1 -1
- package/dist/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.js +89 -9
- 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 +69 -2
- package/dist/task/queue.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 +139 -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/06-local-model.ts +1 -0
- package/examples/08-gemma4-local.ts +76 -87
- 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 +11 -1
- package/src/agent/agent.ts +159 -10
- package/src/agent/loop-detector.ts +137 -0
- package/src/agent/pool.ts +9 -2
- package/src/agent/runner.ts +148 -19
- package/src/index.ts +15 -0
- package/src/llm/adapter.ts +12 -1
- package/src/llm/copilot.ts +2 -1
- package/src/llm/gemini.ts +378 -0
- package/src/llm/grok.ts +29 -0
- package/src/llm/openai-common.ts +41 -2
- package/src/llm/openai.ts +23 -3
- package/src/orchestrator/orchestrator.ts +105 -11
- package/src/task/queue.ts +73 -3
- package/src/tool/text-tool-extractor.ts +219 -0
- package/src/types.ts +157 -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/team-messaging.test.ts +329 -0
- package/tests/text-tool-extractor.test.ts +170 -0
- package/tests/trace.test.ts +453 -0
- package/vitest.config.ts +9 -0
- package/examples/09-gemma4-auto-orchestration.ts +0 -162
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { createAdapter } from '../src/llm/adapter.js'
|
|
3
|
+
import {
|
|
4
|
+
toOpenAITool,
|
|
5
|
+
toOpenAIMessages,
|
|
6
|
+
fromOpenAICompletion,
|
|
7
|
+
normalizeFinishReason,
|
|
8
|
+
buildOpenAIMessageList,
|
|
9
|
+
} from '../src/llm/openai-common.js'
|
|
10
|
+
import type {
|
|
11
|
+
ContentBlock,
|
|
12
|
+
LLMMessage,
|
|
13
|
+
LLMToolDef,
|
|
14
|
+
} from '../src/types.js'
|
|
15
|
+
import type { ChatCompletion } from 'openai/resources/chat/completions/index.js'
|
|
16
|
+
|
|
17
|
+
// ===========================================================================
|
|
18
|
+
// createAdapter factory
|
|
19
|
+
// ===========================================================================
|
|
20
|
+
|
|
21
|
+
describe('createAdapter', () => {
|
|
22
|
+
it('creates an anthropic adapter', async () => {
|
|
23
|
+
const adapter = await createAdapter('anthropic', 'test-key')
|
|
24
|
+
expect(adapter.name).toBe('anthropic')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('creates an openai adapter', async () => {
|
|
28
|
+
const adapter = await createAdapter('openai', 'test-key')
|
|
29
|
+
expect(adapter.name).toBe('openai')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('creates a grok adapter', async () => {
|
|
33
|
+
const adapter = await createAdapter('grok', 'test-key')
|
|
34
|
+
expect(adapter.name).toBe('grok')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('creates a gemini adapter', async () => {
|
|
38
|
+
const adapter = await createAdapter('gemini', 'test-key')
|
|
39
|
+
expect(adapter.name).toBe('gemini')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('throws on unknown provider', async () => {
|
|
43
|
+
await expect(
|
|
44
|
+
createAdapter('unknown' as any, 'test-key'),
|
|
45
|
+
).rejects.toThrow('Unsupported')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// ===========================================================================
|
|
50
|
+
// OpenAI common helpers
|
|
51
|
+
// ===========================================================================
|
|
52
|
+
|
|
53
|
+
describe('normalizeFinishReason', () => {
|
|
54
|
+
it('maps stop → end_turn', () => {
|
|
55
|
+
expect(normalizeFinishReason('stop')).toBe('end_turn')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('maps tool_calls → tool_use', () => {
|
|
59
|
+
expect(normalizeFinishReason('tool_calls')).toBe('tool_use')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('maps length → max_tokens', () => {
|
|
63
|
+
expect(normalizeFinishReason('length')).toBe('max_tokens')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('maps content_filter → content_filter', () => {
|
|
67
|
+
expect(normalizeFinishReason('content_filter')).toBe('content_filter')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('passes through unknown reasons', () => {
|
|
71
|
+
expect(normalizeFinishReason('custom_reason')).toBe('custom_reason')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('toOpenAITool', () => {
|
|
76
|
+
it('converts framework tool def to OpenAI format', () => {
|
|
77
|
+
const tool: LLMToolDef = {
|
|
78
|
+
name: 'search',
|
|
79
|
+
description: 'Search the web',
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: { query: { type: 'string' } },
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = toOpenAITool(tool)
|
|
87
|
+
|
|
88
|
+
expect(result.type).toBe('function')
|
|
89
|
+
expect(result.function.name).toBe('search')
|
|
90
|
+
expect(result.function.description).toBe('Search the web')
|
|
91
|
+
expect(result.function.parameters).toEqual(tool.inputSchema)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('toOpenAIMessages', () => {
|
|
96
|
+
it('converts a simple user text message', () => {
|
|
97
|
+
const msgs: LLMMessage[] = [
|
|
98
|
+
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
const result = toOpenAIMessages(msgs)
|
|
102
|
+
|
|
103
|
+
expect(result).toHaveLength(1)
|
|
104
|
+
expect(result[0]).toEqual({ role: 'user', content: 'hello' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('converts assistant message with text', () => {
|
|
108
|
+
const msgs: LLMMessage[] = [
|
|
109
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
const result = toOpenAIMessages(msgs)
|
|
113
|
+
|
|
114
|
+
expect(result[0]).toEqual({ role: 'assistant', content: 'hi', tool_calls: undefined })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('converts assistant message with tool_use into tool_calls', () => {
|
|
118
|
+
const msgs: LLMMessage[] = [
|
|
119
|
+
{
|
|
120
|
+
role: 'assistant',
|
|
121
|
+
content: [
|
|
122
|
+
{ type: 'tool_use', id: 'tc1', name: 'search', input: { query: 'AI' } },
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
const result = toOpenAIMessages(msgs)
|
|
128
|
+
|
|
129
|
+
expect(result).toHaveLength(1)
|
|
130
|
+
const msg = result[0]! as any
|
|
131
|
+
expect(msg.role).toBe('assistant')
|
|
132
|
+
expect(msg.tool_calls).toHaveLength(1)
|
|
133
|
+
expect(msg.tool_calls[0].function.name).toBe('search')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('splits tool_result blocks into separate tool-role messages', () => {
|
|
137
|
+
const msgs: LLMMessage[] = [
|
|
138
|
+
{
|
|
139
|
+
role: 'user',
|
|
140
|
+
content: [
|
|
141
|
+
{ type: 'tool_result', tool_use_id: 'tc1', content: 'result data' },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
const result = toOpenAIMessages(msgs)
|
|
147
|
+
|
|
148
|
+
expect(result).toHaveLength(1)
|
|
149
|
+
expect(result[0]).toEqual({
|
|
150
|
+
role: 'tool',
|
|
151
|
+
tool_call_id: 'tc1',
|
|
152
|
+
content: 'result data',
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('handles mixed user message with text and tool_result', () => {
|
|
157
|
+
const msgs: LLMMessage[] = [
|
|
158
|
+
{
|
|
159
|
+
role: 'user',
|
|
160
|
+
content: [
|
|
161
|
+
{ type: 'text', text: 'context' },
|
|
162
|
+
{ type: 'tool_result', tool_use_id: 'tc1', content: 'data' },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
const result = toOpenAIMessages(msgs)
|
|
168
|
+
|
|
169
|
+
// Should produce a user message for text, then a tool message for result
|
|
170
|
+
expect(result.length).toBeGreaterThanOrEqual(2)
|
|
171
|
+
expect(result[0]).toEqual({ role: 'user', content: 'context' })
|
|
172
|
+
expect(result[1]).toEqual({
|
|
173
|
+
role: 'tool',
|
|
174
|
+
tool_call_id: 'tc1',
|
|
175
|
+
content: 'data',
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('handles image blocks in user messages', () => {
|
|
180
|
+
const msgs: LLMMessage[] = [
|
|
181
|
+
{
|
|
182
|
+
role: 'user',
|
|
183
|
+
content: [
|
|
184
|
+
{ type: 'text', text: 'describe this' },
|
|
185
|
+
{
|
|
186
|
+
type: 'image',
|
|
187
|
+
source: { type: 'base64', media_type: 'image/png', data: 'abc123' },
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
const result = toOpenAIMessages(msgs)
|
|
194
|
+
|
|
195
|
+
expect(result).toHaveLength(1)
|
|
196
|
+
const content = (result[0] as any).content
|
|
197
|
+
expect(content).toHaveLength(2)
|
|
198
|
+
expect(content[1].type).toBe('image_url')
|
|
199
|
+
expect(content[1].image_url.url).toContain('data:image/png;base64,abc123')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('fromOpenAICompletion', () => {
|
|
204
|
+
function makeCompletion(overrides?: Partial<ChatCompletion>): ChatCompletion {
|
|
205
|
+
return {
|
|
206
|
+
id: 'comp-1',
|
|
207
|
+
object: 'chat.completion',
|
|
208
|
+
created: Date.now(),
|
|
209
|
+
model: 'gpt-4',
|
|
210
|
+
choices: [
|
|
211
|
+
{
|
|
212
|
+
index: 0,
|
|
213
|
+
message: { role: 'assistant', content: 'Hello!', refusal: null },
|
|
214
|
+
finish_reason: 'stop',
|
|
215
|
+
logprobs: null,
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
219
|
+
...overrides,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it('converts a simple text completion', () => {
|
|
224
|
+
const result = fromOpenAICompletion(makeCompletion())
|
|
225
|
+
|
|
226
|
+
expect(result.id).toBe('comp-1')
|
|
227
|
+
expect(result.model).toBe('gpt-4')
|
|
228
|
+
expect(result.stop_reason).toBe('end_turn') // 'stop' → 'end_turn'
|
|
229
|
+
expect(result.content).toHaveLength(1)
|
|
230
|
+
expect(result.content[0]).toEqual({ type: 'text', text: 'Hello!' })
|
|
231
|
+
expect(result.usage.input_tokens).toBe(10)
|
|
232
|
+
expect(result.usage.output_tokens).toBe(20)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('converts tool_calls into tool_use blocks', () => {
|
|
236
|
+
const completion = makeCompletion({
|
|
237
|
+
choices: [
|
|
238
|
+
{
|
|
239
|
+
index: 0,
|
|
240
|
+
message: {
|
|
241
|
+
role: 'assistant',
|
|
242
|
+
content: null,
|
|
243
|
+
refusal: null,
|
|
244
|
+
tool_calls: [
|
|
245
|
+
{
|
|
246
|
+
id: 'tc1',
|
|
247
|
+
type: 'function',
|
|
248
|
+
function: {
|
|
249
|
+
name: 'search',
|
|
250
|
+
arguments: '{"query":"test"}',
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
finish_reason: 'tool_calls',
|
|
256
|
+
logprobs: null,
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const result = fromOpenAICompletion(completion)
|
|
262
|
+
|
|
263
|
+
expect(result.stop_reason).toBe('tool_use')
|
|
264
|
+
expect(result.content).toHaveLength(1)
|
|
265
|
+
expect(result.content[0]).toEqual({
|
|
266
|
+
type: 'tool_use',
|
|
267
|
+
id: 'tc1',
|
|
268
|
+
name: 'search',
|
|
269
|
+
input: { query: 'test' },
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('throws when completion has no choices', () => {
|
|
274
|
+
const completion = makeCompletion({ choices: [] })
|
|
275
|
+
expect(() => fromOpenAICompletion(completion)).toThrow('no choices')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('handles malformed tool arguments gracefully', () => {
|
|
279
|
+
const completion = makeCompletion({
|
|
280
|
+
choices: [
|
|
281
|
+
{
|
|
282
|
+
index: 0,
|
|
283
|
+
message: {
|
|
284
|
+
role: 'assistant',
|
|
285
|
+
content: null,
|
|
286
|
+
refusal: null,
|
|
287
|
+
tool_calls: [
|
|
288
|
+
{
|
|
289
|
+
id: 'tc1',
|
|
290
|
+
type: 'function',
|
|
291
|
+
function: {
|
|
292
|
+
name: 'search',
|
|
293
|
+
arguments: 'not-valid-json',
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
finish_reason: 'tool_calls',
|
|
299
|
+
logprobs: null,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const result = fromOpenAICompletion(completion)
|
|
305
|
+
|
|
306
|
+
// Should not throw; input defaults to {}
|
|
307
|
+
expect(result.content[0]).toEqual({
|
|
308
|
+
type: 'tool_use',
|
|
309
|
+
id: 'tc1',
|
|
310
|
+
name: 'search',
|
|
311
|
+
input: {},
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('handles missing usage gracefully', () => {
|
|
316
|
+
const completion = makeCompletion({ usage: undefined })
|
|
317
|
+
|
|
318
|
+
const result = fromOpenAICompletion(completion)
|
|
319
|
+
|
|
320
|
+
expect(result.usage.input_tokens).toBe(0)
|
|
321
|
+
expect(result.usage.output_tokens).toBe(0)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('buildOpenAIMessageList', () => {
|
|
326
|
+
it('prepends system prompt when provided', () => {
|
|
327
|
+
const msgs: LLMMessage[] = [
|
|
328
|
+
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
const result = buildOpenAIMessageList(msgs, 'You are helpful.')
|
|
332
|
+
|
|
333
|
+
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
|
|
334
|
+
expect(result).toHaveLength(2)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('omits system message when systemPrompt is undefined', () => {
|
|
338
|
+
const msgs: LLMMessage[] = [
|
|
339
|
+
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
const result = buildOpenAIMessageList(msgs, undefined)
|
|
343
|
+
|
|
344
|
+
expect(result).toHaveLength(1)
|
|
345
|
+
expect(result[0]).toEqual({ role: 'user', content: 'hi' })
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('omits system message when systemPrompt is empty string', () => {
|
|
349
|
+
const msgs: LLMMessage[] = [
|
|
350
|
+
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
const result = buildOpenAIMessageList(msgs, '')
|
|
354
|
+
|
|
355
|
+
expect(result).toHaveLength(1)
|
|
356
|
+
})
|
|
357
|
+
})
|