diff --git a/langgraph/src/agent/enterprise/nodes/tool-executor.ts b/langgraph/src/agent/enterprise/nodes/tool-executor.ts index c9a8d65..adfd504 100644 --- a/langgraph/src/agent/enterprise/nodes/tool-executor.ts +++ b/langgraph/src/agent/enterprise/nodes/tool-executor.ts @@ -155,13 +155,29 @@ function hasArtifactForToolCall( async function withTimeout( fn: (signal: AbortSignal) => Promise, timeoutMs: number, + externalSignal?: AbortSignal, ): Promise { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const timer = setTimeout(() => controller.abort(new Error("TimeoutError")), timeoutMs); + + let onExternalAbort: (() => void) | undefined; + if (externalSignal) { + if (externalSignal.aborted) { + clearTimeout(timer); + controller.abort(new Error("RunCancelled")); + } else { + onExternalAbort = () => controller.abort(new Error("RunCancelled")); + externalSignal.addEventListener("abort", onExternalAbort, { once: true }); + } + } + try { return await fn(controller.signal); } finally { clearTimeout(timer); + if (onExternalAbort && externalSignal) { + externalSignal.removeEventListener("abort", onExternalAbort); + } } } @@ -258,6 +274,14 @@ export async function toolExecutorNode( ): Promise { const ui = typedUi(config); + // ── Cancel guard: abort at node entry if run was already cancelled ───── + const runSignal = (config as unknown as { signal?: AbortSignal }).signal; + if (runSignal?.aborted) { + console.log(JSON.stringify({ event: "tool_executor_cancelled", reason: "aborted_at_entry" })); + return { ui: ui.items, timestamp: Date.now() }; + } + // ──────────────────────────────────────────────────────────────────────── + // Find the last AI message with tool_calls const lastAiMessage = [...state.messages] .reverse() @@ -284,6 +308,14 @@ export async function toolExecutorNode( // Execute all tool calls in parallel const executions = toolCalls.map(async (tc) => { + if (runSignal?.aborted) { + return { + role: "tool" as const, + tool_call_id: tc.id ?? "", + content: JSON.stringify({ ok: false, cancelled: true }), + }; + } + const name = tc.name; const args = tc.args; const id = tc.id ?? ""; @@ -339,6 +371,7 @@ export async function toolExecutorNode( { backoffMs: kbCfg.backoffMs, exponential: kbCfg.exponential, onAttempt: () => { kbRetryCount++; } }, ), kbCfg.timeoutMs, + runSignal, ); } catch (kbError) { // Push friendly error card, then try fallback to google_search @@ -365,6 +398,7 @@ export async function toolExecutorNode( const gData = await withTimeout( () => googleSearch(parsed.query), getToolConfig("google_search").timeoutMs, + runSignal, ); const fallbackResults = gData.results.slice(0, 5).map((r) => ({ title: r.title, @@ -546,6 +580,7 @@ export async function toolExecutorNode( const data = await withTimeout( () => executeWithRetry(() => ticketList(parsed.page ?? 1), tlCfg.maxRetries, { backoffMs: tlCfg.backoffMs, onAttempt: () => { tlRetryCount++; } }), tlCfg.timeoutMs, + runSignal, ); const tickets = (data.tickets ?? []).map((t) => ({ id: t.ticketNumber, @@ -675,6 +710,7 @@ export async function toolExecutorNode( const t = await withTimeout( () => executeWithRetry(() => ticketDetail(parsed.ticket_id), tdCfg.maxRetries, { backoffMs: tdCfg.backoffMs, onAttempt: () => { tdRetryCount++; } }), tdCfg.timeoutMs, + runSignal, ); const execSummary = `获取工单 ${String(t.ticketNumber ?? parsed.ticket_id)} 详情`; ui.push( @@ -802,6 +838,7 @@ export async function toolExecutorNode( const fallbackData = await withTimeout( () => executeWithRetry(() => ticketList(1), getToolConfig("ticket_list").maxRetries), getToolConfig("ticket_list").timeoutMs, + runSignal, ); const fallbackTickets = (fallbackData.tickets ?? []) .slice(0, 5) diff --git a/langgraph/src/agent/utils/retry.ts b/langgraph/src/agent/utils/retry.ts index 549c11e..2fe1585 100644 --- a/langgraph/src/agent/utils/retry.ts +++ b/langgraph/src/agent/utils/retry.ts @@ -5,7 +5,7 @@ export async function executeWithRetry( fn: () => Promise, retries = 1, - options?: { backoffMs?: number; exponential?: boolean; onAttempt?: (attempt: number) => void }, + options?: { backoffMs?: number; exponential?: boolean; onAttempt?: (attempt: number) => void; abortSignal?: AbortSignal }, ): Promise { const backoffMs = options?.backoffMs ?? 1000; const exponential = options?.exponential ?? false; @@ -25,7 +25,13 @@ export async function executeWithRetry( console.warn( `[retry] attempt ${attempt}/${maxAttempts} failed, retrying in ${delay}ms`, ); - await new Promise((r) => setTimeout(r, delay)); + await new Promise((resolve, reject) => { + const t = setTimeout(resolve, delay); + options?.abortSignal?.addEventListener("abort", () => { + clearTimeout(t); + reject(new Error("RunCancelled")); + }, { once: true }); + }); } } }