@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/agent.ts
CHANGED
|
@@ -27,15 +27,22 @@ import type {
|
|
|
27
27
|
AgentConfig,
|
|
28
28
|
AgentState,
|
|
29
29
|
AgentRunResult,
|
|
30
|
+
BeforeRunHookContext,
|
|
30
31
|
LLMMessage,
|
|
31
32
|
StreamEvent,
|
|
32
33
|
TokenUsage,
|
|
33
34
|
ToolUseContext,
|
|
34
35
|
} from '../types.js'
|
|
36
|
+
import { emitTrace, generateRunId } from '../utils/trace.js'
|
|
35
37
|
import type { ToolDefinition as FrameworkToolDefinition, ToolRegistry } from '../tool/framework.js'
|
|
36
38
|
import type { ToolExecutor } from '../tool/executor.js'
|
|
37
39
|
import { createAdapter } from '../llm/adapter.js'
|
|
38
|
-
import { AgentRunner, type RunnerOptions, type RunOptions } from './runner.js'
|
|
40
|
+
import { AgentRunner, type RunnerOptions, type RunOptions, type RunResult } from './runner.js'
|
|
41
|
+
import {
|
|
42
|
+
buildStructuredOutputInstruction,
|
|
43
|
+
extractJSON,
|
|
44
|
+
validateOutput,
|
|
45
|
+
} from './structured-output.js'
|
|
39
46
|
|
|
40
47
|
// ---------------------------------------------------------------------------
|
|
41
48
|
// Internal helpers
|
|
@@ -43,6 +50,19 @@ import { AgentRunner, type RunnerOptions, type RunOptions } from './runner.js'
|
|
|
43
50
|
|
|
44
51
|
const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
|
|
45
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Combine two {@link AbortSignal}s so that aborting either one cancels the
|
|
55
|
+
* returned signal. Works on Node 18+ (no `AbortSignal.any` required).
|
|
56
|
+
*/
|
|
57
|
+
function mergeAbortSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
|
|
58
|
+
const controller = new AbortController()
|
|
59
|
+
if (a.aborted || b.aborted) { controller.abort(); return controller.signal }
|
|
60
|
+
const abort = () => controller.abort()
|
|
61
|
+
a.addEventListener('abort', abort, { once: true })
|
|
62
|
+
b.addEventListener('abort', abort, { once: true })
|
|
63
|
+
return controller.signal
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|
47
67
|
return {
|
|
48
68
|
input_tokens: a.input_tokens + b.input_tokens,
|
|
@@ -109,17 +129,27 @@ export class Agent {
|
|
|
109
129
|
}
|
|
110
130
|
|
|
111
131
|
const provider = this.config.provider ?? 'anthropic'
|
|
112
|
-
const adapter = await createAdapter(provider)
|
|
132
|
+
const adapter = await createAdapter(provider, this.config.apiKey, this.config.baseURL)
|
|
133
|
+
|
|
134
|
+
// Append structured-output instructions when an outputSchema is configured.
|
|
135
|
+
let effectiveSystemPrompt = this.config.systemPrompt
|
|
136
|
+
if (this.config.outputSchema) {
|
|
137
|
+
const instruction = buildStructuredOutputInstruction(this.config.outputSchema)
|
|
138
|
+
effectiveSystemPrompt = effectiveSystemPrompt
|
|
139
|
+
? effectiveSystemPrompt + '\n' + instruction
|
|
140
|
+
: instruction
|
|
141
|
+
}
|
|
113
142
|
|
|
114
143
|
const runnerOptions: RunnerOptions = {
|
|
115
144
|
model: this.config.model,
|
|
116
|
-
systemPrompt:
|
|
145
|
+
systemPrompt: effectiveSystemPrompt,
|
|
117
146
|
maxTurns: this.config.maxTurns,
|
|
118
147
|
maxTokens: this.config.maxTokens,
|
|
119
148
|
temperature: this.config.temperature,
|
|
120
149
|
allowedTools: this.config.tools,
|
|
121
150
|
agentName: this.name,
|
|
122
151
|
agentRole: this.config.systemPrompt?.slice(0, 50) ?? 'assistant',
|
|
152
|
+
loopDetection: this.config.loopDetection,
|
|
123
153
|
}
|
|
124
154
|
|
|
125
155
|
this.runner = new AgentRunner(
|
|
@@ -144,12 +174,12 @@ export class Agent {
|
|
|
144
174
|
*
|
|
145
175
|
* Use this for one-shot queries where past context is irrelevant.
|
|
146
176
|
*/
|
|
147
|
-
async run(prompt: string): Promise<AgentRunResult> {
|
|
177
|
+
async run(prompt: string, runOptions?: Partial<RunOptions>): Promise<AgentRunResult> {
|
|
148
178
|
const messages: LLMMessage[] = [
|
|
149
179
|
{ role: 'user', content: [{ type: 'text', text: prompt }] },
|
|
150
180
|
]
|
|
151
181
|
|
|
152
|
-
return this.executeRun(messages)
|
|
182
|
+
return this.executeRun(messages, runOptions)
|
|
153
183
|
}
|
|
154
184
|
|
|
155
185
|
/**
|
|
@@ -160,6 +190,7 @@ export class Agent {
|
|
|
160
190
|
*
|
|
161
191
|
* Use this for multi-turn interactions.
|
|
162
192
|
*/
|
|
193
|
+
// TODO(#18): accept optional RunOptions to forward trace context
|
|
163
194
|
async prompt(message: string): Promise<AgentRunResult> {
|
|
164
195
|
const userMessage: LLMMessage = {
|
|
165
196
|
role: 'user',
|
|
@@ -183,6 +214,7 @@ export class Agent {
|
|
|
183
214
|
*
|
|
184
215
|
* Like {@link run}, this does not use or update the persistent history.
|
|
185
216
|
*/
|
|
217
|
+
// TODO(#18): accept optional RunOptions to forward trace context
|
|
186
218
|
async *stream(prompt: string): AsyncGenerator<StreamEvent> {
|
|
187
219
|
const messages: LLMMessage[] = [
|
|
188
220
|
{ role: 'user', content: [{ type: 'text', text: prompt }] },
|
|
@@ -252,33 +284,194 @@ export class Agent {
|
|
|
252
284
|
* Shared execution path used by both `run` and `prompt`.
|
|
253
285
|
* Handles state transitions and error wrapping.
|
|
254
286
|
*/
|
|
255
|
-
private async executeRun(
|
|
287
|
+
private async executeRun(
|
|
288
|
+
messages: LLMMessage[],
|
|
289
|
+
callerOptions?: Partial<RunOptions>,
|
|
290
|
+
): Promise<AgentRunResult> {
|
|
256
291
|
this.transitionTo('running')
|
|
257
292
|
|
|
293
|
+
const agentStartMs = Date.now()
|
|
294
|
+
|
|
258
295
|
try {
|
|
296
|
+
// --- beforeRun hook ---
|
|
297
|
+
if (this.config.beforeRun) {
|
|
298
|
+
const hookCtx = this.buildBeforeRunHookContext(messages)
|
|
299
|
+
const modified = await this.config.beforeRun(hookCtx)
|
|
300
|
+
this.applyHookContext(messages, modified, hookCtx.prompt)
|
|
301
|
+
}
|
|
302
|
+
|
|
259
303
|
const runner = await this.getRunner()
|
|
304
|
+
const internalOnMessage = (msg: LLMMessage) => {
|
|
305
|
+
this.state.messages.push(msg)
|
|
306
|
+
callerOptions?.onMessage?.(msg)
|
|
307
|
+
}
|
|
308
|
+
// Auto-generate runId when onTrace is provided but runId is missing
|
|
309
|
+
const needsRunId = callerOptions?.onTrace && !callerOptions.runId
|
|
310
|
+
// Create a fresh timeout signal per run (not per runner) so that
|
|
311
|
+
// each run() / prompt() call gets its own timeout window.
|
|
312
|
+
const timeoutSignal = this.config.timeoutMs !== undefined && this.config.timeoutMs > 0
|
|
313
|
+
? AbortSignal.timeout(this.config.timeoutMs)
|
|
314
|
+
: undefined
|
|
315
|
+
// Merge caller-provided abortSignal with the timeout signal so that
|
|
316
|
+
// either cancellation source is respected.
|
|
317
|
+
const callerAbort = callerOptions?.abortSignal
|
|
318
|
+
const effectiveAbort = timeoutSignal && callerAbort
|
|
319
|
+
? mergeAbortSignals(timeoutSignal, callerAbort)
|
|
320
|
+
: timeoutSignal ?? callerAbort
|
|
260
321
|
const runOptions: RunOptions = {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
},
|
|
322
|
+
...callerOptions,
|
|
323
|
+
onMessage: internalOnMessage,
|
|
324
|
+
...(needsRunId ? { runId: generateRunId() } : undefined),
|
|
325
|
+
...(effectiveAbort ? { abortSignal: effectiveAbort } : undefined),
|
|
264
326
|
}
|
|
265
327
|
|
|
266
328
|
const result = await runner.run(messages, runOptions)
|
|
267
|
-
|
|
268
329
|
this.state.tokenUsage = addUsage(this.state.tokenUsage, result.tokenUsage)
|
|
269
|
-
this.transitionTo('completed')
|
|
270
330
|
|
|
271
|
-
|
|
331
|
+
// --- Structured output validation ---
|
|
332
|
+
if (this.config.outputSchema) {
|
|
333
|
+
let validated = await this.validateStructuredOutput(
|
|
334
|
+
messages,
|
|
335
|
+
result,
|
|
336
|
+
runner,
|
|
337
|
+
runOptions,
|
|
338
|
+
)
|
|
339
|
+
// --- afterRun hook ---
|
|
340
|
+
if (this.config.afterRun) {
|
|
341
|
+
validated = await this.config.afterRun(validated)
|
|
342
|
+
}
|
|
343
|
+
this.emitAgentTrace(callerOptions, agentStartMs, validated)
|
|
344
|
+
return validated
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let agentResult = this.toAgentRunResult(result, true)
|
|
348
|
+
|
|
349
|
+
// --- afterRun hook ---
|
|
350
|
+
if (this.config.afterRun) {
|
|
351
|
+
agentResult = await this.config.afterRun(agentResult)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
this.transitionTo('completed')
|
|
355
|
+
this.emitAgentTrace(callerOptions, agentStartMs, agentResult)
|
|
356
|
+
return agentResult
|
|
272
357
|
} catch (err) {
|
|
273
358
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
274
359
|
this.transitionToError(error)
|
|
275
360
|
|
|
276
|
-
|
|
361
|
+
const errorResult: AgentRunResult = {
|
|
277
362
|
success: false,
|
|
278
363
|
output: error.message,
|
|
279
364
|
messages: [],
|
|
280
365
|
tokenUsage: ZERO_USAGE,
|
|
281
366
|
toolCalls: [],
|
|
367
|
+
structured: undefined,
|
|
368
|
+
}
|
|
369
|
+
this.emitAgentTrace(callerOptions, agentStartMs, errorResult)
|
|
370
|
+
return errorResult
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Emit an `agent` trace event if `onTrace` is provided. */
|
|
375
|
+
private emitAgentTrace(
|
|
376
|
+
options: Partial<RunOptions> | undefined,
|
|
377
|
+
startMs: number,
|
|
378
|
+
result: AgentRunResult,
|
|
379
|
+
): void {
|
|
380
|
+
if (!options?.onTrace) return
|
|
381
|
+
const endMs = Date.now()
|
|
382
|
+
emitTrace(options.onTrace, {
|
|
383
|
+
type: 'agent',
|
|
384
|
+
runId: options.runId ?? '',
|
|
385
|
+
taskId: options.taskId,
|
|
386
|
+
agent: options.traceAgent ?? this.name,
|
|
387
|
+
turns: result.messages.filter(m => m.role === 'assistant').length,
|
|
388
|
+
tokens: result.tokenUsage,
|
|
389
|
+
toolCalls: result.toolCalls.length,
|
|
390
|
+
startMs,
|
|
391
|
+
endMs,
|
|
392
|
+
durationMs: endMs - startMs,
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Validate agent output against the configured `outputSchema`.
|
|
398
|
+
* On first validation failure, retry once with error feedback.
|
|
399
|
+
*/
|
|
400
|
+
private async validateStructuredOutput(
|
|
401
|
+
originalMessages: LLMMessage[],
|
|
402
|
+
result: RunResult,
|
|
403
|
+
runner: AgentRunner,
|
|
404
|
+
runOptions: RunOptions,
|
|
405
|
+
): Promise<AgentRunResult> {
|
|
406
|
+
const schema = this.config.outputSchema!
|
|
407
|
+
|
|
408
|
+
// First attempt
|
|
409
|
+
let firstAttemptError: unknown
|
|
410
|
+
try {
|
|
411
|
+
const parsed = extractJSON(result.output)
|
|
412
|
+
const validated = validateOutput(schema, parsed)
|
|
413
|
+
this.transitionTo('completed')
|
|
414
|
+
return this.toAgentRunResult(result, true, validated)
|
|
415
|
+
} catch (e) {
|
|
416
|
+
firstAttemptError = e
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Retry: send full context + error feedback
|
|
420
|
+
const errorMsg = firstAttemptError instanceof Error
|
|
421
|
+
? firstAttemptError.message
|
|
422
|
+
: String(firstAttemptError)
|
|
423
|
+
|
|
424
|
+
const errorFeedbackMessage: LLMMessage = {
|
|
425
|
+
role: 'user' as const,
|
|
426
|
+
content: [{
|
|
427
|
+
type: 'text' as const,
|
|
428
|
+
text: [
|
|
429
|
+
'Your previous response did not produce valid JSON matching the required schema.',
|
|
430
|
+
'',
|
|
431
|
+
`Error: ${errorMsg}`,
|
|
432
|
+
'',
|
|
433
|
+
'Please try again. Respond with ONLY valid JSON, no other text.',
|
|
434
|
+
].join('\n'),
|
|
435
|
+
}],
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const retryMessages: LLMMessage[] = [
|
|
439
|
+
...originalMessages,
|
|
440
|
+
...result.messages,
|
|
441
|
+
errorFeedbackMessage,
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
const retryResult = await runner.run(retryMessages, runOptions)
|
|
445
|
+
this.state.tokenUsage = addUsage(this.state.tokenUsage, retryResult.tokenUsage)
|
|
446
|
+
|
|
447
|
+
const mergedTokenUsage = addUsage(result.tokenUsage, retryResult.tokenUsage)
|
|
448
|
+
// Include the error feedback turn to maintain alternating user/assistant roles,
|
|
449
|
+
// which is required by Anthropic's API for subsequent prompt() calls.
|
|
450
|
+
const mergedMessages = [...result.messages, errorFeedbackMessage, ...retryResult.messages]
|
|
451
|
+
const mergedToolCalls = [...result.toolCalls, ...retryResult.toolCalls]
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
const parsed = extractJSON(retryResult.output)
|
|
455
|
+
const validated = validateOutput(schema, parsed)
|
|
456
|
+
this.transitionTo('completed')
|
|
457
|
+
return {
|
|
458
|
+
success: true,
|
|
459
|
+
output: retryResult.output,
|
|
460
|
+
messages: mergedMessages,
|
|
461
|
+
tokenUsage: mergedTokenUsage,
|
|
462
|
+
toolCalls: mergedToolCalls,
|
|
463
|
+
structured: validated,
|
|
464
|
+
}
|
|
465
|
+
} catch {
|
|
466
|
+
// Retry also failed
|
|
467
|
+
this.transitionTo('completed')
|
|
468
|
+
return {
|
|
469
|
+
success: false,
|
|
470
|
+
output: retryResult.output,
|
|
471
|
+
messages: mergedMessages,
|
|
472
|
+
tokenUsage: mergedTokenUsage,
|
|
473
|
+
toolCalls: mergedToolCalls,
|
|
474
|
+
structured: undefined,
|
|
282
475
|
}
|
|
283
476
|
}
|
|
284
477
|
}
|
|
@@ -291,13 +484,31 @@ export class Agent {
|
|
|
291
484
|
this.transitionTo('running')
|
|
292
485
|
|
|
293
486
|
try {
|
|
487
|
+
// --- beforeRun hook ---
|
|
488
|
+
if (this.config.beforeRun) {
|
|
489
|
+
const hookCtx = this.buildBeforeRunHookContext(messages)
|
|
490
|
+
const modified = await this.config.beforeRun(hookCtx)
|
|
491
|
+
this.applyHookContext(messages, modified, hookCtx.prompt)
|
|
492
|
+
}
|
|
493
|
+
|
|
294
494
|
const runner = await this.getRunner()
|
|
495
|
+
// Fresh timeout per stream call, same as executeRun.
|
|
496
|
+
const timeoutSignal = this.config.timeoutMs !== undefined && this.config.timeoutMs > 0
|
|
497
|
+
? AbortSignal.timeout(this.config.timeoutMs)
|
|
498
|
+
: undefined
|
|
295
499
|
|
|
296
|
-
for await (const event of runner.stream(messages)) {
|
|
500
|
+
for await (const event of runner.stream(messages, timeoutSignal ? { abortSignal: timeoutSignal } : {})) {
|
|
297
501
|
if (event.type === 'done') {
|
|
298
502
|
const result = event.data as import('./runner.js').RunResult
|
|
299
503
|
this.state.tokenUsage = addUsage(this.state.tokenUsage, result.tokenUsage)
|
|
504
|
+
|
|
505
|
+
let agentResult = this.toAgentRunResult(result, true)
|
|
506
|
+
if (this.config.afterRun) {
|
|
507
|
+
agentResult = await this.config.afterRun(agentResult)
|
|
508
|
+
}
|
|
300
509
|
this.transitionTo('completed')
|
|
510
|
+
yield { type: 'done', data: agentResult } satisfies StreamEvent
|
|
511
|
+
continue
|
|
301
512
|
} else if (event.type === 'error') {
|
|
302
513
|
const error = event.data instanceof Error
|
|
303
514
|
? event.data
|
|
@@ -314,6 +525,50 @@ export class Agent {
|
|
|
314
525
|
}
|
|
315
526
|
}
|
|
316
527
|
|
|
528
|
+
// -------------------------------------------------------------------------
|
|
529
|
+
// Hook helpers
|
|
530
|
+
// -------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
/** Extract the prompt text from the last user message to build hook context. */
|
|
533
|
+
private buildBeforeRunHookContext(messages: LLMMessage[]): BeforeRunHookContext {
|
|
534
|
+
let prompt = ''
|
|
535
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
536
|
+
if (messages[i]!.role === 'user') {
|
|
537
|
+
prompt = messages[i]!.content
|
|
538
|
+
.filter((b): b is import('../types.js').TextBlock => b.type === 'text')
|
|
539
|
+
.map(b => b.text)
|
|
540
|
+
.join('')
|
|
541
|
+
break
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Strip hook functions to avoid circular self-references in the context
|
|
545
|
+
const { beforeRun, afterRun, ...agentInfo } = this.config
|
|
546
|
+
return { prompt, agent: agentInfo as AgentConfig }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Apply a (possibly modified) hook context back to the messages array.
|
|
551
|
+
*
|
|
552
|
+
* Only text blocks in the last user message are replaced; non-text content
|
|
553
|
+
* (images, tool results) is preserved. The array element is replaced (not
|
|
554
|
+
* mutated in place) so that shallow copies of the original array (e.g. from
|
|
555
|
+
* `prompt()`) are not affected.
|
|
556
|
+
*/
|
|
557
|
+
private applyHookContext(messages: LLMMessage[], ctx: BeforeRunHookContext, originalPrompt: string): void {
|
|
558
|
+
if (ctx.prompt === originalPrompt) return
|
|
559
|
+
|
|
560
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
561
|
+
if (messages[i]!.role === 'user') {
|
|
562
|
+
const nonTextBlocks = messages[i]!.content.filter(b => b.type !== 'text')
|
|
563
|
+
messages[i] = {
|
|
564
|
+
role: 'user',
|
|
565
|
+
content: [{ type: 'text', text: ctx.prompt }, ...nonTextBlocks],
|
|
566
|
+
}
|
|
567
|
+
break
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
317
572
|
// -------------------------------------------------------------------------
|
|
318
573
|
// State transition helpers
|
|
319
574
|
// -------------------------------------------------------------------------
|
|
@@ -331,8 +586,9 @@ export class Agent {
|
|
|
331
586
|
// -------------------------------------------------------------------------
|
|
332
587
|
|
|
333
588
|
private toAgentRunResult(
|
|
334
|
-
result:
|
|
589
|
+
result: RunResult,
|
|
335
590
|
success: boolean,
|
|
591
|
+
structured?: unknown,
|
|
336
592
|
): AgentRunResult {
|
|
337
593
|
return {
|
|
338
594
|
success,
|
|
@@ -340,6 +596,8 @@ export class Agent {
|
|
|
340
596
|
messages: result.messages,
|
|
341
597
|
tokenUsage: result.tokenUsage,
|
|
342
598
|
toolCalls: result.toolCalls,
|
|
599
|
+
structured,
|
|
600
|
+
...(result.loopDetected ? { loopDetected: true } : {}),
|
|
343
601
|
}
|
|
344
602
|
}
|
|
345
603
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Sliding-window loop detector for the agent conversation loop.
|
|
3
|
+
*
|
|
4
|
+
* Tracks tool-call signatures and text outputs across turns to detect when an
|
|
5
|
+
* agent is stuck repeating the same actions. Used by {@link AgentRunner} when
|
|
6
|
+
* {@link LoopDetectionConfig} is provided.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { LoopDetectionConfig, LoopDetectionInfo } from '../types.js'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Recursively sort object keys so that `{b:1, a:2}` and `{a:2, b:1}` produce
|
|
17
|
+
* the same JSON string.
|
|
18
|
+
*/
|
|
19
|
+
function sortKeys(value: unknown): unknown {
|
|
20
|
+
if (value === null || typeof value !== 'object') return value
|
|
21
|
+
if (Array.isArray(value)) return value.map(sortKeys)
|
|
22
|
+
const sorted: Record<string, unknown> = {}
|
|
23
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
24
|
+
sorted[key] = sortKeys((value as Record<string, unknown>)[key])
|
|
25
|
+
}
|
|
26
|
+
return sorted
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// LoopDetector
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export class LoopDetector {
|
|
34
|
+
private readonly maxRepeats: number
|
|
35
|
+
private readonly windowSize: number
|
|
36
|
+
|
|
37
|
+
private readonly toolSignatures: string[] = []
|
|
38
|
+
private readonly textOutputs: string[] = []
|
|
39
|
+
|
|
40
|
+
constructor(config: LoopDetectionConfig = {}) {
|
|
41
|
+
this.maxRepeats = config.maxRepetitions ?? 3
|
|
42
|
+
const requestedWindow = config.loopDetectionWindow ?? 4
|
|
43
|
+
// Window must be >= threshold, otherwise detection can never trigger.
|
|
44
|
+
this.windowSize = Math.max(requestedWindow, this.maxRepeats)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Record a turn's tool calls. Returns detection info when a loop is found.
|
|
49
|
+
*/
|
|
50
|
+
recordToolCalls(
|
|
51
|
+
blocks: ReadonlyArray<{ name: string; input: Record<string, unknown> }>,
|
|
52
|
+
): LoopDetectionInfo | null {
|
|
53
|
+
if (blocks.length === 0) return null
|
|
54
|
+
|
|
55
|
+
const signature = this.computeToolSignature(blocks)
|
|
56
|
+
this.push(this.toolSignatures, signature)
|
|
57
|
+
|
|
58
|
+
const count = this.consecutiveRepeats(this.toolSignatures)
|
|
59
|
+
if (count >= this.maxRepeats) {
|
|
60
|
+
const names = blocks.map(b => b.name).join(', ')
|
|
61
|
+
return {
|
|
62
|
+
kind: 'tool_repetition',
|
|
63
|
+
repetitions: count,
|
|
64
|
+
detail:
|
|
65
|
+
`Tool call "${names}" with identical arguments has repeated ` +
|
|
66
|
+
`${count} times consecutively. The agent appears to be stuck in a loop.`,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Record a turn's text output. Returns detection info when a loop is found.
|
|
74
|
+
*/
|
|
75
|
+
recordText(text: string): LoopDetectionInfo | null {
|
|
76
|
+
const normalised = text.trim().replace(/\s+/g, ' ')
|
|
77
|
+
if (normalised.length === 0) return null
|
|
78
|
+
|
|
79
|
+
this.push(this.textOutputs, normalised)
|
|
80
|
+
|
|
81
|
+
const count = this.consecutiveRepeats(this.textOutputs)
|
|
82
|
+
if (count >= this.maxRepeats) {
|
|
83
|
+
return {
|
|
84
|
+
kind: 'text_repetition',
|
|
85
|
+
repetitions: count,
|
|
86
|
+
detail:
|
|
87
|
+
`The agent has produced the same text response ${count} times ` +
|
|
88
|
+
`consecutively. It appears to be stuck in a loop.`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// -------------------------------------------------------------------------
|
|
95
|
+
// Private
|
|
96
|
+
// -------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Deterministic JSON signature for a set of tool calls.
|
|
100
|
+
* Sorts calls by name (for multi-tool turns) and keys within each input.
|
|
101
|
+
*/
|
|
102
|
+
private computeToolSignature(
|
|
103
|
+
blocks: ReadonlyArray<{ name: string; input: Record<string, unknown> }>,
|
|
104
|
+
): string {
|
|
105
|
+
const items = blocks
|
|
106
|
+
.map(b => ({ name: b.name, input: sortKeys(b.input) }))
|
|
107
|
+
.sort((a, b) => {
|
|
108
|
+
const cmp = a.name.localeCompare(b.name)
|
|
109
|
+
if (cmp !== 0) return cmp
|
|
110
|
+
return JSON.stringify(a.input).localeCompare(JSON.stringify(b.input))
|
|
111
|
+
})
|
|
112
|
+
return JSON.stringify(items)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Push an entry and trim the buffer to `windowSize`. */
|
|
116
|
+
private push(buffer: string[], entry: string): void {
|
|
117
|
+
buffer.push(entry)
|
|
118
|
+
while (buffer.length > this.windowSize) {
|
|
119
|
+
buffer.shift()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Count how many consecutive identical entries exist at the tail of `buffer`.
|
|
125
|
+
* Returns 1 when the last entry is unique.
|
|
126
|
+
*/
|
|
127
|
+
private consecutiveRepeats(buffer: string[]): number {
|
|
128
|
+
if (buffer.length === 0) return 0
|
|
129
|
+
const last = buffer[buffer.length - 1]
|
|
130
|
+
let count = 0
|
|
131
|
+
for (let i = buffer.length - 1; i >= 0; i--) {
|
|
132
|
+
if (buffer[i] === last) count++
|
|
133
|
+
else break
|
|
134
|
+
}
|
|
135
|
+
return count
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/agent/pool.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { AgentRunResult } from '../types.js'
|
|
24
|
+
import type { RunOptions } from './runner.js'
|
|
24
25
|
import type { Agent } from './agent.js'
|
|
25
26
|
import { Semaphore } from '../utils/semaphore.js'
|
|
26
27
|
|
|
@@ -123,12 +124,16 @@ export class AgentPool {
|
|
|
123
124
|
*
|
|
124
125
|
* @throws {Error} If the agent name is not found.
|
|
125
126
|
*/
|
|
126
|
-
async run(
|
|
127
|
+
async run(
|
|
128
|
+
agentName: string,
|
|
129
|
+
prompt: string,
|
|
130
|
+
runOptions?: Partial<RunOptions>,
|
|
131
|
+
): Promise<AgentRunResult> {
|
|
127
132
|
const agent = this.requireAgent(agentName)
|
|
128
133
|
|
|
129
134
|
await this.semaphore.acquire()
|
|
130
135
|
try {
|
|
131
|
-
return await agent.run(prompt)
|
|
136
|
+
return await agent.run(prompt, runOptions)
|
|
132
137
|
} finally {
|
|
133
138
|
this.semaphore.release()
|
|
134
139
|
}
|
|
@@ -144,6 +149,7 @@ export class AgentPool {
|
|
|
144
149
|
*
|
|
145
150
|
* @param tasks - Array of `{ agent, prompt }` descriptors.
|
|
146
151
|
*/
|
|
152
|
+
// TODO(#18): accept RunOptions per task to forward trace context
|
|
147
153
|
async runParallel(
|
|
148
154
|
tasks: ReadonlyArray<{ readonly agent: string; readonly prompt: string }>,
|
|
149
155
|
): Promise<Map<string, AgentRunResult>> {
|
|
@@ -182,6 +188,7 @@ export class AgentPool {
|
|
|
182
188
|
*
|
|
183
189
|
* @throws {Error} If the pool is empty.
|
|
184
190
|
*/
|
|
191
|
+
// TODO(#18): accept RunOptions to forward trace context
|
|
185
192
|
async runAny(prompt: string): Promise<AgentRunResult> {
|
|
186
193
|
const allAgents = this.list()
|
|
187
194
|
if (allAgents.length === 0) {
|