@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
package/src/agent/runner.ts
CHANGED
|
@@ -25,7 +25,12 @@ import type {
|
|
|
25
25
|
ToolUseContext,
|
|
26
26
|
LLMAdapter,
|
|
27
27
|
LLMChatOptions,
|
|
28
|
+
TraceEvent,
|
|
29
|
+
LoopDetectionConfig,
|
|
30
|
+
LoopDetectionInfo,
|
|
28
31
|
} from '../types.js'
|
|
32
|
+
import { LoopDetector } from './loop-detector.js'
|
|
33
|
+
import { emitTrace } from '../utils/trace.js'
|
|
29
34
|
import type { ToolRegistry } from '../tool/framework.js'
|
|
30
35
|
import type { ToolExecutor } from '../tool/executor.js'
|
|
31
36
|
|
|
@@ -63,6 +68,8 @@ export interface RunnerOptions {
|
|
|
63
68
|
readonly agentName?: string
|
|
64
69
|
/** Short role description of the agent (used in tool context). */
|
|
65
70
|
readonly agentRole?: string
|
|
71
|
+
/** Loop detection configuration. When set, detects stuck agent loops. */
|
|
72
|
+
readonly loopDetection?: LoopDetectionConfig
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
/**
|
|
@@ -76,6 +83,24 @@ export interface RunOptions {
|
|
|
76
83
|
readonly onToolResult?: (name: string, result: ToolResult) => void
|
|
77
84
|
/** Fired after each complete {@link LLMMessage} is appended. */
|
|
78
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
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
/** The aggregated result returned when a full run completes. */
|
|
@@ -90,6 +115,8 @@ export interface RunResult {
|
|
|
90
115
|
readonly tokenUsage: TokenUsage
|
|
91
116
|
/** Total number of LLM turns (including tool-call follow-ups). */
|
|
92
117
|
readonly turns: number
|
|
118
|
+
/** True when the run was terminated or warned due to loop detection. */
|
|
119
|
+
readonly loopDetected?: boolean
|
|
93
120
|
}
|
|
94
121
|
|
|
95
122
|
// ---------------------------------------------------------------------------
|
|
@@ -166,13 +193,7 @@ export class AgentRunner {
|
|
|
166
193
|
options: RunOptions = {},
|
|
167
194
|
): Promise<RunResult> {
|
|
168
195
|
// Collect everything yielded by the internal streaming loop.
|
|
169
|
-
const accumulated: {
|
|
170
|
-
messages: LLMMessage[]
|
|
171
|
-
output: string
|
|
172
|
-
toolCalls: ToolCallRecord[]
|
|
173
|
-
tokenUsage: TokenUsage
|
|
174
|
-
turns: number
|
|
175
|
-
} = {
|
|
196
|
+
const accumulated: RunResult = {
|
|
176
197
|
messages: [],
|
|
177
198
|
output: '',
|
|
178
199
|
toolCalls: [],
|
|
@@ -182,12 +203,7 @@ export class AgentRunner {
|
|
|
182
203
|
|
|
183
204
|
for await (const event of this.stream(messages, options)) {
|
|
184
205
|
if (event.type === 'done') {
|
|
185
|
-
|
|
186
|
-
accumulated.messages = result.messages
|
|
187
|
-
accumulated.output = result.output
|
|
188
|
-
accumulated.toolCalls = result.toolCalls
|
|
189
|
-
accumulated.tokenUsage = result.tokenUsage
|
|
190
|
-
accumulated.turns = result.turns
|
|
206
|
+
Object.assign(accumulated, event.data)
|
|
191
207
|
}
|
|
192
208
|
}
|
|
193
209
|
|
|
@@ -225,22 +241,33 @@ export class AgentRunner {
|
|
|
225
241
|
? allDefs.filter(d => this.options.allowedTools!.includes(d.name))
|
|
226
242
|
: allDefs
|
|
227
243
|
|
|
244
|
+
// Per-call abortSignal takes precedence over the static one.
|
|
245
|
+
const effectiveAbortSignal = options.abortSignal ?? this.options.abortSignal
|
|
246
|
+
|
|
228
247
|
const baseChatOptions: LLMChatOptions = {
|
|
229
248
|
model: this.options.model,
|
|
230
249
|
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
231
250
|
maxTokens: this.options.maxTokens,
|
|
232
251
|
temperature: this.options.temperature,
|
|
233
252
|
systemPrompt: this.options.systemPrompt,
|
|
234
|
-
abortSignal:
|
|
253
|
+
abortSignal: effectiveAbortSignal,
|
|
235
254
|
}
|
|
236
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
|
+
|
|
237
264
|
try {
|
|
238
265
|
// -----------------------------------------------------------------
|
|
239
266
|
// Main agentic loop — `while (true)` until end_turn or maxTurns
|
|
240
267
|
// -----------------------------------------------------------------
|
|
241
268
|
while (true) {
|
|
242
269
|
// Respect abort before each LLM call.
|
|
243
|
-
if (
|
|
270
|
+
if (effectiveAbortSignal?.aborted) {
|
|
244
271
|
break
|
|
245
272
|
}
|
|
246
273
|
|
|
@@ -254,7 +281,23 @@ export class AgentRunner {
|
|
|
254
281
|
// ------------------------------------------------------------------
|
|
255
282
|
// Step 1: Call the LLM and collect the full response for this turn.
|
|
256
283
|
// ------------------------------------------------------------------
|
|
284
|
+
const llmStartMs = Date.now()
|
|
257
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
|
+
}
|
|
258
301
|
|
|
259
302
|
totalUsage = addTokenUsage(totalUsage, response.usage)
|
|
260
303
|
|
|
@@ -275,21 +318,77 @@ export class AgentRunner {
|
|
|
275
318
|
yield { type: 'text', data: turnText } satisfies StreamEvent
|
|
276
319
|
}
|
|
277
320
|
|
|
278
|
-
//
|
|
321
|
+
// Extract tool-use blocks for detection and execution.
|
|
279
322
|
const toolUseBlocks = extractToolUseBlocks(response.content)
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
}
|
|
282
366
|
}
|
|
283
367
|
|
|
284
368
|
// ------------------------------------------------------------------
|
|
285
369
|
// Step 3: Decide whether to continue looping.
|
|
286
370
|
// ------------------------------------------------------------------
|
|
287
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
|
+
}
|
|
288
381
|
// No tools requested — this is the terminal assistant turn.
|
|
289
382
|
finalOutput = turnText
|
|
290
383
|
break
|
|
291
384
|
}
|
|
292
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
|
+
|
|
293
392
|
// ------------------------------------------------------------------
|
|
294
393
|
// Step 4: Execute all tool calls in PARALLEL.
|
|
295
394
|
//
|
|
@@ -319,10 +418,25 @@ export class AgentRunner {
|
|
|
319
418
|
result = { data: message, isError: true }
|
|
320
419
|
}
|
|
321
420
|
|
|
322
|
-
const
|
|
421
|
+
const endTime = Date.now()
|
|
422
|
+
const duration = endTime - startTime
|
|
323
423
|
|
|
324
424
|
options.onToolResult?.(block.name, result)
|
|
325
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
|
+
|
|
326
440
|
const record: ToolCallRecord = {
|
|
327
441
|
toolName: block.name,
|
|
328
442
|
input: block.input,
|
|
@@ -354,6 +468,20 @@ export class AgentRunner {
|
|
|
354
468
|
yield { type: 'tool_result', data: resultBlock } satisfies StreamEvent
|
|
355
469
|
}
|
|
356
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
|
+
|
|
357
485
|
const toolResultMessage: LLMMessage = {
|
|
358
486
|
role: 'user',
|
|
359
487
|
content: toolResultBlocks,
|
|
@@ -387,6 +515,7 @@ export class AgentRunner {
|
|
|
387
515
|
toolCalls: allToolCalls,
|
|
388
516
|
tokenUsage: totalUsage,
|
|
389
517
|
turns,
|
|
518
|
+
...(loopDetected ? { loopDetected: true } : {}),
|
|
390
519
|
}
|
|
391
520
|
|
|
392
521
|
yield { type: 'done', data: runResult } satisfies StreamEvent
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
// Orchestrator (primary entry point)
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
|
|
57
|
-
export { OpenMultiAgent } from './orchestrator/orchestrator.js'
|
|
57
|
+
export { OpenMultiAgent, executeWithRetry, computeRetryDelay } from './orchestrator/orchestrator.js'
|
|
58
58
|
export { Scheduler } from './orchestrator/scheduler.js'
|
|
59
59
|
export type { SchedulingStrategy } from './orchestrator/scheduler.js'
|
|
60
60
|
|
|
@@ -63,6 +63,8 @@ export type { SchedulingStrategy } from './orchestrator/scheduler.js'
|
|
|
63
63
|
// ---------------------------------------------------------------------------
|
|
64
64
|
|
|
65
65
|
export { Agent } from './agent/agent.js'
|
|
66
|
+
export { LoopDetector } from './agent/loop-detector.js'
|
|
67
|
+
export { buildStructuredOutputInstruction, extractJSON, validateOutput } from './agent/structured-output.js'
|
|
66
68
|
export { AgentPool, Semaphore } from './agent/pool.js'
|
|
67
69
|
export type { PoolStatus } from './agent/pool.js'
|
|
68
70
|
|
|
@@ -146,7 +148,10 @@ export type {
|
|
|
146
148
|
AgentConfig,
|
|
147
149
|
AgentState,
|
|
148
150
|
AgentRunResult,
|
|
151
|
+
BeforeRunHookContext,
|
|
149
152
|
ToolCallRecord,
|
|
153
|
+
LoopDetectionConfig,
|
|
154
|
+
LoopDetectionInfo,
|
|
150
155
|
|
|
151
156
|
// Team
|
|
152
157
|
TeamConfig,
|
|
@@ -160,7 +165,18 @@ export type {
|
|
|
160
165
|
OrchestratorConfig,
|
|
161
166
|
OrchestratorEvent,
|
|
162
167
|
|
|
168
|
+
// Trace
|
|
169
|
+
TraceEventType,
|
|
170
|
+
TraceEventBase,
|
|
171
|
+
TraceEvent,
|
|
172
|
+
LLMCallTrace,
|
|
173
|
+
ToolCallTrace,
|
|
174
|
+
TaskTrace,
|
|
175
|
+
AgentTrace,
|
|
176
|
+
|
|
163
177
|
// Memory
|
|
164
178
|
MemoryEntry,
|
|
165
179
|
MemoryStore,
|
|
166
180
|
} from './types.js'
|
|
181
|
+
|
|
182
|
+
export { generateRunId } from './utils/trace.js'
|
package/src/llm/adapter.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* const anthropic = createAdapter('anthropic')
|
|
13
13
|
* const openai = createAdapter('openai', process.env.OPENAI_API_KEY)
|
|
14
|
+
* const gemini = createAdapter('gemini', process.env.GEMINI_API_KEY)
|
|
14
15
|
* ```
|
|
15
16
|
*/
|
|
16
17
|
|
|
@@ -37,33 +38,56 @@ import type { LLMAdapter } from '../types.js'
|
|
|
37
38
|
* Additional providers can be integrated by implementing {@link LLMAdapter}
|
|
38
39
|
* directly and bypassing this factory.
|
|
39
40
|
*/
|
|
40
|
-
export type SupportedProvider = 'anthropic' | 'openai'
|
|
41
|
+
export type SupportedProvider = 'anthropic' | 'copilot' | 'grok' | 'openai' | 'gemini'
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Instantiate the appropriate {@link LLMAdapter} for the given provider.
|
|
44
45
|
*
|
|
45
|
-
* API keys fall back to the standard environment variables
|
|
46
|
-
*
|
|
46
|
+
* API keys fall back to the standard environment variables when not supplied
|
|
47
|
+
* explicitly:
|
|
48
|
+
* - `anthropic` → `ANTHROPIC_API_KEY`
|
|
49
|
+
* - `openai` → `OPENAI_API_KEY`
|
|
50
|
+
* - `gemini` → `GEMINI_API_KEY` / `GOOGLE_API_KEY`
|
|
51
|
+
* - `grok` → `XAI_API_KEY`
|
|
52
|
+
* - `copilot` → `GITHUB_COPILOT_TOKEN` / `GITHUB_TOKEN`, or interactive
|
|
53
|
+
* OAuth2 device flow if neither is set
|
|
47
54
|
*
|
|
48
55
|
* Adapters are imported lazily so that projects using only one provider
|
|
49
56
|
* are not forced to install the SDK for the other.
|
|
50
57
|
*
|
|
51
58
|
* @param provider - Which LLM provider to target.
|
|
52
59
|
* @param apiKey - Optional API key override; falls back to env var.
|
|
60
|
+
* @param baseURL - Optional base URL for OpenAI-compatible APIs (Ollama, vLLM, etc.).
|
|
53
61
|
* @throws {Error} When the provider string is not recognised.
|
|
54
62
|
*/
|
|
55
63
|
export async function createAdapter(
|
|
56
64
|
provider: SupportedProvider,
|
|
57
65
|
apiKey?: string,
|
|
66
|
+
baseURL?: string,
|
|
58
67
|
): Promise<LLMAdapter> {
|
|
59
68
|
switch (provider) {
|
|
60
69
|
case 'anthropic': {
|
|
61
70
|
const { AnthropicAdapter } = await import('./anthropic.js')
|
|
62
|
-
return new AnthropicAdapter(apiKey)
|
|
71
|
+
return new AnthropicAdapter(apiKey, baseURL)
|
|
72
|
+
}
|
|
73
|
+
case 'copilot': {
|
|
74
|
+
if (baseURL) {
|
|
75
|
+
console.warn('[open-multi-agent] baseURL is not supported for the copilot provider and will be ignored.')
|
|
76
|
+
}
|
|
77
|
+
const { CopilotAdapter } = await import('./copilot.js')
|
|
78
|
+
return new CopilotAdapter(apiKey)
|
|
79
|
+
}
|
|
80
|
+
case 'gemini': {
|
|
81
|
+
const { GeminiAdapter } = await import('./gemini.js')
|
|
82
|
+
return new GeminiAdapter(apiKey)
|
|
63
83
|
}
|
|
64
84
|
case 'openai': {
|
|
65
85
|
const { OpenAIAdapter } = await import('./openai.js')
|
|
66
|
-
return new OpenAIAdapter(apiKey)
|
|
86
|
+
return new OpenAIAdapter(apiKey, baseURL)
|
|
87
|
+
}
|
|
88
|
+
case 'grok': {
|
|
89
|
+
const { GrokAdapter } = await import('./grok.js')
|
|
90
|
+
return new GrokAdapter(apiKey, baseURL)
|
|
67
91
|
}
|
|
68
92
|
default: {
|
|
69
93
|
// The `never` cast here makes TypeScript enforce exhaustiveness.
|
package/src/llm/anthropic.ts
CHANGED
|
@@ -189,9 +189,10 @@ export class AnthropicAdapter implements LLMAdapter {
|
|
|
189
189
|
|
|
190
190
|
readonly #client: Anthropic
|
|
191
191
|
|
|
192
|
-
constructor(apiKey?: string) {
|
|
192
|
+
constructor(apiKey?: string, baseURL?: string) {
|
|
193
193
|
this.#client = new Anthropic({
|
|
194
194
|
apiKey: apiKey ?? process.env['ANTHROPIC_API_KEY'],
|
|
195
|
+
baseURL,
|
|
195
196
|
})
|
|
196
197
|
}
|
|
197
198
|
|