feat: abort/cancel support in tool-executor and retry
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f375220eb6
commit
d86da0e48b
@@ -155,13 +155,29 @@ function hasArtifactForToolCall(
|
||||
async function withTimeout<T>(
|
||||
fn: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
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<EnterpriseUpdate> {
|
||||
const ui = typedUi<typeof ComponentMap>(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)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export async function executeWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries = 1,
|
||||
options?: { backoffMs?: number; exponential?: boolean; onAttempt?: (attempt: number) => void },
|
||||
options?: { backoffMs?: number; exponential?: boolean; onAttempt?: (attempt: number) => void; abortSignal?: AbortSignal },
|
||||
): Promise<T> {
|
||||
const backoffMs = options?.backoffMs ?? 1000;
|
||||
const exponential = options?.exponential ?? false;
|
||||
@@ -25,7 +25,13 @@ export async function executeWithRetry<T>(
|
||||
console.warn(
|
||||
`[retry] attempt ${attempt}/${maxAttempts} failed, retrying in ${delay}ms`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(resolve, delay);
|
||||
options?.abortSignal?.addEventListener("abort", () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error("RunCancelled"));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user