@jackchen_me/open-multi-agent 0.2.0 → 1.0.1
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/README.md +87 -20
- 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/package.json +18 -2
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
- package/.github/pull_request_template.md +0 -14
- package/.github/workflows/ci.yml +0 -23
- package/CLAUDE.md +0 -72
- package/CODE_OF_CONDUCT.md +0 -48
- package/CONTRIBUTING.md +0 -72
- package/DECISIONS.md +0 -43
- package/README_zh.md +0 -217
- package/SECURITY.md +0 -17
- package/examples/01-single-agent.ts +0 -131
- package/examples/02-team-collaboration.ts +0 -167
- package/examples/03-task-pipeline.ts +0 -201
- package/examples/04-multi-model-team.ts +0 -261
- package/examples/05-copilot-test.ts +0 -49
- package/examples/06-local-model.ts +0 -199
- package/examples/07-fan-out-aggregate.ts +0 -209
- package/examples/08-gemma4-local.ts +0 -203
- package/examples/09-gemma4-auto-orchestration.ts +0 -162
- package/src/agent/agent.ts +0 -473
- package/src/agent/pool.ts +0 -278
- package/src/agent/runner.ts +0 -413
- package/src/agent/structured-output.ts +0 -126
- package/src/index.ts +0 -167
- package/src/llm/adapter.ts +0 -87
- package/src/llm/anthropic.ts +0 -389
- package/src/llm/copilot.ts +0 -551
- package/src/llm/openai-common.ts +0 -255
- package/src/llm/openai.ts +0 -272
- package/src/memory/shared.ts +0 -181
- package/src/memory/store.ts +0 -124
- package/src/orchestrator/orchestrator.ts +0 -977
- package/src/orchestrator/scheduler.ts +0 -352
- package/src/task/queue.ts +0 -394
- package/src/task/task.ts +0 -239
- package/src/team/messaging.ts +0 -232
- package/src/team/team.ts +0 -334
- package/src/tool/built-in/bash.ts +0 -187
- package/src/tool/built-in/file-edit.ts +0 -154
- package/src/tool/built-in/file-read.ts +0 -105
- package/src/tool/built-in/file-write.ts +0 -81
- package/src/tool/built-in/grep.ts +0 -362
- package/src/tool/built-in/index.ts +0 -50
- package/src/tool/executor.ts +0 -178
- package/src/tool/framework.ts +0 -557
- package/src/types.ts +0 -391
- package/src/utils/semaphore.ts +0 -89
- package/tests/semaphore.test.ts +0 -57
- package/tests/shared-memory.test.ts +0 -122
- package/tests/structured-output.test.ts +0 -331
- package/tests/task-queue.test.ts +0 -244
- package/tests/task-retry.test.ts +0 -368
- package/tests/task-utils.test.ts +0 -155
- package/tests/tool-executor.test.ts +0 -193
- package/tsconfig.json +0 -25
package/src/agent/pool.ts
DELETED
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Agent pool for managing and scheduling multiple agents.
|
|
3
|
-
*
|
|
4
|
-
* {@link AgentPool} is a registry + scheduler that:
|
|
5
|
-
* - Holds any number of named {@link Agent} instances
|
|
6
|
-
* - Enforces a concurrency cap across parallel runs via {@link Semaphore}
|
|
7
|
-
* - Provides `runParallel` for fan-out and `runAny` for round-robin dispatch
|
|
8
|
-
* - Reports aggregate pool health via `getStatus()`
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* ```ts
|
|
12
|
-
* const pool = new AgentPool(3)
|
|
13
|
-
* pool.add(researchAgent)
|
|
14
|
-
* pool.add(writerAgent)
|
|
15
|
-
*
|
|
16
|
-
* const results = await pool.runParallel([
|
|
17
|
-
* { agent: 'researcher', prompt: 'Find recent AI papers.' },
|
|
18
|
-
* { agent: 'writer', prompt: 'Draft an intro section.' },
|
|
19
|
-
* ])
|
|
20
|
-
* ```
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import type { AgentRunResult } from '../types.js'
|
|
24
|
-
import type { Agent } from './agent.js'
|
|
25
|
-
import { Semaphore } from '../utils/semaphore.js'
|
|
26
|
-
|
|
27
|
-
export { Semaphore } from '../utils/semaphore.js'
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Pool status snapshot
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
export interface PoolStatus {
|
|
34
|
-
/** Total number of agents registered in the pool. */
|
|
35
|
-
readonly total: number
|
|
36
|
-
/** Agents currently in `idle` state. */
|
|
37
|
-
readonly idle: number
|
|
38
|
-
/** Agents currently in `running` state. */
|
|
39
|
-
readonly running: number
|
|
40
|
-
/** Agents currently in `completed` state. */
|
|
41
|
-
readonly completed: number
|
|
42
|
-
/** Agents currently in `error` state. */
|
|
43
|
-
readonly error: number
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// AgentPool
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Registry and scheduler for a collection of {@link Agent} instances.
|
|
52
|
-
*
|
|
53
|
-
* Thread-safety note: Node.js is single-threaded, so the semaphore approach
|
|
54
|
-
* is safe — no atomics or mutex primitives are needed. The semaphore gates
|
|
55
|
-
* concurrent async operations, not CPU threads.
|
|
56
|
-
*/
|
|
57
|
-
export class AgentPool {
|
|
58
|
-
private readonly agents: Map<string, Agent> = new Map()
|
|
59
|
-
private readonly semaphore: Semaphore
|
|
60
|
-
/** Cursor used by `runAny` for round-robin dispatch. */
|
|
61
|
-
private roundRobinIndex = 0
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* @param maxConcurrency - Maximum number of agent runs allowed at the same
|
|
65
|
-
* time across the whole pool. Defaults to `5`.
|
|
66
|
-
*/
|
|
67
|
-
constructor(private readonly maxConcurrency: number = 5) {
|
|
68
|
-
this.semaphore = new Semaphore(maxConcurrency)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// -------------------------------------------------------------------------
|
|
72
|
-
// Registry operations
|
|
73
|
-
// -------------------------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Register an agent with the pool.
|
|
77
|
-
*
|
|
78
|
-
* @throws {Error} If an agent with the same name is already registered.
|
|
79
|
-
*/
|
|
80
|
-
add(agent: Agent): void {
|
|
81
|
-
if (this.agents.has(agent.name)) {
|
|
82
|
-
throw new Error(
|
|
83
|
-
`AgentPool: agent '${agent.name}' is already registered. ` +
|
|
84
|
-
`Call remove('${agent.name}') before re-adding.`,
|
|
85
|
-
)
|
|
86
|
-
}
|
|
87
|
-
this.agents.set(agent.name, agent)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Unregister an agent by name.
|
|
92
|
-
*
|
|
93
|
-
* @throws {Error} If the agent is not found.
|
|
94
|
-
*/
|
|
95
|
-
remove(name: string): void {
|
|
96
|
-
if (!this.agents.has(name)) {
|
|
97
|
-
throw new Error(`AgentPool: agent '${name}' is not registered.`)
|
|
98
|
-
}
|
|
99
|
-
this.agents.delete(name)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Retrieve a registered agent by name, or `undefined` if not found.
|
|
104
|
-
*/
|
|
105
|
-
get(name: string): Agent | undefined {
|
|
106
|
-
return this.agents.get(name)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Return all registered agents in insertion order.
|
|
111
|
-
*/
|
|
112
|
-
list(): Agent[] {
|
|
113
|
-
return Array.from(this.agents.values())
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// -------------------------------------------------------------------------
|
|
117
|
-
// Execution API
|
|
118
|
-
// -------------------------------------------------------------------------
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Run a single prompt on the named agent, respecting the pool concurrency
|
|
122
|
-
* limit.
|
|
123
|
-
*
|
|
124
|
-
* @throws {Error} If the agent name is not found.
|
|
125
|
-
*/
|
|
126
|
-
async run(agentName: string, prompt: string): Promise<AgentRunResult> {
|
|
127
|
-
const agent = this.requireAgent(agentName)
|
|
128
|
-
|
|
129
|
-
await this.semaphore.acquire()
|
|
130
|
-
try {
|
|
131
|
-
return await agent.run(prompt)
|
|
132
|
-
} finally {
|
|
133
|
-
this.semaphore.release()
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Run prompts on multiple agents in parallel, subject to the concurrency
|
|
139
|
-
* cap set at construction time.
|
|
140
|
-
*
|
|
141
|
-
* Results are returned as a `Map<agentName, AgentRunResult>`. If two tasks
|
|
142
|
-
* target the same agent name, the map will only contain the last result.
|
|
143
|
-
* Use unique agent names or run tasks sequentially in that case.
|
|
144
|
-
*
|
|
145
|
-
* @param tasks - Array of `{ agent, prompt }` descriptors.
|
|
146
|
-
*/
|
|
147
|
-
async runParallel(
|
|
148
|
-
tasks: ReadonlyArray<{ readonly agent: string; readonly prompt: string }>,
|
|
149
|
-
): Promise<Map<string, AgentRunResult>> {
|
|
150
|
-
const resultMap = new Map<string, AgentRunResult>()
|
|
151
|
-
|
|
152
|
-
const settledResults = await Promise.allSettled(
|
|
153
|
-
tasks.map(async task => {
|
|
154
|
-
const result = await this.run(task.agent, task.prompt)
|
|
155
|
-
return { name: task.agent, result }
|
|
156
|
-
}),
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
for (const settled of settledResults) {
|
|
160
|
-
if (settled.status === 'fulfilled') {
|
|
161
|
-
resultMap.set(settled.value.name, settled.value.result)
|
|
162
|
-
} else {
|
|
163
|
-
// A rejected run is surfaced as an error AgentRunResult so the caller
|
|
164
|
-
// sees it in the map rather than needing to catch Promise.allSettled.
|
|
165
|
-
// We cannot know the agent name from the rejection alone — find it via
|
|
166
|
-
// the original task list index.
|
|
167
|
-
const idx = settledResults.indexOf(settled)
|
|
168
|
-
const agentName = tasks[idx]?.agent ?? 'unknown'
|
|
169
|
-
resultMap.set(agentName, this.errorResult(settled.reason))
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return resultMap
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Run a prompt on the "best available" agent using round-robin selection.
|
|
178
|
-
*
|
|
179
|
-
* Agents are selected in insertion order, cycling back to the start. The
|
|
180
|
-
* concurrency limit is still enforced — if the selected agent is busy the
|
|
181
|
-
* call will queue via the semaphore.
|
|
182
|
-
*
|
|
183
|
-
* @throws {Error} If the pool is empty.
|
|
184
|
-
*/
|
|
185
|
-
async runAny(prompt: string): Promise<AgentRunResult> {
|
|
186
|
-
const allAgents = this.list()
|
|
187
|
-
if (allAgents.length === 0) {
|
|
188
|
-
throw new Error('AgentPool: cannot call runAny on an empty pool.')
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Wrap the index to keep it in bounds even if agents were removed.
|
|
192
|
-
this.roundRobinIndex = this.roundRobinIndex % allAgents.length
|
|
193
|
-
const agent = allAgents[this.roundRobinIndex]!
|
|
194
|
-
this.roundRobinIndex = (this.roundRobinIndex + 1) % allAgents.length
|
|
195
|
-
|
|
196
|
-
await this.semaphore.acquire()
|
|
197
|
-
try {
|
|
198
|
-
return await agent.run(prompt)
|
|
199
|
-
} finally {
|
|
200
|
-
this.semaphore.release()
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// -------------------------------------------------------------------------
|
|
205
|
-
// Observability
|
|
206
|
-
// -------------------------------------------------------------------------
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Snapshot of how many agents are in each lifecycle state.
|
|
210
|
-
*/
|
|
211
|
-
getStatus(): PoolStatus {
|
|
212
|
-
let idle = 0
|
|
213
|
-
let running = 0
|
|
214
|
-
let completed = 0
|
|
215
|
-
let error = 0
|
|
216
|
-
|
|
217
|
-
for (const agent of this.agents.values()) {
|
|
218
|
-
switch (agent.getState().status) {
|
|
219
|
-
case 'idle': idle++; break
|
|
220
|
-
case 'running': running++; break
|
|
221
|
-
case 'completed': completed++; break
|
|
222
|
-
case 'error': error++; break
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return { total: this.agents.size, idle, running, completed, error }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// -------------------------------------------------------------------------
|
|
230
|
-
// Lifecycle
|
|
231
|
-
// -------------------------------------------------------------------------
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Reset all agents in the pool.
|
|
235
|
-
*
|
|
236
|
-
* Clears their conversation histories and returns them to `idle` state.
|
|
237
|
-
* Does not remove agents from the pool.
|
|
238
|
-
*
|
|
239
|
-
* Async for forward compatibility — shutdown may need to perform async
|
|
240
|
-
* cleanup (e.g. draining in-flight requests) in future versions.
|
|
241
|
-
*/
|
|
242
|
-
async shutdown(): Promise<void> {
|
|
243
|
-
for (const agent of this.agents.values()) {
|
|
244
|
-
agent.reset()
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// -------------------------------------------------------------------------
|
|
249
|
-
// Private helpers
|
|
250
|
-
// -------------------------------------------------------------------------
|
|
251
|
-
|
|
252
|
-
private requireAgent(name: string): Agent {
|
|
253
|
-
const agent = this.agents.get(name)
|
|
254
|
-
if (agent === undefined) {
|
|
255
|
-
throw new Error(
|
|
256
|
-
`AgentPool: agent '${name}' is not registered. ` +
|
|
257
|
-
`Registered agents: [${Array.from(this.agents.keys()).join(', ')}]`,
|
|
258
|
-
)
|
|
259
|
-
}
|
|
260
|
-
return agent
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Build a failure {@link AgentRunResult} from a caught rejection reason.
|
|
265
|
-
* This keeps `runParallel` returning a complete map even when individual
|
|
266
|
-
* agents fail.
|
|
267
|
-
*/
|
|
268
|
-
private errorResult(reason: unknown): AgentRunResult {
|
|
269
|
-
const message = reason instanceof Error ? reason.message : String(reason)
|
|
270
|
-
return {
|
|
271
|
-
success: false,
|
|
272
|
-
output: message,
|
|
273
|
-
messages: [],
|
|
274
|
-
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
|
275
|
-
toolCalls: [],
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
package/src/agent/runner.ts
DELETED
|
@@ -1,413 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Core conversation loop engine for open-multi-agent.
|
|
3
|
-
*
|
|
4
|
-
* {@link AgentRunner} is the heart of the framework. It handles:
|
|
5
|
-
* - Sending messages to the LLM adapter
|
|
6
|
-
* - Extracting tool-use blocks from the response
|
|
7
|
-
* - Executing tool calls in parallel via {@link ToolExecutor}
|
|
8
|
-
* - Appending tool results and looping back until `end_turn`
|
|
9
|
-
* - Accumulating token usage and timing data across all turns
|
|
10
|
-
*
|
|
11
|
-
* The loop follows a standard agentic conversation pattern:
|
|
12
|
-
* one outer `while (true)` that breaks on `end_turn` or maxTurns exhaustion.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type {
|
|
16
|
-
LLMMessage,
|
|
17
|
-
ContentBlock,
|
|
18
|
-
TextBlock,
|
|
19
|
-
ToolUseBlock,
|
|
20
|
-
ToolResultBlock,
|
|
21
|
-
ToolCallRecord,
|
|
22
|
-
TokenUsage,
|
|
23
|
-
StreamEvent,
|
|
24
|
-
ToolResult,
|
|
25
|
-
ToolUseContext,
|
|
26
|
-
LLMAdapter,
|
|
27
|
-
LLMChatOptions,
|
|
28
|
-
} from '../types.js'
|
|
29
|
-
import type { ToolRegistry } from '../tool/framework.js'
|
|
30
|
-
import type { ToolExecutor } from '../tool/executor.js'
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Public interfaces
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Static configuration for an {@link AgentRunner} instance.
|
|
38
|
-
* These values are constant across every `run` / `stream` call.
|
|
39
|
-
*/
|
|
40
|
-
export interface RunnerOptions {
|
|
41
|
-
/** LLM model identifier, e.g. `'claude-opus-4-6'`. */
|
|
42
|
-
readonly model: string
|
|
43
|
-
/** Optional system prompt prepended to every conversation. */
|
|
44
|
-
readonly systemPrompt?: string
|
|
45
|
-
/**
|
|
46
|
-
* Maximum number of tool-call round-trips before the runner stops.
|
|
47
|
-
* Prevents unbounded loops. Defaults to `10`.
|
|
48
|
-
*/
|
|
49
|
-
readonly maxTurns?: number
|
|
50
|
-
/** Maximum output tokens per LLM response. */
|
|
51
|
-
readonly maxTokens?: number
|
|
52
|
-
/** Sampling temperature passed to the adapter. */
|
|
53
|
-
readonly temperature?: number
|
|
54
|
-
/** AbortSignal that cancels any in-flight adapter call and stops the loop. */
|
|
55
|
-
readonly abortSignal?: AbortSignal
|
|
56
|
-
/**
|
|
57
|
-
* Whitelist of tool names this runner is allowed to use.
|
|
58
|
-
* When provided, only tools whose name appears in this list are sent to the
|
|
59
|
-
* LLM. When omitted, all registered tools are available.
|
|
60
|
-
*/
|
|
61
|
-
readonly allowedTools?: readonly string[]
|
|
62
|
-
/** Display name of the agent driving this runner (used in tool context). */
|
|
63
|
-
readonly agentName?: string
|
|
64
|
-
/** Short role description of the agent (used in tool context). */
|
|
65
|
-
readonly agentRole?: string
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Per-call callbacks for observing tool execution in real time.
|
|
70
|
-
* All callbacks are optional; unused ones are simply skipped.
|
|
71
|
-
*/
|
|
72
|
-
export interface RunOptions {
|
|
73
|
-
/** Fired just before each tool is dispatched. */
|
|
74
|
-
readonly onToolCall?: (name: string, input: Record<string, unknown>) => void
|
|
75
|
-
/** Fired after each tool result is received. */
|
|
76
|
-
readonly onToolResult?: (name: string, result: ToolResult) => void
|
|
77
|
-
/** Fired after each complete {@link LLMMessage} is appended. */
|
|
78
|
-
readonly onMessage?: (message: LLMMessage) => void
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** The aggregated result returned when a full run completes. */
|
|
82
|
-
export interface RunResult {
|
|
83
|
-
/** All messages accumulated during this run (assistant + tool results). */
|
|
84
|
-
readonly messages: LLMMessage[]
|
|
85
|
-
/** The final text output from the last assistant turn. */
|
|
86
|
-
readonly output: string
|
|
87
|
-
/** All tool calls made during this run, in execution order. */
|
|
88
|
-
readonly toolCalls: ToolCallRecord[]
|
|
89
|
-
/** Aggregated token counts across every LLM call in this run. */
|
|
90
|
-
readonly tokenUsage: TokenUsage
|
|
91
|
-
/** Total number of LLM turns (including tool-call follow-ups). */
|
|
92
|
-
readonly turns: number
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
// Internal helpers
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
|
|
99
|
-
/** Extract every TextBlock from a content array and join them. */
|
|
100
|
-
function extractText(content: readonly ContentBlock[]): string {
|
|
101
|
-
return content
|
|
102
|
-
.filter((b): b is TextBlock => b.type === 'text')
|
|
103
|
-
.map(b => b.text)
|
|
104
|
-
.join('')
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Extract every ToolUseBlock from a content array. */
|
|
108
|
-
function extractToolUseBlocks(content: readonly ContentBlock[]): ToolUseBlock[] {
|
|
109
|
-
return content.filter((b): b is ToolUseBlock => b.type === 'tool_use')
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Add two {@link TokenUsage} values together, returning a new object. */
|
|
113
|
-
function addTokenUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|
114
|
-
return {
|
|
115
|
-
input_tokens: a.input_tokens + b.input_tokens,
|
|
116
|
-
output_tokens: a.output_tokens + b.output_tokens,
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
|
|
121
|
-
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
// AgentRunner
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Drives a full agentic conversation: LLM calls, tool execution, and looping.
|
|
128
|
-
*
|
|
129
|
-
* @example
|
|
130
|
-
* ```ts
|
|
131
|
-
* const runner = new AgentRunner(adapter, registry, executor, {
|
|
132
|
-
* model: 'claude-opus-4-6',
|
|
133
|
-
* maxTurns: 10,
|
|
134
|
-
* })
|
|
135
|
-
* const result = await runner.run(messages)
|
|
136
|
-
* console.log(result.output)
|
|
137
|
-
* ```
|
|
138
|
-
*/
|
|
139
|
-
export class AgentRunner {
|
|
140
|
-
private readonly maxTurns: number
|
|
141
|
-
|
|
142
|
-
constructor(
|
|
143
|
-
private readonly adapter: LLMAdapter,
|
|
144
|
-
private readonly toolRegistry: ToolRegistry,
|
|
145
|
-
private readonly toolExecutor: ToolExecutor,
|
|
146
|
-
private readonly options: RunnerOptions,
|
|
147
|
-
) {
|
|
148
|
-
this.maxTurns = options.maxTurns ?? 10
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// -------------------------------------------------------------------------
|
|
152
|
-
// Public API
|
|
153
|
-
// -------------------------------------------------------------------------
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Run a complete conversation starting from `messages`.
|
|
157
|
-
*
|
|
158
|
-
* The call may internally make multiple LLM requests (one per tool-call
|
|
159
|
-
* round-trip). It returns only when:
|
|
160
|
-
* - The LLM emits `end_turn` with no tool-use blocks, or
|
|
161
|
-
* - `maxTurns` is exceeded, or
|
|
162
|
-
* - The abort signal is triggered.
|
|
163
|
-
*/
|
|
164
|
-
async run(
|
|
165
|
-
messages: LLMMessage[],
|
|
166
|
-
options: RunOptions = {},
|
|
167
|
-
): Promise<RunResult> {
|
|
168
|
-
// 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
|
-
} = {
|
|
176
|
-
messages: [],
|
|
177
|
-
output: '',
|
|
178
|
-
toolCalls: [],
|
|
179
|
-
tokenUsage: ZERO_USAGE,
|
|
180
|
-
turns: 0,
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
for await (const event of this.stream(messages, options)) {
|
|
184
|
-
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
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return accumulated
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Run the conversation and yield {@link StreamEvent}s incrementally.
|
|
199
|
-
*
|
|
200
|
-
* Callers receive:
|
|
201
|
-
* - `{ type: 'text', data: string }` for each text delta
|
|
202
|
-
* - `{ type: 'tool_use', data: ToolUseBlock }` when the model requests a tool
|
|
203
|
-
* - `{ type: 'tool_result', data: ToolResultBlock }` after each execution
|
|
204
|
-
* - `{ type: 'done', data: RunResult }` at the very end
|
|
205
|
-
* - `{ type: 'error', data: Error }` on unrecoverable failure
|
|
206
|
-
*/
|
|
207
|
-
async *stream(
|
|
208
|
-
initialMessages: LLMMessage[],
|
|
209
|
-
options: RunOptions = {},
|
|
210
|
-
): AsyncGenerator<StreamEvent> {
|
|
211
|
-
// Working copy of the conversation — mutated as turns progress.
|
|
212
|
-
const conversationMessages: LLMMessage[] = [...initialMessages]
|
|
213
|
-
|
|
214
|
-
// Accumulated state across all turns.
|
|
215
|
-
let totalUsage: TokenUsage = ZERO_USAGE
|
|
216
|
-
const allToolCalls: ToolCallRecord[] = []
|
|
217
|
-
let finalOutput = ''
|
|
218
|
-
let turns = 0
|
|
219
|
-
|
|
220
|
-
// Build the stable LLM options once; model / tokens / temp don't change.
|
|
221
|
-
// toToolDefs() returns LLMToolDef[] (inputSchema, camelCase) — matches
|
|
222
|
-
// LLMChatOptions.tools from types.ts directly.
|
|
223
|
-
const allDefs = this.toolRegistry.toToolDefs()
|
|
224
|
-
const toolDefs = this.options.allowedTools
|
|
225
|
-
? allDefs.filter(d => this.options.allowedTools!.includes(d.name))
|
|
226
|
-
: allDefs
|
|
227
|
-
|
|
228
|
-
const baseChatOptions: LLMChatOptions = {
|
|
229
|
-
model: this.options.model,
|
|
230
|
-
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
231
|
-
maxTokens: this.options.maxTokens,
|
|
232
|
-
temperature: this.options.temperature,
|
|
233
|
-
systemPrompt: this.options.systemPrompt,
|
|
234
|
-
abortSignal: this.options.abortSignal,
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
try {
|
|
238
|
-
// -----------------------------------------------------------------
|
|
239
|
-
// Main agentic loop — `while (true)` until end_turn or maxTurns
|
|
240
|
-
// -----------------------------------------------------------------
|
|
241
|
-
while (true) {
|
|
242
|
-
// Respect abort before each LLM call.
|
|
243
|
-
if (this.options.abortSignal?.aborted) {
|
|
244
|
-
break
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Guard against unbounded loops.
|
|
248
|
-
if (turns >= this.maxTurns) {
|
|
249
|
-
break
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
turns++
|
|
253
|
-
|
|
254
|
-
// ------------------------------------------------------------------
|
|
255
|
-
// Step 1: Call the LLM and collect the full response for this turn.
|
|
256
|
-
// ------------------------------------------------------------------
|
|
257
|
-
const response = await this.adapter.chat(conversationMessages, baseChatOptions)
|
|
258
|
-
|
|
259
|
-
totalUsage = addTokenUsage(totalUsage, response.usage)
|
|
260
|
-
|
|
261
|
-
// ------------------------------------------------------------------
|
|
262
|
-
// Step 2: Build the assistant message from the response content.
|
|
263
|
-
// ------------------------------------------------------------------
|
|
264
|
-
const assistantMessage: LLMMessage = {
|
|
265
|
-
role: 'assistant',
|
|
266
|
-
content: response.content,
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
conversationMessages.push(assistantMessage)
|
|
270
|
-
options.onMessage?.(assistantMessage)
|
|
271
|
-
|
|
272
|
-
// Yield text deltas so streaming callers can display them promptly.
|
|
273
|
-
const turnText = extractText(response.content)
|
|
274
|
-
if (turnText.length > 0) {
|
|
275
|
-
yield { type: 'text', data: turnText } satisfies StreamEvent
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Announce each tool-use block the model requested.
|
|
279
|
-
const toolUseBlocks = extractToolUseBlocks(response.content)
|
|
280
|
-
for (const block of toolUseBlocks) {
|
|
281
|
-
yield { type: 'tool_use', data: block } satisfies StreamEvent
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ------------------------------------------------------------------
|
|
285
|
-
// Step 3: Decide whether to continue looping.
|
|
286
|
-
// ------------------------------------------------------------------
|
|
287
|
-
if (toolUseBlocks.length === 0) {
|
|
288
|
-
// No tools requested — this is the terminal assistant turn.
|
|
289
|
-
finalOutput = turnText
|
|
290
|
-
break
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// ------------------------------------------------------------------
|
|
294
|
-
// Step 4: Execute all tool calls in PARALLEL.
|
|
295
|
-
//
|
|
296
|
-
// Parallel execution is critical for multi-tool responses where the
|
|
297
|
-
// tools are independent (e.g. reading several files at once).
|
|
298
|
-
// ------------------------------------------------------------------
|
|
299
|
-
const toolContext: ToolUseContext = this.buildToolContext()
|
|
300
|
-
|
|
301
|
-
const executionPromises = toolUseBlocks.map(async (block): Promise<{
|
|
302
|
-
resultBlock: ToolResultBlock
|
|
303
|
-
record: ToolCallRecord
|
|
304
|
-
}> => {
|
|
305
|
-
options.onToolCall?.(block.name, block.input)
|
|
306
|
-
|
|
307
|
-
const startTime = Date.now()
|
|
308
|
-
let result: ToolResult
|
|
309
|
-
|
|
310
|
-
try {
|
|
311
|
-
result = await this.toolExecutor.execute(
|
|
312
|
-
block.name,
|
|
313
|
-
block.input,
|
|
314
|
-
toolContext,
|
|
315
|
-
)
|
|
316
|
-
} catch (err) {
|
|
317
|
-
// Tool executor errors become error results — the loop continues.
|
|
318
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
319
|
-
result = { data: message, isError: true }
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const duration = Date.now() - startTime
|
|
323
|
-
|
|
324
|
-
options.onToolResult?.(block.name, result)
|
|
325
|
-
|
|
326
|
-
const record: ToolCallRecord = {
|
|
327
|
-
toolName: block.name,
|
|
328
|
-
input: block.input,
|
|
329
|
-
output: result.data,
|
|
330
|
-
duration,
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const resultBlock: ToolResultBlock = {
|
|
334
|
-
type: 'tool_result',
|
|
335
|
-
tool_use_id: block.id,
|
|
336
|
-
content: result.data,
|
|
337
|
-
is_error: result.isError,
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return { resultBlock, record }
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
// Wait for every tool in this turn to finish.
|
|
344
|
-
const executions = await Promise.all(executionPromises)
|
|
345
|
-
|
|
346
|
-
// ------------------------------------------------------------------
|
|
347
|
-
// Step 5: Accumulate results and build the user message that carries
|
|
348
|
-
// them back to the LLM in the next turn.
|
|
349
|
-
// ------------------------------------------------------------------
|
|
350
|
-
const toolResultBlocks: ContentBlock[] = executions.map(e => e.resultBlock)
|
|
351
|
-
|
|
352
|
-
for (const { record, resultBlock } of executions) {
|
|
353
|
-
allToolCalls.push(record)
|
|
354
|
-
yield { type: 'tool_result', data: resultBlock } satisfies StreamEvent
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const toolResultMessage: LLMMessage = {
|
|
358
|
-
role: 'user',
|
|
359
|
-
content: toolResultBlocks,
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
conversationMessages.push(toolResultMessage)
|
|
363
|
-
options.onMessage?.(toolResultMessage)
|
|
364
|
-
|
|
365
|
-
// Loop back to Step 1 — send updated conversation to the LLM.
|
|
366
|
-
}
|
|
367
|
-
} catch (err) {
|
|
368
|
-
const error = err instanceof Error ? err : new Error(String(err))
|
|
369
|
-
yield { type: 'error', data: error } satisfies StreamEvent
|
|
370
|
-
return
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// If the loop exited due to maxTurns, use whatever text was last emitted.
|
|
374
|
-
if (finalOutput === '' && conversationMessages.length > 0) {
|
|
375
|
-
const lastAssistant = [...conversationMessages]
|
|
376
|
-
.reverse()
|
|
377
|
-
.find(m => m.role === 'assistant')
|
|
378
|
-
if (lastAssistant !== undefined) {
|
|
379
|
-
finalOutput = extractText(lastAssistant.content)
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const runResult: RunResult = {
|
|
384
|
-
// Return only the messages added during this run (not the initial seed).
|
|
385
|
-
messages: conversationMessages.slice(initialMessages.length),
|
|
386
|
-
output: finalOutput,
|
|
387
|
-
toolCalls: allToolCalls,
|
|
388
|
-
tokenUsage: totalUsage,
|
|
389
|
-
turns,
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
yield { type: 'done', data: runResult } satisfies StreamEvent
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// -------------------------------------------------------------------------
|
|
396
|
-
// Private helpers
|
|
397
|
-
// -------------------------------------------------------------------------
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Build the {@link ToolUseContext} passed to every tool execution.
|
|
401
|
-
* Identifies this runner as the invoking agent.
|
|
402
|
-
*/
|
|
403
|
-
private buildToolContext(): ToolUseContext {
|
|
404
|
-
return {
|
|
405
|
-
agent: {
|
|
406
|
-
name: this.options.agentName ?? 'runner',
|
|
407
|
-
role: this.options.agentRole ?? 'assistant',
|
|
408
|
-
model: this.options.model,
|
|
409
|
-
},
|
|
410
|
-
abortSignal: this.options.abortSignal,
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|