@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
@@ -25,7 +25,12 @@ import type {
25
25
  ToolUseContext,
26
26
  LLMAdapter,
27
27
  LLMChatOptions,
28
+ TraceEvent,
29
+ LoopDetectionConfig,
30
+ LoopDetectionInfo,
28
31
  } from '../types.js'
32
+ import { LoopDetector } from './loop-detector.js'
33
+ import { emitTrace } from '../utils/trace.js'
29
34
  import type { ToolRegistry } from '../tool/framework.js'
30
35
  import type { ToolExecutor } from '../tool/executor.js'
31
36
 
@@ -63,6 +68,8 @@ export interface RunnerOptions {
63
68
  readonly agentName?: string
64
69
  /** Short role description of the agent (used in tool context). */
65
70
  readonly agentRole?: string
71
+ /** Loop detection configuration. When set, detects stuck agent loops. */
72
+ readonly loopDetection?: LoopDetectionConfig
66
73
  }
67
74
 
68
75
  /**
@@ -76,6 +83,24 @@ export interface RunOptions {
76
83
  readonly onToolResult?: (name: string, result: ToolResult) => void
77
84
  /** Fired after each complete {@link LLMMessage} is appended. */
78
85
  readonly onMessage?: (message: LLMMessage) => void
86
+ /**
87
+ * Fired when the runner detects a potential configuration issue.
88
+ * For example, when a model appears to ignore tool definitions.
89
+ */
90
+ readonly onWarning?: (message: string) => void
91
+ /** Trace callback for observability spans. Async callbacks are safe. */
92
+ readonly onTrace?: (event: TraceEvent) => void | Promise<void>
93
+ /** Run ID for trace correlation. */
94
+ readonly runId?: string
95
+ /** Task ID for trace correlation. */
96
+ readonly taskId?: string
97
+ /** Agent name for trace correlation (overrides RunnerOptions.agentName). */
98
+ readonly traceAgent?: string
99
+ /**
100
+ * Per-call abort signal. When set, takes precedence over the static
101
+ * {@link RunnerOptions.abortSignal}. Useful for per-run timeouts.
102
+ */
103
+ readonly abortSignal?: AbortSignal
79
104
  }
80
105
 
81
106
  /** The aggregated result returned when a full run completes. */
@@ -90,6 +115,8 @@ export interface RunResult {
90
115
  readonly tokenUsage: TokenUsage
91
116
  /** Total number of LLM turns (including tool-call follow-ups). */
92
117
  readonly turns: number
118
+ /** True when the run was terminated or warned due to loop detection. */
119
+ readonly loopDetected?: boolean
93
120
  }
94
121
 
95
122
  // ---------------------------------------------------------------------------
@@ -166,13 +193,7 @@ export class AgentRunner {
166
193
  options: RunOptions = {},
167
194
  ): Promise<RunResult> {
168
195
  // Collect everything yielded by the internal streaming loop.
169
- const accumulated: {
170
- messages: LLMMessage[]
171
- output: string
172
- toolCalls: ToolCallRecord[]
173
- tokenUsage: TokenUsage
174
- turns: number
175
- } = {
196
+ const accumulated: RunResult = {
176
197
  messages: [],
177
198
  output: '',
178
199
  toolCalls: [],
@@ -182,12 +203,7 @@ export class AgentRunner {
182
203
 
183
204
  for await (const event of this.stream(messages, options)) {
184
205
  if (event.type === 'done') {
185
- const result = event.data as RunResult
186
- accumulated.messages = result.messages
187
- accumulated.output = result.output
188
- accumulated.toolCalls = result.toolCalls
189
- accumulated.tokenUsage = result.tokenUsage
190
- accumulated.turns = result.turns
206
+ Object.assign(accumulated, event.data)
191
207
  }
192
208
  }
193
209
 
@@ -225,22 +241,33 @@ export class AgentRunner {
225
241
  ? allDefs.filter(d => this.options.allowedTools!.includes(d.name))
226
242
  : allDefs
227
243
 
244
+ // Per-call abortSignal takes precedence over the static one.
245
+ const effectiveAbortSignal = options.abortSignal ?? this.options.abortSignal
246
+
228
247
  const baseChatOptions: LLMChatOptions = {
229
248
  model: this.options.model,
230
249
  tools: toolDefs.length > 0 ? toolDefs : undefined,
231
250
  maxTokens: this.options.maxTokens,
232
251
  temperature: this.options.temperature,
233
252
  systemPrompt: this.options.systemPrompt,
234
- abortSignal: this.options.abortSignal,
253
+ abortSignal: effectiveAbortSignal,
235
254
  }
236
255
 
256
+ // Loop detection state — only allocated when configured.
257
+ const detector = this.options.loopDetection
258
+ ? new LoopDetector(this.options.loopDetection)
259
+ : null
260
+ let loopDetected = false
261
+ let loopWarned = false
262
+ const loopAction = this.options.loopDetection?.onLoopDetected ?? 'warn'
263
+
237
264
  try {
238
265
  // -----------------------------------------------------------------
239
266
  // Main agentic loop — `while (true)` until end_turn or maxTurns
240
267
  // -----------------------------------------------------------------
241
268
  while (true) {
242
269
  // Respect abort before each LLM call.
243
- if (this.options.abortSignal?.aborted) {
270
+ if (effectiveAbortSignal?.aborted) {
244
271
  break
245
272
  }
246
273
 
@@ -254,7 +281,23 @@ export class AgentRunner {
254
281
  // ------------------------------------------------------------------
255
282
  // Step 1: Call the LLM and collect the full response for this turn.
256
283
  // ------------------------------------------------------------------
284
+ const llmStartMs = Date.now()
257
285
  const response = await this.adapter.chat(conversationMessages, baseChatOptions)
286
+ if (options.onTrace) {
287
+ const llmEndMs = Date.now()
288
+ emitTrace(options.onTrace, {
289
+ type: 'llm_call',
290
+ runId: options.runId ?? '',
291
+ taskId: options.taskId,
292
+ agent: options.traceAgent ?? this.options.agentName ?? 'unknown',
293
+ model: this.options.model,
294
+ turn: turns,
295
+ tokens: response.usage,
296
+ startMs: llmStartMs,
297
+ endMs: llmEndMs,
298
+ durationMs: llmEndMs - llmStartMs,
299
+ })
300
+ }
258
301
 
259
302
  totalUsage = addTokenUsage(totalUsage, response.usage)
260
303
 
@@ -275,21 +318,77 @@ export class AgentRunner {
275
318
  yield { type: 'text', data: turnText } satisfies StreamEvent
276
319
  }
277
320
 
278
- // Announce each tool-use block the model requested.
321
+ // Extract tool-use blocks for detection and execution.
279
322
  const toolUseBlocks = extractToolUseBlocks(response.content)
280
- for (const block of toolUseBlocks) {
281
- yield { type: 'tool_use', data: block } satisfies StreamEvent
323
+
324
+ // ------------------------------------------------------------------
325
+ // Step 2.5: Loop detection — check before yielding tool_use events
326
+ // so that terminate mode doesn't emit orphaned tool_use without
327
+ // matching tool_result.
328
+ // ------------------------------------------------------------------
329
+ let injectWarning = false
330
+ let injectWarningKind: 'tool_repetition' | 'text_repetition' = 'tool_repetition'
331
+ if (detector && toolUseBlocks.length > 0) {
332
+ const toolInfo = detector.recordToolCalls(toolUseBlocks)
333
+ const textInfo = turnText.length > 0 ? detector.recordText(turnText) : null
334
+ const info = toolInfo ?? textInfo
335
+
336
+ if (info) {
337
+ yield { type: 'loop_detected', data: info } satisfies StreamEvent
338
+ options.onWarning?.(info.detail)
339
+
340
+ const action = typeof loopAction === 'function'
341
+ ? await loopAction(info)
342
+ : loopAction
343
+
344
+ if (action === 'terminate') {
345
+ loopDetected = true
346
+ finalOutput = turnText
347
+ break
348
+ } else if (action === 'warn' || action === 'inject') {
349
+ if (loopWarned) {
350
+ // Second detection after a warning — force terminate.
351
+ loopDetected = true
352
+ finalOutput = turnText
353
+ break
354
+ }
355
+ loopWarned = true
356
+ injectWarning = true
357
+ injectWarningKind = info.kind
358
+ // Fall through to execute tools, then inject warning.
359
+ }
360
+ // 'continue' — do nothing, let the loop proceed normally.
361
+ } else {
362
+ // No loop detected this turn — agent has recovered, so reset
363
+ // the warning state. A future loop gets a fresh warning cycle.
364
+ loopWarned = false
365
+ }
282
366
  }
283
367
 
284
368
  // ------------------------------------------------------------------
285
369
  // Step 3: Decide whether to continue looping.
286
370
  // ------------------------------------------------------------------
287
371
  if (toolUseBlocks.length === 0) {
372
+ // Warn on first turn if tools were provided but model didn't use them.
373
+ if (turns === 1 && toolDefs.length > 0 && options.onWarning) {
374
+ const agentName = this.options.agentName ?? 'unknown'
375
+ options.onWarning(
376
+ `Agent "${agentName}" has ${toolDefs.length} tool(s) available but the model ` +
377
+ `returned no tool calls. If using a local model, verify it supports tool calling ` +
378
+ `(see https://ollama.com/search?c=tools).`,
379
+ )
380
+ }
288
381
  // No tools requested — this is the terminal assistant turn.
289
382
  finalOutput = turnText
290
383
  break
291
384
  }
292
385
 
386
+ // Announce each tool-use block the model requested (after loop
387
+ // detection, so terminate mode never emits unpaired events).
388
+ for (const block of toolUseBlocks) {
389
+ yield { type: 'tool_use', data: block } satisfies StreamEvent
390
+ }
391
+
293
392
  // ------------------------------------------------------------------
294
393
  // Step 4: Execute all tool calls in PARALLEL.
295
394
  //
@@ -319,10 +418,25 @@ export class AgentRunner {
319
418
  result = { data: message, isError: true }
320
419
  }
321
420
 
322
- const duration = Date.now() - startTime
421
+ const endTime = Date.now()
422
+ const duration = endTime - startTime
323
423
 
324
424
  options.onToolResult?.(block.name, result)
325
425
 
426
+ if (options.onTrace) {
427
+ emitTrace(options.onTrace, {
428
+ type: 'tool_call',
429
+ runId: options.runId ?? '',
430
+ taskId: options.taskId,
431
+ agent: options.traceAgent ?? this.options.agentName ?? 'unknown',
432
+ tool: block.name,
433
+ isError: result.isError ?? false,
434
+ startMs: startTime,
435
+ endMs: endTime,
436
+ durationMs: duration,
437
+ })
438
+ }
439
+
326
440
  const record: ToolCallRecord = {
327
441
  toolName: block.name,
328
442
  input: block.input,
@@ -354,6 +468,20 @@ export class AgentRunner {
354
468
  yield { type: 'tool_result', data: resultBlock } satisfies StreamEvent
355
469
  }
356
470
 
471
+ // Inject a loop-detection warning into the tool-result message so
472
+ // the LLM sees it alongside the results (avoids two consecutive user
473
+ // messages which violates the alternating-role constraint).
474
+ if (injectWarning) {
475
+ const warningText = injectWarningKind === 'text_repetition'
476
+ ? 'WARNING: You appear to be generating the same response repeatedly. ' +
477
+ 'This suggests you are stuck in a loop. Please try a different approach ' +
478
+ 'or provide new information.'
479
+ : 'WARNING: You appear to be repeating the same tool calls with identical arguments. ' +
480
+ 'This suggests you are stuck in a loop. Please try a different approach, use different ' +
481
+ 'parameters, or explain what you are trying to accomplish.'
482
+ toolResultBlocks.push({ type: 'text' as const, text: warningText })
483
+ }
484
+
357
485
  const toolResultMessage: LLMMessage = {
358
486
  role: 'user',
359
487
  content: toolResultBlocks,
@@ -387,6 +515,7 @@ export class AgentRunner {
387
515
  toolCalls: allToolCalls,
388
516
  tokenUsage: totalUsage,
389
517
  turns,
518
+ ...(loopDetected ? { loopDetected: true } : {}),
390
519
  }
391
520
 
392
521
  yield { type: 'done', data: runResult } satisfies StreamEvent
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @fileoverview Structured output utilities for agent responses.
3
+ *
4
+ * Provides JSON extraction, Zod validation, and system-prompt injection so
5
+ * that agents can return typed, schema-validated output.
6
+ */
7
+
8
+ import { type ZodSchema } from 'zod'
9
+ import { zodToJsonSchema } from '../tool/framework.js'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // System-prompt instruction builder
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Build a JSON-mode instruction block to append to the agent's system prompt.
17
+ *
18
+ * Converts the Zod schema to JSON Schema and formats it as a clear directive
19
+ * for the LLM to respond with valid JSON matching the schema.
20
+ */
21
+ export function buildStructuredOutputInstruction(schema: ZodSchema): string {
22
+ const jsonSchema = zodToJsonSchema(schema)
23
+ return [
24
+ '',
25
+ '## Output Format (REQUIRED)',
26
+ 'You MUST respond with ONLY valid JSON that conforms to the following JSON Schema.',
27
+ 'Do NOT include any text, markdown fences, or explanation outside the JSON object.',
28
+ 'Do NOT wrap the JSON in ```json code fences.',
29
+ '',
30
+ '```',
31
+ JSON.stringify(jsonSchema, null, 2),
32
+ '```',
33
+ ].join('\n')
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // JSON extraction
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Attempt to extract and parse JSON from the agent's raw text output.
42
+ *
43
+ * Handles three cases in order:
44
+ * 1. The output is already valid JSON (ideal case)
45
+ * 2. The output contains a ` ```json ` fenced block
46
+ * 3. The output contains a bare JSON object/array (first `{`/`[` to last `}`/`]`)
47
+ *
48
+ * @throws {Error} when no valid JSON can be extracted
49
+ */
50
+ export function extractJSON(raw: string): unknown {
51
+ const trimmed = raw.trim()
52
+
53
+ // Case 1: Direct parse
54
+ try {
55
+ return JSON.parse(trimmed)
56
+ } catch {
57
+ // Continue to fallback strategies
58
+ }
59
+
60
+ // Case 2a: Prefer ```json tagged fence
61
+ const jsonFenceMatch = trimmed.match(/```json\s*([\s\S]*?)```/)
62
+ if (jsonFenceMatch?.[1]) {
63
+ try {
64
+ return JSON.parse(jsonFenceMatch[1].trim())
65
+ } catch {
66
+ // Continue
67
+ }
68
+ }
69
+
70
+ // Case 2b: Fall back to bare ``` fence
71
+ const bareFenceMatch = trimmed.match(/```\s*([\s\S]*?)```/)
72
+ if (bareFenceMatch?.[1]) {
73
+ try {
74
+ return JSON.parse(bareFenceMatch[1].trim())
75
+ } catch {
76
+ // Continue
77
+ }
78
+ }
79
+
80
+ // Case 3: Find first { to last } (object)
81
+ const objStart = trimmed.indexOf('{')
82
+ const objEnd = trimmed.lastIndexOf('}')
83
+ if (objStart !== -1 && objEnd > objStart) {
84
+ try {
85
+ return JSON.parse(trimmed.slice(objStart, objEnd + 1))
86
+ } catch {
87
+ // Fall through
88
+ }
89
+ }
90
+
91
+ // Case 3b: Find first [ to last ] (array)
92
+ const arrStart = trimmed.indexOf('[')
93
+ const arrEnd = trimmed.lastIndexOf(']')
94
+ if (arrStart !== -1 && arrEnd > arrStart) {
95
+ try {
96
+ return JSON.parse(trimmed.slice(arrStart, arrEnd + 1))
97
+ } catch {
98
+ // Fall through
99
+ }
100
+ }
101
+
102
+ throw new Error(
103
+ `Failed to extract JSON from output. Raw output begins with: "${trimmed.slice(0, 100)}"`,
104
+ )
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Zod validation
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Validate a parsed JSON value against a Zod schema.
113
+ *
114
+ * @returns The validated (and potentially transformed) value on success.
115
+ * @throws {Error} with a human-readable Zod error message on failure.
116
+ */
117
+ export function validateOutput(schema: ZodSchema, data: unknown): unknown {
118
+ const result = schema.safeParse(data)
119
+ if (result.success) {
120
+ return result.data
121
+ }
122
+ const issues = result.error.issues
123
+ .map(issue => ` - ${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`)
124
+ .join('\n')
125
+ throw new Error(`Output validation failed:\n${issues}`)
126
+ }
package/src/index.ts CHANGED
@@ -54,7 +54,7 @@
54
54
  // Orchestrator (primary entry point)
55
55
  // ---------------------------------------------------------------------------
56
56
 
57
- export { OpenMultiAgent } from './orchestrator/orchestrator.js'
57
+ export { OpenMultiAgent, executeWithRetry, computeRetryDelay } from './orchestrator/orchestrator.js'
58
58
  export { Scheduler } from './orchestrator/scheduler.js'
59
59
  export type { SchedulingStrategy } from './orchestrator/scheduler.js'
60
60
 
@@ -63,6 +63,8 @@ export type { SchedulingStrategy } from './orchestrator/scheduler.js'
63
63
  // ---------------------------------------------------------------------------
64
64
 
65
65
  export { Agent } from './agent/agent.js'
66
+ export { LoopDetector } from './agent/loop-detector.js'
67
+ export { buildStructuredOutputInstruction, extractJSON, validateOutput } from './agent/structured-output.js'
66
68
  export { AgentPool, Semaphore } from './agent/pool.js'
67
69
  export type { PoolStatus } from './agent/pool.js'
68
70
 
@@ -146,7 +148,10 @@ export type {
146
148
  AgentConfig,
147
149
  AgentState,
148
150
  AgentRunResult,
151
+ BeforeRunHookContext,
149
152
  ToolCallRecord,
153
+ LoopDetectionConfig,
154
+ LoopDetectionInfo,
150
155
 
151
156
  // Team
152
157
  TeamConfig,
@@ -160,7 +165,18 @@ export type {
160
165
  OrchestratorConfig,
161
166
  OrchestratorEvent,
162
167
 
168
+ // Trace
169
+ TraceEventType,
170
+ TraceEventBase,
171
+ TraceEvent,
172
+ LLMCallTrace,
173
+ ToolCallTrace,
174
+ TaskTrace,
175
+ AgentTrace,
176
+
163
177
  // Memory
164
178
  MemoryEntry,
165
179
  MemoryStore,
166
180
  } from './types.js'
181
+
182
+ export { generateRunId } from './utils/trace.js'
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * const anthropic = createAdapter('anthropic')
13
13
  * const openai = createAdapter('openai', process.env.OPENAI_API_KEY)
14
+ * const gemini = createAdapter('gemini', process.env.GEMINI_API_KEY)
14
15
  * ```
15
16
  */
16
17
 
@@ -37,33 +38,56 @@ import type { LLMAdapter } from '../types.js'
37
38
  * Additional providers can be integrated by implementing {@link LLMAdapter}
38
39
  * directly and bypassing this factory.
39
40
  */
40
- export type SupportedProvider = 'anthropic' | 'openai'
41
+ export type SupportedProvider = 'anthropic' | 'copilot' | 'grok' | 'openai' | 'gemini'
41
42
 
42
43
  /**
43
44
  * Instantiate the appropriate {@link LLMAdapter} for the given provider.
44
45
  *
45
- * API keys fall back to the standard environment variables
46
- * (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) when not supplied explicitly.
46
+ * API keys fall back to the standard environment variables when not supplied
47
+ * explicitly:
48
+ * - `anthropic` → `ANTHROPIC_API_KEY`
49
+ * - `openai` → `OPENAI_API_KEY`
50
+ * - `gemini` → `GEMINI_API_KEY` / `GOOGLE_API_KEY`
51
+ * - `grok` → `XAI_API_KEY`
52
+ * - `copilot` → `GITHUB_COPILOT_TOKEN` / `GITHUB_TOKEN`, or interactive
53
+ * OAuth2 device flow if neither is set
47
54
  *
48
55
  * Adapters are imported lazily so that projects using only one provider
49
56
  * are not forced to install the SDK for the other.
50
57
  *
51
58
  * @param provider - Which LLM provider to target.
52
59
  * @param apiKey - Optional API key override; falls back to env var.
60
+ * @param baseURL - Optional base URL for OpenAI-compatible APIs (Ollama, vLLM, etc.).
53
61
  * @throws {Error} When the provider string is not recognised.
54
62
  */
55
63
  export async function createAdapter(
56
64
  provider: SupportedProvider,
57
65
  apiKey?: string,
66
+ baseURL?: string,
58
67
  ): Promise<LLMAdapter> {
59
68
  switch (provider) {
60
69
  case 'anthropic': {
61
70
  const { AnthropicAdapter } = await import('./anthropic.js')
62
- return new AnthropicAdapter(apiKey)
71
+ return new AnthropicAdapter(apiKey, baseURL)
72
+ }
73
+ case 'copilot': {
74
+ if (baseURL) {
75
+ console.warn('[open-multi-agent] baseURL is not supported for the copilot provider and will be ignored.')
76
+ }
77
+ const { CopilotAdapter } = await import('./copilot.js')
78
+ return new CopilotAdapter(apiKey)
79
+ }
80
+ case 'gemini': {
81
+ const { GeminiAdapter } = await import('./gemini.js')
82
+ return new GeminiAdapter(apiKey)
63
83
  }
64
84
  case 'openai': {
65
85
  const { OpenAIAdapter } = await import('./openai.js')
66
- return new OpenAIAdapter(apiKey)
86
+ return new OpenAIAdapter(apiKey, baseURL)
87
+ }
88
+ case 'grok': {
89
+ const { GrokAdapter } = await import('./grok.js')
90
+ return new GrokAdapter(apiKey, baseURL)
67
91
  }
68
92
  default: {
69
93
  // The `never` cast here makes TypeScript enforce exhaustiveness.
@@ -189,9 +189,10 @@ export class AnthropicAdapter implements LLMAdapter {
189
189
 
190
190
  readonly #client: Anthropic
191
191
 
192
- constructor(apiKey?: string) {
192
+ constructor(apiKey?: string, baseURL?: string) {
193
193
  this.#client = new Anthropic({
194
194
  apiKey: apiKey ?? process.env['ANTHROPIC_API_KEY'],
195
+ baseURL,
195
196
  })
196
197
  }
197
198