@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.
- package/.github/workflows/ci.yml +1 -1
- package/CLAUDE.md +11 -3
- package/README.md +87 -20
- package/README_zh.md +85 -25
- package/dist/agent/agent.d.ts +15 -1
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +144 -10
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/loop-detector.d.ts +39 -0
- package/dist/agent/loop-detector.d.ts.map +1 -0
- package/dist/agent/loop-detector.js +122 -0
- package/dist/agent/loop-detector.js.map +1 -0
- package/dist/agent/pool.d.ts +2 -1
- package/dist/agent/pool.d.ts.map +1 -1
- package/dist/agent/pool.js +4 -2
- package/dist/agent/pool.js.map +1 -1
- package/dist/agent/runner.d.ts +23 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +113 -12
- package/dist/agent/runner.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/llm/adapter.d.ts +4 -1
- package/dist/llm/adapter.d.ts.map +1 -1
- package/dist/llm/adapter.js +11 -0
- package/dist/llm/adapter.js.map +1 -1
- package/dist/llm/copilot.d.ts.map +1 -1
- package/dist/llm/copilot.js +2 -1
- package/dist/llm/copilot.js.map +1 -1
- package/dist/llm/gemini.d.ts +65 -0
- package/dist/llm/gemini.d.ts.map +1 -0
- package/dist/llm/gemini.js +317 -0
- package/dist/llm/gemini.js.map +1 -0
- package/dist/llm/grok.d.ts +21 -0
- package/dist/llm/grok.d.ts.map +1 -0
- package/dist/llm/grok.js +24 -0
- package/dist/llm/grok.js.map +1 -0
- package/dist/llm/openai-common.d.ts +8 -1
- package/dist/llm/openai-common.d.ts.map +1 -1
- package/dist/llm/openai-common.js +35 -2
- package/dist/llm/openai-common.js.map +1 -1
- package/dist/llm/openai.d.ts +1 -1
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +20 -2
- package/dist/llm/openai.js.map +1 -1
- package/dist/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.js +89 -9
- package/dist/orchestrator/orchestrator.js.map +1 -1
- package/dist/task/queue.d.ts +31 -2
- package/dist/task/queue.d.ts.map +1 -1
- package/dist/task/queue.js +69 -2
- package/dist/task/queue.js.map +1 -1
- package/dist/tool/text-tool-extractor.d.ts +32 -0
- package/dist/tool/text-tool-extractor.d.ts.map +1 -0
- package/dist/tool/text-tool-extractor.js +187 -0
- package/dist/tool/text-tool-extractor.js.map +1 -0
- package/dist/types.d.ts +139 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/trace.d.ts +12 -0
- package/dist/utils/trace.d.ts.map +1 -0
- package/dist/utils/trace.js +30 -0
- package/dist/utils/trace.js.map +1 -0
- package/examples/06-local-model.ts +1 -0
- package/examples/08-gemma4-local.ts +76 -87
- package/examples/09-structured-output.ts +73 -0
- package/examples/10-task-retry.ts +132 -0
- package/examples/11-trace-observability.ts +133 -0
- package/examples/12-grok.ts +154 -0
- package/examples/13-gemini.ts +48 -0
- package/package.json +11 -1
- package/src/agent/agent.ts +159 -10
- package/src/agent/loop-detector.ts +137 -0
- package/src/agent/pool.ts +9 -2
- package/src/agent/runner.ts +148 -19
- package/src/index.ts +15 -0
- package/src/llm/adapter.ts +12 -1
- package/src/llm/copilot.ts +2 -1
- package/src/llm/gemini.ts +378 -0
- package/src/llm/grok.ts +29 -0
- package/src/llm/openai-common.ts +41 -2
- package/src/llm/openai.ts +23 -3
- package/src/orchestrator/orchestrator.ts +105 -11
- package/src/task/queue.ts +73 -3
- package/src/tool/text-tool-extractor.ts +219 -0
- package/src/types.ts +157 -6
- package/src/utils/trace.ts +34 -0
- package/tests/agent-hooks.test.ts +473 -0
- package/tests/agent-pool.test.ts +212 -0
- package/tests/approval.test.ts +464 -0
- package/tests/built-in-tools.test.ts +393 -0
- package/tests/gemini-adapter.test.ts +97 -0
- package/tests/grok-adapter.test.ts +74 -0
- package/tests/llm-adapters.test.ts +357 -0
- package/tests/loop-detection.test.ts +456 -0
- package/tests/openai-fallback.test.ts +159 -0
- package/tests/orchestrator.test.ts +281 -0
- package/tests/scheduler.test.ts +221 -0
- package/tests/team-messaging.test.ts +329 -0
- package/tests/text-tool-extractor.test.ts +170 -0
- package/tests/trace.test.ts +453 -0
- package/vitest.config.ts +9 -0
- 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.
|
|
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"
|
package/src/agent/agent.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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) {
|