@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.
Files changed (80) hide show
  1. package/package.json +8 -2
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
  4. package/.github/pull_request_template.md +0 -14
  5. package/.github/workflows/ci.yml +0 -23
  6. package/CLAUDE.md +0 -80
  7. package/CODE_OF_CONDUCT.md +0 -48
  8. package/CONTRIBUTING.md +0 -72
  9. package/DECISIONS.md +0 -43
  10. package/README_zh.md +0 -277
  11. package/SECURITY.md +0 -17
  12. package/examples/01-single-agent.ts +0 -131
  13. package/examples/02-team-collaboration.ts +0 -167
  14. package/examples/03-task-pipeline.ts +0 -201
  15. package/examples/04-multi-model-team.ts +0 -261
  16. package/examples/05-copilot-test.ts +0 -49
  17. package/examples/06-local-model.ts +0 -200
  18. package/examples/07-fan-out-aggregate.ts +0 -209
  19. package/examples/08-gemma4-local.ts +0 -192
  20. package/examples/09-structured-output.ts +0 -73
  21. package/examples/10-task-retry.ts +0 -132
  22. package/examples/11-trace-observability.ts +0 -133
  23. package/examples/12-grok.ts +0 -154
  24. package/examples/13-gemini.ts +0 -48
  25. package/src/agent/agent.ts +0 -622
  26. package/src/agent/loop-detector.ts +0 -137
  27. package/src/agent/pool.ts +0 -285
  28. package/src/agent/runner.ts +0 -542
  29. package/src/agent/structured-output.ts +0 -126
  30. package/src/index.ts +0 -182
  31. package/src/llm/adapter.ts +0 -98
  32. package/src/llm/anthropic.ts +0 -389
  33. package/src/llm/copilot.ts +0 -552
  34. package/src/llm/gemini.ts +0 -378
  35. package/src/llm/grok.ts +0 -29
  36. package/src/llm/openai-common.ts +0 -294
  37. package/src/llm/openai.ts +0 -292
  38. package/src/memory/shared.ts +0 -181
  39. package/src/memory/store.ts +0 -124
  40. package/src/orchestrator/orchestrator.ts +0 -1071
  41. package/src/orchestrator/scheduler.ts +0 -352
  42. package/src/task/queue.ts +0 -464
  43. package/src/task/task.ts +0 -239
  44. package/src/team/messaging.ts +0 -232
  45. package/src/team/team.ts +0 -334
  46. package/src/tool/built-in/bash.ts +0 -187
  47. package/src/tool/built-in/file-edit.ts +0 -154
  48. package/src/tool/built-in/file-read.ts +0 -105
  49. package/src/tool/built-in/file-write.ts +0 -81
  50. package/src/tool/built-in/grep.ts +0 -362
  51. package/src/tool/built-in/index.ts +0 -50
  52. package/src/tool/executor.ts +0 -178
  53. package/src/tool/framework.ts +0 -557
  54. package/src/tool/text-tool-extractor.ts +0 -219
  55. package/src/types.ts +0 -542
  56. package/src/utils/semaphore.ts +0 -89
  57. package/src/utils/trace.ts +0 -34
  58. package/tests/agent-hooks.test.ts +0 -473
  59. package/tests/agent-pool.test.ts +0 -212
  60. package/tests/approval.test.ts +0 -464
  61. package/tests/built-in-tools.test.ts +0 -393
  62. package/tests/gemini-adapter.test.ts +0 -97
  63. package/tests/grok-adapter.test.ts +0 -74
  64. package/tests/llm-adapters.test.ts +0 -357
  65. package/tests/loop-detection.test.ts +0 -456
  66. package/tests/openai-fallback.test.ts +0 -159
  67. package/tests/orchestrator.test.ts +0 -281
  68. package/tests/scheduler.test.ts +0 -221
  69. package/tests/semaphore.test.ts +0 -57
  70. package/tests/shared-memory.test.ts +0 -122
  71. package/tests/structured-output.test.ts +0 -331
  72. package/tests/task-queue.test.ts +0 -244
  73. package/tests/task-retry.test.ts +0 -368
  74. package/tests/task-utils.test.ts +0 -155
  75. package/tests/team-messaging.test.ts +0 -329
  76. package/tests/text-tool-extractor.test.ts +0 -170
  77. package/tests/tool-executor.test.ts +0 -193
  78. package/tests/trace.test.ts +0 -453
  79. package/tsconfig.json +0 -25
  80. package/vitest.config.ts +0 -9
@@ -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
- }