@jackchen_me/open-multi-agent 0.1.0 → 0.2.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 (84) 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 +72 -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 +73 -140
  10. package/README_zh.md +217 -0
  11. package/SECURITY.md +17 -0
  12. package/dist/agent/agent.d.ts +5 -0
  13. package/dist/agent/agent.d.ts.map +1 -1
  14. package/dist/agent/agent.js +90 -3
  15. package/dist/agent/agent.js.map +1 -1
  16. package/dist/agent/structured-output.d.ts +33 -0
  17. package/dist/agent/structured-output.d.ts.map +1 -0
  18. package/dist/agent/structured-output.js +116 -0
  19. package/dist/agent/structured-output.js.map +1 -0
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/llm/adapter.d.ts +9 -4
  25. package/dist/llm/adapter.d.ts.map +1 -1
  26. package/dist/llm/adapter.js +17 -5
  27. package/dist/llm/adapter.js.map +1 -1
  28. package/dist/llm/anthropic.d.ts +1 -1
  29. package/dist/llm/anthropic.d.ts.map +1 -1
  30. package/dist/llm/anthropic.js +2 -1
  31. package/dist/llm/anthropic.js.map +1 -1
  32. package/dist/llm/copilot.d.ts +92 -0
  33. package/dist/llm/copilot.d.ts.map +1 -0
  34. package/dist/llm/copilot.js +426 -0
  35. package/dist/llm/copilot.js.map +1 -0
  36. package/dist/llm/openai-common.d.ts +47 -0
  37. package/dist/llm/openai-common.d.ts.map +1 -0
  38. package/dist/llm/openai-common.js +209 -0
  39. package/dist/llm/openai-common.js.map +1 -0
  40. package/dist/llm/openai.d.ts +1 -1
  41. package/dist/llm/openai.d.ts.map +1 -1
  42. package/dist/llm/openai.js +3 -224
  43. package/dist/llm/openai.js.map +1 -1
  44. package/dist/orchestrator/orchestrator.d.ts +25 -1
  45. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  46. package/dist/orchestrator/orchestrator.js +130 -37
  47. package/dist/orchestrator/orchestrator.js.map +1 -1
  48. package/dist/task/queue.js +1 -1
  49. package/dist/task/queue.js.map +1 -1
  50. package/dist/task/task.d.ts +3 -0
  51. package/dist/task/task.d.ts.map +1 -1
  52. package/dist/task/task.js +5 -1
  53. package/dist/task/task.js.map +1 -1
  54. package/dist/team/messaging.d.ts.map +1 -1
  55. package/dist/team/messaging.js +2 -1
  56. package/dist/team/messaging.js.map +1 -1
  57. package/dist/types.d.ts +31 -3
  58. package/dist/types.d.ts.map +1 -1
  59. package/examples/05-copilot-test.ts +49 -0
  60. package/examples/06-local-model.ts +199 -0
  61. package/examples/07-fan-out-aggregate.ts +209 -0
  62. package/examples/08-gemma4-local.ts +203 -0
  63. package/examples/09-gemma4-auto-orchestration.ts +162 -0
  64. package/package.json +4 -3
  65. package/src/agent/agent.ts +115 -6
  66. package/src/agent/structured-output.ts +126 -0
  67. package/src/index.ts +2 -1
  68. package/src/llm/adapter.ts +18 -5
  69. package/src/llm/anthropic.ts +2 -1
  70. package/src/llm/copilot.ts +551 -0
  71. package/src/llm/openai-common.ts +255 -0
  72. package/src/llm/openai.ts +8 -258
  73. package/src/orchestrator/orchestrator.ts +164 -38
  74. package/src/task/queue.ts +1 -1
  75. package/src/task/task.ts +8 -1
  76. package/src/team/messaging.ts +3 -1
  77. package/src/types.ts +31 -2
  78. package/tests/semaphore.test.ts +57 -0
  79. package/tests/shared-memory.test.ts +122 -0
  80. package/tests/structured-output.test.ts +331 -0
  81. package/tests/task-queue.test.ts +244 -0
  82. package/tests/task-retry.test.ts +368 -0
  83. package/tests/task-utils.test.ts +155 -0
  84. package/tests/tool-executor.test.ts +193 -0
@@ -92,6 +92,104 @@ function buildAgent(config: AgentConfig): Agent {
92
92
  return new Agent(config, registry, executor)
93
93
  }
94
94
 
95
+ /** Promise-based delay. */
96
+ function sleep(ms: number): Promise<void> {
97
+ return new Promise((resolve) => setTimeout(resolve, ms))
98
+ }
99
+
100
+ /** Maximum delay cap to prevent runaway exponential backoff (30 seconds). */
101
+ const MAX_RETRY_DELAY_MS = 30_000
102
+
103
+ /**
104
+ * Compute the retry delay for a given attempt, capped at {@link MAX_RETRY_DELAY_MS}.
105
+ */
106
+ export function computeRetryDelay(
107
+ baseDelay: number,
108
+ backoff: number,
109
+ attempt: number,
110
+ ): number {
111
+ return Math.min(baseDelay * backoff ** (attempt - 1), MAX_RETRY_DELAY_MS)
112
+ }
113
+
114
+ /**
115
+ * Execute an agent task with optional retry and exponential backoff.
116
+ *
117
+ * Exported for testability — called internally by {@link executeQueue}.
118
+ *
119
+ * @param run - The function that executes the task (typically `pool.run`).
120
+ * @param task - The task to execute (retry config read from its fields).
121
+ * @param onRetry - Called before each retry sleep with event data.
122
+ * @param delayFn - Injectable delay function (defaults to real `sleep`).
123
+ * @returns The final {@link AgentRunResult} from the last attempt.
124
+ */
125
+ export async function executeWithRetry(
126
+ run: () => Promise<AgentRunResult>,
127
+ task: Task,
128
+ onRetry?: (data: { attempt: number; maxAttempts: number; error: string; nextDelayMs: number }) => void,
129
+ delayFn: (ms: number) => Promise<void> = sleep,
130
+ ): Promise<AgentRunResult> {
131
+ const maxAttempts = Math.max(0, task.maxRetries ?? 0) + 1
132
+ const baseDelay = Math.max(0, task.retryDelayMs ?? 1000)
133
+ const backoff = Math.max(1, task.retryBackoff ?? 2)
134
+
135
+ let lastError: string = ''
136
+ // Accumulate token usage across all attempts so billing/observability
137
+ // reflects the true cost of retries.
138
+ let totalUsage: TokenUsage = { input_tokens: 0, output_tokens: 0 }
139
+
140
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
141
+ try {
142
+ const result = await run()
143
+ totalUsage = {
144
+ input_tokens: totalUsage.input_tokens + result.tokenUsage.input_tokens,
145
+ output_tokens: totalUsage.output_tokens + result.tokenUsage.output_tokens,
146
+ }
147
+
148
+ if (result.success) {
149
+ return { ...result, tokenUsage: totalUsage }
150
+ }
151
+ lastError = result.output
152
+
153
+ // Failure — retry or give up
154
+ if (attempt < maxAttempts) {
155
+ const delay = computeRetryDelay(baseDelay, backoff, attempt)
156
+ onRetry?.({ attempt, maxAttempts, error: lastError, nextDelayMs: delay })
157
+ await delayFn(delay)
158
+ continue
159
+ }
160
+
161
+ return { ...result, tokenUsage: totalUsage }
162
+ } catch (err) {
163
+ lastError = err instanceof Error ? err.message : String(err)
164
+
165
+ if (attempt < maxAttempts) {
166
+ const delay = computeRetryDelay(baseDelay, backoff, attempt)
167
+ onRetry?.({ attempt, maxAttempts, error: lastError, nextDelayMs: delay })
168
+ await delayFn(delay)
169
+ continue
170
+ }
171
+
172
+ // All retries exhausted — return a failure result
173
+ return {
174
+ success: false,
175
+ output: lastError,
176
+ messages: [],
177
+ tokenUsage: totalUsage,
178
+ toolCalls: [],
179
+ }
180
+ }
181
+ }
182
+
183
+ // Should not be reached, but TypeScript needs a return
184
+ return {
185
+ success: false,
186
+ output: lastError,
187
+ messages: [],
188
+ tokenUsage: totalUsage,
189
+ toolCalls: [],
190
+ }
191
+ }
192
+
95
193
  // ---------------------------------------------------------------------------
96
194
  // Parsed task spec (result of coordinator decomposition)
97
195
  // ---------------------------------------------------------------------------
@@ -239,49 +337,50 @@ async function executeQueue(
239
337
  // Build the prompt: inject shared memory context + task description
240
338
  const prompt = await buildTaskPrompt(task, team)
241
339
 
242
- try {
243
- const result = await pool.run(assignee, prompt)
244
- ctx.agentResults.set(`${assignee}:${task.id}`, result)
245
-
246
- if (result.success) {
247
- // Persist result into shared memory so other agents can read it
248
- const sharedMem = team.getSharedMemoryInstance()
249
- if (sharedMem) {
250
- await sharedMem.write(assignee, `task:${task.id}:result`, result.output)
251
- }
252
-
253
- queue.complete(task.id, result.output)
254
-
340
+ const result = await executeWithRetry(
341
+ () => pool.run(assignee, prompt),
342
+ task,
343
+ (retryData) => {
255
344
  config.onProgress?.({
256
- type: 'task_complete',
345
+ type: 'task_retry',
257
346
  task: task.id,
258
347
  agent: assignee,
259
- data: result,
348
+ data: retryData,
260
349
  } satisfies OrchestratorEvent)
350
+ },
351
+ )
261
352
 
262
- config.onProgress?.({
263
- type: 'agent_complete',
264
- agent: assignee,
265
- task: task.id,
266
- data: result,
267
- } satisfies OrchestratorEvent)
268
- } else {
269
- queue.fail(task.id, result.output)
270
- config.onProgress?.({
271
- type: 'error',
272
- task: task.id,
273
- agent: assignee,
274
- data: result,
275
- } satisfies OrchestratorEvent)
353
+ ctx.agentResults.set(`${assignee}:${task.id}`, result)
354
+
355
+ if (result.success) {
356
+ // Persist result into shared memory so other agents can read it
357
+ const sharedMem = team.getSharedMemoryInstance()
358
+ if (sharedMem) {
359
+ await sharedMem.write(assignee, `task:${task.id}:result`, result.output)
276
360
  }
277
- } catch (err) {
278
- const message = err instanceof Error ? err.message : String(err)
279
- queue.fail(task.id, message)
361
+
362
+ queue.complete(task.id, result.output)
363
+
364
+ config.onProgress?.({
365
+ type: 'task_complete',
366
+ task: task.id,
367
+ agent: assignee,
368
+ data: result,
369
+ } satisfies OrchestratorEvent)
370
+
371
+ config.onProgress?.({
372
+ type: 'agent_complete',
373
+ agent: assignee,
374
+ task: task.id,
375
+ data: result,
376
+ } satisfies OrchestratorEvent)
377
+ } else {
378
+ queue.fail(task.id, result.output)
280
379
  config.onProgress?.({
281
380
  type: 'error',
282
381
  task: task.id,
283
382
  agent: assignee,
284
- data: err,
383
+ data: result,
285
384
  } satisfies OrchestratorEvent)
286
385
  }
287
386
  })
@@ -341,8 +440,8 @@ async function buildTaskPrompt(task: Task, team: Team): Promise<string> {
341
440
  */
342
441
  export class OpenMultiAgent {
343
442
  private readonly config: Required<
344
- Omit<OrchestratorConfig, 'onProgress'>
345
- > & Pick<OrchestratorConfig, 'onProgress'>
443
+ Omit<OrchestratorConfig, 'onProgress' | 'defaultBaseURL' | 'defaultApiKey'>
444
+ > & Pick<OrchestratorConfig, 'onProgress' | 'defaultBaseURL' | 'defaultApiKey'>
346
445
 
347
446
  private readonly teams: Map<string, Team> = new Map()
348
447
  private completedTaskCount = 0
@@ -360,6 +459,8 @@ export class OpenMultiAgent {
360
459
  maxConcurrency: config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
361
460
  defaultModel: config.defaultModel ?? DEFAULT_MODEL,
362
461
  defaultProvider: config.defaultProvider ?? 'anthropic',
462
+ defaultBaseURL: config.defaultBaseURL,
463
+ defaultApiKey: config.defaultApiKey,
363
464
  onProgress: config.onProgress,
364
465
  }
365
466
  }
@@ -405,7 +506,13 @@ export class OpenMultiAgent {
405
506
  * @param prompt - The user prompt to send.
406
507
  */
407
508
  async runAgent(config: AgentConfig, prompt: string): Promise<AgentRunResult> {
408
- const agent = buildAgent(config)
509
+ const effective: AgentConfig = {
510
+ ...config,
511
+ provider: config.provider ?? this.config.defaultProvider,
512
+ baseURL: config.baseURL ?? this.config.defaultBaseURL,
513
+ apiKey: config.apiKey ?? this.config.defaultApiKey,
514
+ }
515
+ const agent = buildAgent(effective)
409
516
  this.config.onProgress?.({
410
517
  type: 'agent_start',
411
518
  agent: config.name,
@@ -462,6 +569,8 @@ export class OpenMultiAgent {
462
569
  name: 'coordinator',
463
570
  model: this.config.defaultModel,
464
571
  provider: this.config.defaultProvider,
572
+ baseURL: this.config.defaultBaseURL,
573
+ apiKey: this.config.defaultApiKey,
465
574
  systemPrompt: this.buildCoordinatorSystemPrompt(agentConfigs),
466
575
  maxTurns: 3,
467
576
  }
@@ -564,6 +673,9 @@ export class OpenMultiAgent {
564
673
  description: string
565
674
  assignee?: string
566
675
  dependsOn?: string[]
676
+ maxRetries?: number
677
+ retryDelayMs?: number
678
+ retryBackoff?: number
567
679
  }>,
568
680
  ): Promise<TeamRunResult> {
569
681
  const agentConfigs = team.getAgents()
@@ -576,6 +688,9 @@ export class OpenMultiAgent {
576
688
  description: t.description,
577
689
  assignee: t.assignee,
578
690
  dependsOn: t.dependsOn,
691
+ maxRetries: t.maxRetries,
692
+ retryDelayMs: t.retryDelayMs,
693
+ retryBackoff: t.retryBackoff,
579
694
  })),
580
695
  agentConfigs,
581
696
  queue,
@@ -733,7 +848,11 @@ export class OpenMultiAgent {
733
848
  * then resolving them to real IDs before adding tasks to the queue.
734
849
  */
735
850
  private loadSpecsIntoQueue(
736
- specs: ReadonlyArray<ParsedTaskSpec>,
851
+ specs: ReadonlyArray<ParsedTaskSpec & {
852
+ maxRetries?: number
853
+ retryDelayMs?: number
854
+ retryBackoff?: number
855
+ }>,
737
856
  agentConfigs: AgentConfig[],
738
857
  queue: TaskQueue,
739
858
  ): void {
@@ -750,6 +869,9 @@ export class OpenMultiAgent {
750
869
  assignee: spec.assignee && agentNames.has(spec.assignee)
751
870
  ? spec.assignee
752
871
  : undefined,
872
+ maxRetries: spec.maxRetries,
873
+ retryDelayMs: spec.retryDelayMs,
874
+ retryBackoff: spec.retryBackoff,
753
875
  })
754
876
  titleToId.set(spec.title.toLowerCase().trim(), task.id)
755
877
  createdTasks.push(task)
@@ -792,6 +914,8 @@ export class OpenMultiAgent {
792
914
  ...config,
793
915
  model: config.model,
794
916
  provider: config.provider ?? this.config.defaultProvider,
917
+ baseURL: config.baseURL ?? this.config.defaultBaseURL,
918
+ apiKey: config.apiKey ?? this.config.defaultApiKey,
795
919
  }
796
920
  pool.add(buildAgent(effective))
797
921
  }
@@ -825,13 +949,15 @@ export class OpenMultiAgent {
825
949
  if (!existing) {
826
950
  collapsed.set(agentName, result)
827
951
  } else {
828
- // Merge multiple results for the same agent (multi-task case)
952
+ // Merge multiple results for the same agent (multi-task case).
953
+ // Keep the latest `structured` value (last completed task wins).
829
954
  collapsed.set(agentName, {
830
955
  success: existing.success && result.success,
831
956
  output: [existing.output, result.output].filter(Boolean).join('\n\n---\n\n'),
832
957
  messages: [...existing.messages, ...result.messages],
833
958
  tokenUsage: addUsage(existing.tokenUsage, result.tokenUsage),
834
959
  toolCalls: [...existing.toolCalls, ...result.toolCalls],
960
+ structured: result.structured !== undefined ? result.structured : existing.structured,
835
961
  })
836
962
  }
837
963
 
package/src/task/queue.ts CHANGED
@@ -356,7 +356,7 @@ export class TaskQueue {
356
356
 
357
357
  // Re-check against the current state of the whole task set.
358
358
  // Pass the pre-built map to avoid rebuilding it for every candidate task.
359
- if (isTaskReady(task, allTasks, taskById)) {
359
+ if (isTaskReady({ ...task, status: 'pending' }, allTasks, taskById)) {
360
360
  const unblocked: Task = {
361
361
  ...task,
362
362
  status: 'pending',
package/src/task/task.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * Stateful orchestration belongs in {@link TaskQueue}.
7
7
  */
8
8
 
9
+ import { randomUUID } from 'node:crypto'
9
10
  import type { Task, TaskStatus } from '../types.js'
10
11
 
11
12
  // ---------------------------------------------------------------------------
@@ -30,10 +31,13 @@ export function createTask(input: {
30
31
  description: string
31
32
  assignee?: string
32
33
  dependsOn?: string[]
34
+ maxRetries?: number
35
+ retryDelayMs?: number
36
+ retryBackoff?: number
33
37
  }): Task {
34
38
  const now = new Date()
35
39
  return {
36
- id: crypto.randomUUID(),
40
+ id: randomUUID(),
37
41
  title: input.title,
38
42
  description: input.description,
39
43
  status: 'pending' as TaskStatus,
@@ -42,6 +46,9 @@ export function createTask(input: {
42
46
  result: undefined,
43
47
  createdAt: now,
44
48
  updatedAt: now,
49
+ maxRetries: input.maxRetries,
50
+ retryDelayMs: input.retryDelayMs,
51
+ retryBackoff: input.retryBackoff,
45
52
  }
46
53
  }
47
54
 
@@ -6,6 +6,8 @@
6
6
  * for replay and audit; read-state is tracked per recipient.
7
7
  */
8
8
 
9
+ import { randomUUID } from 'node:crypto'
10
+
9
11
  // ---------------------------------------------------------------------------
10
12
  // Message type
11
13
  // ---------------------------------------------------------------------------
@@ -93,7 +95,7 @@ export class MessageBus {
93
95
  */
94
96
  send(from: string, to: string, content: string): Message {
95
97
  const message: Message = {
96
- id: crypto.randomUUID(),
98
+ id: randomUUID(),
97
99
  from,
98
100
  to,
99
101
  content,
package/src/types.ts CHANGED
@@ -186,13 +186,27 @@ export interface ToolDefinition<TInput = Record<string, unknown>> {
186
186
  export interface AgentConfig {
187
187
  readonly name: string
188
188
  readonly model: string
189
- readonly provider?: 'anthropic' | 'openai'
189
+ readonly provider?: 'anthropic' | 'copilot' | 'openai'
190
+ /**
191
+ * Custom base URL for OpenAI-compatible APIs (Ollama, vLLM, LM Studio, etc.).
192
+ * Note: local servers that don't require auth still need `apiKey` set to a
193
+ * non-empty placeholder (e.g. `'ollama'`) because the OpenAI SDK validates it.
194
+ */
195
+ readonly baseURL?: string
196
+ /** API key override; falls back to the provider's standard env var. */
197
+ readonly apiKey?: string
190
198
  readonly systemPrompt?: string
191
199
  /** Names of tools (from the tool registry) available to this agent. */
192
200
  readonly tools?: readonly string[]
193
201
  readonly maxTurns?: number
194
202
  readonly maxTokens?: number
195
203
  readonly temperature?: number
204
+ /**
205
+ * Optional Zod schema for structured output. When set, the agent's final
206
+ * output is parsed as JSON and validated against this schema. A single
207
+ * retry with error feedback is attempted on validation failure.
208
+ */
209
+ readonly outputSchema?: ZodSchema
196
210
  }
197
211
 
198
212
  /** Lifecycle state tracked during an agent run. */
@@ -219,6 +233,12 @@ export interface AgentRunResult {
219
233
  readonly messages: LLMMessage[]
220
234
  readonly tokenUsage: TokenUsage
221
235
  readonly toolCalls: ToolCallRecord[]
236
+ /**
237
+ * Parsed and validated structured output when `outputSchema` is set on the
238
+ * agent config. `undefined` when no schema is configured or validation
239
+ * failed after retry.
240
+ */
241
+ readonly structured?: unknown
222
242
  }
223
243
 
224
244
  // ---------------------------------------------------------------------------
@@ -261,6 +281,12 @@ export interface Task {
261
281
  result?: string
262
282
  readonly createdAt: Date
263
283
  updatedAt: Date
284
+ /** Maximum number of retry attempts on failure (default: 0 — no retry). */
285
+ readonly maxRetries?: number
286
+ /** Base delay in ms before the first retry (default: 1000). */
287
+ readonly retryDelayMs?: number
288
+ /** Exponential backoff multiplier (default: 2). */
289
+ readonly retryBackoff?: number
264
290
  }
265
291
 
266
292
  // ---------------------------------------------------------------------------
@@ -274,6 +300,7 @@ export interface OrchestratorEvent {
274
300
  | 'agent_complete'
275
301
  | 'task_start'
276
302
  | 'task_complete'
303
+ | 'task_retry'
277
304
  | 'message'
278
305
  | 'error'
279
306
  readonly agent?: string
@@ -285,7 +312,9 @@ export interface OrchestratorEvent {
285
312
  export interface OrchestratorConfig {
286
313
  readonly maxConcurrency?: number
287
314
  readonly defaultModel?: string
288
- readonly defaultProvider?: 'anthropic' | 'openai'
315
+ readonly defaultProvider?: 'anthropic' | 'copilot' | 'openai'
316
+ readonly defaultBaseURL?: string
317
+ readonly defaultApiKey?: string
289
318
  onProgress?: (event: OrchestratorEvent) => void
290
319
  }
291
320
 
@@ -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
+ })