@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.
Files changed (140) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  3. package/.github/pull_request_template.md +14 -0
  4. package/.github/workflows/ci.yml +23 -0
  5. package/CLAUDE.md +80 -0
  6. package/CODE_OF_CONDUCT.md +48 -0
  7. package/CONTRIBUTING.md +72 -0
  8. package/DECISIONS.md +43 -0
  9. package/README.md +144 -144
  10. package/README_zh.md +277 -0
  11. package/SECURITY.md +17 -0
  12. package/dist/agent/agent.d.ts +20 -1
  13. package/dist/agent/agent.d.ts.map +1 -1
  14. package/dist/agent/agent.js +233 -12
  15. package/dist/agent/agent.js.map +1 -1
  16. package/dist/agent/loop-detector.d.ts +39 -0
  17. package/dist/agent/loop-detector.d.ts.map +1 -0
  18. package/dist/agent/loop-detector.js +122 -0
  19. package/dist/agent/loop-detector.js.map +1 -0
  20. package/dist/agent/pool.d.ts +2 -1
  21. package/dist/agent/pool.d.ts.map +1 -1
  22. package/dist/agent/pool.js +4 -2
  23. package/dist/agent/pool.js.map +1 -1
  24. package/dist/agent/runner.d.ts +23 -1
  25. package/dist/agent/runner.d.ts.map +1 -1
  26. package/dist/agent/runner.js +113 -12
  27. package/dist/agent/runner.js.map +1 -1
  28. package/dist/agent/structured-output.d.ts +33 -0
  29. package/dist/agent/structured-output.d.ts.map +1 -0
  30. package/dist/agent/structured-output.js +116 -0
  31. package/dist/agent/structured-output.js.map +1 -0
  32. package/dist/index.d.ts +5 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/llm/adapter.d.ts +12 -4
  37. package/dist/llm/adapter.d.ts.map +1 -1
  38. package/dist/llm/adapter.js +28 -5
  39. package/dist/llm/adapter.js.map +1 -1
  40. package/dist/llm/anthropic.d.ts +1 -1
  41. package/dist/llm/anthropic.d.ts.map +1 -1
  42. package/dist/llm/anthropic.js +2 -1
  43. package/dist/llm/anthropic.js.map +1 -1
  44. package/dist/llm/copilot.d.ts +92 -0
  45. package/dist/llm/copilot.d.ts.map +1 -0
  46. package/dist/llm/copilot.js +427 -0
  47. package/dist/llm/copilot.js.map +1 -0
  48. package/dist/llm/gemini.d.ts +65 -0
  49. package/dist/llm/gemini.d.ts.map +1 -0
  50. package/dist/llm/gemini.js +317 -0
  51. package/dist/llm/gemini.js.map +1 -0
  52. package/dist/llm/grok.d.ts +21 -0
  53. package/dist/llm/grok.d.ts.map +1 -0
  54. package/dist/llm/grok.js +24 -0
  55. package/dist/llm/grok.js.map +1 -0
  56. package/dist/llm/openai-common.d.ts +54 -0
  57. package/dist/llm/openai-common.d.ts.map +1 -0
  58. package/dist/llm/openai-common.js +242 -0
  59. package/dist/llm/openai-common.js.map +1 -0
  60. package/dist/llm/openai.d.ts +2 -2
  61. package/dist/llm/openai.d.ts.map +1 -1
  62. package/dist/llm/openai.js +23 -226
  63. package/dist/llm/openai.js.map +1 -1
  64. package/dist/orchestrator/orchestrator.d.ts +25 -1
  65. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  66. package/dist/orchestrator/orchestrator.js +214 -41
  67. package/dist/orchestrator/orchestrator.js.map +1 -1
  68. package/dist/task/queue.d.ts +31 -2
  69. package/dist/task/queue.d.ts.map +1 -1
  70. package/dist/task/queue.js +70 -3
  71. package/dist/task/queue.js.map +1 -1
  72. package/dist/task/task.d.ts +3 -0
  73. package/dist/task/task.d.ts.map +1 -1
  74. package/dist/task/task.js +5 -1
  75. package/dist/task/task.js.map +1 -1
  76. package/dist/team/messaging.d.ts.map +1 -1
  77. package/dist/team/messaging.js +2 -1
  78. package/dist/team/messaging.js.map +1 -1
  79. package/dist/tool/text-tool-extractor.d.ts +32 -0
  80. package/dist/tool/text-tool-extractor.d.ts.map +1 -0
  81. package/dist/tool/text-tool-extractor.js +187 -0
  82. package/dist/tool/text-tool-extractor.js.map +1 -0
  83. package/dist/types.d.ts +167 -7
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/utils/trace.d.ts +12 -0
  86. package/dist/utils/trace.d.ts.map +1 -0
  87. package/dist/utils/trace.js +30 -0
  88. package/dist/utils/trace.js.map +1 -0
  89. package/examples/05-copilot-test.ts +49 -0
  90. package/examples/06-local-model.ts +200 -0
  91. package/examples/07-fan-out-aggregate.ts +209 -0
  92. package/examples/08-gemma4-local.ts +192 -0
  93. package/examples/09-structured-output.ts +73 -0
  94. package/examples/10-task-retry.ts +132 -0
  95. package/examples/11-trace-observability.ts +133 -0
  96. package/examples/12-grok.ts +154 -0
  97. package/examples/13-gemini.ts +48 -0
  98. package/package.json +14 -3
  99. package/src/agent/agent.ts +273 -15
  100. package/src/agent/loop-detector.ts +137 -0
  101. package/src/agent/pool.ts +9 -2
  102. package/src/agent/runner.ts +148 -19
  103. package/src/agent/structured-output.ts +126 -0
  104. package/src/index.ts +17 -1
  105. package/src/llm/adapter.ts +29 -5
  106. package/src/llm/anthropic.ts +2 -1
  107. package/src/llm/copilot.ts +552 -0
  108. package/src/llm/gemini.ts +378 -0
  109. package/src/llm/grok.ts +29 -0
  110. package/src/llm/openai-common.ts +294 -0
  111. package/src/llm/openai.ts +31 -261
  112. package/src/orchestrator/orchestrator.ts +260 -40
  113. package/src/task/queue.ts +74 -4
  114. package/src/task/task.ts +8 -1
  115. package/src/team/messaging.ts +3 -1
  116. package/src/tool/text-tool-extractor.ts +219 -0
  117. package/src/types.ts +186 -6
  118. package/src/utils/trace.ts +34 -0
  119. package/tests/agent-hooks.test.ts +473 -0
  120. package/tests/agent-pool.test.ts +212 -0
  121. package/tests/approval.test.ts +464 -0
  122. package/tests/built-in-tools.test.ts +393 -0
  123. package/tests/gemini-adapter.test.ts +97 -0
  124. package/tests/grok-adapter.test.ts +74 -0
  125. package/tests/llm-adapters.test.ts +357 -0
  126. package/tests/loop-detection.test.ts +456 -0
  127. package/tests/openai-fallback.test.ts +159 -0
  128. package/tests/orchestrator.test.ts +281 -0
  129. package/tests/scheduler.test.ts +221 -0
  130. package/tests/semaphore.test.ts +57 -0
  131. package/tests/shared-memory.test.ts +122 -0
  132. package/tests/structured-output.test.ts +331 -0
  133. package/tests/task-queue.test.ts +244 -0
  134. package/tests/task-retry.test.ts +368 -0
  135. package/tests/task-utils.test.ts +155 -0
  136. package/tests/team-messaging.test.ts +329 -0
  137. package/tests/text-tool-extractor.test.ts +170 -0
  138. package/tests/tool-executor.test.ts +193 -0
  139. package/tests/trace.test.ts +453 -0
  140. 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
- /** Progress event emitted by the orchestrator during a run. */
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
- onProgress?: (event: OrchestratorEvent) => void
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
+ }