@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,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
+ })
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { z } from 'zod'
3
+ import { ToolRegistry, defineTool } from '../src/tool/framework.js'
4
+ import { ToolExecutor } from '../src/tool/executor.js'
5
+ import type { ToolUseContext } from '../src/types.js'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const dummyContext: ToolUseContext = {
12
+ agent: { name: 'test-agent', role: 'tester', model: 'test-model' },
13
+ }
14
+
15
+ function echoTool() {
16
+ return defineTool({
17
+ name: 'echo',
18
+ description: 'Echoes the message.',
19
+ inputSchema: z.object({ message: z.string() }),
20
+ execute: async ({ message }) => ({ data: message, isError: false }),
21
+ })
22
+ }
23
+
24
+ function failTool() {
25
+ return defineTool({
26
+ name: 'fail',
27
+ description: 'Always throws.',
28
+ inputSchema: z.object({}),
29
+ execute: async () => {
30
+ throw new Error('intentional failure')
31
+ },
32
+ })
33
+ }
34
+
35
+ function makeExecutor(...tools: ReturnType<typeof defineTool>[]) {
36
+ const registry = new ToolRegistry()
37
+ for (const t of tools) registry.register(t)
38
+ return { executor: new ToolExecutor(registry), registry }
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Tests
43
+ // ---------------------------------------------------------------------------
44
+
45
+ describe('ToolExecutor', () => {
46
+ // -------------------------------------------------------------------------
47
+ // Single execution
48
+ // -------------------------------------------------------------------------
49
+
50
+ it('executes a tool and returns its result', async () => {
51
+ const { executor } = makeExecutor(echoTool())
52
+ const result = await executor.execute('echo', { message: 'hello' }, dummyContext)
53
+ expect(result.data).toBe('hello')
54
+ expect(result.isError).toBeFalsy()
55
+ })
56
+
57
+ it('returns an error result for an unknown tool', async () => {
58
+ const { executor } = makeExecutor()
59
+ const result = await executor.execute('ghost', {}, dummyContext)
60
+ expect(result.isError).toBe(true)
61
+ expect(result.data).toContain('not registered')
62
+ })
63
+
64
+ it('returns an error result when Zod validation fails', async () => {
65
+ const { executor } = makeExecutor(echoTool())
66
+ // 'message' is required but missing
67
+ const result = await executor.execute('echo', {}, dummyContext)
68
+ expect(result.isError).toBe(true)
69
+ expect(result.data).toContain('Invalid input')
70
+ })
71
+
72
+ it('catches tool execution errors and returns them as error results', async () => {
73
+ const { executor } = makeExecutor(failTool())
74
+ const result = await executor.execute('fail', {}, dummyContext)
75
+ expect(result.isError).toBe(true)
76
+ expect(result.data).toContain('intentional failure')
77
+ })
78
+
79
+ it('returns an error result when aborted before execution', async () => {
80
+ const { executor } = makeExecutor(echoTool())
81
+ const controller = new AbortController()
82
+ controller.abort()
83
+
84
+ const result = await executor.execute(
85
+ 'echo',
86
+ { message: 'hi' },
87
+ { ...dummyContext, abortSignal: controller.signal },
88
+ )
89
+ expect(result.isError).toBe(true)
90
+ expect(result.data).toContain('aborted')
91
+ })
92
+
93
+ // -------------------------------------------------------------------------
94
+ // Batch execution
95
+ // -------------------------------------------------------------------------
96
+
97
+ it('executeBatch runs multiple tools and returns a map of results', async () => {
98
+ const { executor } = makeExecutor(echoTool())
99
+ const results = await executor.executeBatch(
100
+ [
101
+ { id: 'c1', name: 'echo', input: { message: 'a' } },
102
+ { id: 'c2', name: 'echo', input: { message: 'b' } },
103
+ ],
104
+ dummyContext,
105
+ )
106
+
107
+ expect(results.size).toBe(2)
108
+ expect(results.get('c1')!.data).toBe('a')
109
+ expect(results.get('c2')!.data).toBe('b')
110
+ })
111
+
112
+ it('executeBatch isolates errors — one failure does not affect others', async () => {
113
+ const { executor } = makeExecutor(echoTool(), failTool())
114
+ const results = await executor.executeBatch(
115
+ [
116
+ { id: 'ok', name: 'echo', input: { message: 'fine' } },
117
+ { id: 'bad', name: 'fail', input: {} },
118
+ ],
119
+ dummyContext,
120
+ )
121
+
122
+ expect(results.get('ok')!.isError).toBeFalsy()
123
+ expect(results.get('bad')!.isError).toBe(true)
124
+ })
125
+
126
+ // -------------------------------------------------------------------------
127
+ // Concurrency control
128
+ // -------------------------------------------------------------------------
129
+
130
+ it('respects maxConcurrency limit', async () => {
131
+ let peak = 0
132
+ let running = 0
133
+
134
+ const trackTool = defineTool({
135
+ name: 'track',
136
+ description: 'Tracks concurrency.',
137
+ inputSchema: z.object({}),
138
+ execute: async () => {
139
+ running++
140
+ peak = Math.max(peak, running)
141
+ await new Promise((r) => setTimeout(r, 50))
142
+ running--
143
+ return { data: 'ok', isError: false }
144
+ },
145
+ })
146
+
147
+ const registry = new ToolRegistry()
148
+ registry.register(trackTool)
149
+ const executor = new ToolExecutor(registry, { maxConcurrency: 2 })
150
+
151
+ await executor.executeBatch(
152
+ Array.from({ length: 5 }, (_, i) => ({ id: `t${i}`, name: 'track', input: {} })),
153
+ dummyContext,
154
+ )
155
+
156
+ expect(peak).toBeLessThanOrEqual(2)
157
+ })
158
+ })
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // ToolRegistry
162
+ // ---------------------------------------------------------------------------
163
+
164
+ describe('ToolRegistry', () => {
165
+ it('registers and retrieves a tool', () => {
166
+ const registry = new ToolRegistry()
167
+ registry.register(echoTool())
168
+ expect(registry.get('echo')).toBeDefined()
169
+ expect(registry.has('echo')).toBe(true)
170
+ })
171
+
172
+ it('throws on duplicate registration', () => {
173
+ const registry = new ToolRegistry()
174
+ registry.register(echoTool())
175
+ expect(() => registry.register(echoTool())).toThrow('already registered')
176
+ })
177
+
178
+ it('unregister removes the tool', () => {
179
+ const registry = new ToolRegistry()
180
+ registry.register(echoTool())
181
+ registry.unregister('echo')
182
+ expect(registry.has('echo')).toBe(false)
183
+ })
184
+
185
+ it('toToolDefs produces JSON schema representations', () => {
186
+ const registry = new ToolRegistry()
187
+ registry.register(echoTool())
188
+ const defs = registry.toToolDefs()
189
+ expect(defs).toHaveLength(1)
190
+ expect(defs[0].name).toBe('echo')
191
+ expect(defs[0].inputSchema).toHaveProperty('properties')
192
+ })
193
+ })