@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
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Example 12 — Multi-Agent Team Collaboration with Grok (xAI)
3
+ *
4
+ * Three specialized agents (architect, developer, reviewer) collaborate via `runTeam()`
5
+ * to build a minimal Express.js REST API. Every agent uses Grok's coding-optimized model.
6
+ *
7
+ * Run:
8
+ * npx tsx examples/12-grok.ts
9
+ *
10
+ * Prerequisites:
11
+ * XAI_API_KEY environment variable must be set.
12
+ */
13
+
14
+ import { OpenMultiAgent } from '../src/index.js'
15
+ import type { AgentConfig, OrchestratorEvent } from '../src/types.js'
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Agent definitions (all using grok-code-fast-1)
19
+ // ---------------------------------------------------------------------------
20
+ const architect: AgentConfig = {
21
+ name: 'architect',
22
+ model: 'grok-code-fast-1',
23
+ provider: 'grok',
24
+ systemPrompt: `You are a software architect with deep experience in Node.js and REST API design.
25
+ Your job is to design clear, production-quality API contracts and file/directory structures.
26
+ Output concise plans in markdown — no unnecessary prose.`,
27
+ tools: ['bash', 'file_write'],
28
+ maxTurns: 5,
29
+ temperature: 0.2,
30
+ }
31
+
32
+ const developer: AgentConfig = {
33
+ name: 'developer',
34
+ model: 'grok-code-fast-1',
35
+ provider: 'grok',
36
+ systemPrompt: `You are a TypeScript/Node.js developer. You implement what the architect specifies.
37
+ Write clean, runnable code with proper error handling. Use the tools to write files and run tests.`,
38
+ tools: ['bash', 'file_read', 'file_write', 'file_edit'],
39
+ maxTurns: 12,
40
+ temperature: 0.1,
41
+ }
42
+
43
+ const reviewer: AgentConfig = {
44
+ name: 'reviewer',
45
+ model: 'grok-code-fast-1',
46
+ provider: 'grok',
47
+ systemPrompt: `You are a senior code reviewer. Review code for correctness, security, and clarity.
48
+ Provide a structured review with: LGTM items, suggestions, and any blocking issues.
49
+ Read files using the tools before reviewing.`,
50
+ tools: ['bash', 'file_read', 'grep'],
51
+ maxTurns: 5,
52
+ temperature: 0.3,
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Progress tracking
57
+ // ---------------------------------------------------------------------------
58
+ const startTimes = new Map<string, number>()
59
+
60
+ function handleProgress(event: OrchestratorEvent): void {
61
+ const ts = new Date().toISOString().slice(11, 23) // HH:MM:SS.mmm
62
+ switch (event.type) {
63
+ case 'agent_start':
64
+ startTimes.set(event.agent ?? '', Date.now())
65
+ console.log(`[${ts}] AGENT START → ${event.agent}`)
66
+ break
67
+ case 'agent_complete': {
68
+ const elapsed = Date.now() - (startTimes.get(event.agent ?? '') ?? Date.now())
69
+ console.log(`[${ts}] AGENT DONE ← ${event.agent} (${elapsed}ms)`)
70
+ break
71
+ }
72
+ case 'task_start':
73
+ console.log(`[${ts}] TASK START ↓ ${event.task}`)
74
+ break
75
+ case 'task_complete':
76
+ console.log(`[${ts}] TASK DONE ↑ ${event.task}`)
77
+ break
78
+ case 'message':
79
+ console.log(`[${ts}] MESSAGE • ${event.agent} → (team)`)
80
+ break
81
+ case 'error':
82
+ console.error(`[${ts}] ERROR ✗ agent=${event.agent} task=${event.task}`)
83
+ if (event.data instanceof Error) console.error(` ${event.data.message}`)
84
+ break
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Orchestrate
90
+ // ---------------------------------------------------------------------------
91
+ const orchestrator = new OpenMultiAgent({
92
+ defaultModel: 'grok-code-fast-1',
93
+ defaultProvider: 'grok',
94
+ maxConcurrency: 1, // sequential for readable output
95
+ onProgress: handleProgress,
96
+ })
97
+
98
+ const team = orchestrator.createTeam('api-team', {
99
+ name: 'api-team',
100
+ agents: [architect, developer, reviewer],
101
+ sharedMemory: true,
102
+ maxConcurrency: 1,
103
+ })
104
+
105
+ console.log(`Team "${team.name}" created with agents: ${team.getAgents().map(a => a.name).join(', ')}`)
106
+ console.log('\nStarting team run...\n')
107
+ console.log('='.repeat(60))
108
+
109
+ const goal = `Create a minimal Express.js REST API in /tmp/express-api/ with:
110
+ - GET /health → { status: "ok" }
111
+ - GET /users → returns a hardcoded array of 2 user objects
112
+ - POST /users → accepts { name, email } body, logs it, returns 201
113
+ - Proper error handling middleware
114
+ - The server should listen on port 3001
115
+ - Include a package.json with the required dependencies`
116
+
117
+ const result = await orchestrator.runTeam(team, goal)
118
+
119
+ console.log('\n' + '='.repeat(60))
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Results
123
+ // ---------------------------------------------------------------------------
124
+ console.log('\nTeam run complete.')
125
+ console.log(`Success: ${result.success}`)
126
+ console.log(`Total tokens — input: ${result.totalTokenUsage.input_tokens}, output: ${result.totalTokenUsage.output_tokens}`)
127
+
128
+ console.log('\nPer-agent results:')
129
+ for (const [agentName, agentResult] of result.agentResults) {
130
+ const status = agentResult.success ? 'OK' : 'FAILED'
131
+ const tools = agentResult.toolCalls.length
132
+ console.log(` ${agentName.padEnd(12)} [${status}] tool_calls=${tools}`)
133
+ if (!agentResult.success) {
134
+ console.log(` Error: ${agentResult.output.slice(0, 120)}`)
135
+ }
136
+ }
137
+
138
+ // Sample outputs
139
+ const developerResult = result.agentResults.get('developer')
140
+ if (developerResult?.success) {
141
+ console.log('\nDeveloper output (last 600 chars):')
142
+ console.log('─'.repeat(60))
143
+ const out = developerResult.output
144
+ console.log(out.length > 600 ? '...' + out.slice(-600) : out)
145
+ console.log('─'.repeat(60))
146
+ }
147
+
148
+ const reviewerResult = result.agentResults.get('reviewer')
149
+ if (reviewerResult?.success) {
150
+ console.log('\nReviewer output:')
151
+ console.log('─'.repeat(60))
152
+ console.log(reviewerResult.output)
153
+ console.log('─'.repeat(60))
154
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Quick smoke test for the Gemini adapter.
3
+ *
4
+ * Run:
5
+ * npx tsx examples/13-gemini.ts
6
+ *
7
+ * If GEMINI_API_KEY is not set, the adapter will not work.
8
+ */
9
+
10
+ import { OpenMultiAgent } from '../src/index.js'
11
+ import type { OrchestratorEvent } from '../src/types.js'
12
+
13
+ const orchestrator = new OpenMultiAgent({
14
+ defaultModel: 'gemini-2.5-flash',
15
+ defaultProvider: 'gemini',
16
+ onProgress: (event: OrchestratorEvent) => {
17
+ if (event.type === 'agent_start') {
18
+ console.log(`[start] agent=${event.agent}`)
19
+ } else if (event.type === 'agent_complete') {
20
+ console.log(`[complete] agent=${event.agent}`)
21
+ }
22
+ },
23
+ })
24
+
25
+ console.log('Testing Gemini adapter with gemini-2.5-flash...\n')
26
+
27
+ const result = await orchestrator.runAgent(
28
+ {
29
+ name: 'assistant',
30
+ model: 'gemini-2.5-flash',
31
+ provider: 'gemini',
32
+ systemPrompt: 'You are a helpful assistant. Keep answers brief.',
33
+ maxTurns: 1,
34
+ maxTokens: 256,
35
+ },
36
+ 'What is 2 + 2? Reply in one sentence.',
37
+ )
38
+
39
+ if (result.success) {
40
+ console.log('\nAgent output:')
41
+ console.log('─'.repeat(60))
42
+ console.log(result.output)
43
+ console.log('─'.repeat(60))
44
+ console.log(`\nTokens: input=${result.tokenUsage.input_tokens}, output=${result.tokenUsage.output_tokens}`)
45
+ } else {
46
+ console.error('Agent failed:', result.output)
47
+ process.exit(1)
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackchen_me/open-multi-agent",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "description": "Production-grade multi-agent orchestration framework. Model-agnostic, supports team collaboration, task scheduling, and inter-agent communication.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,8 +41,18 @@
41
41
  "openai": "^4.73.0",
42
42
  "zod": "^3.23.0"
43
43
  },
44
+ "peerDependencies": {
45
+ "@google/genai": "^1.48.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@google/genai": {
49
+ "optional": true
50
+ }
51
+ },
44
52
  "devDependencies": {
53
+ "@google/genai": "^1.48.0",
45
54
  "@types/node": "^22.0.0",
55
+ "@vitest/coverage-v8": "^2.1.9",
46
56
  "tsx": "^4.21.0",
47
57
  "typescript": "^5.6.0",
48
58
  "vitest": "^2.1.0"
@@ -27,11 +27,13 @@ import type {
27
27
  AgentConfig,
28
28
  AgentState,
29
29
  AgentRunResult,
30
+ BeforeRunHookContext,
30
31
  LLMMessage,
31
32
  StreamEvent,
32
33
  TokenUsage,
33
34
  ToolUseContext,
34
35
  } from '../types.js'
36
+ import { emitTrace, generateRunId } from '../utils/trace.js'
35
37
  import type { ToolDefinition as FrameworkToolDefinition, ToolRegistry } from '../tool/framework.js'
36
38
  import type { ToolExecutor } from '../tool/executor.js'
37
39
  import { createAdapter } from '../llm/adapter.js'
@@ -48,6 +50,19 @@ import {
48
50
 
49
51
  const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
50
52
 
53
+ /**
54
+ * Combine two {@link AbortSignal}s so that aborting either one cancels the
55
+ * returned signal. Works on Node 18+ (no `AbortSignal.any` required).
56
+ */
57
+ function mergeAbortSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
58
+ const controller = new AbortController()
59
+ if (a.aborted || b.aborted) { controller.abort(); return controller.signal }
60
+ const abort = () => controller.abort()
61
+ a.addEventListener('abort', abort, { once: true })
62
+ b.addEventListener('abort', abort, { once: true })
63
+ return controller.signal
64
+ }
65
+
51
66
  function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
52
67
  return {
53
68
  input_tokens: a.input_tokens + b.input_tokens,
@@ -134,6 +149,7 @@ export class Agent {
134
149
  allowedTools: this.config.tools,
135
150
  agentName: this.name,
136
151
  agentRole: this.config.systemPrompt?.slice(0, 50) ?? 'assistant',
152
+ loopDetection: this.config.loopDetection,
137
153
  }
138
154
 
139
155
  this.runner = new AgentRunner(
@@ -158,12 +174,12 @@ export class Agent {
158
174
  *
159
175
  * Use this for one-shot queries where past context is irrelevant.
160
176
  */
161
- async run(prompt: string): Promise<AgentRunResult> {
177
+ async run(prompt: string, runOptions?: Partial<RunOptions>): Promise<AgentRunResult> {
162
178
  const messages: LLMMessage[] = [
163
179
  { role: 'user', content: [{ type: 'text', text: prompt }] },
164
180
  ]
165
181
 
166
- return this.executeRun(messages)
182
+ return this.executeRun(messages, runOptions)
167
183
  }
168
184
 
169
185
  /**
@@ -174,6 +190,7 @@ export class Agent {
174
190
  *
175
191
  * Use this for multi-turn interactions.
176
192
  */
193
+ // TODO(#18): accept optional RunOptions to forward trace context
177
194
  async prompt(message: string): Promise<AgentRunResult> {
178
195
  const userMessage: LLMMessage = {
179
196
  role: 'user',
@@ -197,6 +214,7 @@ export class Agent {
197
214
  *
198
215
  * Like {@link run}, this does not use or update the persistent history.
199
216
  */
217
+ // TODO(#18): accept optional RunOptions to forward trace context
200
218
  async *stream(prompt: string): AsyncGenerator<StreamEvent> {
201
219
  const messages: LLMMessage[] = [
202
220
  { role: 'user', content: [{ type: 'text', text: prompt }] },
@@ -266,15 +284,45 @@ export class Agent {
266
284
  * Shared execution path used by both `run` and `prompt`.
267
285
  * Handles state transitions and error wrapping.
268
286
  */
269
- private async executeRun(messages: LLMMessage[]): Promise<AgentRunResult> {
287
+ private async executeRun(
288
+ messages: LLMMessage[],
289
+ callerOptions?: Partial<RunOptions>,
290
+ ): Promise<AgentRunResult> {
270
291
  this.transitionTo('running')
271
292
 
293
+ const agentStartMs = Date.now()
294
+
272
295
  try {
296
+ // --- beforeRun hook ---
297
+ if (this.config.beforeRun) {
298
+ const hookCtx = this.buildBeforeRunHookContext(messages)
299
+ const modified = await this.config.beforeRun(hookCtx)
300
+ this.applyHookContext(messages, modified, hookCtx.prompt)
301
+ }
302
+
273
303
  const runner = await this.getRunner()
304
+ const internalOnMessage = (msg: LLMMessage) => {
305
+ this.state.messages.push(msg)
306
+ callerOptions?.onMessage?.(msg)
307
+ }
308
+ // Auto-generate runId when onTrace is provided but runId is missing
309
+ const needsRunId = callerOptions?.onTrace && !callerOptions.runId
310
+ // Create a fresh timeout signal per run (not per runner) so that
311
+ // each run() / prompt() call gets its own timeout window.
312
+ const timeoutSignal = this.config.timeoutMs !== undefined && this.config.timeoutMs > 0
313
+ ? AbortSignal.timeout(this.config.timeoutMs)
314
+ : undefined
315
+ // Merge caller-provided abortSignal with the timeout signal so that
316
+ // either cancellation source is respected.
317
+ const callerAbort = callerOptions?.abortSignal
318
+ const effectiveAbort = timeoutSignal && callerAbort
319
+ ? mergeAbortSignals(timeoutSignal, callerAbort)
320
+ : timeoutSignal ?? callerAbort
274
321
  const runOptions: RunOptions = {
275
- onMessage: msg => {
276
- this.state.messages.push(msg)
277
- },
322
+ ...callerOptions,
323
+ onMessage: internalOnMessage,
324
+ ...(needsRunId ? { runId: generateRunId() } : undefined),
325
+ ...(effectiveAbort ? { abortSignal: effectiveAbort } : undefined),
278
326
  }
279
327
 
280
328
  const result = await runner.run(messages, runOptions)
@@ -282,21 +330,35 @@ export class Agent {
282
330
 
283
331
  // --- Structured output validation ---
284
332
  if (this.config.outputSchema) {
285
- return this.validateStructuredOutput(
333
+ let validated = await this.validateStructuredOutput(
286
334
  messages,
287
335
  result,
288
336
  runner,
289
337
  runOptions,
290
338
  )
339
+ // --- afterRun hook ---
340
+ if (this.config.afterRun) {
341
+ validated = await this.config.afterRun(validated)
342
+ }
343
+ this.emitAgentTrace(callerOptions, agentStartMs, validated)
344
+ return validated
345
+ }
346
+
347
+ let agentResult = this.toAgentRunResult(result, true)
348
+
349
+ // --- afterRun hook ---
350
+ if (this.config.afterRun) {
351
+ agentResult = await this.config.afterRun(agentResult)
291
352
  }
292
353
 
293
354
  this.transitionTo('completed')
294
- return this.toAgentRunResult(result, true)
355
+ this.emitAgentTrace(callerOptions, agentStartMs, agentResult)
356
+ return agentResult
295
357
  } catch (err) {
296
358
  const error = err instanceof Error ? err : new Error(String(err))
297
359
  this.transitionToError(error)
298
360
 
299
- return {
361
+ const errorResult: AgentRunResult = {
300
362
  success: false,
301
363
  output: error.message,
302
364
  messages: [],
@@ -304,9 +366,33 @@ export class Agent {
304
366
  toolCalls: [],
305
367
  structured: undefined,
306
368
  }
369
+ this.emitAgentTrace(callerOptions, agentStartMs, errorResult)
370
+ return errorResult
307
371
  }
308
372
  }
309
373
 
374
+ /** Emit an `agent` trace event if `onTrace` is provided. */
375
+ private emitAgentTrace(
376
+ options: Partial<RunOptions> | undefined,
377
+ startMs: number,
378
+ result: AgentRunResult,
379
+ ): void {
380
+ if (!options?.onTrace) return
381
+ const endMs = Date.now()
382
+ emitTrace(options.onTrace, {
383
+ type: 'agent',
384
+ runId: options.runId ?? '',
385
+ taskId: options.taskId,
386
+ agent: options.traceAgent ?? this.name,
387
+ turns: result.messages.filter(m => m.role === 'assistant').length,
388
+ tokens: result.tokenUsage,
389
+ toolCalls: result.toolCalls.length,
390
+ startMs,
391
+ endMs,
392
+ durationMs: endMs - startMs,
393
+ })
394
+ }
395
+
310
396
  /**
311
397
  * Validate agent output against the configured `outputSchema`.
312
398
  * On first validation failure, retry once with error feedback.
@@ -398,13 +484,31 @@ export class Agent {
398
484
  this.transitionTo('running')
399
485
 
400
486
  try {
487
+ // --- beforeRun hook ---
488
+ if (this.config.beforeRun) {
489
+ const hookCtx = this.buildBeforeRunHookContext(messages)
490
+ const modified = await this.config.beforeRun(hookCtx)
491
+ this.applyHookContext(messages, modified, hookCtx.prompt)
492
+ }
493
+
401
494
  const runner = await this.getRunner()
495
+ // Fresh timeout per stream call, same as executeRun.
496
+ const timeoutSignal = this.config.timeoutMs !== undefined && this.config.timeoutMs > 0
497
+ ? AbortSignal.timeout(this.config.timeoutMs)
498
+ : undefined
402
499
 
403
- for await (const event of runner.stream(messages)) {
500
+ for await (const event of runner.stream(messages, timeoutSignal ? { abortSignal: timeoutSignal } : {})) {
404
501
  if (event.type === 'done') {
405
502
  const result = event.data as import('./runner.js').RunResult
406
503
  this.state.tokenUsage = addUsage(this.state.tokenUsage, result.tokenUsage)
504
+
505
+ let agentResult = this.toAgentRunResult(result, true)
506
+ if (this.config.afterRun) {
507
+ agentResult = await this.config.afterRun(agentResult)
508
+ }
407
509
  this.transitionTo('completed')
510
+ yield { type: 'done', data: agentResult } satisfies StreamEvent
511
+ continue
408
512
  } else if (event.type === 'error') {
409
513
  const error = event.data instanceof Error
410
514
  ? event.data
@@ -421,6 +525,50 @@ export class Agent {
421
525
  }
422
526
  }
423
527
 
528
+ // -------------------------------------------------------------------------
529
+ // Hook helpers
530
+ // -------------------------------------------------------------------------
531
+
532
+ /** Extract the prompt text from the last user message to build hook context. */
533
+ private buildBeforeRunHookContext(messages: LLMMessage[]): BeforeRunHookContext {
534
+ let prompt = ''
535
+ for (let i = messages.length - 1; i >= 0; i--) {
536
+ if (messages[i]!.role === 'user') {
537
+ prompt = messages[i]!.content
538
+ .filter((b): b is import('../types.js').TextBlock => b.type === 'text')
539
+ .map(b => b.text)
540
+ .join('')
541
+ break
542
+ }
543
+ }
544
+ // Strip hook functions to avoid circular self-references in the context
545
+ const { beforeRun, afterRun, ...agentInfo } = this.config
546
+ return { prompt, agent: agentInfo as AgentConfig }
547
+ }
548
+
549
+ /**
550
+ * Apply a (possibly modified) hook context back to the messages array.
551
+ *
552
+ * Only text blocks in the last user message are replaced; non-text content
553
+ * (images, tool results) is preserved. The array element is replaced (not
554
+ * mutated in place) so that shallow copies of the original array (e.g. from
555
+ * `prompt()`) are not affected.
556
+ */
557
+ private applyHookContext(messages: LLMMessage[], ctx: BeforeRunHookContext, originalPrompt: string): void {
558
+ if (ctx.prompt === originalPrompt) return
559
+
560
+ for (let i = messages.length - 1; i >= 0; i--) {
561
+ if (messages[i]!.role === 'user') {
562
+ const nonTextBlocks = messages[i]!.content.filter(b => b.type !== 'text')
563
+ messages[i] = {
564
+ role: 'user',
565
+ content: [{ type: 'text', text: ctx.prompt }, ...nonTextBlocks],
566
+ }
567
+ break
568
+ }
569
+ }
570
+ }
571
+
424
572
  // -------------------------------------------------------------------------
425
573
  // State transition helpers
426
574
  // -------------------------------------------------------------------------
@@ -449,6 +597,7 @@ export class Agent {
449
597
  tokenUsage: result.tokenUsage,
450
598
  toolCalls: result.toolCalls,
451
599
  structured,
600
+ ...(result.loopDetected ? { loopDetected: true } : {}),
452
601
  }
453
602
  }
454
603
 
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @fileoverview Sliding-window loop detector for the agent conversation loop.
3
+ *
4
+ * Tracks tool-call signatures and text outputs across turns to detect when an
5
+ * agent is stuck repeating the same actions. Used by {@link AgentRunner} when
6
+ * {@link LoopDetectionConfig} is provided.
7
+ */
8
+
9
+ import type { LoopDetectionConfig, LoopDetectionInfo } from '../types.js'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Recursively sort object keys so that `{b:1, a:2}` and `{a:2, b:1}` produce
17
+ * the same JSON string.
18
+ */
19
+ function sortKeys(value: unknown): unknown {
20
+ if (value === null || typeof value !== 'object') return value
21
+ if (Array.isArray(value)) return value.map(sortKeys)
22
+ const sorted: Record<string, unknown> = {}
23
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
24
+ sorted[key] = sortKeys((value as Record<string, unknown>)[key])
25
+ }
26
+ return sorted
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // LoopDetector
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export class LoopDetector {
34
+ private readonly maxRepeats: number
35
+ private readonly windowSize: number
36
+
37
+ private readonly toolSignatures: string[] = []
38
+ private readonly textOutputs: string[] = []
39
+
40
+ constructor(config: LoopDetectionConfig = {}) {
41
+ this.maxRepeats = config.maxRepetitions ?? 3
42
+ const requestedWindow = config.loopDetectionWindow ?? 4
43
+ // Window must be >= threshold, otherwise detection can never trigger.
44
+ this.windowSize = Math.max(requestedWindow, this.maxRepeats)
45
+ }
46
+
47
+ /**
48
+ * Record a turn's tool calls. Returns detection info when a loop is found.
49
+ */
50
+ recordToolCalls(
51
+ blocks: ReadonlyArray<{ name: string; input: Record<string, unknown> }>,
52
+ ): LoopDetectionInfo | null {
53
+ if (blocks.length === 0) return null
54
+
55
+ const signature = this.computeToolSignature(blocks)
56
+ this.push(this.toolSignatures, signature)
57
+
58
+ const count = this.consecutiveRepeats(this.toolSignatures)
59
+ if (count >= this.maxRepeats) {
60
+ const names = blocks.map(b => b.name).join(', ')
61
+ return {
62
+ kind: 'tool_repetition',
63
+ repetitions: count,
64
+ detail:
65
+ `Tool call "${names}" with identical arguments has repeated ` +
66
+ `${count} times consecutively. The agent appears to be stuck in a loop.`,
67
+ }
68
+ }
69
+ return null
70
+ }
71
+
72
+ /**
73
+ * Record a turn's text output. Returns detection info when a loop is found.
74
+ */
75
+ recordText(text: string): LoopDetectionInfo | null {
76
+ const normalised = text.trim().replace(/\s+/g, ' ')
77
+ if (normalised.length === 0) return null
78
+
79
+ this.push(this.textOutputs, normalised)
80
+
81
+ const count = this.consecutiveRepeats(this.textOutputs)
82
+ if (count >= this.maxRepeats) {
83
+ return {
84
+ kind: 'text_repetition',
85
+ repetitions: count,
86
+ detail:
87
+ `The agent has produced the same text response ${count} times ` +
88
+ `consecutively. It appears to be stuck in a loop.`,
89
+ }
90
+ }
91
+ return null
92
+ }
93
+
94
+ // -------------------------------------------------------------------------
95
+ // Private
96
+ // -------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Deterministic JSON signature for a set of tool calls.
100
+ * Sorts calls by name (for multi-tool turns) and keys within each input.
101
+ */
102
+ private computeToolSignature(
103
+ blocks: ReadonlyArray<{ name: string; input: Record<string, unknown> }>,
104
+ ): string {
105
+ const items = blocks
106
+ .map(b => ({ name: b.name, input: sortKeys(b.input) }))
107
+ .sort((a, b) => {
108
+ const cmp = a.name.localeCompare(b.name)
109
+ if (cmp !== 0) return cmp
110
+ return JSON.stringify(a.input).localeCompare(JSON.stringify(b.input))
111
+ })
112
+ return JSON.stringify(items)
113
+ }
114
+
115
+ /** Push an entry and trim the buffer to `windowSize`. */
116
+ private push(buffer: string[], entry: string): void {
117
+ buffer.push(entry)
118
+ while (buffer.length > this.windowSize) {
119
+ buffer.shift()
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Count how many consecutive identical entries exist at the tail of `buffer`.
125
+ * Returns 1 when the last entry is unique.
126
+ */
127
+ private consecutiveRepeats(buffer: string[]): number {
128
+ if (buffer.length === 0) return 0
129
+ const last = buffer[buffer.length - 1]
130
+ let count = 0
131
+ for (let i = buffer.length - 1; i >= 0; i--) {
132
+ if (buffer[i] === last) count++
133
+ else break
134
+ }
135
+ return count
136
+ }
137
+ }
package/src/agent/pool.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { AgentRunResult } from '../types.js'
24
+ import type { RunOptions } from './runner.js'
24
25
  import type { Agent } from './agent.js'
25
26
  import { Semaphore } from '../utils/semaphore.js'
26
27
 
@@ -123,12 +124,16 @@ export class AgentPool {
123
124
  *
124
125
  * @throws {Error} If the agent name is not found.
125
126
  */
126
- async run(agentName: string, prompt: string): Promise<AgentRunResult> {
127
+ async run(
128
+ agentName: string,
129
+ prompt: string,
130
+ runOptions?: Partial<RunOptions>,
131
+ ): Promise<AgentRunResult> {
127
132
  const agent = this.requireAgent(agentName)
128
133
 
129
134
  await this.semaphore.acquire()
130
135
  try {
131
- return await agent.run(prompt)
136
+ return await agent.run(prompt, runOptions)
132
137
  } finally {
133
138
  this.semaphore.release()
134
139
  }
@@ -144,6 +149,7 @@ export class AgentPool {
144
149
  *
145
150
  * @param tasks - Array of `{ agent, prompt }` descriptors.
146
151
  */
152
+ // TODO(#18): accept RunOptions per task to forward trace context
147
153
  async runParallel(
148
154
  tasks: ReadonlyArray<{ readonly agent: string; readonly prompt: string }>,
149
155
  ): Promise<Map<string, AgentRunResult>> {
@@ -182,6 +188,7 @@ export class AgentPool {
182
188
  *
183
189
  * @throws {Error} If the pool is empty.
184
190
  */
191
+ // TODO(#18): accept RunOptions to forward trace context
185
192
  async runAny(prompt: string): Promise<AgentRunResult> {
186
193
  const allAgents = this.list()
187
194
  if (allAgents.length === 0) {