From a1a63dbf887eddf5c58441ee89880b44984ded70 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 12 Oct 2026 18:03:12 +0300 Subject: [PATCH 1/3] fix(execution): grouped sub-agent edited args validated, abort ends wedged tool waits, re-pause keeps approvalIds (Eve2-F4/F5/F6) - Eve2-F4: approve-with-edits on a sub-agent's grouped approval stored decision.args raw, so schema-invalid args ran the tool; the sub-agent's resume now validates every decided group's edited args before the step runs (LOUSHO_TOOL_ARGS_INVALID leaves the approval pending). - Eve2-F5: a tool that ignores its abortSignal held send() / stream() / approvals.resolve() open forever. The tool batch's settle wait and the resume's approved-tool wait are raced against the run's signal like the model call already was (Eve CORE-F15): the run ends 'aborted' with a 'cancelled' result for calls still running, a settlement already landing as the signal fires is still kept, and the tool keeps running. - Eve2-F6: markAwaitingApproval() wrote only approvalId on a re-pause, so durable discovery saw 1 of N pending approvals; it now carries the group's approvalIds like markGroupPending() does. --- CHANGELOG.md | 3 + docs/runs.md | 7 +- src/execution/AgentExecutor.ts | 4 +- src/execution/abortEdges.test.ts | 59 ++++++++++++++ .../approvalResumeDurability.test.ts | 47 +++++++++++ src/execution/generateStep.ts | 1 + src/execution/resume.ts | 80 ++++++++++++++++--- src/execution/toolBatch.ts | 48 +++++++++-- src/parallelApprovals.test.ts | 34 ++++++++ 9 files changed, 266 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d1969f..f5e9f683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -321,6 +321,9 @@ This section lists what is on `main` and not yet on npm. - Slack and Discord signature checks accept a timestamp at most a minute ahead of local time (was five minutes either way) and read it in seconds only (a milliseconds value was taken as "now"); the HMAC `timestampHeader` of `WebhookAuth` keeps its seconds-or-milliseconds reading with the same one-minute future limit. - `githubChannel({ deliveries })` takes a `DeliveryStore` (`seen(id)`), so `X-GitHub-Delivery` replay protection can be shared across replicas and restarts; the default stays in memory for an hour. See [GitHub](docs/channels.md). - The `@modelcontextprotocol/sdk` peer range is now `^1.31.0` (was `^1.30.1`), which has the fix for GHSA-6qxp-vccf-f47h that MCP OAuth relies on. +- Approve-with-edits on a sub-agent's *grouped* approval validates the edited args against the tool's schema like a top-level or single sub-agent decision does: before, `snapshot.subagent` stored `decision.args` raw, so an invalid edit (e.g. a string where `z.number()` wants a number) ran the tool with garbage args; it now throws `LOUSHO_TOOL_ARGS_INVALID` and leaves the approval pending. (Eve2-F4) +- Aborting a run during a tool call no longer hangs `send()` / `stream()` / `approvals.resolve()` forever on a tool that ignores its `abortSignal`: the tool batch and the approved-tool wait of `resumeAfterApproval()` are raced against the run's signal the way the model call already was (Eve CORE-F15), the run ends `finishReason: 'aborted'` with a 'cancelled' result for calls still running (a result already landing as the signal fires is still kept), and the tool keeps running in the background. (Eve2-F5) +- A run that re-pauses on several approvals after a resume persists every pending approval id in its checkpoint: `markAwaitingApproval()` wrote only `approvalId`, so durable discovery (`pausedApprovalIds`, `agent.approvals.list()`) named 1 of N approvals; it now carries the group's `approvalIds` like `markGroupPending()` does. (Eve2-F6) ### Changed - **Behavior change (CLI):** every `lousho` usage error exits 2 (an unknown command, an unknown flag, a missing or invalid value, an extra argument, a missing `--target`, a bad `lousho init` flag); 1 is left for a command that ran and failed. Before, only `eval` and `traces` exited 2 and the others 1. See [CLI](docs/cli.md). diff --git a/docs/runs.md b/docs/runs.md index 18ee5437..dc0cbd7c 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -73,8 +73,11 @@ How it behaves: - The run does not wait for a provider that ignores the signal: the model call is abandoned at the abort (its late reply, and anything it streams afterwards, is dropped) and the run ends `'aborted'`. A tool that ignores - its `abortSignal` is waited for; the run then ends `'aborted'` without - another model call. `defineTool({ timeoutMs })` bounds a tool's call: one + its `abortSignal` is not waited for either: the run ends `'aborted'` + without another model call, the still-running call gets a 'cancelled' + result, and the tool keeps running in the background (a result already + landing as the signal fires is still kept). `defineTool({ timeoutMs })` + bounds a tool's call: one still running then fails with a tool error the model sees, and a run aborted while such a call runs stops waiting for it. - `result.abortReason` says why: the signal's reason as `{ name, message }`, diff --git a/src/execution/AgentExecutor.ts b/src/execution/AgentExecutor.ts index 227422a8..9e9431dc 100644 --- a/src/execution/AgentExecutor.ts +++ b/src/execution/AgentExecutor.ts @@ -1537,7 +1537,9 @@ export class AgentExecutor { persist: () => saveStepCheckpoint(options, state), }, // LOU-V10: a steer stops the batch from starting more calls; running ones finish. - withSteerSignal(options.signal, options.inputQueue?.startBatch()) + // Eve2-F5: the run's signal also ends the wait for a tool that ignores it. + withSteerSignal(options.signal, options.inputQueue?.startBatch()), + options.signal ); options.inputQueue?.endPhase(); diff --git a/src/execution/abortEdges.test.ts b/src/execution/abortEdges.test.ts index a1a27f92..fe3f9377 100644 --- a/src/execution/abortEdges.test.ts +++ b/src/execution/abortEdges.test.ts @@ -52,6 +52,65 @@ describe('abort with a provider that ignores the signal (Eve CORE-F15)', () => { expect(result.finishReason).toBe('aborted'); expect(model.calls).toHaveLength(1); }); + + it('a tool that never settles does not hold an aborted run open (Eve2-F5)', async () => { + const controller = new AbortController(); + let release!: (value: string) => void; + const wedged = defineTool({ + name: 'wedged', + description: 'wedged', + input: z.object({}), + execute: () => { + controller.abort(); + return new Promise((resolve) => { + release = resolve; + }); + }, + }); + const model = mockModel([{ toolCalls: [{ name: 'wedged' }] }, 'done']); + const started = Date.now(); + const result = await createAgent({ provider: model, tools: [wedged] }).send('go', { signal: controller.signal }); + + expect(Date.now() - started).toBeLessThan(1500); + expect(result.finishReason).toBe('aborted'); + expect(result.abortReason?.name).toBe('AbortError'); + expect(model.calls).toHaveLength(1); + // The transcript stays well-formed: the unanswered call has a cancelled result. + const toolMessages = result.messages.filter((m) => m.role === 'tool'); + expect(toolMessages).toHaveLength(1); + expect(JSON.parse(String(toolMessages[0].content))).toMatchObject({ kind: 'not-run' }); + + // A wedged tool that settles after the run ended is not recorded on top of it. + release('too late'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.messages.filter((m) => m.role === 'tool')).toHaveLength(1); + }); + + it('a wedged approved tool does not hold a resume open on abort (Eve2-F5)', async () => { + const controller = new AbortController(); + const wedged = defineTool({ + name: 'wedged', + description: 'wedged', + input: z.object({}), + needsApproval: true, + execute: () => { + controller.abort(); + return new Promise(() => {}); + }, + }); + const agent = createAgent({ provider: mockModel([{ toolCalls: [{ name: 'wedged' }] }, 'done']), tools: [wedged] }); + const paused = await agent.send('go'); + expect(paused.finishReason).toBe('awaiting-approval'); + + const started = Date.now(); + const resumed = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }, { signal: controller.signal }); + + expect(Date.now() - started).toBeLessThan(1500); + expect(resumed.finishReason).toBe('aborted'); + expect(resumed.abortReason?.name).toBe('AbortError'); + // The decided call still has a result, so the transcript stays well-formed. + expect(resumed.messages.some((m) => m.role === 'tool')).toBe(true); + }); }); describe('mockModel honours the signal (Eve CORE-F15)', () => { diff --git a/src/execution/approvalResumeDurability.test.ts b/src/execution/approvalResumeDurability.test.ts index e2a268d6..68c027c7 100644 --- a/src/execution/approvalResumeDurability.test.ts +++ b/src/execution/approvalResumeDurability.test.ts @@ -65,6 +65,53 @@ describe('approval resume durability', () => { await expect(agent.approvals.resolve({ id: paused.approvalId!, approved: true })).rejects.toMatchObject({ code: 'LOUSHO_APPROVAL_NOT_FOUND' }); }); + it("a resumed run that pauses again on a step's several calls records every pending approval id (Eve2-F6)", async () => { + const store = memoryStore(); + const log: string[] = []; + const make = (name: string) => + defineTool({ + name, + description: name, + input: z.object({ n: z.number() }), + needsApproval: true, + execute: async ({ n }) => { + log.push(`${name}(${n})`); + return 'ok'; + }, + }); + const first = { + toolCalls: [ + { name: 'wire_money', args: { n: 1 }, id: 'call_wire' }, + { name: 'delete_db', args: { n: 2 }, id: 'call_delete' }, + ], + }; + const second = { + toolCalls: [ + { name: 'wire_money', args: { n: 3 }, id: 'call_wire2' }, + { name: 'delete_db', args: { n: 4 }, id: 'call_delete2' }, + ], + }; + const worker = createAgent({ provider: mockModel([first, second, 'worker done']), tools: [make('wire_money'), make('delete_db')], description: 'Does risky things' }); + const lead = createAgent({ + provider: mockModel([{ toolCalls: [{ name: 'task', args: { agent: 'worker', prompt: 'do it', description: 'risky' }, id: 'call_task' }] }, 'lead done']), + subagents: { worker }, + store, + }); + const session = lead.session({ id: 's1' }); + const paused = await session.send('go'); + expect(paused.approvalIds).toHaveLength(2); + + await lead.approvals.resolve({ id: paused.approvalIds![0], approved: true }); + const repaused = await lead.approvals.resolve({ id: paused.approvalIds![1], approved: true }); + + // The sub-agent's next step paused on two calls too: the durable reader sees both, not just the first. + expect(repaused.finishReason).toBe('awaiting-approval'); + expect(repaused.approvalIds).toHaveLength(2); + const pending = await session.pending(); + expect(pending).toMatchObject({ status: 'awaiting-approval', approvalId: repaused.approvalIds![0], approvalIds: repaused.approvalIds }); + expect(log).toEqual(['wire_money(1)', 'delete_db(2)']); + }); + it("discardPending() withdraws the dropped turn's approvals", async () => { const store = memoryStore(); const refund = defineTool({ name: 'refund', description: 'refund', input: z.object({}), needsApproval: true, execute: async () => 'refunded' }); diff --git a/src/execution/generateStep.ts b/src/execution/generateStep.ts index 20c06aec..e18e8df8 100644 --- a/src/execution/generateStep.ts +++ b/src/execution/generateStep.ts @@ -231,6 +231,7 @@ export interface GeneratedStep { /** * LOU-V10: `call`, or its rejection with `signal`'s reason as soon as * `signal` aborts - a provider that ignores the signal is not waited for. + * `call` keeps running either way. */ function abortable(call: Promise, signal: AbortSignal | undefined): Promise { if (!signal) return call; diff --git a/src/execution/resume.ts b/src/execution/resume.ts index 8429bc9a..9a47251f 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -549,7 +549,14 @@ async function decideGroupMember( for (const member of group) if (member.decision) decided.set(member.pending.id, member.decision); decided.set(pending.id, mine); const waiting = await claimWaitingSiblings(group, decided, approvalStore, pending.id); - if (waiting.length === 0) return { record: { pending, snapshot: withGroupDecisions(snapshot, decided) } }; + if (waiting.length === 0) { + // Eve2-F4: a decision made on a sub-agent's record stored its edited + // args unchecked (the lead's registry holds no such tool) - the + // sub-agent's own resume is where they can be validated, before any + // call of the step runs. + await validateGroupArgs(group, decided, toolRegistry); + return { record: { pending, snapshot: withGroupDecisions(snapshot, decided) } }; + } for (const sibling of waiting) await approvalStore.save(sibling.pending, withGroupDecisions(sibling.snapshot, decided)); return { paused: groupPaused(snapshot, waiting) }; } catch (error) { @@ -591,6 +598,29 @@ function adoptDecisions(snapshot: ExecutionSnapshot, decided: Map, + toolRegistry: ToolRegistry +): Promise { + for (const member of group) { + const decision = decided.get(member.pending.id); + if (!decision?.approved || decision.args === undefined) continue; + const toolDesc = toolRegistry.get(member.pending.toolName); + if (!toolDesc) continue; + decided.set(member.pending.id, { ...decision, args: (await validateToolArguments(member.pending.toolName, toolDesc, decision.args)) as Record }); + } +} + /** Eve TOOLS-F12: the run's result while the `waiting` calls of its step are undecided. */ function groupPaused(snapshot: ExecutionSnapshot, waiting: ResolvedApproval[]): ExecutionResult { const ids = waiting.map((sibling) => sibling.pending.id); @@ -1306,6 +1336,8 @@ async function markAwaitingApproval( businessState, status: 'awaiting-approval', approvalId: paused.approvalId, + // Eve2-F6: a re-pause on several of a step's calls keeps every pending id, like markGroupPending() does. + ...(paused.approvalIds !== undefined && paused.approvalIds.length > 1 && { approvalIds: paused.approvalIds }), agentFingerprint: snapshot.agentFingerprint, runConfig: snapshot.agent.metadata?.[RUN_CONFIG_KEY], ...(snapshot.principal && { principal: snapshot.principal }), @@ -1439,6 +1471,29 @@ interface SettledCall { replacedByHook?: string; } +/** + * Eve2-F5: like `abortable()` (a tool that ignores the signal does not hold + * the run open forever), except a settlement already landing when the + * signal fires still wins - the abort side yields one event-loop turn + * first, so a tool that aborts the signal inside its own execute() keeps + * its real result, exactly like ToolBatch.settled()'s drained wait. + */ +function abortableToolCall(call: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return call; + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const onAbort = () => { + timer = setTimeout(() => reject(signal.reason), 0); + }; + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + call.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + clearTimeout(timer); + }); + }); +} + /** * Executes the approved tool itself. The try/catch here handles ONLY the * tool's own execution failure - NOT the postToolCall hook call that @@ -1470,14 +1525,21 @@ async function executeApprovedTool( // sandbox seam the way it previously did. const sandbox = executeOptions.sandbox ?? NoopSandbox; return { - result: await executeToolWithSandboxGuard( - pending.toolName, - toolDesc, - args, - sandbox, - executeOptions.signal, - { toolCallId: pending.toolCallId, messages, approval, sessionId, principal: executeOptions.principal, tokens: executeOptions.tokens, onPartial: partialsOf(pending, executeOptions) }, - scope + // Eve2-F5: raced against the run's signal - a tool that ignores it + // would otherwise hold the resume open forever. The rejection lands + // in the catch below like the tool's own abort error (the tool keeps + // running, documented for ExecuteOptions.signal). + result: await abortableToolCall( + executeToolWithSandboxGuard( + pending.toolName, + toolDesc, + args, + sandbox, + executeOptions.signal, + { toolCallId: pending.toolCallId, messages, approval, sessionId, principal: executeOptions.principal, tokens: executeOptions.tokens, onPartial: partialsOf(pending, executeOptions) }, + scope + ), + executeOptions.signal ), }; } catch (error) { diff --git a/src/execution/toolBatch.ts b/src/execution/toolBatch.ts index 3b2338f7..2983009c 100644 --- a/src/execution/toolBatch.ts +++ b/src/execution/toolBatch.ts @@ -125,13 +125,16 @@ class ToolBatch { private readonly running: Array> = []; private readonly recorded = new Set(); private halted = false; + /** Eve2-F5: the run's signal aborted while a tool ignored it - the batch stopped waiting; late settlements are dropped. */ + private abandoned = false; private persisting: Promise = Promise.resolve(); constructor( private readonly toolCalls: ToolCall[], concurrency: ToolConcurrency, private readonly callbacks: ToolBatchCallbacks, - private readonly signal: AbortSignal | undefined + private readonly signal: AbortSignal | undefined, + private readonly runSignal: AbortSignal | undefined ) { this.slots = toolCalls.map(() => ({ status: 'not-started' })); this.limiter = new Limiter(concurrency === 'unbounded' ? Infinity : concurrency); @@ -144,10 +147,37 @@ class ToolBatch { } await this.launch(index, toolCall); } - await Promise.all(this.running); + await this.settled(); return this.result(); } + /** + * Every started call settled - or the run's signal aborted: waiting ends + * there, a tool that ignores the signal must not hold the run open + * forever (it keeps running, documented for ExecuteOptions.signal; the + * batch reports its call as unrecorded, which the run settles like the + * calls an abort kept from starting). `signal` alone is not raced: a + * steer stops new starts but lets running calls finish. + */ + private async settled(): Promise { + const { runSignal } = this; + const all = Promise.all(this.running); + if (!runSignal) { + await all; + return; + } + // A settlement already landing when the signal fires still records: the + // abort side yields one event-loop turn first, so a tool released just + // as the run aborted keeps its result - only a call still running then + // is left behind (Eve2-F5). + const drained = new Promise<'aborted'>((resolve) => { + const onAbort = () => setTimeout(() => resolve('aborted'), 0); + runSignal.addEventListener('abort', onAbort, { once: true }); + if (runSignal.aborted) onAbort(); + }); + if ((await Promise.race([all.then(() => 'done' as const), drained])) === 'aborted') this.abandoned = true; + } + private stopped(): boolean { return this.halted || Boolean(this.signal?.aborted); } @@ -182,6 +212,9 @@ class ToolBatch { private async track(index: number, done: Promise): Promise { try { const outcome = await done; + // The run aborted and ended while this tool ignored the signal: its + // call already has a cancelled result - a late settlement records nothing more. + if (this.abandoned) return; this.slots[index] = { status: 'completed', outcome }; // N9b: a call paused for sign-in has no result yet, like one awaiting approval. if (!outcome.requiresApproval && !outcome.signIn) { @@ -225,12 +258,17 @@ class ToolBatch { } } -/** Runs one turn's tool calls - see {@link ToolBatch}. */ +/** + * Runs one turn's tool calls - see {@link ToolBatch}. `signal` stops new + * starts (abort or steer); `runSignal` is the run's own signal, whose abort + * also ends the wait for calls still running (Eve2-F5). + */ export function runToolBatch( toolCalls: ToolCall[], concurrency: ToolConcurrency, callbacks: ToolBatchCallbacks, - signal?: AbortSignal + signal?: AbortSignal, + runSignal?: AbortSignal ): Promise { - return new ToolBatch(toolCalls, concurrency, callbacks, signal).run(); + return new ToolBatch(toolCalls, concurrency, callbacks, signal, runSignal).run(); } diff --git a/src/parallelApprovals.test.ts b/src/parallelApprovals.test.ts index 9106f565..3f8980da 100644 --- a/src/parallelApprovals.test.ts +++ b/src/parallelApprovals.test.ts @@ -229,6 +229,40 @@ describe('Eve TOOLS-F12: parallel approvals', () => { expect(result.text).toBe('lead done'); expect(log).toEqual(['wire_money(1)']); }); + + it("edited args on a sub-agent's grouped approval are validated before its calls run (Eve2-F4)", async () => { + const log: string[] = []; + const worker = createAgent({ provider: mockModel([twoApprovals, 'worker done']), tools: tools(log), description: 'Does risky things' }); + const task = { name: 'task', args: { agent: 'worker', prompt: 'do it', description: 'risky' } }; + const lead = createAgent({ provider: mockModel([{ toolCalls: [task] }, 'lead done']), subagents: { worker } }); + + const paused = await lead.send('go'); + const [wire, del] = paused.approvalIds!; + await lead.approvals.resolve({ id: del, approved: true }); + // { n: 'x' } fails the tool's zod schema: it must not reach execute(). + const result = await lead.approvals.resolve({ id: wire, approved: true, args: { n: 'x' } }); + + expect(log).toEqual([]); + expect(result.text).toBe('lead done'); + const taskResult = result.messages.find((m) => m.role === 'tool' && m.toolName === 'task'); + expect(taskResult?.isError).toBe(true); + expect(String(taskResult?.content)).toContain("Invalid arguments for tool 'wire_money'"); + }); + + it("valid edited args on a sub-agent's grouped approval run the call with them (Eve2-F4)", async () => { + const log: string[] = []; + const worker = createAgent({ provider: mockModel([twoApprovals, 'worker done']), tools: tools(log), description: 'Does risky things' }); + const task = { name: 'task', args: { agent: 'worker', prompt: 'do it', description: 'risky' } }; + const lead = createAgent({ provider: mockModel([{ toolCalls: [task] }, 'lead done']), subagents: { worker } }); + + const paused = await lead.send('go'); + const [wire, del] = paused.approvalIds!; + await lead.approvals.resolve({ id: del, approved: true }); + const result = await lead.approvals.resolve({ id: wire, approved: true, args: { n: 10 } }); + + expect(result.text).toBe('lead done'); + expect(log).toEqual(['wire_money(10)', 'delete_db(2)']); + }); }); describe('Eve TOOLS-F12: parallel approvals with durable stores', () => { From dbc474c888c98c2e1c329605287c6e7a58641373 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 12 Oct 2026 18:30:53 +0300 Subject: [PATCH 2/3] fix(execution): drain in-flight tools for a bounded grace on abort (Eve2-F5) The settle-on-abort race yielded a single event-loop turn, which dropped the result of any tool still running when the signal fired - a 20ms tool aborted mid-flight lost its B4 'waited' transcript entry. The abort side now drains for TOOL_ABORT_GRACE_MS (500ms), so a tool released while the run unwinds keeps its real result (B4 contract restored) while a truly wedged tool still abandons inside ~500ms, well under any caller-visible latency (F5 contract kept). Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/execution/resume.ts | 11 ++++++----- src/execution/toolBatch.ts | 20 +++++++++++++++----- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/execution/resume.ts b/src/execution/resume.ts index 9a47251f..d6b4851f 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -23,6 +23,7 @@ import { withGroupDecisions, } from './ApprovalGate'; import { validateToolArguments } from './toolArgsValidation'; +import { TOOL_ABORT_GRACE_MS } from './toolBatch'; import { AgentExecutor, ExecuteOptions, ExecutionResult, extendRunOptions } from './AgentExecutor'; import { observeRun, partialSink, runEventsOf, streamResumed, type AgentRun, type ToolSettled } from './agentRun'; import { Checkpoint, CheckpointStore, RUN_CONFIG_KEY, pausedApprovalIds } from './checkpoint'; @@ -1473,17 +1474,17 @@ interface SettledCall { /** * Eve2-F5: like `abortable()` (a tool that ignores the signal does not hold - * the run open forever), except a settlement already landing when the - * signal fires still wins - the abort side yields one event-loop turn - * first, so a tool that aborts the signal inside its own execute() keeps - * its real result, exactly like ToolBatch.settled()'s drained wait. + * the run open forever), except a settlement landing while the run unwinds + * still wins - the abort side drains for TOOL_ABORT_GRACE_MS first, so a + * tool released just as the run aborted keeps its real result (B4), exactly + * like ToolBatch.settled()'s drained wait. */ function abortableToolCall(call: Promise, signal: AbortSignal | undefined): Promise { if (!signal) return call; return new Promise((resolve, reject) => { let timer: ReturnType | undefined; const onAbort = () => { - timer = setTimeout(() => reject(signal.reason), 0); + timer = setTimeout(() => reject(signal.reason), TOOL_ABORT_GRACE_MS); }; signal.addEventListener('abort', onAbort, { once: true }); if (signal.aborted) onAbort(); diff --git a/src/execution/toolBatch.ts b/src/execution/toolBatch.ts index 2983009c..7ced96cd 100644 --- a/src/execution/toolBatch.ts +++ b/src/execution/toolBatch.ts @@ -19,6 +19,16 @@ export type ToolConcurrency = number | 'unbounded'; * Throws a descriptive error unless `value` is a valid {@link ToolConcurrency} * (`undefined` means "use the default" and is accepted). */ +/** + * Eve2-F5: how long an aborted run still waits for calls already running. + * A tool that finishes while the run unwinds keeps its real result in the + * transcript (B4: calls that ran stay); anything still running after the + * grace is abandoned and settles 'cancelled' - a tool ignoring the signal + * never holds the run open. 500ms is well past a settling tool's tail and + * well under any caller-visible abort latency. + */ +export const TOOL_ABORT_GRACE_MS = 500; + export function assertToolConcurrency(value: unknown, caller: string): void { if (value === undefined || value === 'unbounded') { return; @@ -166,12 +176,12 @@ class ToolBatch { await all; return; } - // A settlement already landing when the signal fires still records: the - // abort side yields one event-loop turn first, so a tool released just - // as the run aborted keeps its result - only a call still running then - // is left behind (Eve2-F5). + // A settlement landing while the run unwinds still records: the abort + // side drains for TOOL_ABORT_GRACE_MS first, so a tool released just as + // the run aborted keeps its result (B4) - only a call still running + // past the grace is left behind (Eve2-F5). const drained = new Promise<'aborted'>((resolve) => { - const onAbort = () => setTimeout(() => resolve('aborted'), 0); + const onAbort = () => setTimeout(() => resolve('aborted'), TOOL_ABORT_GRACE_MS); runSignal.addEventListener('abort', onAbort, { once: true }); if (runSignal.aborted) onAbort(); }); From 445c8f724a2a11bae286d6275bd253111ecbd32b Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 12 Oct 2026 18:42:05 +0300 Subject: [PATCH 3/3] docs: regenerate llms.txt for runs.md abort-grace section --- llms-full.txt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 5b8f1269..579b1eaa 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -14855,8 +14855,11 @@ How it behaves: - The run does not wait for a provider that ignores the signal: the model call is abandoned at the abort (its late reply, and anything it streams afterwards, is dropped) and the run ends `'aborted'`. A tool that ignores - its `abortSignal` is waited for; the run then ends `'aborted'` without - another model call. `defineTool({ timeoutMs })` bounds a tool's call: one + its `abortSignal` is not waited for either: the run ends `'aborted'` + without another model call, the still-running call gets a 'cancelled' + result, and the tool keeps running in the background (a result already + landing as the signal fires is still kept). `defineTool({ timeoutMs })` + bounds a tool's call: one still running then fails with a tool error the model sees, and a run aborted while such a call runs stops waiting for it. - `result.abortReason` says why: the signal's reason as `{ name, message }`,