@jackchen_me/open-multi-agent 1.0.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/package.json +8 -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 -80
- package/CODE_OF_CONDUCT.md +0 -48
- package/CONTRIBUTING.md +0 -72
- package/DECISIONS.md +0 -43
- package/README_zh.md +0 -277
- 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 -200
- package/examples/07-fan-out-aggregate.ts +0 -209
- package/examples/08-gemma4-local.ts +0 -192
- package/examples/09-structured-output.ts +0 -73
- package/examples/10-task-retry.ts +0 -132
- package/examples/11-trace-observability.ts +0 -133
- package/examples/12-grok.ts +0 -154
- package/examples/13-gemini.ts +0 -48
- package/src/agent/agent.ts +0 -622
- package/src/agent/loop-detector.ts +0 -137
- package/src/agent/pool.ts +0 -285
- package/src/agent/runner.ts +0 -542
- package/src/agent/structured-output.ts +0 -126
- package/src/index.ts +0 -182
- package/src/llm/adapter.ts +0 -98
- package/src/llm/anthropic.ts +0 -389
- package/src/llm/copilot.ts +0 -552
- package/src/llm/gemini.ts +0 -378
- package/src/llm/grok.ts +0 -29
- package/src/llm/openai-common.ts +0 -294
- package/src/llm/openai.ts +0 -292
- package/src/memory/shared.ts +0 -181
- package/src/memory/store.ts +0 -124
- package/src/orchestrator/orchestrator.ts +0 -1071
- package/src/orchestrator/scheduler.ts +0 -352
- package/src/task/queue.ts +0 -464
- 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/tool/text-tool-extractor.ts +0 -219
- package/src/types.ts +0 -542
- package/src/utils/semaphore.ts +0 -89
- package/src/utils/trace.ts +0 -34
- package/tests/agent-hooks.test.ts +0 -473
- package/tests/agent-pool.test.ts +0 -212
- package/tests/approval.test.ts +0 -464
- package/tests/built-in-tools.test.ts +0 -393
- package/tests/gemini-adapter.test.ts +0 -97
- package/tests/grok-adapter.test.ts +0 -74
- package/tests/llm-adapters.test.ts +0 -357
- package/tests/loop-detection.test.ts +0 -456
- package/tests/openai-fallback.test.ts +0 -159
- package/tests/orchestrator.test.ts +0 -281
- package/tests/scheduler.test.ts +0 -221
- 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/team-messaging.test.ts +0 -329
- package/tests/text-tool-extractor.test.ts +0 -170
- package/tests/tool-executor.test.ts +0 -193
- package/tests/trace.test.ts +0 -453
- package/tsconfig.json +0 -25
- package/vitest.config.ts +0 -9
package/src/agent/runner.ts
DELETED
|
@@ -1,542 +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
|
-
TraceEvent,
|
|
29
|
-
LoopDetectionConfig,
|
|
30
|
-
LoopDetectionInfo,
|
|
31
|
-
} from '../types.js'
|
|
32
|
-
import { LoopDetector } from './loop-detector.js'
|
|
33
|
-
import { emitTrace } from '../utils/trace.js'
|
|
34
|
-
import type { ToolRegistry } from '../tool/framework.js'
|
|
35
|
-
import type { ToolExecutor } from '../tool/executor.js'
|
|
36
|
-
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
// Public interfaces
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Static configuration for an {@link AgentRunner} instance.
|
|
43
|
-
* These values are constant across every `run` / `stream` call.
|
|
44
|
-
*/
|
|
45
|
-
export interface RunnerOptions {
|
|
46
|
-
/** LLM model identifier, e.g. `'claude-opus-4-6'`. */
|
|
47
|
-
readonly model: string
|
|
48
|
-
/** Optional system prompt prepended to every conversation. */
|
|
49
|
-
readonly systemPrompt?: string
|
|
50
|
-
/**
|
|
51
|
-
* Maximum number of tool-call round-trips before the runner stops.
|
|
52
|
-
* Prevents unbounded loops. Defaults to `10`.
|
|
53
|
-
*/
|
|
54
|
-
readonly maxTurns?: number
|
|
55
|
-
/** Maximum output tokens per LLM response. */
|
|
56
|
-
readonly maxTokens?: number
|
|
57
|
-
/** Sampling temperature passed to the adapter. */
|
|
58
|
-
readonly temperature?: number
|
|
59
|
-
/** AbortSignal that cancels any in-flight adapter call and stops the loop. */
|
|
60
|
-
readonly abortSignal?: AbortSignal
|
|
61
|
-
/**
|
|
62
|
-
* Whitelist of tool names this runner is allowed to use.
|
|
63
|
-
* When provided, only tools whose name appears in this list are sent to the
|
|
64
|
-
* LLM. When omitted, all registered tools are available.
|
|
65
|
-
*/
|
|
66
|
-
readonly allowedTools?: readonly string[]
|
|
67
|
-
/** Display name of the agent driving this runner (used in tool context). */
|
|
68
|
-
readonly agentName?: string
|
|
69
|
-
/** Short role description of the agent (used in tool context). */
|
|
70
|
-
readonly agentRole?: string
|
|
71
|
-
/** Loop detection configuration. When set, detects stuck agent loops. */
|
|
72
|
-
readonly loopDetection?: LoopDetectionConfig
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Per-call callbacks for observing tool execution in real time.
|
|
77
|
-
* All callbacks are optional; unused ones are simply skipped.
|
|
78
|
-
*/
|
|
79
|
-
export interface RunOptions {
|
|
80
|
-
/** Fired just before each tool is dispatched. */
|
|
81
|
-
readonly onToolCall?: (name: string, input: Record<string, unknown>) => void
|
|
82
|
-
/** Fired after each tool result is received. */
|
|
83
|
-
readonly onToolResult?: (name: string, result: ToolResult) => void
|
|
84
|
-
/** Fired after each complete {@link LLMMessage} is appended. */
|
|
85
|
-
readonly onMessage?: (message: LLMMessage) => void
|
|
86
|
-
/**
|
|
87
|
-
* Fired when the runner detects a potential configuration issue.
|
|
88
|
-
* For example, when a model appears to ignore tool definitions.
|
|
89
|
-
*/
|
|
90
|
-
readonly onWarning?: (message: string) => void
|
|
91
|
-
/** Trace callback for observability spans. Async callbacks are safe. */
|
|
92
|
-
readonly onTrace?: (event: TraceEvent) => void | Promise<void>
|
|
93
|
-
/** Run ID for trace correlation. */
|
|
94
|
-
readonly runId?: string
|
|
95
|
-
/** Task ID for trace correlation. */
|
|
96
|
-
readonly taskId?: string
|
|
97
|
-
/** Agent name for trace correlation (overrides RunnerOptions.agentName). */
|
|
98
|
-
readonly traceAgent?: string
|
|
99
|
-
/**
|
|
100
|
-
* Per-call abort signal. When set, takes precedence over the static
|
|
101
|
-
* {@link RunnerOptions.abortSignal}. Useful for per-run timeouts.
|
|
102
|
-
*/
|
|
103
|
-
readonly abortSignal?: AbortSignal
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** The aggregated result returned when a full run completes. */
|
|
107
|
-
export interface RunResult {
|
|
108
|
-
/** All messages accumulated during this run (assistant + tool results). */
|
|
109
|
-
readonly messages: LLMMessage[]
|
|
110
|
-
/** The final text output from the last assistant turn. */
|
|
111
|
-
readonly output: string
|
|
112
|
-
/** All tool calls made during this run, in execution order. */
|
|
113
|
-
readonly toolCalls: ToolCallRecord[]
|
|
114
|
-
/** Aggregated token counts across every LLM call in this run. */
|
|
115
|
-
readonly tokenUsage: TokenUsage
|
|
116
|
-
/** Total number of LLM turns (including tool-call follow-ups). */
|
|
117
|
-
readonly turns: number
|
|
118
|
-
/** True when the run was terminated or warned due to loop detection. */
|
|
119
|
-
readonly loopDetected?: boolean
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
// Internal helpers
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
/** Extract every TextBlock from a content array and join them. */
|
|
127
|
-
function extractText(content: readonly ContentBlock[]): string {
|
|
128
|
-
return content
|
|
129
|
-
.filter((b): b is TextBlock => b.type === 'text')
|
|
130
|
-
.map(b => b.text)
|
|
131
|
-
.join('')
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Extract every ToolUseBlock from a content array. */
|
|
135
|
-
function extractToolUseBlocks(content: readonly ContentBlock[]): ToolUseBlock[] {
|
|
136
|
-
return content.filter((b): b is ToolUseBlock => b.type === 'tool_use')
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Add two {@link TokenUsage} values together, returning a new object. */
|
|
140
|
-
function addTokenUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|
141
|
-
return {
|
|
142
|
-
input_tokens: a.input_tokens + b.input_tokens,
|
|
143
|
-
output_tokens: a.output_tokens + b.output_tokens,
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
|
|
148
|
-
|
|
149
|
-
// ---------------------------------------------------------------------------
|
|
150
|
-
// AgentRunner
|
|
151
|
-
// ---------------------------------------------------------------------------
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Drives a full agentic conversation: LLM calls, tool execution, and looping.
|
|
155
|
-
*
|
|
156
|
-
* @example
|
|
157
|
-
* ```ts
|
|
158
|
-
* const runner = new AgentRunner(adapter, registry, executor, {
|
|
159
|
-
* model: 'claude-opus-4-6',
|
|
160
|
-
* maxTurns: 10,
|
|
161
|
-
* })
|
|
162
|
-
* const result = await runner.run(messages)
|
|
163
|
-
* console.log(result.output)
|
|
164
|
-
* ```
|
|
165
|
-
*/
|
|
166
|
-
export class AgentRunner {
|
|
167
|
-
private readonly maxTurns: number
|
|
168
|
-
|
|
169
|
-
constructor(
|
|
170
|
-
private readonly adapter: LLMAdapter,
|
|
171
|
-
private readonly toolRegistry: ToolRegistry,
|
|
172
|
-
private readonly toolExecutor: ToolExecutor,
|
|
173
|
-
private readonly options: RunnerOptions,
|
|
174
|
-
) {
|
|
175
|
-
this.maxTurns = options.maxTurns ?? 10
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// -------------------------------------------------------------------------
|
|
179
|
-
// Public API
|
|
180
|
-
// -------------------------------------------------------------------------
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Run a complete conversation starting from `messages`.
|
|
184
|
-
*
|
|
185
|
-
* The call may internally make multiple LLM requests (one per tool-call
|
|
186
|
-
* round-trip). It returns only when:
|
|
187
|
-
* - The LLM emits `end_turn` with no tool-use blocks, or
|
|
188
|
-
* - `maxTurns` is exceeded, or
|
|
189
|
-
* - The abort signal is triggered.
|
|
190
|
-
*/
|
|
191
|
-
async run(
|
|
192
|
-
messages: LLMMessage[],
|
|
193
|
-
options: RunOptions = {},
|
|
194
|
-
): Promise<RunResult> {
|
|
195
|
-
// Collect everything yielded by the internal streaming loop.
|
|
196
|
-
const accumulated: RunResult = {
|
|
197
|
-
messages: [],
|
|
198
|
-
output: '',
|
|
199
|
-
toolCalls: [],
|
|
200
|
-
tokenUsage: ZERO_USAGE,
|
|
201
|
-
turns: 0,
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
for await (const event of this.stream(messages, options)) {
|
|
205
|
-
if (event.type === 'done') {
|
|
206
|
-
Object.assign(accumulated, event.data)
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return accumulated
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Run the conversation and yield {@link StreamEvent}s incrementally.
|
|
215
|
-
*
|
|
216
|
-
* Callers receive:
|
|
217
|
-
* - `{ type: 'text', data: string }` for each text delta
|
|
218
|
-
* - `{ type: 'tool_use', data: ToolUseBlock }` when the model requests a tool
|
|
219
|
-
* - `{ type: 'tool_result', data: ToolResultBlock }` after each execution
|
|
220
|
-
* - `{ type: 'done', data: RunResult }` at the very end
|
|
221
|
-
* - `{ type: 'error', data: Error }` on unrecoverable failure
|
|
222
|
-
*/
|
|
223
|
-
async *stream(
|
|
224
|
-
initialMessages: LLMMessage[],
|
|
225
|
-
options: RunOptions = {},
|
|
226
|
-
): AsyncGenerator<StreamEvent> {
|
|
227
|
-
// Working copy of the conversation — mutated as turns progress.
|
|
228
|
-
const conversationMessages: LLMMessage[] = [...initialMessages]
|
|
229
|
-
|
|
230
|
-
// Accumulated state across all turns.
|
|
231
|
-
let totalUsage: TokenUsage = ZERO_USAGE
|
|
232
|
-
const allToolCalls: ToolCallRecord[] = []
|
|
233
|
-
let finalOutput = ''
|
|
234
|
-
let turns = 0
|
|
235
|
-
|
|
236
|
-
// Build the stable LLM options once; model / tokens / temp don't change.
|
|
237
|
-
// toToolDefs() returns LLMToolDef[] (inputSchema, camelCase) — matches
|
|
238
|
-
// LLMChatOptions.tools from types.ts directly.
|
|
239
|
-
const allDefs = this.toolRegistry.toToolDefs()
|
|
240
|
-
const toolDefs = this.options.allowedTools
|
|
241
|
-
? allDefs.filter(d => this.options.allowedTools!.includes(d.name))
|
|
242
|
-
: allDefs
|
|
243
|
-
|
|
244
|
-
// Per-call abortSignal takes precedence over the static one.
|
|
245
|
-
const effectiveAbortSignal = options.abortSignal ?? this.options.abortSignal
|
|
246
|
-
|
|
247
|
-
const baseChatOptions: LLMChatOptions = {
|
|
248
|
-
model: this.options.model,
|
|
249
|
-
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
250
|
-
maxTokens: this.options.maxTokens,
|
|
251
|
-
temperature: this.options.temperature,
|
|
252
|
-
systemPrompt: this.options.systemPrompt,
|
|
253
|
-
abortSignal: effectiveAbortSignal,
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Loop detection state — only allocated when configured.
|
|
257
|
-
const detector = this.options.loopDetection
|
|
258
|
-
? new LoopDetector(this.options.loopDetection)
|
|
259
|
-
: null
|
|
260
|
-
let loopDetected = false
|
|
261
|
-
let loopWarned = false
|
|
262
|
-
const loopAction = this.options.loopDetection?.onLoopDetected ?? 'warn'
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
// -----------------------------------------------------------------
|
|
266
|
-
// Main agentic loop — `while (true)` until end_turn or maxTurns
|
|
267
|
-
// -----------------------------------------------------------------
|
|
268
|
-
while (true) {
|
|
269
|
-
// Respect abort before each LLM call.
|
|
270
|
-
if (effectiveAbortSignal?.aborted) {
|
|
271
|
-
break
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Guard against unbounded loops.
|
|
275
|
-
if (turns >= this.maxTurns) {
|
|
276
|
-
break
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
turns++
|
|
280
|
-
|
|
281
|
-
// ------------------------------------------------------------------
|
|
282
|
-
// Step 1: Call the LLM and collect the full response for this turn.
|
|
283
|
-
// ------------------------------------------------------------------
|
|
284
|
-
const llmStartMs = Date.now()
|
|
285
|
-
const response = await this.adapter.chat(conversationMessages, baseChatOptions)
|
|
286
|
-
if (options.onTrace) {
|
|
287
|
-
const llmEndMs = Date.now()
|
|
288
|
-
emitTrace(options.onTrace, {
|
|
289
|
-
type: 'llm_call',
|
|
290
|
-
runId: options.runId ?? '',
|
|
291
|
-
taskId: options.taskId,
|
|
292
|
-
agent: options.traceAgent ?? this.options.agentName ?? 'unknown',
|
|
293
|
-
model: this.options.model,
|
|
294
|
-
turn: turns,
|
|
295
|
-
tokens: response.usage,
|
|
296
|
-
startMs: llmStartMs,
|
|
297
|
-
endMs: llmEndMs,
|
|
298
|
-
durationMs: llmEndMs - llmStartMs,
|
|
299
|
-
})
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
totalUsage = addTokenUsage(totalUsage, response.usage)
|
|
303
|
-
|
|
304
|
-
// ------------------------------------------------------------------
|
|
305
|
-
// Step 2: Build the assistant message from the response content.
|
|
306
|
-
// ------------------------------------------------------------------
|
|
307
|
-
const assistantMessage: LLMMessage = {
|
|
308
|
-
role: 'assistant',
|
|
309
|
-
content: response.content,
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
conversationMessages.push(assistantMessage)
|
|
313
|
-
options.onMessage?.(assistantMessage)
|
|
314
|
-
|
|
315
|
-
// Yield text deltas so streaming callers can display them promptly.
|
|
316
|
-
const turnText = extractText(response.content)
|
|
317
|
-
if (turnText.length > 0) {
|
|
318
|
-
yield { type: 'text', data: turnText } satisfies StreamEvent
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Extract tool-use blocks for detection and execution.
|
|
322
|
-
const toolUseBlocks = extractToolUseBlocks(response.content)
|
|
323
|
-
|
|
324
|
-
// ------------------------------------------------------------------
|
|
325
|
-
// Step 2.5: Loop detection — check before yielding tool_use events
|
|
326
|
-
// so that terminate mode doesn't emit orphaned tool_use without
|
|
327
|
-
// matching tool_result.
|
|
328
|
-
// ------------------------------------------------------------------
|
|
329
|
-
let injectWarning = false
|
|
330
|
-
let injectWarningKind: 'tool_repetition' | 'text_repetition' = 'tool_repetition'
|
|
331
|
-
if (detector && toolUseBlocks.length > 0) {
|
|
332
|
-
const toolInfo = detector.recordToolCalls(toolUseBlocks)
|
|
333
|
-
const textInfo = turnText.length > 0 ? detector.recordText(turnText) : null
|
|
334
|
-
const info = toolInfo ?? textInfo
|
|
335
|
-
|
|
336
|
-
if (info) {
|
|
337
|
-
yield { type: 'loop_detected', data: info } satisfies StreamEvent
|
|
338
|
-
options.onWarning?.(info.detail)
|
|
339
|
-
|
|
340
|
-
const action = typeof loopAction === 'function'
|
|
341
|
-
? await loopAction(info)
|
|
342
|
-
: loopAction
|
|
343
|
-
|
|
344
|
-
if (action === 'terminate') {
|
|
345
|
-
loopDetected = true
|
|
346
|
-
finalOutput = turnText
|
|
347
|
-
break
|
|
348
|
-
} else if (action === 'warn' || action === 'inject') {
|
|
349
|
-
if (loopWarned) {
|
|
350
|
-
// Second detection after a warning — force terminate.
|
|
351
|
-
loopDetected = true
|
|
352
|
-
finalOutput = turnText
|
|
353
|
-
break
|
|
354
|
-
}
|
|
355
|
-
loopWarned = true
|
|
356
|
-
injectWarning = true
|
|
357
|
-
injectWarningKind = info.kind
|
|
358
|
-
// Fall through to execute tools, then inject warning.
|
|
359
|
-
}
|
|
360
|
-
// 'continue' — do nothing, let the loop proceed normally.
|
|
361
|
-
} else {
|
|
362
|
-
// No loop detected this turn — agent has recovered, so reset
|
|
363
|
-
// the warning state. A future loop gets a fresh warning cycle.
|
|
364
|
-
loopWarned = false
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// ------------------------------------------------------------------
|
|
369
|
-
// Step 3: Decide whether to continue looping.
|
|
370
|
-
// ------------------------------------------------------------------
|
|
371
|
-
if (toolUseBlocks.length === 0) {
|
|
372
|
-
// Warn on first turn if tools were provided but model didn't use them.
|
|
373
|
-
if (turns === 1 && toolDefs.length > 0 && options.onWarning) {
|
|
374
|
-
const agentName = this.options.agentName ?? 'unknown'
|
|
375
|
-
options.onWarning(
|
|
376
|
-
`Agent "${agentName}" has ${toolDefs.length} tool(s) available but the model ` +
|
|
377
|
-
`returned no tool calls. If using a local model, verify it supports tool calling ` +
|
|
378
|
-
`(see https://ollama.com/search?c=tools).`,
|
|
379
|
-
)
|
|
380
|
-
}
|
|
381
|
-
// No tools requested — this is the terminal assistant turn.
|
|
382
|
-
finalOutput = turnText
|
|
383
|
-
break
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Announce each tool-use block the model requested (after loop
|
|
387
|
-
// detection, so terminate mode never emits unpaired events).
|
|
388
|
-
for (const block of toolUseBlocks) {
|
|
389
|
-
yield { type: 'tool_use', data: block } satisfies StreamEvent
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// ------------------------------------------------------------------
|
|
393
|
-
// Step 4: Execute all tool calls in PARALLEL.
|
|
394
|
-
//
|
|
395
|
-
// Parallel execution is critical for multi-tool responses where the
|
|
396
|
-
// tools are independent (e.g. reading several files at once).
|
|
397
|
-
// ------------------------------------------------------------------
|
|
398
|
-
const toolContext: ToolUseContext = this.buildToolContext()
|
|
399
|
-
|
|
400
|
-
const executionPromises = toolUseBlocks.map(async (block): Promise<{
|
|
401
|
-
resultBlock: ToolResultBlock
|
|
402
|
-
record: ToolCallRecord
|
|
403
|
-
}> => {
|
|
404
|
-
options.onToolCall?.(block.name, block.input)
|
|
405
|
-
|
|
406
|
-
const startTime = Date.now()
|
|
407
|
-
let result: ToolResult
|
|
408
|
-
|
|
409
|
-
try {
|
|
410
|
-
result = await this.toolExecutor.execute(
|
|
411
|
-
block.name,
|
|
412
|
-
block.input,
|
|
413
|
-
toolContext,
|
|
414
|
-
)
|
|
415
|
-
} catch (err) {
|
|
416
|
-
// Tool executor errors become error results — the loop continues.
|
|
417
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
418
|
-
result = { data: message, isError: true }
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const endTime = Date.now()
|
|
422
|
-
const duration = endTime - startTime
|
|
423
|
-
|
|
424
|
-
options.onToolResult?.(block.name, result)
|
|
425
|
-
|
|
426
|
-
if (options.onTrace) {
|
|
427
|
-
emitTrace(options.onTrace, {
|
|
428
|
-
type: 'tool_call',
|
|
429
|
-
runId: options.runId ?? '',
|
|
430
|
-
taskId: options.taskId,
|
|
431
|
-
agent: options.traceAgent ?? this.options.agentName ?? 'unknown',
|
|
432
|
-
tool: block.name,
|
|
433
|
-
isError: result.isError ?? false,
|
|
434
|
-
startMs: startTime,
|
|
435
|
-
endMs: endTime,
|
|
436
|
-
durationMs: duration,
|
|
437
|
-
})
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const record: ToolCallRecord = {
|
|
441
|
-
toolName: block.name,
|
|
442
|
-
input: block.input,
|
|
443
|
-
output: result.data,
|
|
444
|
-
duration,
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const resultBlock: ToolResultBlock = {
|
|
448
|
-
type: 'tool_result',
|
|
449
|
-
tool_use_id: block.id,
|
|
450
|
-
content: result.data,
|
|
451
|
-
is_error: result.isError,
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
return { resultBlock, record }
|
|
455
|
-
})
|
|
456
|
-
|
|
457
|
-
// Wait for every tool in this turn to finish.
|
|
458
|
-
const executions = await Promise.all(executionPromises)
|
|
459
|
-
|
|
460
|
-
// ------------------------------------------------------------------
|
|
461
|
-
// Step 5: Accumulate results and build the user message that carries
|
|
462
|
-
// them back to the LLM in the next turn.
|
|
463
|
-
// ------------------------------------------------------------------
|
|
464
|
-
const toolResultBlocks: ContentBlock[] = executions.map(e => e.resultBlock)
|
|
465
|
-
|
|
466
|
-
for (const { record, resultBlock } of executions) {
|
|
467
|
-
allToolCalls.push(record)
|
|
468
|
-
yield { type: 'tool_result', data: resultBlock } satisfies StreamEvent
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Inject a loop-detection warning into the tool-result message so
|
|
472
|
-
// the LLM sees it alongside the results (avoids two consecutive user
|
|
473
|
-
// messages which violates the alternating-role constraint).
|
|
474
|
-
if (injectWarning) {
|
|
475
|
-
const warningText = injectWarningKind === 'text_repetition'
|
|
476
|
-
? 'WARNING: You appear to be generating the same response repeatedly. ' +
|
|
477
|
-
'This suggests you are stuck in a loop. Please try a different approach ' +
|
|
478
|
-
'or provide new information.'
|
|
479
|
-
: 'WARNING: You appear to be repeating the same tool calls with identical arguments. ' +
|
|
480
|
-
'This suggests you are stuck in a loop. Please try a different approach, use different ' +
|
|
481
|
-
'parameters, or explain what you are trying to accomplish.'
|
|
482
|
-
toolResultBlocks.push({ type: 'text' as const, text: warningText })
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const toolResultMessage: LLMMessage = {
|
|
486
|
-
role: 'user',
|
|
487
|
-
content: toolResultBlocks,
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
conversationMessages.push(toolResultMessage)
|
|
491
|
-
options.onMessage?.(toolResultMessage)
|
|
492
|
-
|
|
493
|
-
// Loop back to Step 1 — send updated conversation to the LLM.
|
|
494
|
-
}
|
|
495
|
-
} catch (err) {
|
|
496
|
-
const error = err instanceof Error ? err : new Error(String(err))
|
|
497
|
-
yield { type: 'error', data: error } satisfies StreamEvent
|
|
498
|
-
return
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// If the loop exited due to maxTurns, use whatever text was last emitted.
|
|
502
|
-
if (finalOutput === '' && conversationMessages.length > 0) {
|
|
503
|
-
const lastAssistant = [...conversationMessages]
|
|
504
|
-
.reverse()
|
|
505
|
-
.find(m => m.role === 'assistant')
|
|
506
|
-
if (lastAssistant !== undefined) {
|
|
507
|
-
finalOutput = extractText(lastAssistant.content)
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const runResult: RunResult = {
|
|
512
|
-
// Return only the messages added during this run (not the initial seed).
|
|
513
|
-
messages: conversationMessages.slice(initialMessages.length),
|
|
514
|
-
output: finalOutput,
|
|
515
|
-
toolCalls: allToolCalls,
|
|
516
|
-
tokenUsage: totalUsage,
|
|
517
|
-
turns,
|
|
518
|
-
...(loopDetected ? { loopDetected: true } : {}),
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
yield { type: 'done', data: runResult } satisfies StreamEvent
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// -------------------------------------------------------------------------
|
|
525
|
-
// Private helpers
|
|
526
|
-
// -------------------------------------------------------------------------
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* Build the {@link ToolUseContext} passed to every tool execution.
|
|
530
|
-
* Identifies this runner as the invoking agent.
|
|
531
|
-
*/
|
|
532
|
-
private buildToolContext(): ToolUseContext {
|
|
533
|
-
return {
|
|
534
|
-
agent: {
|
|
535
|
-
name: this.options.agentName ?? 'runner',
|
|
536
|
-
role: this.options.agentRole ?? 'assistant',
|
|
537
|
-
model: this.options.model,
|
|
538
|
-
},
|
|
539
|
-
abortSignal: this.options.abortSignal,
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
}
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Structured output utilities for agent responses.
|
|
3
|
-
*
|
|
4
|
-
* Provides JSON extraction, Zod validation, and system-prompt injection so
|
|
5
|
-
* that agents can return typed, schema-validated output.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { type ZodSchema } from 'zod'
|
|
9
|
-
import { zodToJsonSchema } from '../tool/framework.js'
|
|
10
|
-
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// System-prompt instruction builder
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Build a JSON-mode instruction block to append to the agent's system prompt.
|
|
17
|
-
*
|
|
18
|
-
* Converts the Zod schema to JSON Schema and formats it as a clear directive
|
|
19
|
-
* for the LLM to respond with valid JSON matching the schema.
|
|
20
|
-
*/
|
|
21
|
-
export function buildStructuredOutputInstruction(schema: ZodSchema): string {
|
|
22
|
-
const jsonSchema = zodToJsonSchema(schema)
|
|
23
|
-
return [
|
|
24
|
-
'',
|
|
25
|
-
'## Output Format (REQUIRED)',
|
|
26
|
-
'You MUST respond with ONLY valid JSON that conforms to the following JSON Schema.',
|
|
27
|
-
'Do NOT include any text, markdown fences, or explanation outside the JSON object.',
|
|
28
|
-
'Do NOT wrap the JSON in ```json code fences.',
|
|
29
|
-
'',
|
|
30
|
-
'```',
|
|
31
|
-
JSON.stringify(jsonSchema, null, 2),
|
|
32
|
-
'```',
|
|
33
|
-
].join('\n')
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// JSON extraction
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Attempt to extract and parse JSON from the agent's raw text output.
|
|
42
|
-
*
|
|
43
|
-
* Handles three cases in order:
|
|
44
|
-
* 1. The output is already valid JSON (ideal case)
|
|
45
|
-
* 2. The output contains a ` ```json ` fenced block
|
|
46
|
-
* 3. The output contains a bare JSON object/array (first `{`/`[` to last `}`/`]`)
|
|
47
|
-
*
|
|
48
|
-
* @throws {Error} when no valid JSON can be extracted
|
|
49
|
-
*/
|
|
50
|
-
export function extractJSON(raw: string): unknown {
|
|
51
|
-
const trimmed = raw.trim()
|
|
52
|
-
|
|
53
|
-
// Case 1: Direct parse
|
|
54
|
-
try {
|
|
55
|
-
return JSON.parse(trimmed)
|
|
56
|
-
} catch {
|
|
57
|
-
// Continue to fallback strategies
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Case 2a: Prefer ```json tagged fence
|
|
61
|
-
const jsonFenceMatch = trimmed.match(/```json\s*([\s\S]*?)```/)
|
|
62
|
-
if (jsonFenceMatch?.[1]) {
|
|
63
|
-
try {
|
|
64
|
-
return JSON.parse(jsonFenceMatch[1].trim())
|
|
65
|
-
} catch {
|
|
66
|
-
// Continue
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Case 2b: Fall back to bare ``` fence
|
|
71
|
-
const bareFenceMatch = trimmed.match(/```\s*([\s\S]*?)```/)
|
|
72
|
-
if (bareFenceMatch?.[1]) {
|
|
73
|
-
try {
|
|
74
|
-
return JSON.parse(bareFenceMatch[1].trim())
|
|
75
|
-
} catch {
|
|
76
|
-
// Continue
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Case 3: Find first { to last } (object)
|
|
81
|
-
const objStart = trimmed.indexOf('{')
|
|
82
|
-
const objEnd = trimmed.lastIndexOf('}')
|
|
83
|
-
if (objStart !== -1 && objEnd > objStart) {
|
|
84
|
-
try {
|
|
85
|
-
return JSON.parse(trimmed.slice(objStart, objEnd + 1))
|
|
86
|
-
} catch {
|
|
87
|
-
// Fall through
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Case 3b: Find first [ to last ] (array)
|
|
92
|
-
const arrStart = trimmed.indexOf('[')
|
|
93
|
-
const arrEnd = trimmed.lastIndexOf(']')
|
|
94
|
-
if (arrStart !== -1 && arrEnd > arrStart) {
|
|
95
|
-
try {
|
|
96
|
-
return JSON.parse(trimmed.slice(arrStart, arrEnd + 1))
|
|
97
|
-
} catch {
|
|
98
|
-
// Fall through
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
throw new Error(
|
|
103
|
-
`Failed to extract JSON from output. Raw output begins with: "${trimmed.slice(0, 100)}"`,
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ---------------------------------------------------------------------------
|
|
108
|
-
// Zod validation
|
|
109
|
-
// ---------------------------------------------------------------------------
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Validate a parsed JSON value against a Zod schema.
|
|
113
|
-
*
|
|
114
|
-
* @returns The validated (and potentially transformed) value on success.
|
|
115
|
-
* @throws {Error} with a human-readable Zod error message on failure.
|
|
116
|
-
*/
|
|
117
|
-
export function validateOutput(schema: ZodSchema, data: unknown): unknown {
|
|
118
|
-
const result = schema.safeParse(data)
|
|
119
|
-
if (result.success) {
|
|
120
|
-
return result.data
|
|
121
|
-
}
|
|
122
|
-
const issues = result.error.issues
|
|
123
|
-
.map(issue => ` - ${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`)
|
|
124
|
-
.join('\n')
|
|
125
|
-
throw new Error(`Output validation failed:\n${issues}`)
|
|
126
|
-
}
|