From a4a1add8cae0c6553f698d44c0775000822adb6e Mon Sep 17 00:00:00 2001 From: JackChen Date: Sun, 5 Apr 2026 12:00:16 +0800 Subject: [PATCH] fix(agent): merge abort signals instead of overriding caller's signal When both timeoutMs and a caller-provided abortSignal were set, the timeout signal silently replaced the caller's signal. Now they are combined via mergeAbortSignals() so either source can cancel the run. Also removes dead array-handling branch in text-tool-extractor.ts (extractJSONObjects only returns objects, never arrays). --- src/agent/agent.ts | 21 ++++++++++++++++++++- src/tool/text-tool-extractor.ts | 9 --------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 0d7f665..3290347 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -50,6 +50,19 @@ import { const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 } +/** + * Combine two {@link AbortSignal}s so that aborting either one cancels the + * returned signal. Works on Node 18+ (no `AbortSignal.any` required). + */ +function mergeAbortSignals(a: AbortSignal, b: AbortSignal): AbortSignal { + const controller = new AbortController() + if (a.aborted || b.aborted) { controller.abort(); return controller.signal } + const abort = () => controller.abort() + a.addEventListener('abort', abort, { once: true }) + b.addEventListener('abort', abort, { once: true }) + return controller.signal +} + function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage { return { input_tokens: a.input_tokens + b.input_tokens, @@ -298,11 +311,17 @@ export class Agent { const timeoutSignal = this.config.timeoutMs !== undefined && this.config.timeoutMs > 0 ? AbortSignal.timeout(this.config.timeoutMs) : undefined + // Merge caller-provided abortSignal with the timeout signal so that + // either cancellation source is respected. + const callerAbort = callerOptions?.abortSignal + const effectiveAbort = timeoutSignal && callerAbort + ? mergeAbortSignals(timeoutSignal, callerAbort) + : timeoutSignal ?? callerAbort const runOptions: RunOptions = { ...callerOptions, onMessage: internalOnMessage, ...(needsRunId ? { runId: generateRunId() } : undefined), - ...(timeoutSignal ? { abortSignal: timeoutSignal } : undefined), + ...(effectiveAbort ? { abortSignal: effectiveAbort } : undefined), } const result = await runner.run(messages, runOptions) diff --git a/src/tool/text-tool-extractor.ts b/src/tool/text-tool-extractor.ts index 79e3197..8c64d1d 100644 --- a/src/tool/text-tool-extractor.ts +++ b/src/tool/text-tool-extractor.ts @@ -211,15 +211,6 @@ export function extractToolCallsFromText( const results: ToolUseBlock[] = [] for (const obj of jsonObjects) { - // Handle array of tool calls - if (Array.isArray(obj)) { - for (const item of obj) { - const block = parseToolCallJSON(item, nameSet) - if (block !== null) results.push(block) - } - continue - } - const block = parseToolCallJSON(obj, nameSet) if (block !== null) results.push(block) }