@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,329 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { MessageBus } from '../src/team/messaging.js'
|
|
3
|
+
import { Team } from '../src/team/team.js'
|
|
4
|
+
import type { AgentConfig, TeamConfig } from '../src/types.js'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function agent(name: string): AgentConfig {
|
|
11
|
+
return { name, model: 'test-model', systemPrompt: `You are ${name}.` }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function teamConfig(opts?: Partial<TeamConfig>): TeamConfig {
|
|
15
|
+
return {
|
|
16
|
+
name: 'test-team',
|
|
17
|
+
agents: [agent('alice'), agent('bob')],
|
|
18
|
+
...opts,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ===========================================================================
|
|
23
|
+
// MessageBus
|
|
24
|
+
// ===========================================================================
|
|
25
|
+
|
|
26
|
+
describe('MessageBus', () => {
|
|
27
|
+
describe('send / getAll / getUnread', () => {
|
|
28
|
+
it('delivers a point-to-point message', () => {
|
|
29
|
+
const bus = new MessageBus()
|
|
30
|
+
bus.send('alice', 'bob', 'hello')
|
|
31
|
+
|
|
32
|
+
const msgs = bus.getAll('bob')
|
|
33
|
+
expect(msgs).toHaveLength(1)
|
|
34
|
+
expect(msgs[0]!.from).toBe('alice')
|
|
35
|
+
expect(msgs[0]!.to).toBe('bob')
|
|
36
|
+
expect(msgs[0]!.content).toBe('hello')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not deliver messages to sender', () => {
|
|
40
|
+
const bus = new MessageBus()
|
|
41
|
+
bus.send('alice', 'bob', 'hello')
|
|
42
|
+
expect(bus.getAll('alice')).toHaveLength(0)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('tracks unread state', () => {
|
|
46
|
+
const bus = new MessageBus()
|
|
47
|
+
const msg = bus.send('alice', 'bob', 'hello')
|
|
48
|
+
|
|
49
|
+
expect(bus.getUnread('bob')).toHaveLength(1)
|
|
50
|
+
|
|
51
|
+
bus.markRead('bob', [msg.id])
|
|
52
|
+
expect(bus.getUnread('bob')).toHaveLength(0)
|
|
53
|
+
// getAll still returns the message
|
|
54
|
+
expect(bus.getAll('bob')).toHaveLength(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('markRead with empty array is a no-op', () => {
|
|
58
|
+
const bus = new MessageBus()
|
|
59
|
+
bus.markRead('bob', [])
|
|
60
|
+
expect(bus.getUnread('bob')).toHaveLength(0)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('broadcast', () => {
|
|
65
|
+
it('delivers to all except sender', () => {
|
|
66
|
+
const bus = new MessageBus()
|
|
67
|
+
// Set up subscribers so the bus knows about agents
|
|
68
|
+
bus.subscribe('alice', () => {})
|
|
69
|
+
bus.subscribe('bob', () => {})
|
|
70
|
+
bus.subscribe('carol', () => {})
|
|
71
|
+
|
|
72
|
+
bus.broadcast('alice', 'everyone listen')
|
|
73
|
+
|
|
74
|
+
expect(bus.getAll('bob')).toHaveLength(1)
|
|
75
|
+
expect(bus.getAll('carol')).toHaveLength(1)
|
|
76
|
+
expect(bus.getAll('alice')).toHaveLength(0) // sender excluded
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('broadcast message has to === "*"', () => {
|
|
80
|
+
const bus = new MessageBus()
|
|
81
|
+
const msg = bus.broadcast('alice', 'hi')
|
|
82
|
+
expect(msg.to).toBe('*')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('subscribe', () => {
|
|
87
|
+
it('notifies subscriber on new direct message', () => {
|
|
88
|
+
const bus = new MessageBus()
|
|
89
|
+
const received: string[] = []
|
|
90
|
+
bus.subscribe('bob', (msg) => received.push(msg.content))
|
|
91
|
+
|
|
92
|
+
bus.send('alice', 'bob', 'ping')
|
|
93
|
+
|
|
94
|
+
expect(received).toEqual(['ping'])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('notifies subscriber on broadcast', () => {
|
|
98
|
+
const bus = new MessageBus()
|
|
99
|
+
const received: string[] = []
|
|
100
|
+
bus.subscribe('bob', (msg) => received.push(msg.content))
|
|
101
|
+
|
|
102
|
+
bus.broadcast('alice', 'broadcast msg')
|
|
103
|
+
|
|
104
|
+
expect(received).toEqual(['broadcast msg'])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not notify sender of own broadcast', () => {
|
|
108
|
+
const bus = new MessageBus()
|
|
109
|
+
const received: string[] = []
|
|
110
|
+
bus.subscribe('alice', (msg) => received.push(msg.content))
|
|
111
|
+
|
|
112
|
+
bus.broadcast('alice', 'my broadcast')
|
|
113
|
+
|
|
114
|
+
expect(received).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('unsubscribe stops notifications', () => {
|
|
118
|
+
const bus = new MessageBus()
|
|
119
|
+
const received: string[] = []
|
|
120
|
+
const unsub = bus.subscribe('bob', (msg) => received.push(msg.content))
|
|
121
|
+
|
|
122
|
+
bus.send('alice', 'bob', 'first')
|
|
123
|
+
unsub()
|
|
124
|
+
bus.send('alice', 'bob', 'second')
|
|
125
|
+
|
|
126
|
+
expect(received).toEqual(['first'])
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('getConversation', () => {
|
|
131
|
+
it('returns messages in both directions', () => {
|
|
132
|
+
const bus = new MessageBus()
|
|
133
|
+
bus.send('alice', 'bob', 'hello')
|
|
134
|
+
bus.send('bob', 'alice', 'hi back')
|
|
135
|
+
bus.send('alice', 'carol', 'unrelated')
|
|
136
|
+
|
|
137
|
+
const convo = bus.getConversation('alice', 'bob')
|
|
138
|
+
expect(convo).toHaveLength(2)
|
|
139
|
+
expect(convo[0]!.content).toBe('hello')
|
|
140
|
+
expect(convo[1]!.content).toBe('hi back')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// ===========================================================================
|
|
146
|
+
// Team
|
|
147
|
+
// ===========================================================================
|
|
148
|
+
|
|
149
|
+
describe('Team', () => {
|
|
150
|
+
describe('agent roster', () => {
|
|
151
|
+
it('returns all agents via getAgents()', () => {
|
|
152
|
+
const team = new Team(teamConfig())
|
|
153
|
+
const agents = team.getAgents()
|
|
154
|
+
expect(agents).toHaveLength(2)
|
|
155
|
+
expect(agents.map(a => a.name)).toEqual(['alice', 'bob'])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('looks up agent by name', () => {
|
|
159
|
+
const team = new Team(teamConfig())
|
|
160
|
+
expect(team.getAgent('alice')?.name).toBe('alice')
|
|
161
|
+
expect(team.getAgent('nonexistent')).toBeUndefined()
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('messaging', () => {
|
|
166
|
+
it('sends point-to-point messages and emits event', () => {
|
|
167
|
+
const team = new Team(teamConfig())
|
|
168
|
+
const events: unknown[] = []
|
|
169
|
+
team.on('message', (d) => events.push(d))
|
|
170
|
+
|
|
171
|
+
team.sendMessage('alice', 'bob', 'hey')
|
|
172
|
+
|
|
173
|
+
expect(team.getMessages('bob')).toHaveLength(1)
|
|
174
|
+
expect(team.getMessages('bob')[0]!.content).toBe('hey')
|
|
175
|
+
expect(events).toHaveLength(1)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('broadcasts and emits broadcast event', () => {
|
|
179
|
+
const team = new Team(teamConfig())
|
|
180
|
+
const events: unknown[] = []
|
|
181
|
+
team.on('broadcast', (d) => events.push(d))
|
|
182
|
+
|
|
183
|
+
team.broadcast('alice', 'all hands')
|
|
184
|
+
|
|
185
|
+
expect(events).toHaveLength(1)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('task management', () => {
|
|
190
|
+
it('adds and retrieves tasks', () => {
|
|
191
|
+
const team = new Team(teamConfig())
|
|
192
|
+
const task = team.addTask({
|
|
193
|
+
title: 'Do something',
|
|
194
|
+
description: 'Details here',
|
|
195
|
+
status: 'pending',
|
|
196
|
+
assignee: 'alice',
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
expect(task.id).toBeDefined()
|
|
200
|
+
expect(task.title).toBe('Do something')
|
|
201
|
+
expect(team.getTasks()).toHaveLength(1)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('filters tasks by assignee', () => {
|
|
205
|
+
const team = new Team(teamConfig())
|
|
206
|
+
team.addTask({ title: 't1', description: 'd', status: 'pending', assignee: 'alice' })
|
|
207
|
+
team.addTask({ title: 't2', description: 'd', status: 'pending', assignee: 'bob' })
|
|
208
|
+
|
|
209
|
+
expect(team.getTasksByAssignee('alice')).toHaveLength(1)
|
|
210
|
+
expect(team.getTasksByAssignee('alice')[0]!.title).toBe('t1')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('updates a task', () => {
|
|
214
|
+
const team = new Team(teamConfig())
|
|
215
|
+
const task = team.addTask({ title: 't1', description: 'd', status: 'pending' })
|
|
216
|
+
|
|
217
|
+
const updated = team.updateTask(task.id, { status: 'in_progress' })
|
|
218
|
+
expect(updated.status).toBe('in_progress')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('getNextTask prefers assigned tasks', () => {
|
|
222
|
+
const team = new Team(teamConfig())
|
|
223
|
+
team.addTask({ title: 'unassigned', description: 'd', status: 'pending' })
|
|
224
|
+
team.addTask({ title: 'for alice', description: 'd', status: 'pending', assignee: 'alice' })
|
|
225
|
+
|
|
226
|
+
const next = team.getNextTask('alice')
|
|
227
|
+
expect(next?.title).toBe('for alice')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('getNextTask falls back to unassigned', () => {
|
|
231
|
+
const team = new Team(teamConfig())
|
|
232
|
+
team.addTask({ title: 'unassigned', description: 'd', status: 'pending' })
|
|
233
|
+
|
|
234
|
+
const next = team.getNextTask('alice')
|
|
235
|
+
expect(next?.title).toBe('unassigned')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('getNextTask returns undefined when no tasks available', () => {
|
|
239
|
+
const team = new Team(teamConfig())
|
|
240
|
+
expect(team.getNextTask('alice')).toBeUndefined()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('preserves non-default status on addTask', () => {
|
|
244
|
+
const team = new Team(teamConfig())
|
|
245
|
+
const task = team.addTask({
|
|
246
|
+
title: 'blocked task',
|
|
247
|
+
description: 'd',
|
|
248
|
+
status: 'blocked',
|
|
249
|
+
result: 'waiting on dep',
|
|
250
|
+
})
|
|
251
|
+
expect(task.status).toBe('blocked')
|
|
252
|
+
expect(task.result).toBe('waiting on dep')
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('shared memory', () => {
|
|
257
|
+
it('returns undefined when sharedMemory is disabled', () => {
|
|
258
|
+
const team = new Team(teamConfig({ sharedMemory: false }))
|
|
259
|
+
expect(team.getSharedMemory()).toBeUndefined()
|
|
260
|
+
expect(team.getSharedMemoryInstance()).toBeUndefined()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('returns a MemoryStore when sharedMemory is enabled', () => {
|
|
264
|
+
const team = new Team(teamConfig({ sharedMemory: true }))
|
|
265
|
+
const store = team.getSharedMemory()
|
|
266
|
+
expect(store).toBeDefined()
|
|
267
|
+
expect(typeof store!.get).toBe('function')
|
|
268
|
+
expect(typeof store!.set).toBe('function')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('returns SharedMemory instance', () => {
|
|
272
|
+
const team = new Team(teamConfig({ sharedMemory: true }))
|
|
273
|
+
const mem = team.getSharedMemoryInstance()
|
|
274
|
+
expect(mem).toBeDefined()
|
|
275
|
+
expect(typeof mem!.write).toBe('function')
|
|
276
|
+
expect(typeof mem!.getSummary).toBe('function')
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
describe('events', () => {
|
|
281
|
+
it('emits task:ready when a pending task becomes runnable', () => {
|
|
282
|
+
const team = new Team(teamConfig())
|
|
283
|
+
const events: unknown[] = []
|
|
284
|
+
team.on('task:ready', (d) => events.push(d))
|
|
285
|
+
|
|
286
|
+
team.addTask({ title: 't1', description: 'd', status: 'pending' })
|
|
287
|
+
|
|
288
|
+
// task:ready is fired by the queue when a task with no deps is added
|
|
289
|
+
expect(events.length).toBeGreaterThanOrEqual(1)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('emits custom events via emit()', () => {
|
|
293
|
+
const team = new Team(teamConfig())
|
|
294
|
+
const received: unknown[] = []
|
|
295
|
+
team.on('custom:event', (d) => received.push(d))
|
|
296
|
+
|
|
297
|
+
team.emit('custom:event', { foo: 'bar' })
|
|
298
|
+
|
|
299
|
+
expect(received).toEqual([{ foo: 'bar' }])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('unsubscribe works', () => {
|
|
303
|
+
const team = new Team(teamConfig())
|
|
304
|
+
const received: unknown[] = []
|
|
305
|
+
const unsub = team.on('custom:event', (d) => received.push(d))
|
|
306
|
+
|
|
307
|
+
team.emit('custom:event', 'first')
|
|
308
|
+
unsub()
|
|
309
|
+
team.emit('custom:event', 'second')
|
|
310
|
+
|
|
311
|
+
expect(received).toEqual(['first'])
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('bridges task:complete and task:failed from the queue', () => {
|
|
315
|
+
// These events fire via queue.complete()/queue.fail(), which happen
|
|
316
|
+
// during orchestration. Team only exposes updateTask() which calls
|
|
317
|
+
// queue.update() — no event is emitted. We verify the bridge is
|
|
318
|
+
// wired correctly by checking that task:ready fires on addTask.
|
|
319
|
+
const team = new Team(teamConfig())
|
|
320
|
+
const readyEvents: unknown[] = []
|
|
321
|
+
team.on('task:ready', (d) => readyEvents.push(d))
|
|
322
|
+
|
|
323
|
+
team.addTask({ title: 't1', description: 'd', status: 'pending' })
|
|
324
|
+
|
|
325
|
+
// task:ready fires because a pending task with no deps is immediately ready
|
|
326
|
+
expect(readyEvents.length).toBeGreaterThanOrEqual(1)
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { extractToolCallsFromText } from '../src/tool/text-tool-extractor.js'
|
|
3
|
+
|
|
4
|
+
const TOOLS = ['bash', 'file_read', 'file_write']
|
|
5
|
+
|
|
6
|
+
describe('extractToolCallsFromText', () => {
|
|
7
|
+
// -------------------------------------------------------------------------
|
|
8
|
+
// No tool calls
|
|
9
|
+
// -------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
it('returns empty array for empty text', () => {
|
|
12
|
+
expect(extractToolCallsFromText('', TOOLS)).toEqual([])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns empty array for plain text with no JSON', () => {
|
|
16
|
+
expect(extractToolCallsFromText('Hello, I am a helpful assistant.', TOOLS)).toEqual([])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns empty array for JSON that does not match any known tool', () => {
|
|
20
|
+
const text = '{"name": "unknown_tool", "arguments": {"x": 1}}'
|
|
21
|
+
expect(extractToolCallsFromText(text, TOOLS)).toEqual([])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// -------------------------------------------------------------------------
|
|
25
|
+
// Bare JSON
|
|
26
|
+
// -------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
it('extracts a bare JSON tool call with "arguments"', () => {
|
|
29
|
+
const text = 'I will run this command:\n{"name": "bash", "arguments": {"command": "ls -la"}}'
|
|
30
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
31
|
+
expect(result).toHaveLength(1)
|
|
32
|
+
expect(result[0]!.type).toBe('tool_use')
|
|
33
|
+
expect(result[0]!.name).toBe('bash')
|
|
34
|
+
expect(result[0]!.input).toEqual({ command: 'ls -la' })
|
|
35
|
+
expect(result[0]!.id).toMatch(/^extracted_call_/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('extracts a bare JSON tool call with "parameters"', () => {
|
|
39
|
+
const text = '{"name": "file_read", "parameters": {"path": "/tmp/test.txt"}}'
|
|
40
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
41
|
+
expect(result).toHaveLength(1)
|
|
42
|
+
expect(result[0]!.name).toBe('file_read')
|
|
43
|
+
expect(result[0]!.input).toEqual({ path: '/tmp/test.txt' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('extracts a bare JSON tool call with "input"', () => {
|
|
47
|
+
const text = '{"name": "bash", "input": {"command": "pwd"}}'
|
|
48
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
49
|
+
expect(result).toHaveLength(1)
|
|
50
|
+
expect(result[0]!.name).toBe('bash')
|
|
51
|
+
expect(result[0]!.input).toEqual({ command: 'pwd' })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('extracts { function: { name, arguments } } shape', () => {
|
|
55
|
+
const text = '{"function": {"name": "bash", "arguments": {"command": "echo hi"}}}'
|
|
56
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
57
|
+
expect(result).toHaveLength(1)
|
|
58
|
+
expect(result[0]!.name).toBe('bash')
|
|
59
|
+
expect(result[0]!.input).toEqual({ command: 'echo hi' })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('handles string-encoded arguments', () => {
|
|
63
|
+
const text = '{"name": "bash", "arguments": "{\\"command\\": \\"ls\\"}"}'
|
|
64
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
65
|
+
expect(result).toHaveLength(1)
|
|
66
|
+
expect(result[0]!.input).toEqual({ command: 'ls' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// -------------------------------------------------------------------------
|
|
70
|
+
// Multiple tool calls
|
|
71
|
+
// -------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
it('extracts multiple tool calls from text', () => {
|
|
74
|
+
const text = `Let me do two things:
|
|
75
|
+
{"name": "bash", "arguments": {"command": "ls"}}
|
|
76
|
+
And then:
|
|
77
|
+
{"name": "file_read", "arguments": {"path": "/tmp/x"}}`
|
|
78
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
79
|
+
expect(result).toHaveLength(2)
|
|
80
|
+
expect(result[0]!.name).toBe('bash')
|
|
81
|
+
expect(result[1]!.name).toBe('file_read')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// -------------------------------------------------------------------------
|
|
85
|
+
// Code fence wrapped
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
it('extracts tool call from markdown code fence', () => {
|
|
89
|
+
const text = 'Here is the tool call:\n```json\n{"name": "bash", "arguments": {"command": "whoami"}}\n```'
|
|
90
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
91
|
+
expect(result).toHaveLength(1)
|
|
92
|
+
expect(result[0]!.name).toBe('bash')
|
|
93
|
+
expect(result[0]!.input).toEqual({ command: 'whoami' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('extracts tool call from code fence without language tag', () => {
|
|
97
|
+
const text = '```\n{"name": "file_write", "arguments": {"path": "/tmp/a.txt", "content": "hi"}}\n```'
|
|
98
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
99
|
+
expect(result).toHaveLength(1)
|
|
100
|
+
expect(result[0]!.name).toBe('file_write')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// -------------------------------------------------------------------------
|
|
104
|
+
// Hermes format
|
|
105
|
+
// -------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
it('extracts tool call from <tool_call> tags', () => {
|
|
108
|
+
const text = '<tool_call>\n{"name": "bash", "arguments": {"command": "date"}}\n</tool_call>'
|
|
109
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
110
|
+
expect(result).toHaveLength(1)
|
|
111
|
+
expect(result[0]!.name).toBe('bash')
|
|
112
|
+
expect(result[0]!.input).toEqual({ command: 'date' })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('extracts multiple hermes tool calls', () => {
|
|
116
|
+
const text = `<tool_call>{"name": "bash", "arguments": {"command": "ls"}}</tool_call>
|
|
117
|
+
Some text in between
|
|
118
|
+
<tool_call>{"name": "file_read", "arguments": {"path": "/tmp/x"}}</tool_call>`
|
|
119
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
120
|
+
expect(result).toHaveLength(2)
|
|
121
|
+
expect(result[0]!.name).toBe('bash')
|
|
122
|
+
expect(result[1]!.name).toBe('file_read')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// -------------------------------------------------------------------------
|
|
126
|
+
// Edge cases
|
|
127
|
+
// -------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
it('skips malformed JSON gracefully', () => {
|
|
130
|
+
const text = '{"name": "bash", "arguments": {invalid json}}'
|
|
131
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
132
|
+
expect(result).toEqual([])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('skips JSON objects without a name field', () => {
|
|
136
|
+
const text = '{"command": "ls", "arguments": {"x": 1}}'
|
|
137
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
138
|
+
expect(result).toEqual([])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('works with empty knownToolNames (no whitelist filtering)', () => {
|
|
142
|
+
const text = '{"name": "anything", "arguments": {"x": 1}}'
|
|
143
|
+
const result = extractToolCallsFromText(text, [])
|
|
144
|
+
expect(result).toHaveLength(1)
|
|
145
|
+
expect(result[0]!.name).toBe('anything')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('generates unique IDs for each extracted call', () => {
|
|
149
|
+
const text = `{"name": "bash", "arguments": {"command": "a"}}
|
|
150
|
+
{"name": "bash", "arguments": {"command": "b"}}`
|
|
151
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
152
|
+
expect(result).toHaveLength(2)
|
|
153
|
+
expect(result[0]!.id).not.toBe(result[1]!.id)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('handles tool call with no arguments', () => {
|
|
157
|
+
const text = '{"name": "bash"}'
|
|
158
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
159
|
+
expect(result).toHaveLength(1)
|
|
160
|
+
expect(result[0]!.input).toEqual({})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('handles text with nested JSON objects that are not tool calls', () => {
|
|
164
|
+
const text = `Here is some config: {"port": 3000, "host": "localhost"}
|
|
165
|
+
And a tool call: {"name": "bash", "arguments": {"command": "ls"}}`
|
|
166
|
+
const result = extractToolCallsFromText(text, TOOLS)
|
|
167
|
+
expect(result).toHaveLength(1)
|
|
168
|
+
expect(result[0]!.name).toBe('bash')
|
|
169
|
+
})
|
|
170
|
+
})
|