@jackchen_me/open-multi-agent 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/CLAUDE.md +11 -3
  3. package/README.md +87 -20
  4. package/README_zh.md +85 -25
  5. package/dist/agent/agent.d.ts +15 -1
  6. package/dist/agent/agent.d.ts.map +1 -1
  7. package/dist/agent/agent.js +144 -10
  8. package/dist/agent/agent.js.map +1 -1
  9. package/dist/agent/loop-detector.d.ts +39 -0
  10. package/dist/agent/loop-detector.d.ts.map +1 -0
  11. package/dist/agent/loop-detector.js +122 -0
  12. package/dist/agent/loop-detector.js.map +1 -0
  13. package/dist/agent/pool.d.ts +2 -1
  14. package/dist/agent/pool.d.ts.map +1 -1
  15. package/dist/agent/pool.js +4 -2
  16. package/dist/agent/pool.js.map +1 -1
  17. package/dist/agent/runner.d.ts +23 -1
  18. package/dist/agent/runner.d.ts.map +1 -1
  19. package/dist/agent/runner.js +113 -12
  20. package/dist/agent/runner.js.map +1 -1
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/llm/adapter.d.ts +4 -1
  26. package/dist/llm/adapter.d.ts.map +1 -1
  27. package/dist/llm/adapter.js +11 -0
  28. package/dist/llm/adapter.js.map +1 -1
  29. package/dist/llm/copilot.d.ts.map +1 -1
  30. package/dist/llm/copilot.js +2 -1
  31. package/dist/llm/copilot.js.map +1 -1
  32. package/dist/llm/gemini.d.ts +65 -0
  33. package/dist/llm/gemini.d.ts.map +1 -0
  34. package/dist/llm/gemini.js +317 -0
  35. package/dist/llm/gemini.js.map +1 -0
  36. package/dist/llm/grok.d.ts +21 -0
  37. package/dist/llm/grok.d.ts.map +1 -0
  38. package/dist/llm/grok.js +24 -0
  39. package/dist/llm/grok.js.map +1 -0
  40. package/dist/llm/openai-common.d.ts +8 -1
  41. package/dist/llm/openai-common.d.ts.map +1 -1
  42. package/dist/llm/openai-common.js +35 -2
  43. package/dist/llm/openai-common.js.map +1 -1
  44. package/dist/llm/openai.d.ts +1 -1
  45. package/dist/llm/openai.d.ts.map +1 -1
  46. package/dist/llm/openai.js +20 -2
  47. package/dist/llm/openai.js.map +1 -1
  48. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  49. package/dist/orchestrator/orchestrator.js +89 -9
  50. package/dist/orchestrator/orchestrator.js.map +1 -1
  51. package/dist/task/queue.d.ts +31 -2
  52. package/dist/task/queue.d.ts.map +1 -1
  53. package/dist/task/queue.js +69 -2
  54. package/dist/task/queue.js.map +1 -1
  55. package/dist/tool/text-tool-extractor.d.ts +32 -0
  56. package/dist/tool/text-tool-extractor.d.ts.map +1 -0
  57. package/dist/tool/text-tool-extractor.js +187 -0
  58. package/dist/tool/text-tool-extractor.js.map +1 -0
  59. package/dist/types.d.ts +139 -7
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/utils/trace.d.ts +12 -0
  62. package/dist/utils/trace.d.ts.map +1 -0
  63. package/dist/utils/trace.js +30 -0
  64. package/dist/utils/trace.js.map +1 -0
  65. package/examples/06-local-model.ts +1 -0
  66. package/examples/08-gemma4-local.ts +76 -87
  67. package/examples/09-structured-output.ts +73 -0
  68. package/examples/10-task-retry.ts +132 -0
  69. package/examples/11-trace-observability.ts +133 -0
  70. package/examples/12-grok.ts +154 -0
  71. package/examples/13-gemini.ts +48 -0
  72. package/package.json +11 -1
  73. package/src/agent/agent.ts +159 -10
  74. package/src/agent/loop-detector.ts +137 -0
  75. package/src/agent/pool.ts +9 -2
  76. package/src/agent/runner.ts +148 -19
  77. package/src/index.ts +15 -0
  78. package/src/llm/adapter.ts +12 -1
  79. package/src/llm/copilot.ts +2 -1
  80. package/src/llm/gemini.ts +378 -0
  81. package/src/llm/grok.ts +29 -0
  82. package/src/llm/openai-common.ts +41 -2
  83. package/src/llm/openai.ts +23 -3
  84. package/src/orchestrator/orchestrator.ts +105 -11
  85. package/src/task/queue.ts +73 -3
  86. package/src/tool/text-tool-extractor.ts +219 -0
  87. package/src/types.ts +157 -6
  88. package/src/utils/trace.ts +34 -0
  89. package/tests/agent-hooks.test.ts +473 -0
  90. package/tests/agent-pool.test.ts +212 -0
  91. package/tests/approval.test.ts +464 -0
  92. package/tests/built-in-tools.test.ts +393 -0
  93. package/tests/gemini-adapter.test.ts +97 -0
  94. package/tests/grok-adapter.test.ts +74 -0
  95. package/tests/llm-adapters.test.ts +357 -0
  96. package/tests/loop-detection.test.ts +456 -0
  97. package/tests/openai-fallback.test.ts +159 -0
  98. package/tests/orchestrator.test.ts +281 -0
  99. package/tests/scheduler.test.ts +221 -0
  100. package/tests/team-messaging.test.ts +329 -0
  101. package/tests/text-tool-extractor.test.ts +170 -0
  102. package/tests/trace.test.ts +453 -0
  103. package/vitest.config.ts +9 -0
  104. package/examples/09-gemma4-auto-orchestration.ts +0 -162
@@ -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
package/src/index.ts CHANGED
@@ -63,6 +63,7 @@ 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'
66
67
  export { buildStructuredOutputInstruction, extractJSON, validateOutput } from './agent/structured-output.js'
67
68
  export { AgentPool, Semaphore } from './agent/pool.js'
68
69
  export type { PoolStatus } from './agent/pool.js'
@@ -147,7 +148,10 @@ export type {
147
148
  AgentConfig,
148
149
  AgentState,
149
150
  AgentRunResult,
151
+ BeforeRunHookContext,
150
152
  ToolCallRecord,
153
+ LoopDetectionConfig,
154
+ LoopDetectionInfo,
151
155
 
152
156
  // Team
153
157
  TeamConfig,
@@ -161,7 +165,18 @@ export type {
161
165
  OrchestratorConfig,
162
166
  OrchestratorEvent,
163
167
 
168
+ // Trace
169
+ TraceEventType,
170
+ TraceEventBase,
171
+ TraceEvent,
172
+ LLMCallTrace,
173
+ ToolCallTrace,
174
+ TaskTrace,
175
+ AgentTrace,
176
+
164
177
  // Memory
165
178
  MemoryEntry,
166
179
  MemoryStore,
167
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,7 +38,7 @@ 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' | 'copilot' | '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.
@@ -46,6 +47,8 @@ export type SupportedProvider = 'anthropic' | 'copilot' | 'openai'
46
47
  * explicitly:
47
48
  * - `anthropic` → `ANTHROPIC_API_KEY`
48
49
  * - `openai` → `OPENAI_API_KEY`
50
+ * - `gemini` → `GEMINI_API_KEY` / `GOOGLE_API_KEY`
51
+ * - `grok` → `XAI_API_KEY`
49
52
  * - `copilot` → `GITHUB_COPILOT_TOKEN` / `GITHUB_TOKEN`, or interactive
50
53
  * OAuth2 device flow if neither is set
51
54
  *
@@ -74,10 +77,18 @@ export async function createAdapter(
74
77
  const { CopilotAdapter } = await import('./copilot.js')
75
78
  return new CopilotAdapter(apiKey)
76
79
  }
80
+ case 'gemini': {
81
+ const { GeminiAdapter } = await import('./gemini.js')
82
+ return new GeminiAdapter(apiKey)
83
+ }
77
84
  case 'openai': {
78
85
  const { OpenAIAdapter } = await import('./openai.js')
79
86
  return new OpenAIAdapter(apiKey, baseURL)
80
87
  }
88
+ case 'grok': {
89
+ const { GrokAdapter } = await import('./grok.js')
90
+ return new GrokAdapter(apiKey, baseURL)
91
+ }
81
92
  default: {
82
93
  // The `never` cast here makes TypeScript enforce exhaustiveness.
83
94
  const _exhaustive: never = provider
@@ -313,7 +313,8 @@ export class CopilotAdapter implements LLMAdapter {
313
313
  },
314
314
  )
315
315
 
316
- return fromOpenAICompletion(completion)
316
+ const toolNames = options.tools?.map(t => t.name)
317
+ return fromOpenAICompletion(completion, toolNames)
317
318
  }
318
319
 
319
320
  // -------------------------------------------------------------------------