@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,281 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { OpenMultiAgent } from '../src/orchestrator/orchestrator.js'
|
|
3
|
+
import type {
|
|
4
|
+
AgentConfig,
|
|
5
|
+
AgentRunResult,
|
|
6
|
+
LLMAdapter,
|
|
7
|
+
LLMChatOptions,
|
|
8
|
+
LLMMessage,
|
|
9
|
+
LLMResponse,
|
|
10
|
+
OrchestratorEvent,
|
|
11
|
+
TeamConfig,
|
|
12
|
+
} from '../src/types.js'
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Mock LLM adapter
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** A controllable fake LLM adapter for orchestrator tests. */
|
|
19
|
+
function createMockAdapter(responses: string[]): LLMAdapter {
|
|
20
|
+
let callIndex = 0
|
|
21
|
+
return {
|
|
22
|
+
name: 'mock',
|
|
23
|
+
async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
|
|
24
|
+
const text = responses[callIndex] ?? 'no response configured'
|
|
25
|
+
callIndex++
|
|
26
|
+
return {
|
|
27
|
+
id: `resp-${callIndex}`,
|
|
28
|
+
content: [{ type: 'text', text }],
|
|
29
|
+
model: options.model,
|
|
30
|
+
stop_reason: 'end_turn',
|
|
31
|
+
usage: { input_tokens: 10, output_tokens: 20 },
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
async *stream() {
|
|
35
|
+
yield { type: 'done' as const, data: {} }
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mock the createAdapter factory to return our mock adapter.
|
|
42
|
+
* We need to do this at the module level because Agent calls createAdapter internally.
|
|
43
|
+
*/
|
|
44
|
+
let mockAdapterResponses: string[] = []
|
|
45
|
+
|
|
46
|
+
vi.mock('../src/llm/adapter.js', () => ({
|
|
47
|
+
createAdapter: async () => {
|
|
48
|
+
let callIndex = 0
|
|
49
|
+
return {
|
|
50
|
+
name: 'mock',
|
|
51
|
+
async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
|
|
52
|
+
const text = mockAdapterResponses[callIndex] ?? 'default mock response'
|
|
53
|
+
callIndex++
|
|
54
|
+
return {
|
|
55
|
+
id: `resp-${callIndex}`,
|
|
56
|
+
content: [{ type: 'text', text }],
|
|
57
|
+
model: options.model ?? 'mock-model',
|
|
58
|
+
stop_reason: 'end_turn',
|
|
59
|
+
usage: { input_tokens: 10, output_tokens: 20 },
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
async *stream() {
|
|
63
|
+
yield { type: 'done' as const, data: {} }
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Helpers
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function agentConfig(name: string): AgentConfig {
|
|
74
|
+
return {
|
|
75
|
+
name,
|
|
76
|
+
model: 'mock-model',
|
|
77
|
+
provider: 'openai',
|
|
78
|
+
systemPrompt: `You are ${name}.`,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function teamCfg(agents?: AgentConfig[]): TeamConfig {
|
|
83
|
+
return {
|
|
84
|
+
name: 'test-team',
|
|
85
|
+
agents: agents ?? [agentConfig('worker-a'), agentConfig('worker-b')],
|
|
86
|
+
sharedMemory: true,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Tests
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe('OpenMultiAgent', () => {
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
mockAdapterResponses = []
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('createTeam', () => {
|
|
100
|
+
it('creates and registers a team', () => {
|
|
101
|
+
const oma = new OpenMultiAgent()
|
|
102
|
+
const team = oma.createTeam('my-team', teamCfg())
|
|
103
|
+
expect(team.name).toBe('test-team')
|
|
104
|
+
expect(oma.getStatus().teams).toBe(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('throws on duplicate team name', () => {
|
|
108
|
+
const oma = new OpenMultiAgent()
|
|
109
|
+
oma.createTeam('my-team', teamCfg())
|
|
110
|
+
expect(() => oma.createTeam('my-team', teamCfg())).toThrow('already exists')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('shutdown', () => {
|
|
115
|
+
it('clears teams and counters', async () => {
|
|
116
|
+
const oma = new OpenMultiAgent()
|
|
117
|
+
oma.createTeam('t1', teamCfg())
|
|
118
|
+
await oma.shutdown()
|
|
119
|
+
expect(oma.getStatus().teams).toBe(0)
|
|
120
|
+
expect(oma.getStatus().completedTasks).toBe(0)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('getStatus', () => {
|
|
125
|
+
it('reports initial state', () => {
|
|
126
|
+
const oma = new OpenMultiAgent()
|
|
127
|
+
const status = oma.getStatus()
|
|
128
|
+
expect(status).toEqual({ teams: 0, activeAgents: 0, completedTasks: 0 })
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('runAgent', () => {
|
|
133
|
+
it('runs a single agent and returns result', async () => {
|
|
134
|
+
mockAdapterResponses = ['Hello from agent!']
|
|
135
|
+
|
|
136
|
+
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
|
137
|
+
const result = await oma.runAgent(
|
|
138
|
+
agentConfig('solo'),
|
|
139
|
+
'Say hello',
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(result.success).toBe(true)
|
|
143
|
+
expect(result.output).toBe('Hello from agent!')
|
|
144
|
+
expect(oma.getStatus().completedTasks).toBe(1)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('fires onProgress events', async () => {
|
|
148
|
+
mockAdapterResponses = ['done']
|
|
149
|
+
|
|
150
|
+
const events: OrchestratorEvent[] = []
|
|
151
|
+
const oma = new OpenMultiAgent({
|
|
152
|
+
defaultModel: 'mock-model',
|
|
153
|
+
onProgress: (e) => events.push(e),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
await oma.runAgent(agentConfig('solo'), 'test')
|
|
157
|
+
|
|
158
|
+
const types = events.map(e => e.type)
|
|
159
|
+
expect(types).toContain('agent_start')
|
|
160
|
+
expect(types).toContain('agent_complete')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('runTasks', () => {
|
|
165
|
+
it('executes explicit tasks assigned to agents', async () => {
|
|
166
|
+
// Each agent run produces one LLM call
|
|
167
|
+
mockAdapterResponses = ['result-a', 'result-b']
|
|
168
|
+
|
|
169
|
+
const events: OrchestratorEvent[] = []
|
|
170
|
+
const oma = new OpenMultiAgent({
|
|
171
|
+
defaultModel: 'mock-model',
|
|
172
|
+
onProgress: (e) => events.push(e),
|
|
173
|
+
})
|
|
174
|
+
const team = oma.createTeam('t', teamCfg())
|
|
175
|
+
|
|
176
|
+
const result = await oma.runTasks(team, [
|
|
177
|
+
{ title: 'Task A', description: 'Do A', assignee: 'worker-a' },
|
|
178
|
+
{ title: 'Task B', description: 'Do B', assignee: 'worker-b' },
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
expect(result.success).toBe(true)
|
|
182
|
+
expect(result.agentResults.size).toBeGreaterThanOrEqual(1)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('handles task dependencies sequentially', async () => {
|
|
186
|
+
mockAdapterResponses = ['first done', 'second done']
|
|
187
|
+
|
|
188
|
+
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
|
189
|
+
const team = oma.createTeam('t', teamCfg())
|
|
190
|
+
|
|
191
|
+
const result = await oma.runTasks(team, [
|
|
192
|
+
{ title: 'First', description: 'Do first', assignee: 'worker-a' },
|
|
193
|
+
{ title: 'Second', description: 'Do second', assignee: 'worker-b', dependsOn: ['First'] },
|
|
194
|
+
])
|
|
195
|
+
|
|
196
|
+
expect(result.success).toBe(true)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('runTeam', () => {
|
|
201
|
+
it('runs coordinator decomposition + execution + synthesis', async () => {
|
|
202
|
+
// Response 1: coordinator decomposition (returns JSON task array)
|
|
203
|
+
// Response 2: worker-a executes task
|
|
204
|
+
// Response 3: coordinator synthesis
|
|
205
|
+
mockAdapterResponses = [
|
|
206
|
+
'```json\n[{"title": "Research", "description": "Research the topic", "assignee": "worker-a"}]\n```',
|
|
207
|
+
'Research results here',
|
|
208
|
+
'Final synthesized answer based on research results',
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
const events: OrchestratorEvent[] = []
|
|
212
|
+
const oma = new OpenMultiAgent({
|
|
213
|
+
defaultModel: 'mock-model',
|
|
214
|
+
onProgress: (e) => events.push(e),
|
|
215
|
+
})
|
|
216
|
+
const team = oma.createTeam('t', teamCfg())
|
|
217
|
+
|
|
218
|
+
const result = await oma.runTeam(team, 'Research AI safety')
|
|
219
|
+
|
|
220
|
+
expect(result.success).toBe(true)
|
|
221
|
+
// Should have coordinator result
|
|
222
|
+
expect(result.agentResults.has('coordinator')).toBe(true)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('falls back to one-task-per-agent when coordinator output is unparseable', async () => {
|
|
226
|
+
mockAdapterResponses = [
|
|
227
|
+
'I cannot produce JSON output', // invalid coordinator output
|
|
228
|
+
'worker-a result',
|
|
229
|
+
'worker-b result',
|
|
230
|
+
'synthesis',
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
|
234
|
+
const team = oma.createTeam('t', teamCfg())
|
|
235
|
+
|
|
236
|
+
const result = await oma.runTeam(team, 'Do something')
|
|
237
|
+
|
|
238
|
+
expect(result.success).toBe(true)
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('config defaults', () => {
|
|
243
|
+
it('uses default model and provider', () => {
|
|
244
|
+
const oma = new OpenMultiAgent()
|
|
245
|
+
const status = oma.getStatus()
|
|
246
|
+
expect(status).toBeDefined()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('accepts custom config', () => {
|
|
250
|
+
const oma = new OpenMultiAgent({
|
|
251
|
+
maxConcurrency: 3,
|
|
252
|
+
defaultModel: 'custom-model',
|
|
253
|
+
defaultProvider: 'openai',
|
|
254
|
+
})
|
|
255
|
+
expect(oma.getStatus().teams).toBe(0)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('onApproval gate', () => {
|
|
260
|
+
it('skips remaining tasks when approval rejects', async () => {
|
|
261
|
+
mockAdapterResponses = ['first done', 'should not run']
|
|
262
|
+
|
|
263
|
+
const oma = new OpenMultiAgent({
|
|
264
|
+
defaultModel: 'mock-model',
|
|
265
|
+
onApproval: async () => false, // reject all
|
|
266
|
+
})
|
|
267
|
+
const team = oma.createTeam('t', teamCfg([agentConfig('worker')]))
|
|
268
|
+
|
|
269
|
+
const result = await oma.runTasks(team, [
|
|
270
|
+
{ title: 'First', description: 'Do first', assignee: 'worker' },
|
|
271
|
+
{ title: 'Second', description: 'Do second', assignee: 'worker', dependsOn: ['First'] },
|
|
272
|
+
])
|
|
273
|
+
|
|
274
|
+
// The first task succeeded; the second was skipped (no agentResult entry).
|
|
275
|
+
// Overall success is based on agentResults only, so it's true.
|
|
276
|
+
expect(result.success).toBe(true)
|
|
277
|
+
// But we should have fewer agent results than tasks
|
|
278
|
+
expect(result.agentResults.size).toBeLessThanOrEqual(1)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { Scheduler } from '../src/orchestrator/scheduler.js'
|
|
3
|
+
import { TaskQueue } from '../src/task/queue.js'
|
|
4
|
+
import { createTask } from '../src/task/task.js'
|
|
5
|
+
import type { AgentConfig, Task } from '../src/types.js'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function agent(name: string, systemPrompt?: string): AgentConfig {
|
|
12
|
+
return { name, model: 'test-model', systemPrompt }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pendingTask(title: string, opts?: { assignee?: string; dependsOn?: string[] }): Task {
|
|
16
|
+
return createTask({ title, description: title, assignee: opts?.assignee, ...opts })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// round-robin
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
describe('Scheduler: round-robin', () => {
|
|
24
|
+
it('distributes tasks evenly across agents', () => {
|
|
25
|
+
const s = new Scheduler('round-robin')
|
|
26
|
+
const agents = [agent('a'), agent('b'), agent('c')]
|
|
27
|
+
const tasks = [
|
|
28
|
+
pendingTask('t1'),
|
|
29
|
+
pendingTask('t2'),
|
|
30
|
+
pendingTask('t3'),
|
|
31
|
+
pendingTask('t4'),
|
|
32
|
+
pendingTask('t5'),
|
|
33
|
+
pendingTask('t6'),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const assignments = s.schedule(tasks, agents)
|
|
37
|
+
|
|
38
|
+
expect(assignments.get(tasks[0]!.id)).toBe('a')
|
|
39
|
+
expect(assignments.get(tasks[1]!.id)).toBe('b')
|
|
40
|
+
expect(assignments.get(tasks[2]!.id)).toBe('c')
|
|
41
|
+
expect(assignments.get(tasks[3]!.id)).toBe('a')
|
|
42
|
+
expect(assignments.get(tasks[4]!.id)).toBe('b')
|
|
43
|
+
expect(assignments.get(tasks[5]!.id)).toBe('c')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('skips already-assigned tasks', () => {
|
|
47
|
+
const s = new Scheduler('round-robin')
|
|
48
|
+
const agents = [agent('a'), agent('b')]
|
|
49
|
+
const tasks = [
|
|
50
|
+
pendingTask('t1', { assignee: 'a' }),
|
|
51
|
+
pendingTask('t2'),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const assignments = s.schedule(tasks, agents)
|
|
55
|
+
|
|
56
|
+
// Only t2 should be assigned
|
|
57
|
+
expect(assignments.size).toBe(1)
|
|
58
|
+
expect(assignments.has(tasks[1]!.id)).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns empty map when no agents', () => {
|
|
62
|
+
const s = new Scheduler('round-robin')
|
|
63
|
+
const tasks = [pendingTask('t1')]
|
|
64
|
+
expect(s.schedule(tasks, []).size).toBe(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('cursor advances across calls', () => {
|
|
68
|
+
const s = new Scheduler('round-robin')
|
|
69
|
+
const agents = [agent('a'), agent('b')]
|
|
70
|
+
const t1 = [pendingTask('t1')]
|
|
71
|
+
const t2 = [pendingTask('t2')]
|
|
72
|
+
|
|
73
|
+
const a1 = s.schedule(t1, agents)
|
|
74
|
+
const a2 = s.schedule(t2, agents)
|
|
75
|
+
|
|
76
|
+
expect(a1.get(t1[0]!.id)).toBe('a')
|
|
77
|
+
expect(a2.get(t2[0]!.id)).toBe('b')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// least-busy
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe('Scheduler: least-busy', () => {
|
|
86
|
+
it('assigns to agent with fewest in_progress tasks', () => {
|
|
87
|
+
const s = new Scheduler('least-busy')
|
|
88
|
+
const agents = [agent('a'), agent('b')]
|
|
89
|
+
|
|
90
|
+
// Create some in-progress tasks for agent 'a'
|
|
91
|
+
const inProgress: Task = {
|
|
92
|
+
...pendingTask('busy'),
|
|
93
|
+
status: 'in_progress',
|
|
94
|
+
assignee: 'a',
|
|
95
|
+
}
|
|
96
|
+
const newTask = pendingTask('new')
|
|
97
|
+
const allTasks = [inProgress, newTask]
|
|
98
|
+
|
|
99
|
+
const assignments = s.schedule(allTasks, agents)
|
|
100
|
+
|
|
101
|
+
// 'b' has 0 in-progress, 'a' has 1 → assign to 'b'
|
|
102
|
+
expect(assignments.get(newTask.id)).toBe('b')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('balances load across batch', () => {
|
|
106
|
+
const s = new Scheduler('least-busy')
|
|
107
|
+
const agents = [agent('a'), agent('b')]
|
|
108
|
+
const tasks = [pendingTask('t1'), pendingTask('t2'), pendingTask('t3'), pendingTask('t4')]
|
|
109
|
+
|
|
110
|
+
const assignments = s.schedule(tasks, agents)
|
|
111
|
+
|
|
112
|
+
// Should alternate: a, b, a, b
|
|
113
|
+
const values = [...assignments.values()]
|
|
114
|
+
const aCount = values.filter(v => v === 'a').length
|
|
115
|
+
const bCount = values.filter(v => v === 'b').length
|
|
116
|
+
expect(aCount).toBe(2)
|
|
117
|
+
expect(bCount).toBe(2)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// capability-match
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe('Scheduler: capability-match', () => {
|
|
126
|
+
it('matches task keywords to agent system prompt', () => {
|
|
127
|
+
const s = new Scheduler('capability-match')
|
|
128
|
+
const agents = [
|
|
129
|
+
agent('researcher', 'You are a research expert who analyzes data and writes reports'),
|
|
130
|
+
agent('coder', 'You are a software engineer who writes TypeScript code'),
|
|
131
|
+
]
|
|
132
|
+
const tasks = [
|
|
133
|
+
pendingTask('Write TypeScript code for the API'),
|
|
134
|
+
pendingTask('Research and analyze market data'),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
const assignments = s.schedule(tasks, agents)
|
|
138
|
+
|
|
139
|
+
expect(assignments.get(tasks[0]!.id)).toBe('coder')
|
|
140
|
+
expect(assignments.get(tasks[1]!.id)).toBe('researcher')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('falls back to first agent when no keywords match', () => {
|
|
144
|
+
const s = new Scheduler('capability-match')
|
|
145
|
+
const agents = [agent('alpha'), agent('beta')]
|
|
146
|
+
const tasks = [pendingTask('xyz')]
|
|
147
|
+
|
|
148
|
+
const assignments = s.schedule(tasks, agents)
|
|
149
|
+
|
|
150
|
+
// When scores are tied (all 0), first agent wins
|
|
151
|
+
expect(assignments.size).toBe(1)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// dependency-first
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
describe('Scheduler: dependency-first', () => {
|
|
160
|
+
it('prioritises tasks that unblock more dependents', () => {
|
|
161
|
+
const s = new Scheduler('dependency-first')
|
|
162
|
+
const agents = [agent('a')]
|
|
163
|
+
|
|
164
|
+
// t1 blocks t2 and t3; t2 blocks nothing
|
|
165
|
+
const t1 = pendingTask('t1')
|
|
166
|
+
const t2 = pendingTask('t2')
|
|
167
|
+
const t3 = { ...pendingTask('t3'), dependsOn: [t1.id] }
|
|
168
|
+
const t4 = { ...pendingTask('t4'), dependsOn: [t1.id] }
|
|
169
|
+
|
|
170
|
+
const allTasks = [t2, t1, t3, t4] // t2 first in input order
|
|
171
|
+
|
|
172
|
+
const assignments = s.schedule(allTasks, agents)
|
|
173
|
+
|
|
174
|
+
// t1 should be assigned first (unblocks 2 others)
|
|
175
|
+
const entries = [...assignments.entries()]
|
|
176
|
+
expect(entries[0]![0]).toBe(t1.id)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('returns empty map for empty task list', () => {
|
|
180
|
+
const s = new Scheduler('dependency-first')
|
|
181
|
+
const assignments = s.schedule([], [agent('a')])
|
|
182
|
+
expect(assignments.size).toBe(0)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// autoAssign
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
describe('Scheduler: autoAssign', () => {
|
|
191
|
+
it('updates queue tasks with assignees', () => {
|
|
192
|
+
const s = new Scheduler('round-robin')
|
|
193
|
+
const agents = [agent('a'), agent('b')]
|
|
194
|
+
const queue = new TaskQueue()
|
|
195
|
+
|
|
196
|
+
const t1 = pendingTask('t1')
|
|
197
|
+
const t2 = pendingTask('t2')
|
|
198
|
+
queue.add(t1)
|
|
199
|
+
queue.add(t2)
|
|
200
|
+
|
|
201
|
+
s.autoAssign(queue, agents)
|
|
202
|
+
|
|
203
|
+
const tasks = queue.list()
|
|
204
|
+
const assignees = tasks.map(t => t.assignee)
|
|
205
|
+
expect(assignees).toContain('a')
|
|
206
|
+
expect(assignees).toContain('b')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('does not overwrite existing assignees', () => {
|
|
210
|
+
const s = new Scheduler('round-robin')
|
|
211
|
+
const agents = [agent('a'), agent('b')]
|
|
212
|
+
const queue = new TaskQueue()
|
|
213
|
+
|
|
214
|
+
const t1 = pendingTask('t1', { assignee: 'x' })
|
|
215
|
+
queue.add(t1)
|
|
216
|
+
|
|
217
|
+
s.autoAssign(queue, agents)
|
|
218
|
+
|
|
219
|
+
expect(queue.list()[0]!.assignee).toBe('x')
|
|
220
|
+
})
|
|
221
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { Semaphore } from '../src/utils/semaphore.js'
|
|
3
|
+
|
|
4
|
+
describe('Semaphore', () => {
|
|
5
|
+
it('throws on max < 1', () => {
|
|
6
|
+
expect(() => new Semaphore(0)).toThrow()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('allows up to max concurrent holders', async () => {
|
|
10
|
+
const sem = new Semaphore(2)
|
|
11
|
+
let running = 0
|
|
12
|
+
let peak = 0
|
|
13
|
+
|
|
14
|
+
const work = async () => {
|
|
15
|
+
await sem.acquire()
|
|
16
|
+
running++
|
|
17
|
+
peak = Math.max(peak, running)
|
|
18
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
19
|
+
running--
|
|
20
|
+
sem.release()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await Promise.all([work(), work(), work(), work()])
|
|
24
|
+
expect(peak).toBeLessThanOrEqual(2)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('run() auto-releases on success', async () => {
|
|
28
|
+
const sem = new Semaphore(1)
|
|
29
|
+
const result = await sem.run(async () => 42)
|
|
30
|
+
expect(result).toBe(42)
|
|
31
|
+
expect(sem.active).toBe(0)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('run() auto-releases on error', async () => {
|
|
35
|
+
const sem = new Semaphore(1)
|
|
36
|
+
await expect(sem.run(async () => { throw new Error('oops') })).rejects.toThrow('oops')
|
|
37
|
+
expect(sem.active).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('tracks active and pending counts', async () => {
|
|
41
|
+
const sem = new Semaphore(1)
|
|
42
|
+
await sem.acquire()
|
|
43
|
+
expect(sem.active).toBe(1)
|
|
44
|
+
|
|
45
|
+
// This will queue
|
|
46
|
+
const p = sem.acquire()
|
|
47
|
+
expect(sem.pending).toBe(1)
|
|
48
|
+
|
|
49
|
+
sem.release()
|
|
50
|
+
await p
|
|
51
|
+
expect(sem.active).toBe(1)
|
|
52
|
+
expect(sem.pending).toBe(0)
|
|
53
|
+
|
|
54
|
+
sem.release()
|
|
55
|
+
expect(sem.active).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { SharedMemory } from '../src/memory/shared.js'
|
|
3
|
+
|
|
4
|
+
describe('SharedMemory', () => {
|
|
5
|
+
// -------------------------------------------------------------------------
|
|
6
|
+
// Write & read
|
|
7
|
+
// -------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
it('writes and reads a value under a namespaced key', async () => {
|
|
10
|
+
const mem = new SharedMemory()
|
|
11
|
+
await mem.write('researcher', 'findings', 'TS 5.5 ships const type params')
|
|
12
|
+
|
|
13
|
+
const entry = await mem.read('researcher/findings')
|
|
14
|
+
expect(entry).not.toBeNull()
|
|
15
|
+
expect(entry!.value).toBe('TS 5.5 ships const type params')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns null for a non-existent key', async () => {
|
|
19
|
+
const mem = new SharedMemory()
|
|
20
|
+
expect(await mem.read('nope/nothing')).toBeNull()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// -------------------------------------------------------------------------
|
|
24
|
+
// Namespace isolation
|
|
25
|
+
// -------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
it('isolates writes between agents', async () => {
|
|
28
|
+
const mem = new SharedMemory()
|
|
29
|
+
await mem.write('alice', 'plan', 'plan A')
|
|
30
|
+
await mem.write('bob', 'plan', 'plan B')
|
|
31
|
+
|
|
32
|
+
const alice = await mem.read('alice/plan')
|
|
33
|
+
const bob = await mem.read('bob/plan')
|
|
34
|
+
expect(alice!.value).toBe('plan A')
|
|
35
|
+
expect(bob!.value).toBe('plan B')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('listByAgent returns only that agent\'s entries', async () => {
|
|
39
|
+
const mem = new SharedMemory()
|
|
40
|
+
await mem.write('alice', 'a1', 'v1')
|
|
41
|
+
await mem.write('alice', 'a2', 'v2')
|
|
42
|
+
await mem.write('bob', 'b1', 'v3')
|
|
43
|
+
|
|
44
|
+
const aliceEntries = await mem.listByAgent('alice')
|
|
45
|
+
expect(aliceEntries).toHaveLength(2)
|
|
46
|
+
expect(aliceEntries.every((e) => e.key.startsWith('alice/'))).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// -------------------------------------------------------------------------
|
|
50
|
+
// Overwrite
|
|
51
|
+
// -------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
it('overwrites a value and preserves createdAt', async () => {
|
|
54
|
+
const mem = new SharedMemory()
|
|
55
|
+
await mem.write('agent', 'key', 'first')
|
|
56
|
+
const first = await mem.read('agent/key')
|
|
57
|
+
|
|
58
|
+
await mem.write('agent', 'key', 'second')
|
|
59
|
+
const second = await mem.read('agent/key')
|
|
60
|
+
|
|
61
|
+
expect(second!.value).toBe('second')
|
|
62
|
+
expect(second!.createdAt.getTime()).toBe(first!.createdAt.getTime())
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
// Metadata
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
it('stores metadata alongside the value', async () => {
|
|
70
|
+
const mem = new SharedMemory()
|
|
71
|
+
await mem.write('agent', 'key', 'val', { priority: 'high' })
|
|
72
|
+
|
|
73
|
+
const entry = await mem.read('agent/key')
|
|
74
|
+
expect(entry!.metadata).toMatchObject({ priority: 'high', agent: 'agent' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// -------------------------------------------------------------------------
|
|
78
|
+
// Summary
|
|
79
|
+
// -------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
it('returns empty string for an empty store', async () => {
|
|
82
|
+
const mem = new SharedMemory()
|
|
83
|
+
expect(await mem.getSummary()).toBe('')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('produces a markdown summary grouped by agent', async () => {
|
|
87
|
+
const mem = new SharedMemory()
|
|
88
|
+
await mem.write('researcher', 'findings', 'result A')
|
|
89
|
+
await mem.write('coder', 'plan', 'implement X')
|
|
90
|
+
|
|
91
|
+
const summary = await mem.getSummary()
|
|
92
|
+
expect(summary).toContain('## Shared Team Memory')
|
|
93
|
+
expect(summary).toContain('### researcher')
|
|
94
|
+
expect(summary).toContain('### coder')
|
|
95
|
+
expect(summary).toContain('findings: result A')
|
|
96
|
+
expect(summary).toContain('plan: implement X')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('truncates long values in the summary', async () => {
|
|
100
|
+
const mem = new SharedMemory()
|
|
101
|
+
const longValue = 'x'.repeat(300)
|
|
102
|
+
await mem.write('agent', 'big', longValue)
|
|
103
|
+
|
|
104
|
+
const summary = await mem.getSummary()
|
|
105
|
+
// Summary truncates at 200 chars → 197 + '…'
|
|
106
|
+
expect(summary.length).toBeLessThan(longValue.length)
|
|
107
|
+
expect(summary).toContain('…')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// listAll
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
it('listAll returns entries from all agents', async () => {
|
|
115
|
+
const mem = new SharedMemory()
|
|
116
|
+
await mem.write('a', 'k1', 'v1')
|
|
117
|
+
await mem.write('b', 'k2', 'v2')
|
|
118
|
+
|
|
119
|
+
const all = await mem.listAll()
|
|
120
|
+
expect(all).toHaveLength(2)
|
|
121
|
+
})
|
|
122
|
+
})
|