@jackchen_me/open-multi-agent 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
- package/.github/pull_request_template.md +14 -0
- package/.github/workflows/ci.yml +23 -0
- package/CLAUDE.md +80 -0
- package/CODE_OF_CONDUCT.md +48 -0
- package/CONTRIBUTING.md +72 -0
- package/DECISIONS.md +43 -0
- package/README.md +144 -144
- package/README_zh.md +277 -0
- package/SECURITY.md +17 -0
- package/dist/agent/agent.d.ts +20 -1
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +233 -12
- 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/agent/structured-output.d.ts +33 -0
- package/dist/agent/structured-output.d.ts.map +1 -0
- package/dist/agent/structured-output.js +116 -0
- package/dist/agent/structured-output.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/adapter.d.ts +12 -4
- package/dist/llm/adapter.d.ts.map +1 -1
- package/dist/llm/adapter.js +28 -5
- package/dist/llm/adapter.js.map +1 -1
- package/dist/llm/anthropic.d.ts +1 -1
- package/dist/llm/anthropic.d.ts.map +1 -1
- package/dist/llm/anthropic.js +2 -1
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/copilot.d.ts +92 -0
- package/dist/llm/copilot.d.ts.map +1 -0
- package/dist/llm/copilot.js +427 -0
- package/dist/llm/copilot.js.map +1 -0
- 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 +54 -0
- package/dist/llm/openai-common.d.ts.map +1 -0
- package/dist/llm/openai-common.js +242 -0
- package/dist/llm/openai-common.js.map +1 -0
- package/dist/llm/openai.d.ts +2 -2
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +23 -226
- package/dist/llm/openai.js.map +1 -1
- package/dist/orchestrator/orchestrator.d.ts +25 -1
- package/dist/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.js +214 -41
- 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 +70 -3
- package/dist/task/queue.js.map +1 -1
- package/dist/task/task.d.ts +3 -0
- package/dist/task/task.d.ts.map +1 -1
- package/dist/task/task.js +5 -1
- package/dist/task/task.js.map +1 -1
- package/dist/team/messaging.d.ts.map +1 -1
- package/dist/team/messaging.js +2 -1
- package/dist/team/messaging.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 +167 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/trace.d.ts +12 -0
- package/dist/utils/trace.d.ts.map +1 -0
- package/dist/utils/trace.js +30 -0
- package/dist/utils/trace.js.map +1 -0
- package/examples/05-copilot-test.ts +49 -0
- package/examples/06-local-model.ts +200 -0
- package/examples/07-fan-out-aggregate.ts +209 -0
- package/examples/08-gemma4-local.ts +192 -0
- package/examples/09-structured-output.ts +73 -0
- package/examples/10-task-retry.ts +132 -0
- package/examples/11-trace-observability.ts +133 -0
- package/examples/12-grok.ts +154 -0
- package/examples/13-gemini.ts +48 -0
- package/package.json +14 -3
- package/src/agent/agent.ts +273 -15
- package/src/agent/loop-detector.ts +137 -0
- package/src/agent/pool.ts +9 -2
- package/src/agent/runner.ts +148 -19
- package/src/agent/structured-output.ts +126 -0
- package/src/index.ts +17 -1
- package/src/llm/adapter.ts +29 -5
- package/src/llm/anthropic.ts +2 -1
- package/src/llm/copilot.ts +552 -0
- package/src/llm/gemini.ts +378 -0
- package/src/llm/grok.ts +29 -0
- package/src/llm/openai-common.ts +294 -0
- package/src/llm/openai.ts +31 -261
- package/src/orchestrator/orchestrator.ts +260 -40
- package/src/task/queue.ts +74 -4
- package/src/task/task.ts +8 -1
- package/src/team/messaging.ts +3 -1
- package/src/tool/text-tool-extractor.ts +219 -0
- package/src/types.ts +186 -6
- package/src/utils/trace.ts +34 -0
- package/tests/agent-hooks.test.ts +473 -0
- package/tests/agent-pool.test.ts +212 -0
- package/tests/approval.test.ts +464 -0
- package/tests/built-in-tools.test.ts +393 -0
- package/tests/gemini-adapter.test.ts +97 -0
- package/tests/grok-adapter.test.ts +74 -0
- package/tests/llm-adapters.test.ts +357 -0
- package/tests/loop-detection.test.ts +456 -0
- package/tests/openai-fallback.test.ts +159 -0
- package/tests/orchestrator.test.ts +281 -0
- package/tests/scheduler.test.ts +221 -0
- package/tests/semaphore.test.ts +57 -0
- package/tests/shared-memory.test.ts +122 -0
- package/tests/structured-output.test.ts +331 -0
- package/tests/task-queue.test.ts +244 -0
- package/tests/task-retry.test.ts +368 -0
- package/tests/task-utils.test.ts +155 -0
- package/tests/team-messaging.test.ts +329 -0
- package/tests/text-tool-extractor.test.ts +170 -0
- package/tests/tool-executor.test.ts +193 -0
- package/tests/trace.test.ts +453 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Fallback tool-call extractor for local models.
|
|
3
|
+
*
|
|
4
|
+
* When a local model (Ollama, vLLM, LM Studio) returns tool calls as plain
|
|
5
|
+
* text instead of using the OpenAI `tool_calls` wire format, this module
|
|
6
|
+
* attempts to extract them from the text output.
|
|
7
|
+
*
|
|
8
|
+
* Common scenarios:
|
|
9
|
+
* - Ollama thinking-model bug: tool call JSON ends up inside unclosed `<think>` tags
|
|
10
|
+
* - Model outputs raw JSON tool calls without the server parsing them
|
|
11
|
+
* - Model wraps tool calls in markdown code fences
|
|
12
|
+
* - Hermes-format `<tool_call>` tags
|
|
13
|
+
*
|
|
14
|
+
* This is a **safety net**, not the primary path. Native `tool_calls` from
|
|
15
|
+
* the server are always preferred.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ToolUseBlock } from '../types.js'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// ID generation
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
let callCounter = 0
|
|
25
|
+
|
|
26
|
+
/** Generate a unique tool-call ID for extracted calls. */
|
|
27
|
+
function generateToolCallId(): string {
|
|
28
|
+
return `extracted_call_${Date.now()}_${++callCounter}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Internal parsers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Try to parse a single JSON object as a tool call.
|
|
37
|
+
*
|
|
38
|
+
* Accepted shapes:
|
|
39
|
+
* ```json
|
|
40
|
+
* { "name": "bash", "arguments": { "command": "ls" } }
|
|
41
|
+
* { "name": "bash", "parameters": { "command": "ls" } }
|
|
42
|
+
* { "function": { "name": "bash", "arguments": { "command": "ls" } } }
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
function parseToolCallJSON(
|
|
46
|
+
json: unknown,
|
|
47
|
+
knownToolNames: ReadonlySet<string>,
|
|
48
|
+
): ToolUseBlock | null {
|
|
49
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const obj = json as Record<string, unknown>
|
|
54
|
+
|
|
55
|
+
// Shape: { function: { name, arguments } }
|
|
56
|
+
if (typeof obj['function'] === 'object' && obj['function'] !== null) {
|
|
57
|
+
const fn = obj['function'] as Record<string, unknown>
|
|
58
|
+
return parseFlat(fn, knownToolNames)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Shape: { name, arguments|parameters }
|
|
62
|
+
return parseFlat(obj, knownToolNames)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseFlat(
|
|
66
|
+
obj: Record<string, unknown>,
|
|
67
|
+
knownToolNames: ReadonlySet<string>,
|
|
68
|
+
): ToolUseBlock | null {
|
|
69
|
+
const name = obj['name']
|
|
70
|
+
if (typeof name !== 'string' || name.length === 0) return null
|
|
71
|
+
|
|
72
|
+
// Whitelist check — don't treat arbitrary JSON as a tool call
|
|
73
|
+
if (knownToolNames.size > 0 && !knownToolNames.has(name)) return null
|
|
74
|
+
|
|
75
|
+
let input: Record<string, unknown> = {}
|
|
76
|
+
const args = obj['arguments'] ?? obj['parameters'] ?? obj['input']
|
|
77
|
+
if (args !== null && args !== undefined) {
|
|
78
|
+
if (typeof args === 'string') {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(args)
|
|
81
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
82
|
+
input = parsed as Record<string, unknown>
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Malformed — use empty input
|
|
86
|
+
}
|
|
87
|
+
} else if (typeof args === 'object' && !Array.isArray(args)) {
|
|
88
|
+
input = args as Record<string, unknown>
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
type: 'tool_use',
|
|
94
|
+
id: generateToolCallId(),
|
|
95
|
+
name,
|
|
96
|
+
input,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// JSON extraction from text
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Find all top-level JSON objects in a string by tracking brace depth.
|
|
106
|
+
* Returns the parsed objects (not sub-objects).
|
|
107
|
+
*/
|
|
108
|
+
function extractJSONObjects(text: string): unknown[] {
|
|
109
|
+
const results: unknown[] = []
|
|
110
|
+
let depth = 0
|
|
111
|
+
let start = -1
|
|
112
|
+
let inString = false
|
|
113
|
+
let escape = false
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < text.length; i++) {
|
|
116
|
+
const ch = text[i]!
|
|
117
|
+
|
|
118
|
+
if (escape) {
|
|
119
|
+
escape = false
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (ch === '\\' && inString) {
|
|
124
|
+
escape = true
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (ch === '"') {
|
|
129
|
+
inString = !inString
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (inString) continue
|
|
134
|
+
|
|
135
|
+
if (ch === '{') {
|
|
136
|
+
if (depth === 0) start = i
|
|
137
|
+
depth++
|
|
138
|
+
} else if (ch === '}') {
|
|
139
|
+
depth--
|
|
140
|
+
if (depth === 0 && start !== -1) {
|
|
141
|
+
const candidate = text.slice(start, i + 1)
|
|
142
|
+
try {
|
|
143
|
+
results.push(JSON.parse(candidate))
|
|
144
|
+
} catch {
|
|
145
|
+
// Not valid JSON — skip
|
|
146
|
+
}
|
|
147
|
+
start = -1
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return results
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Hermes format: <tool_call>...</tool_call>
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
function extractHermesToolCalls(
|
|
160
|
+
text: string,
|
|
161
|
+
knownToolNames: ReadonlySet<string>,
|
|
162
|
+
): ToolUseBlock[] {
|
|
163
|
+
const results: ToolUseBlock[] = []
|
|
164
|
+
|
|
165
|
+
for (const match of text.matchAll(/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g)) {
|
|
166
|
+
const inner = match[1]!.trim()
|
|
167
|
+
try {
|
|
168
|
+
const parsed: unknown = JSON.parse(inner)
|
|
169
|
+
const block = parseToolCallJSON(parsed, knownToolNames)
|
|
170
|
+
if (block !== null) results.push(block)
|
|
171
|
+
} catch {
|
|
172
|
+
// Malformed hermes content — skip
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return results
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Public API
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Attempt to extract tool calls from a model's text output.
|
|
185
|
+
*
|
|
186
|
+
* Tries multiple strategies in order:
|
|
187
|
+
* 1. Hermes `<tool_call>` tags
|
|
188
|
+
* 2. JSON objects in text (bare or inside code fences)
|
|
189
|
+
*
|
|
190
|
+
* @param text - The model's text output.
|
|
191
|
+
* @param knownToolNames - Whitelist of registered tool names. When non-empty,
|
|
192
|
+
* only JSON objects whose `name` matches a known tool
|
|
193
|
+
* are treated as tool calls.
|
|
194
|
+
* @returns Extracted {@link ToolUseBlock}s, or an empty array if none found.
|
|
195
|
+
*/
|
|
196
|
+
export function extractToolCallsFromText(
|
|
197
|
+
text: string,
|
|
198
|
+
knownToolNames: string[],
|
|
199
|
+
): ToolUseBlock[] {
|
|
200
|
+
if (text.length === 0) return []
|
|
201
|
+
|
|
202
|
+
const nameSet = new Set(knownToolNames)
|
|
203
|
+
|
|
204
|
+
// Strategy 1: Hermes format
|
|
205
|
+
const hermesResults = extractHermesToolCalls(text, nameSet)
|
|
206
|
+
if (hermesResults.length > 0) return hermesResults
|
|
207
|
+
|
|
208
|
+
// Strategy 2: Strip code fences, then extract JSON objects
|
|
209
|
+
const stripped = text.replace(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/g, '$1')
|
|
210
|
+
const jsonObjects = extractJSONObjects(stripped)
|
|
211
|
+
|
|
212
|
+
const results: ToolUseBlock[] = []
|
|
213
|
+
for (const obj of jsonObjects) {
|
|
214
|
+
const block = parseToolCallJSON(obj, nameSet)
|
|
215
|
+
if (block !== null) results.push(block)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return results
|
|
219
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -94,7 +94,7 @@ export interface LLMResponse {
|
|
|
94
94
|
* - `error` — an unrecoverable error occurred; `data` is an `Error`
|
|
95
95
|
*/
|
|
96
96
|
export interface StreamEvent {
|
|
97
|
-
readonly type: 'text' | 'tool_use' | 'tool_result' | 'done' | 'error'
|
|
97
|
+
readonly type: 'text' | 'tool_use' | 'tool_result' | 'loop_detected' | 'done' | 'error'
|
|
98
98
|
readonly data: unknown
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -182,17 +182,97 @@ export interface ToolDefinition<TInput = Record<string, unknown>> {
|
|
|
182
182
|
// Agent
|
|
183
183
|
// ---------------------------------------------------------------------------
|
|
184
184
|
|
|
185
|
+
/** Context passed to the {@link AgentConfig.beforeRun} hook. */
|
|
186
|
+
export interface BeforeRunHookContext {
|
|
187
|
+
/** The user prompt text. */
|
|
188
|
+
readonly prompt: string
|
|
189
|
+
/** The agent's static configuration. */
|
|
190
|
+
readonly agent: AgentConfig
|
|
191
|
+
}
|
|
192
|
+
|
|
185
193
|
/** Static configuration for a single agent. */
|
|
186
194
|
export interface AgentConfig {
|
|
187
195
|
readonly name: string
|
|
188
196
|
readonly model: string
|
|
189
|
-
readonly provider?: 'anthropic' | 'openai'
|
|
197
|
+
readonly provider?: 'anthropic' | 'copilot' | 'grok' | 'openai' | 'gemini'
|
|
198
|
+
/**
|
|
199
|
+
* Custom base URL for OpenAI-compatible APIs (Ollama, vLLM, LM Studio, etc.).
|
|
200
|
+
* Note: local servers that don't require auth still need `apiKey` set to a
|
|
201
|
+
* non-empty placeholder (e.g. `'ollama'`) because the OpenAI SDK validates it.
|
|
202
|
+
*/
|
|
203
|
+
readonly baseURL?: string
|
|
204
|
+
/** API key override; falls back to the provider's standard env var. */
|
|
205
|
+
readonly apiKey?: string
|
|
190
206
|
readonly systemPrompt?: string
|
|
191
207
|
/** Names of tools (from the tool registry) available to this agent. */
|
|
192
208
|
readonly tools?: readonly string[]
|
|
193
209
|
readonly maxTurns?: number
|
|
194
210
|
readonly maxTokens?: number
|
|
195
211
|
readonly temperature?: number
|
|
212
|
+
/**
|
|
213
|
+
* Maximum wall-clock time (in milliseconds) for the entire agent run.
|
|
214
|
+
* When exceeded, the run is aborted via `AbortSignal.timeout()`.
|
|
215
|
+
* Useful for local models where inference can be unpredictably slow.
|
|
216
|
+
*/
|
|
217
|
+
readonly timeoutMs?: number
|
|
218
|
+
/**
|
|
219
|
+
* Loop detection configuration. When set, the agent tracks repeated tool
|
|
220
|
+
* calls and text outputs to detect stuck loops before `maxTurns` is reached.
|
|
221
|
+
*/
|
|
222
|
+
readonly loopDetection?: LoopDetectionConfig
|
|
223
|
+
/**
|
|
224
|
+
* Optional Zod schema for structured output. When set, the agent's final
|
|
225
|
+
* output is parsed as JSON and validated against this schema. A single
|
|
226
|
+
* retry with error feedback is attempted on validation failure.
|
|
227
|
+
*/
|
|
228
|
+
readonly outputSchema?: ZodSchema
|
|
229
|
+
/**
|
|
230
|
+
* Called before each agent run. Receives the prompt and agent config.
|
|
231
|
+
* Return a (possibly modified) context to continue, or throw to abort the run.
|
|
232
|
+
* Only `prompt` from the returned context is applied; `agent` is read-only informational.
|
|
233
|
+
*/
|
|
234
|
+
readonly beforeRun?: (context: BeforeRunHookContext) => Promise<BeforeRunHookContext> | BeforeRunHookContext
|
|
235
|
+
/**
|
|
236
|
+
* Called after each agent run completes successfully. Receives the run result.
|
|
237
|
+
* Return a (possibly modified) result, or throw to mark the run as failed.
|
|
238
|
+
* Not called when the run throws. For error observation, handle errors at the call site.
|
|
239
|
+
*/
|
|
240
|
+
readonly afterRun?: (result: AgentRunResult) => Promise<AgentRunResult> | AgentRunResult
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Loop detection
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/** Configuration for agent loop detection. */
|
|
248
|
+
export interface LoopDetectionConfig {
|
|
249
|
+
/**
|
|
250
|
+
* Maximum consecutive times the same tool call (name + args) or text
|
|
251
|
+
* output can repeat before detection triggers. Default: `3`.
|
|
252
|
+
*/
|
|
253
|
+
readonly maxRepetitions?: number
|
|
254
|
+
/**
|
|
255
|
+
* Number of recent turns to track for repetition analysis. Default: `4`.
|
|
256
|
+
*/
|
|
257
|
+
readonly loopDetectionWindow?: number
|
|
258
|
+
/**
|
|
259
|
+
* Action to take when a loop is detected.
|
|
260
|
+
* - `'warn'` — inject a "you appear stuck" message, give the LLM one
|
|
261
|
+
* more chance; terminate if the loop persists (default)
|
|
262
|
+
* - `'terminate'` — stop the run immediately
|
|
263
|
+
* - `function` — custom callback (sync or async); return `'continue'`,
|
|
264
|
+
* `'inject'`, or `'terminate'` to control the outcome
|
|
265
|
+
*/
|
|
266
|
+
readonly onLoopDetected?: 'warn' | 'terminate' | ((info: LoopDetectionInfo) => 'continue' | 'inject' | 'terminate' | Promise<'continue' | 'inject' | 'terminate'>)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Diagnostic payload emitted when a loop is detected. */
|
|
270
|
+
export interface LoopDetectionInfo {
|
|
271
|
+
readonly kind: 'tool_repetition' | 'text_repetition'
|
|
272
|
+
/** Number of consecutive identical occurrences observed. */
|
|
273
|
+
readonly repetitions: number
|
|
274
|
+
/** Human-readable description of the detected loop. */
|
|
275
|
+
readonly detail: string
|
|
196
276
|
}
|
|
197
277
|
|
|
198
278
|
/** Lifecycle state tracked during an agent run. */
|
|
@@ -219,6 +299,14 @@ export interface AgentRunResult {
|
|
|
219
299
|
readonly messages: LLMMessage[]
|
|
220
300
|
readonly tokenUsage: TokenUsage
|
|
221
301
|
readonly toolCalls: ToolCallRecord[]
|
|
302
|
+
/**
|
|
303
|
+
* Parsed and validated structured output when `outputSchema` is set on the
|
|
304
|
+
* agent config. `undefined` when no schema is configured or validation
|
|
305
|
+
* failed after retry.
|
|
306
|
+
*/
|
|
307
|
+
readonly structured?: unknown
|
|
308
|
+
/** True when the run was terminated or warned due to loop detection. */
|
|
309
|
+
readonly loopDetected?: boolean
|
|
222
310
|
}
|
|
223
311
|
|
|
224
312
|
// ---------------------------------------------------------------------------
|
|
@@ -246,7 +334,7 @@ export interface TeamRunResult {
|
|
|
246
334
|
// ---------------------------------------------------------------------------
|
|
247
335
|
|
|
248
336
|
/** Valid states for a {@link Task}. */
|
|
249
|
-
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'
|
|
337
|
+
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked' | 'skipped'
|
|
250
338
|
|
|
251
339
|
/** A discrete unit of work tracked by the orchestrator. */
|
|
252
340
|
export interface Task {
|
|
@@ -261,19 +349,32 @@ export interface Task {
|
|
|
261
349
|
result?: string
|
|
262
350
|
readonly createdAt: Date
|
|
263
351
|
updatedAt: Date
|
|
352
|
+
/** Maximum number of retry attempts on failure (default: 0 — no retry). */
|
|
353
|
+
readonly maxRetries?: number
|
|
354
|
+
/** Base delay in ms before the first retry (default: 1000). */
|
|
355
|
+
readonly retryDelayMs?: number
|
|
356
|
+
/** Exponential backoff multiplier (default: 2). */
|
|
357
|
+
readonly retryBackoff?: number
|
|
264
358
|
}
|
|
265
359
|
|
|
266
360
|
// ---------------------------------------------------------------------------
|
|
267
361
|
// Orchestrator
|
|
268
362
|
// ---------------------------------------------------------------------------
|
|
269
363
|
|
|
270
|
-
/**
|
|
364
|
+
/**
|
|
365
|
+
* Progress event emitted by the orchestrator during a run.
|
|
366
|
+
*
|
|
367
|
+
* **v0.3 addition:** `'task_skipped'` — consumers with exhaustive switches
|
|
368
|
+
* on `type` will need to add a case for this variant.
|
|
369
|
+
*/
|
|
271
370
|
export interface OrchestratorEvent {
|
|
272
371
|
readonly type:
|
|
273
372
|
| 'agent_start'
|
|
274
373
|
| 'agent_complete'
|
|
275
374
|
| 'task_start'
|
|
276
375
|
| 'task_complete'
|
|
376
|
+
| 'task_skipped'
|
|
377
|
+
| 'task_retry'
|
|
277
378
|
| 'message'
|
|
278
379
|
| 'error'
|
|
279
380
|
readonly agent?: string
|
|
@@ -285,10 +386,89 @@ export interface OrchestratorEvent {
|
|
|
285
386
|
export interface OrchestratorConfig {
|
|
286
387
|
readonly maxConcurrency?: number
|
|
287
388
|
readonly defaultModel?: string
|
|
288
|
-
readonly defaultProvider?: 'anthropic' | 'openai'
|
|
289
|
-
|
|
389
|
+
readonly defaultProvider?: 'anthropic' | 'copilot' | 'grok' | 'openai' | 'gemini'
|
|
390
|
+
readonly defaultBaseURL?: string
|
|
391
|
+
readonly defaultApiKey?: string
|
|
392
|
+
readonly onProgress?: (event: OrchestratorEvent) => void
|
|
393
|
+
readonly onTrace?: (event: TraceEvent) => void | Promise<void>
|
|
394
|
+
/**
|
|
395
|
+
* Optional approval gate called between task execution rounds.
|
|
396
|
+
*
|
|
397
|
+
* After a batch of tasks completes, this callback receives all
|
|
398
|
+
* completed {@link Task}s from that round and the list of tasks about
|
|
399
|
+
* to start next. Return `true` to continue or `false` to abort —
|
|
400
|
+
* remaining tasks will be marked `'skipped'`.
|
|
401
|
+
*
|
|
402
|
+
* Not called when:
|
|
403
|
+
* - No tasks succeeded in the round (all failed).
|
|
404
|
+
* - No pending tasks remain after the round (final batch).
|
|
405
|
+
*
|
|
406
|
+
* **Note:** Do not mutate the {@link Task} objects passed to this
|
|
407
|
+
* callback — they are live references to queue state. Mutation is
|
|
408
|
+
* undefined behavior.
|
|
409
|
+
*/
|
|
410
|
+
readonly onApproval?: (completedTasks: readonly Task[], nextTasks: readonly Task[]) => Promise<boolean>
|
|
290
411
|
}
|
|
291
412
|
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Trace events — lightweight observability spans
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
/** Trace event type discriminants. */
|
|
418
|
+
export type TraceEventType = 'llm_call' | 'tool_call' | 'task' | 'agent'
|
|
419
|
+
|
|
420
|
+
/** Shared fields present on every trace event. */
|
|
421
|
+
export interface TraceEventBase {
|
|
422
|
+
/** Unique identifier for the entire run (runTeam / runTasks / runAgent call). */
|
|
423
|
+
readonly runId: string
|
|
424
|
+
readonly type: TraceEventType
|
|
425
|
+
/** Unix epoch ms when the span started. */
|
|
426
|
+
readonly startMs: number
|
|
427
|
+
/** Unix epoch ms when the span ended. */
|
|
428
|
+
readonly endMs: number
|
|
429
|
+
/** Wall-clock duration in milliseconds (`endMs - startMs`). */
|
|
430
|
+
readonly durationMs: number
|
|
431
|
+
/** Agent name associated with this span. */
|
|
432
|
+
readonly agent: string
|
|
433
|
+
/** Task ID associated with this span. */
|
|
434
|
+
readonly taskId?: string
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Emitted for each LLM API call (one per agent turn). */
|
|
438
|
+
export interface LLMCallTrace extends TraceEventBase {
|
|
439
|
+
readonly type: 'llm_call'
|
|
440
|
+
readonly model: string
|
|
441
|
+
readonly turn: number
|
|
442
|
+
readonly tokens: TokenUsage
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Emitted for each tool execution. */
|
|
446
|
+
export interface ToolCallTrace extends TraceEventBase {
|
|
447
|
+
readonly type: 'tool_call'
|
|
448
|
+
readonly tool: string
|
|
449
|
+
readonly isError: boolean
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Emitted when a task completes (wraps the full retry sequence). */
|
|
453
|
+
export interface TaskTrace extends TraceEventBase {
|
|
454
|
+
readonly type: 'task'
|
|
455
|
+
readonly taskId: string
|
|
456
|
+
readonly taskTitle: string
|
|
457
|
+
readonly success: boolean
|
|
458
|
+
readonly retries: number
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Emitted when an agent run completes (wraps the full conversation loop). */
|
|
462
|
+
export interface AgentTrace extends TraceEventBase {
|
|
463
|
+
readonly type: 'agent'
|
|
464
|
+
readonly turns: number
|
|
465
|
+
readonly tokens: TokenUsage
|
|
466
|
+
readonly toolCalls: number
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Discriminated union of all trace event types. */
|
|
470
|
+
export type TraceEvent = LLMCallTrace | ToolCallTrace | TaskTrace | AgentTrace
|
|
471
|
+
|
|
292
472
|
// ---------------------------------------------------------------------------
|
|
293
473
|
// Memory
|
|
294
474
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Trace emission utilities for the observability layer.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import type { TraceEvent } from '../types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Safely emit a trace event. Swallows callback errors so a broken
|
|
10
|
+
* subscriber never crashes agent execution.
|
|
11
|
+
*/
|
|
12
|
+
export function emitTrace(
|
|
13
|
+
fn: ((event: TraceEvent) => void | Promise<void>) | undefined,
|
|
14
|
+
event: TraceEvent,
|
|
15
|
+
): void {
|
|
16
|
+
if (!fn) return
|
|
17
|
+
try {
|
|
18
|
+
// Guard async callbacks: if fn returns a Promise, swallow its rejection
|
|
19
|
+
// so an async onTrace never produces an unhandled promise rejection.
|
|
20
|
+
const result = fn(event) as unknown
|
|
21
|
+
if (result && typeof (result as Promise<unknown>).catch === 'function') {
|
|
22
|
+
;(result as Promise<unknown>).catch(noop)
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// Intentionally swallowed — observability must never break execution.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function noop() {}
|
|
30
|
+
|
|
31
|
+
/** Generate a unique run ID for trace correlation. */
|
|
32
|
+
export function generateRunId(): string {
|
|
33
|
+
return randomUUID()
|
|
34
|
+
}
|