From aa7eea0d00a97877112fed7ca59abd61c3331812 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 09:11:20 +0300 Subject: [PATCH] LOU-V1: cancel a run with an AbortSignal What: `signal?: AbortSignal` on AgentExecutor.execute(), createAgent().send(input, { signal }) and resumeAfterApproval() (via its executeOptions). The loop checks the signal before every model call and every tool call. An aborted run resolves (does not reject) with finishReason 'aborted' and the transcript/steps so far, emits a typed `abort` event (with `abortReason`) followed by `finish`, and is checkpointed when sessionId + checkpointStore are set, so a later execute() with the same sessionId resumes it. Tool calls the run never reached get a cancelled {error} result so the transcript stays valid for the provider. An already-aborted signal returns without calling the provider. The signal is forwarded to the provider (new GenerateOptions.signal, passed to the ai SDK as `abortSignal`; the mock provider rejects promptly, including mid-delay), to tools as `execute(args, { abortSignal })` (and as a third `{ abortSignal }` argument to sandboxExecute), to fetch in the built-in http tool, and to delegated child agents. A rejection caused by the abort is the abort, not a failure: it is not compacted or surfaced to the model for another attempt, and retry() never retries an AbortError (new RetryOptions.signal also stops retries). Adds an exported ExecutionFinishReason type (known reasons incl. 'awaiting-approval' and 'aborted', still open to any string) used by ExecutionResult and ExecutionEvent. Why: runs could not be cancelled, which blocks responsive UIs and timeouts. Co-Authored-By: Claude Fable 5.1 --- README.md | 1 + docs/api-overview.md | 44 +++ src/createAgent.ts | 20 +- src/execution/AgentExecutor.ts | 164 ++++++++-- src/execution/DelegationTool.ts | 19 +- src/execution/agentRunState.ts | 20 ++ src/execution/cancellation.test.ts | 474 ++++++++++++++++++++++++++++ src/execution/generateStep.ts | 4 +- src/execution/resume.ts | 16 +- src/execution/retry.ts | 27 +- src/execution/sandboxGuard.ts | 19 +- src/execution/toolCallExecution.ts | 10 +- src/providers/aiSdkProvider.test.ts | 19 ++ src/providers/aiSdkProvider.ts | 1 + src/providers/llm.ts | 11 + src/providers/mock.ts | 24 +- src/tools/built-in/http.test.ts | 18 ++ src/tools/built-in/http.ts | 89 ++++-- src/types/tool.ts | 10 +- 19 files changed, 910 insertions(+), 80 deletions(-) create mode 100644 src/execution/cancellation.test.ts diff --git a/README.md b/README.md index 6b6225e78..6d652eba4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ dashboard — any provider, any deploy target, no lock-in. - **Zero-config to full control** — `createAgent({ prompt, provider })` in one line, or the full `AgentBuilder` + `AgentExecutor` API when you need `maxSteps`, checkpoints, or tracing hooks - **Human-in-the-loop** — flag a tool `needsApproval` and pause execution until a human approves or rejects it, then `resumeAfterApproval()` from any process - **Durable execution** — pass a `sessionId` + `checkpointStore` and a crash mid-conversation resumes instead of restarting +- **Cancellation** — pass an `AbortSignal` (`agent.send(input, { signal })`) to stop a run; it resolves with `finishReason: 'aborted'` and the transcript so far, and the signal reaches the provider, tools and delegated agents - **Multi-agent delegation** — wrap a child agent as a tool with `createDelegateTool()`, with a `maxDepth` guard against delegation loops - **Pre/post hooks** — a `HookRegistry` of `AgentHook`s that can inspect or mutate a tool call/LLM generate step, or throw to abort it, run sandboxed by Agent Forge's hook editor - **Guardrails** — fail-closed, concurrently-run checks (secret scan, diff size, test/lint commands) that gate a fixer agent's patch before it's used diff --git a/docs/api-overview.md b/docs/api-overview.md index 6ec6d583c..4c098167c 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -19,6 +19,50 @@ npm run docs:build # writes docs/api/index.html | `resumeAfterApproval()` | Resume an execution paused for human approval. | | `createDelegateTool()` | Wrap a child agent as a tool for multi-agent delegation. | +### Cancellation + +Pass an `AbortSignal` to stop a run: `agent.send(input, { signal })`, +`AgentExecutor.execute({ ..., signal })` or +`resumeAfterApproval(..., { signal })`. + +```ts +import { createAgent, createMockProvider } from '@loushy/build-ai-agent'; +const agent = createAgent({ prompt: 'You are helpful.', provider: createMockProvider() }); +const controller = new AbortController(); +setTimeout(() => controller.abort(), 5_000); // e.g. from a Stop button +const result = await agent.send('Write a long report', { signal: controller.signal }); +console.log(result.finishReason); // 'aborted' if it was cancelled, else 'stop' +``` + +For a time limit, use `AbortSignal.timeout(ms)`: + +```ts +import { createAgent, createMockProvider } from '@loushy/build-ai-agent'; +const agent = createAgent({ prompt: 'You are helpful.', provider: createMockProvider() }); +const result = await agent.send('Summarize this', { signal: AbortSignal.timeout(30_000) }); +``` + +How it behaves: + +- The signal is checked before every model call and every tool call. It is + passed to the provider (`GenerateOptions.signal`, sent to the `ai` SDK as + `abortSignal`) and to each tool as `execute(args, { abortSignal })`, so + in-flight work can stop early. The built-in `httpTool` passes it to + `fetch`, and agents created with `createDelegateTool()` are aborted along + with their parent. +- An aborted run **resolves** (it does not reject) with + `finishReason: 'aborted'` and the messages and steps so far. A rejection + caused by the abort, such as an `AbortError`, is not treated as a failure: + it is not retried (including by `retry()`) and is not compacted into a + provider error. +- `onEvent` receives an `abort` event (its `abortReason` is the signal's + `reason`), then `finish` with `finishReason: 'aborted'`. +- With `sessionId` + `checkpointStore`, the state is checkpointed. Calling + `execute()` again with the same `sessionId` resumes where the run stopped. + Tool calls the run never reached get an `{ error }` result saying they were + cancelled, so the conversation stays valid for the provider. +- An already-aborted signal returns at once without calling the provider. + ## Declarative specs | Export | Description | diff --git a/src/createAgent.ts b/src/createAgent.ts index bda04bfbf..1d18a393e 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -40,9 +40,24 @@ export interface CreateAgentConfig { maxSteps?: number; } +/** Per-call options for `SimpleAgent.send()`. */ +export interface SendOptions { + /** + * Cancels this run (LOU-V1). The returned promise then resolves - it + * does not reject - with `finishReason: 'aborted'` and the transcript so + * far. See `ExecuteOptions.signal`. + * + * @example + * ```ts + * const result = await agent.send('hi', { signal: AbortSignal.timeout(10_000) }); + * ``` + */ + signal?: AbortSignal; +} + export interface SimpleAgent { /** Send a single user message and get back the full execution result text. */ - send: (message: string) => Promise; + send: (message: string, options?: SendOptions) => Promise; } /** @@ -63,13 +78,14 @@ export function createAgent(config: CreateAgentConfig): SimpleAgent { .build(); return { - async send(message: string): Promise { + async send(message: string, options: SendOptions = {}): Promise { return AgentExecutor.execute({ agent, input: message, provider: config.provider, toolRegistry, maxSteps: config.maxSteps, + signal: options.signal, }); }, }; diff --git a/src/execution/AgentExecutor.ts b/src/execution/AgentExecutor.ts index feb536a3c..9c7745fd5 100644 --- a/src/execution/AgentExecutor.ts +++ b/src/execution/AgentExecutor.ts @@ -25,6 +25,7 @@ import { AgentRunState, addUsage, loadRunState, + pushCancelledToolResults, saveStepCheckpoint, toExecutionResult, } from './agentRunState'; @@ -41,7 +42,25 @@ export type ExecutionEventType = | 'tool-call' | 'tool-result' | 'finish' - | 'error'; + | 'error' + | 'abort'; + +/** + * Why a run ended, as reported on `ExecutionResult.finishReason` and the + * `finish` event. The known values are listed for autocomplete; a provider + * may report others, so this stays open to any string. + * + * - `'stop'`, `'length'`, `'tool_calls'`, `'content_filter'`, `'error'`: + * the model's own finish reason for its last turn. + * - `'awaiting-approval'`: paused on a tool call that needs a human + * decision (see `resumeAfterApproval()`). + * - `'aborted'`: cancelled through `ExecuteOptions.signal` (LOU-V1). + */ +export type ExecutionFinishReason = + | GenerateResult['finishReason'] + | 'awaiting-approval' + | 'aborted' + | (string & {}); /** * Execution event @@ -60,13 +79,19 @@ export interface ExecutionEvent { result: any; error?: string; }; - finishReason?: string; + finishReason?: ExecutionFinishReason; usage?: { promptTokens: number; completionTokens: number; totalTokens: number; }; error?: Error; + /** + * On an `abort` event: the `reason` of the aborted signal (a + * `DOMException` named `AbortError` unless the caller passed their own + * reason to `controller.abort(reason)`). + */ + abortReason?: unknown; } /** @@ -230,6 +255,32 @@ export interface ExecuteOptions { * silently returning a hollow "successful" result). */ surfaceRetryableProviderErrors?: boolean; + /** + * LOU-V1: cancels the run. The signal is checked before every model call + * and every tool call, and is forwarded to the provider (as + * `GenerateOptions.signal`) and to each tool's + * `execute(args, { abortSignal })` so in-flight work can stop early. + * Delegated child agents inherit it. + * + * An aborted run does NOT reject: it resolves with + * `finishReason: 'aborted'` and the transcript/steps so far, after + * emitting an `abort` event and then a `finish` event. A rejection caused + * by the abort (e.g. an `AbortError` from the provider) counts as the + * abort, never as a failure. With `sessionId` + `checkpointStore` the + * state is checkpointed, so calling `execute()` again with the same + * `sessionId` resumes where the run stopped. An already-aborted signal + * returns at once without calling the provider. + * + * @example + * ```ts + * const result = await AgentExecutor.execute({ + * agent, input: 'Summarize the report', provider, + * signal: AbortSignal.timeout(30_000), // or controller.signal + * }); + * if (result.finishReason === 'aborted') console.log('cancelled'); + * ``` + */ + signal?: AbortSignal; } /** @@ -244,7 +295,7 @@ export interface ExecutionResult { completionTokens: number; totalTokens: number; }; - finishReason: string; + finishReason: ExecutionFinishReason; steps: number; approvalId?: string; } @@ -293,7 +344,7 @@ export class AgentExecutor { options: ExecuteOptions, agentSpanId: string ): Promise { - const { agent, toolRegistry, maxSteps = 10, onEvent } = options; + const { agent, toolRegistry, maxSteps = 10, onEvent, signal } = options; // Emit start event this.emitEvent(onEvent, { @@ -308,29 +359,51 @@ export class AgentExecutor { const state = await loadRunState(options); - // Execution loop with tool calling + // Execution loop with tool calling. LOU-V1: the signal is checked + // before every model call (here) and every tool call (runToolCalls()). while (state.steps < maxSteps) { + if (signal?.aborted) { + return this.abortRun(options, state); + } state.steps++; - try { - const outcome = await this.runStep(options, state, tools, agentSpanId); - if (outcome === 'stop') { - break; - } - if (outcome !== 'continue') { - return outcome; - } - } catch (error) { - this.emitEvent(onEvent, { - type: 'error', - timestamp: new Date(), - error: error as Error, - }); - throw error; + const outcome = await this.runStepOrAbort(options, state, tools, agentSpanId); + if (outcome === 'stop') { + return this.finishRun(options, state); + } + if (outcome !== 'continue') { + return outcome; } } - return this.finishRun(options, state); + return signal?.aborted ? this.abortRun(options, state) : this.finishRun(options, state); + } + + /** + * runStep(), with a thrown error emitted as an `error` event and + * rethrown - unless the run's signal was aborted, in which case the + * rejection is the abort itself (e.g. the provider's AbortError) and the + * run ends as 'aborted' instead (LOU-V1). + */ + private static async runStepOrAbort( + options: ExecuteOptions, + state: AgentRunState, + tools: ToolDefinition[], + agentSpanId: string + ): Promise<'continue' | 'stop' | ExecutionResult> { + try { + return await this.runStep(options, state, tools, agentSpanId); + } catch (error) { + if (options.signal?.aborted) { + return this.abortRun(options, state); + } + this.emitEvent(options.onEvent, { + type: 'error', + timestamp: new Date(), + error: error as Error, + }); + throw error; + } } /** @@ -414,6 +487,11 @@ export class AgentExecutor { try { return await generateInSpan(options, generateRequest, state.messages, agentSpanId); } catch (generateError) { + // LOU-V1: a rejection caused by the abort is not a provider failure - + // never compact it or fold it into the conversation for a retry. + if (options.signal?.aborted) { + throw generateError; + } const { compacted, error: compactedError } = compactGenerateError( generateError, options.provider.name @@ -466,7 +544,12 @@ export class AgentExecutor { }); // Execute tools and add results - for (const toolCall of toolCalls) { + for (const [index, toolCall] of toolCalls.entries()) { + if (options.signal?.aborted) { + pushCancelledToolResults(state, toolCalls.slice(index)); + return this.abortRun(options, state); + } + this.emitEvent(onEvent, { type: 'tool-call', timestamp: new Date(), @@ -519,6 +602,7 @@ export class AgentExecutor { sessionId, exporter, redactContent = false, + signal, } = options; return withSpan( @@ -536,7 +620,8 @@ export class AgentExecutor { sandbox, hooks, sessionId, - state.messages + state.messages, + signal ); const parsedArgs = executed.args === undefined @@ -603,6 +688,35 @@ export class AgentExecutor { }; } + /** + * Ends a run whose signal was aborted (LOU-V1): checkpoints the state so + * far (when checkpointing is on, so the session can be resumed later), + * emits `abort` then `finish`, and resolves with finishReason 'aborted'. + */ + private static async abortRun( + options: ExecuteOptions, + state: AgentRunState + ): Promise { + const { onEvent, signal } = options; + state.finishReason = 'aborted'; + await saveStepCheckpoint(options, state); + + this.emitEvent(onEvent, { + type: 'abort', + timestamp: new Date(), + abortReason: signal?.reason, + usage: state.usage, + }); + this.emitEvent(onEvent, { + type: 'finish', + timestamp: new Date(), + finishReason: 'aborted', + usage: state.usage, + }); + + return toExecutionResult(state, state.finalText, 'aborted'); + } + /** * Ends a run that left the loop - the model stopped requesting tools, * or maxSteps was exhausted. @@ -664,7 +778,8 @@ export class AgentExecutor { sandbox: SandboxAdapter = NoopSandbox, hooks?: HookRegistry, sessionId?: string, - messages: Message[] = [] + messages: Message[] = [], + signal?: AbortSignal ): Promise { return runToolCall(toolCall, { agent, @@ -675,6 +790,7 @@ export class AgentExecutor { hooks, sessionId, messages, + signal, }); } diff --git a/src/execution/DelegationTool.ts b/src/execution/DelegationTool.ts index ab7347385..059c22d0c 100644 --- a/src/execution/DelegationTool.ts +++ b/src/execution/DelegationTool.ts @@ -111,13 +111,16 @@ export function createDelegateTool(opts: DelegateAgentOptions): ToolDescriptor { 'Optional prior conversation history to share with the child agent (only used when contextMode is "full-history")' ), }), - execute: async ({ - task, - context, - }: { - task: string; - context?: Message[]; - }): Promise => { + execute: async ( + { + task, + context, + }: { + task: string; + context?: Message[]; + }, + options?: { abortSignal?: AbortSignal } + ): Promise => { const currentDepth = delegationDepthStorage.getStore() ?? 0; if (currentDepth >= maxDepth) { @@ -136,6 +139,8 @@ export function createDelegateTool(opts: DelegateAgentOptions): ToolDescriptor { provider: opts.provider, toolRegistry: opts.toolRegistry, maxSteps: opts.maxSteps, + // LOU-V1: aborting the parent run aborts the child with it. + signal: options?.abortSignal, }); return { diff --git a/src/execution/agentRunState.ts b/src/execution/agentRunState.ts index e88372044..4abf34732 100644 --- a/src/execution/agentRunState.ts +++ b/src/execution/agentRunState.ts @@ -156,6 +156,26 @@ export async function saveStepCheckpoint( await checkpointStore.save(sessionId, checkpoint); } +/** + * LOU-V1: gives each tool call the run was aborted before reaching a + * `{error}` tool result, so the transcript stays well-formed (every + * assistant tool call has a matching result) and a checkpointed run can be + * resumed without the provider rejecting an unanswered tool call. + */ +export function pushCancelledToolResults(state: AgentRunState, toolCalls: ToolCall[]): void { + for (const toolCall of toolCalls) { + state.messages.push({ + role: 'tool', + content: JSON.stringify({ + error: 'Tool call was cancelled before it ran because the run was aborted', + }), + name: toolCall.function.name, + toolCallId: toolCall.id, + toolName: toolCall.function.name, + }); + } +} + /** The ExecutionResult reflecting the run's current state. */ export function toExecutionResult( state: AgentRunState, diff --git a/src/execution/cancellation.test.ts b/src/execution/cancellation.test.ts new file mode 100644 index 000000000..63aa69b5a --- /dev/null +++ b/src/execution/cancellation.test.ts @@ -0,0 +1,474 @@ +/** + * LOU-V1: cancelling a run with an AbortSignal. + * + * Covers the executor loop (abort before start, during a model call, during + * a tool call, between steps), checkpoint-on-abort + resume, the "an abort is + * not a failure" rules (no retry, no failure compaction), delegated child + * agents, createAgent().send() and resumeAfterApproval(). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { tool } from 'ai'; +import { z } from 'zod'; +import { AgentExecutor, ExecutionEvent } from './AgentExecutor'; +import { createDelegateTool } from './DelegationTool'; +import type { ApprovalStore, ExecutionSnapshot, PendingApproval } from './ApprovalGate'; +import { resumeAfterApproval } from './resume'; +import { retry } from './retry'; +import { Checkpoint, CheckpointStore } from './checkpoint'; +import { createMockProvider } from '../providers/mock'; +import { createAgent } from '../createAgent'; +import { ToolRegistry } from '../tools'; +import { AgentConfig, AgentType, ToolDescriptor } from '../types'; +import type { GenerateOptions, GenerateResult, LLMProvider, ToolCall } from '../providers'; + +const usage = { promptTokens: 1, completionTokens: 1, totalTokens: 2 }; + +function textResult(text: string): GenerateResult { + return { text, finishReason: 'stop', usage }; +} + +function toolCall(name: string, id = `call_${name}`): ToolCall { + return { id, type: 'function', function: { name, arguments: '{}' } }; +} + +function toolCallResult(...calls: ToolCall[]): GenerateResult { + return { text: '', finishReason: 'tool_calls', usage, toolCalls: calls }; +} + +function scriptedProvider(generate: (options: GenerateOptions) => Promise) { + const spy = vi.fn(generate); + const provider: LLMProvider = { + name: 'scripted', + generate: spy, + stream: vi.fn() as never, + supportsTools: () => true, + supportsStreaming: () => true, + getModels: async () => [], + }; + return { provider, generate: spy }; +} + +/** A generate() that never settles on its own - it only rejects once aborted. */ +function hangUntilAborted(options: GenerateOptions): Promise { + return new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true }); + }); +} + +function agentWithTools(...toolNames: string[]): AgentConfig { + const tools: AgentConfig['tools'] = {}; + for (const name of toolNames) { + tools[name] = { tool: name }; + } + return { id: 'agent-1', name: 'Agent', agentType: AgentType.SmartAssistant, prompt: 'p', tools }; +} + +function registryWith(tools: Record): ToolRegistry { + const registry = new ToolRegistry(); + for (const [name, descriptor] of Object.entries(tools)) { + registry.register(name, descriptor); + } + return registry; +} + +function simpleTool( + execute: (args: Record, options: { abortSignal?: AbortSignal }) => Promise, + extra: Partial = {} +): ToolDescriptor { + return { + displayName: 'Test tool', + tool: tool({ description: 'test tool', parameters: z.object({}), execute }), + ...extra, + }; +} + +function createInMemoryCheckpointStore(): CheckpointStore & { checkpoints: Map } { + const checkpoints = new Map(); + return { + checkpoints, + async save(sessionId, checkpoint) { + checkpoints.set(sessionId, checkpoint); + }, + async load(sessionId) { + return checkpoints.get(sessionId) ?? null; + }, + async delete(sessionId) { + checkpoints.delete(sessionId); + }, + }; +} + +function createApprovalStore(): ApprovalStore { + const records = new Map(); + return { + save: async (pending, snapshot) => void records.set(pending.id, { pending, snapshot }), + resolve: async (id) => records.get(id) ?? null, + }; +} + +describe('AgentExecutor cancellation (LOU-V1)', () => { + it('an already-aborted signal resolves immediately with finishReason "aborted" and never calls the provider', async () => { + const { provider, generate } = scriptedProvider(async () => textResult('never')); + const events: ExecutionEvent[] = []; + const controller = new AbortController(); + controller.abort(); + + const result = await AgentExecutor.execute({ + agent: agentWithTools(), + input: 'hi', + provider, + signal: controller.signal, + onEvent: (event) => events.push(event), + }); + + expect(generate).not.toHaveBeenCalled(); + expect(result.finishReason).toBe('aborted'); + expect(result.steps).toBe(0); + expect(result.messages.map((m) => m.role)).toEqual(['system', 'user']); + const abortEvent = events.find((e) => e.type === 'abort'); + expect(abortEvent).toBeDefined(); + expect(abortEvent?.abortReason).toBe(controller.signal.reason); + expect(events.at(-1)).toMatchObject({ type: 'finish', finishReason: 'aborted' }); + expect(events.some((e) => e.type === 'error')).toBe(false); + }); + + it('aborting during a model call resolves (does not reject) with the partial transcript', async () => { + const { provider, generate } = scriptedProvider(hangUntilAborted); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 10); + + const result = await AgentExecutor.execute({ + agent: agentWithTools(), + input: 'hi', + provider, + signal: controller.signal, + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(generate.mock.calls[0][0].signal).toBe(controller.signal); + expect(result.finishReason).toBe('aborted'); + expect(result.messages.map((m) => m.role)).toEqual(['system', 'user']); + }); + + it('the mock provider honors the signal mid-delay', async () => { + const provider = createMockProvider({ name: 'mock', delay: 60_000 }); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 10); + const started = Date.now(); + + const result = await AgentExecutor.execute({ + agent: agentWithTools(), + input: 'hi', + provider, + signal: controller.signal, + }); + + expect(result.finishReason).toBe('aborted'); + expect(Date.now() - started).toBeLessThan(5_000); + }); + + it('passes the signal to a tool as `abortSignal` and aborts during the tool call', async () => { + let observedAborted: boolean | undefined; + const slowTool = simpleTool( + (_args, { abortSignal }) => + new Promise((_resolve, reject) => { + abortSignal?.addEventListener('abort', () => { + observedAborted = abortSignal.aborted; + reject(abortSignal.reason); + }); + }) + ); + const { provider, generate } = scriptedProvider(async () => toolCallResult(toolCall('slow'))); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 10); + + const result = await AgentExecutor.execute({ + agent: agentWithTools('slow'), + input: 'hi', + provider, + toolRegistry: registryWith({ slow: slowTool }), + signal: controller.signal, + }); + + expect(observedAborted).toBe(true); + expect(generate).toHaveBeenCalledTimes(1); + expect(result.finishReason).toBe('aborted'); + // The transcript stays well-formed: the tool call has a matching result. + const toolMessage = result.messages.find((m) => m.role === 'tool'); + expect(toolMessage?.toolCallId).toBe('call_slow'); + }); + + it('aborting between steps stops before the next model call', async () => { + const controller = new AbortController(); + const fast = simpleTool(async () => 'done'); + const { provider, generate } = scriptedProvider(async () => toolCallResult(toolCall('fast'))); + + const result = await AgentExecutor.execute({ + agent: agentWithTools('fast'), + input: 'hi', + provider, + toolRegistry: registryWith({ fast }), + signal: controller.signal, + onEvent: (event) => { + if (event.type === 'tool-result') controller.abort(); + }, + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(result.finishReason).toBe('aborted'); + expect(result.steps).toBe(1); + expect(result.messages.at(-1)).toMatchObject({ role: 'tool', content: '"done"' }); + }); + + it('skips the remaining tool calls of a turn, giving each a cancelled result', async () => { + const controller = new AbortController(); + const first = simpleTool(async () => { + controller.abort(); + return 'first done'; + }); + const secondExecute = vi.fn(async () => 'second done'); + const second = simpleTool(secondExecute); + const { provider } = scriptedProvider(async () => + toolCallResult(toolCall('first'), toolCall('second')) + ); + + const result = await AgentExecutor.execute({ + agent: agentWithTools('first', 'second'), + input: 'hi', + provider, + toolRegistry: registryWith({ first, second }), + signal: controller.signal, + }); + + expect(secondExecute).not.toHaveBeenCalled(); + expect(result.finishReason).toBe('aborted'); + const toolMessages = result.messages.filter((m) => m.role === 'tool'); + expect(toolMessages.map((m) => m.toolCallId)).toEqual(['call_first', 'call_second']); + expect(JSON.parse(toolMessages[1].content)).toEqual({ + error: expect.stringContaining('cancelled'), + }); + }); + + it('checkpoints an aborted run so a later run with the same sessionId resumes it', async () => { + const checkpointStore = createInMemoryCheckpointStore(); + const controller = new AbortController(); + const fast = simpleTool(async () => 'done'); + const first = scriptedProvider(async () => toolCallResult(toolCall('fast'))); + + const aborted = await AgentExecutor.execute({ + agent: agentWithTools('fast'), + input: 'do the thing', + provider: first.provider, + toolRegistry: registryWith({ fast }), + sessionId: 's1', + checkpointStore, + signal: controller.signal, + onEvent: (event) => { + if (event.type === 'tool-result') controller.abort(); + }, + }); + + expect(aborted.finishReason).toBe('aborted'); + const saved = checkpointStore.checkpoints.get('s1'); + expect(saved?.finishReason).toBe('aborted'); + expect(saved?.messages.at(-1)).toMatchObject({ role: 'tool', content: '"done"' }); + + let sentRoles: string[] = []; + const second = scriptedProvider(async (options) => { + sentRoles = options.messages.map((m) => m.role); + return textResult('all done'); + }); + const resumed = await AgentExecutor.execute({ + agent: agentWithTools('fast'), + input: 'ignored - the checkpoint wins', + provider: second.provider, + toolRegistry: registryWith({ fast }), + sessionId: 's1', + checkpointStore, + }); + + expect(resumed.finishReason).toBe('stop'); + expect(resumed.text).toBe('all done'); + expect(resumed.steps).toBe(2); + expect(sentRoles).toEqual(['system', 'user', 'assistant', 'tool']); + expect(checkpointStore.checkpoints.has('s1')).toBe(false); + }); + + it('treats an abort-caused provider rejection as the abort: no compaction, no surfaced retry, no error event', async () => { + // "Request aborted" would otherwise be categorized as a retryable + // 'timeout' and folded back into the conversation for another attempt. + const { provider, generate } = scriptedProvider( + (options) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(new Error('Request aborted'))); + }) + ); + const events: ExecutionEvent[] = []; + const controller = new AbortController(); + setTimeout(() => controller.abort(), 10); + + const result = await AgentExecutor.execute({ + agent: agentWithTools(), + input: 'hi', + provider, + maxSteps: 5, + surfaceRetryableProviderErrors: true, + signal: controller.signal, + onEvent: (event) => events.push(event), + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(result.finishReason).toBe('aborted'); + expect(result.messages.some((m) => m.content.startsWith('[provider-error]'))).toBe(false); + expect(events.some((e) => e.type === 'error')).toBe(false); + }); + + it('still rejects for a genuine failure while the signal is not aborted', async () => { + const { provider } = scriptedProvider(async () => { + throw new Error('boom'); + }); + + await expect( + AgentExecutor.execute({ + agent: agentWithTools(), + input: 'hi', + provider, + signal: new AbortController().signal, + }) + ).rejects.toThrow(); + }); + + it('a delegated child agent is aborted together with its parent', async () => { + const child = scriptedProvider(hangUntilAborted); + const childAgent: AgentConfig = { name: 'Child', agentType: AgentType.SmartAssistant }; + const delegate = createDelegateTool({ agent: childAgent, provider: child.provider }); + const parent = scriptedProvider(async () => ({ + ...toolCallResult({ + id: 'call_delegate', + type: 'function', + function: { name: 'delegate', arguments: JSON.stringify({ task: 'sub task' }) }, + }), + })); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 20); + + const result = await AgentExecutor.execute({ + agent: agentWithTools('delegate'), + input: 'hi', + provider: parent.provider, + toolRegistry: registryWith({ delegate }), + signal: controller.signal, + }); + + expect(child.generate).toHaveBeenCalledTimes(1); + expect(child.generate.mock.calls[0][0].signal?.aborted).toBe(true); + expect(parent.generate).toHaveBeenCalledTimes(1); + expect(result.finishReason).toBe('aborted'); + }); +}); + +describe('retry() and aborts (LOU-V1)', () => { + it('never retries an AbortError, even when shouldRetry says yes', async () => { + const operation = vi.fn(async () => { + throw new DOMException('This operation was aborted', 'AbortError'); + }); + + await expect( + retry(operation, { maxAttempts: 3, initialDelayMs: 1, shouldRetry: () => true }) + ).rejects.toThrow('aborted'); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('stops retrying once its own signal is aborted', async () => { + const controller = new AbortController(); + const operation = vi.fn(async () => { + controller.abort(); + throw new Error('transient'); + }); + + await expect( + retry(operation, { + maxAttempts: 3, + initialDelayMs: 1, + shouldRetry: () => true, + signal: controller.signal, + }) + ).rejects.toThrow('transient'); + expect(operation).toHaveBeenCalledTimes(1); + }); +}); + +describe('cancellation entry points (LOU-V1)', () => { + it('createAgent().send(input, { signal }) forwards the signal', async () => { + const provider = createMockProvider({ name: 'mock' }); + const generateSpy = vi.spyOn(provider, 'generate'); + const agent = createAgent({ prompt: 'p', provider }); + const controller = new AbortController(); + controller.abort(); + + const result = await agent.send('hi', { signal: controller.signal }); + + expect(result.finishReason).toBe('aborted'); + expect(generateSpy).not.toHaveBeenCalled(); + expect((await agent.send('hi')).finishReason).toBe('stop'); + }); + + it('the mock provider rejects with the abort reason before and during its delay', async () => { + const provider = createMockProvider({ name: 'mock', delay: 60_000 }); + const before = new AbortController(); + before.abort(); + await expect( + provider.generate({ messages: [], signal: before.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }); + + const during = new AbortController(); + setTimeout(() => during.abort(), 10); + await expect( + provider.generate({ messages: [], signal: during.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }); + await expect(provider.stream({ messages: [], signal: before.signal })).rejects.toMatchObject({ + name: 'AbortError', + }); + }); + + it('resumeAfterApproval() passes the signal to the approved tool and the continued run', async () => { + const approvalStore = createApprovalStore(); + const controller = new AbortController(); + let observedSignal: AbortSignal | undefined; + const guarded = simpleTool( + async (_args, { abortSignal }) => { + observedSignal = abortSignal; + controller.abort(); + return 'approved result'; + }, + { needsApproval: true } + ); + const toolRegistry = registryWith({ guarded }); + const agent = agentWithTools('guarded'); + const first = scriptedProvider(async () => toolCallResult(toolCall('guarded'))); + + const paused = await AgentExecutor.execute({ + agent, + input: 'hi', + provider: first.provider, + toolRegistry, + approvalStore, + }); + expect(paused.finishReason).toBe('awaiting-approval'); + + const second = scriptedProvider(async () => textResult('never')); + const resumed = await resumeAfterApproval( + { id: paused.approvalId!, approved: true }, + approvalStore, + toolRegistry, + second.provider, + { signal: controller.signal } + ); + + expect(observedSignal).toBe(controller.signal); + expect(second.generate).not.toHaveBeenCalled(); + expect(resumed.finishReason).toBe('aborted'); + expect(resumed.messages.at(-1)).toMatchObject({ role: 'tool', content: '"approved result"' }); + }); +}); diff --git a/src/execution/generateStep.ts b/src/execution/generateStep.ts index 17c8865c2..ed5ae2702 100644 --- a/src/execution/generateStep.ts +++ b/src/execution/generateStep.ts @@ -77,7 +77,7 @@ export async function prepareGenerateRequest( messages: Message[], tools: ToolDefinition[] ): Promise { - const { agent, provider, temperature, maxTokens, onLLMRequest, hooks } = options; + const { agent, provider, temperature, maxTokens, onLLMRequest, hooks, signal } = options; const generateRequest: GenerateOptions = { // agent.settings.model > the model the provider was configured with > @@ -87,6 +87,8 @@ export async function prepareGenerateRequest( temperature, maxTokens, tools: tools.length > 0 ? tools : undefined, + // LOU-V1: lets the provider cancel the in-flight request. + ...(signal ? { signal } : {}), }; if (onLLMRequest) { diff --git a/src/execution/resume.ts b/src/execution/resume.ts index 6a1a73d86..3285ed858 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -88,6 +88,12 @@ export type ResumeExecuteOptions = Omit< * all - it never rehydrates. If `checkpointStore` is omitted entirely, * both the delete and the pass-through are skipped and resume behaves * exactly as it did before durable execution existed. + * + * LOU-V1: `executeOptions.signal` cancels the resumed run exactly like + * `ExecuteOptions.signal` - the approved tool receives it as `abortSignal`, + * and the continued run resolves with `finishReason: 'aborted'` once it is + * aborted, e.g. `resumeAfterApproval(decision, approvals, tools, provider, + * { signal: controller.signal })`. */ export async function resumeAfterApproval( decision: ApprovalDecision, @@ -272,7 +278,15 @@ async function executeApprovedTool( // tool executed after human approval can't silently bypass the // sandbox seam the way it previously did. const sandbox = executeOptions.sandbox ?? NoopSandbox; - return { result: await executeToolWithSandboxGuard(pending.toolName, toolDesc, args, sandbox) }; + return { + result: await executeToolWithSandboxGuard( + pending.toolName, + toolDesc, + args, + sandbox, + executeOptions.signal + ), + }; } catch (error) { // Mirror AgentExecutor.executeToolCall's (post-fix) handling of a // thrown tool error: errors that mark themselves as diff --git a/src/execution/retry.ts b/src/execution/retry.ts index 33aa74114..e81ddfe6c 100644 --- a/src/execution/retry.ts +++ b/src/execution/retry.ts @@ -16,6 +16,23 @@ export interface RetryOptions { timeout?: number; onRetry?: (error: Error, attempt: number, delayMs: number) => void; shouldRetry?: (error: Error, attempt: number) => boolean; + /** + * Stops retrying once aborted (LOU-V1): the error of the attempt that + * was running is rethrown instead of scheduling another attempt. + * Independently of this option, an error named `AbortError` is never + * retried - a cancelled operation is not a transient failure. + * + * @example + * ```ts + * await retry(() => provider.generate({ messages, signal }), { signal }); + * ``` + */ + signal?: AbortSignal; +} + +/** Whether an attempt's failure is a cancellation, which is never retried. */ +function isAbort(error: Error, signal: AbortSignal | undefined): boolean { + return error?.name === 'AbortError' || signal?.aborted === true; } /** @@ -58,6 +75,7 @@ export async function retry( timeout, onRetry, shouldRetry = isRetryableError, + signal, } = options; let attempt = 0; @@ -78,8 +96,13 @@ export async function retry( } catch (error) { lastError = error as Error; - // Don't retry if it's the last attempt, or if the error isn't retryable - if (attempt >= maxAttempts || !shouldRetry(lastError, attempt)) { + // Don't retry if it's the last attempt, the operation was cancelled, + // or the error isn't retryable + if ( + attempt >= maxAttempts || + isAbort(lastError, signal) || + !shouldRetry(lastError, attempt) + ) { break; } diff --git a/src/execution/sandboxGuard.ts b/src/execution/sandboxGuard.ts index 3bdbb940f..ff321aadc 100644 --- a/src/execution/sandboxGuard.ts +++ b/src/execution/sandboxGuard.ts @@ -18,6 +18,7 @@ * function rather than calling `toolDesc.tool.execute()` directly. */ +import type { ToolExecutionOptions } from 'ai'; import { ToolDescriptor } from '../types'; import { SandboxAdapter } from '../security/sandboxCore'; @@ -32,15 +33,20 @@ import { SandboxAdapter } from '../security/sandboxCore'; * to `tool.execute()` would run the tool's real code unsandboxed on the * host while claiming it was isolated. So this fails closed and throws, * matching this codebase's established fail-closed philosophy. - * - Otherwise (no `requiresSandbox`), calls `tool.execute(args, {} as any)` + * - Otherwise (no `requiresSandbox`), calls `tool.execute(args, { abortSignal })` * directly - the exact, unchanged pre-existing path - or resolves to * `null` if the tool has no `execute` implementation at all. + * + * LOU-V1: `signal` (the run's cancellation signal) reaches the tool as + * `abortSignal` - the option name the 'ai' SDK's own `tool()` execute + * signature uses - in both branches. */ export async function executeToolWithSandboxGuard( toolName: string, toolDesc: ToolDescriptor, args: Record, - sandbox: SandboxAdapter + sandbox: SandboxAdapter, + signal?: AbortSignal ): Promise { if (toolDesc.requiresSandbox) { if (!toolDesc.sandboxExecute) { @@ -49,8 +55,13 @@ export async function executeToolWithSandboxGuard( `- cannot be safely sandboxed, refusing to fall back to unsandboxed execution` ); } - return toolDesc.sandboxExecute(args, sandbox); + return signal + ? toolDesc.sandboxExecute(args, sandbox, { abortSignal: signal }) + : toolDesc.sandboxExecute(args, sandbox); } - return toolDesc.tool.execute ? toolDesc.tool.execute(args, {} as any) : null; + // The 'ai' SDK types toolCallId/messages as required, but tools invoked + // here are not part of an 'ai' SDK generation, so only abortSignal is set. + const executeOptions = { abortSignal: signal } as ToolExecutionOptions; + return toolDesc.tool.execute ? toolDesc.tool.execute(args, executeOptions) : null; } diff --git a/src/execution/toolCallExecution.ts b/src/execution/toolCallExecution.ts index d6ef1085f..1dc9ec993 100644 --- a/src/execution/toolCallExecution.ts +++ b/src/execution/toolCallExecution.ts @@ -37,6 +37,8 @@ export interface ToolCallContext { hooks?: HookRegistry; sessionId?: string; messages: Message[]; + /** LOU-V1: the run's signal, handed to the tool as `abortSignal`. */ + signal?: AbortSignal; } /** @@ -114,7 +116,7 @@ export async function runToolCall( try { outcome = checked.rejection ?? - (await doExecuteToolCall(toolCall, ctx.toolRegistry, ctx.sandbox, hookArgs)); + (await doExecuteToolCall(toolCall, ctx.toolRegistry, ctx.sandbox, hookArgs, ctx.signal)); if (ctx.hooks) { await ctx.hooks.runPostToolCall(toolHookContext(toolCall, ctx, hookArgs), { result: outcome.result, @@ -202,7 +204,8 @@ async function doExecuteToolCall( toolCall: ToolCall, toolRegistry: ToolRegistry | undefined, sandbox: SandboxAdapter, - overrideArgs?: Record + overrideArgs?: Record, + signal?: AbortSignal ): Promise { if (!toolRegistry) { return toolFailure(toolCall, 'No tool registry available'); @@ -242,7 +245,8 @@ async function doExecuteToolCall( toolCall.function.name, toolDesc, args, - sandbox + sandbox, + signal ); return { diff --git a/src/providers/aiSdkProvider.test.ts b/src/providers/aiSdkProvider.test.ts index bcef4be90..4550700ba 100644 --- a/src/providers/aiSdkProvider.test.ts +++ b/src/providers/aiSdkProvider.test.ts @@ -66,6 +66,25 @@ describe('AiSdkProvider', () => { expect(generateTextMock.mock.calls[0][0].model.modelId).toBe('gpt-4'); }); + it('LOU-V1: forwards GenerateOptions.signal to the ai SDK as abortSignal', async () => { + generateTextMock.mockResolvedValue(textResult('stop')); + streamTextMock.mockResolvedValue({ + textStream: (async function* () {})(), + text: Promise.resolve(''), + usage: Promise.resolve(usage), + finishReason: Promise.resolve('stop'), + toolCalls: Promise.resolve([]), + }); + const provider = new OpenAIProvider({ name: 'openai', apiKey: 'k' }); + const { signal } = new AbortController(); + + await provider.generate({ messages: [], signal }); + await provider.stream({ messages: [], signal }); + + expect(generateTextMock.mock.calls[0][0].abortSignal).toBe(signal); + expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(signal); + }); + it.each([ ['stop', 'stop'], ['length', 'length'], diff --git a/src/providers/aiSdkProvider.ts b/src/providers/aiSdkProvider.ts index 9cdaae4be..b49f6cb57 100644 --- a/src/providers/aiSdkProvider.ts +++ b/src/providers/aiSdkProvider.ts @@ -217,6 +217,7 @@ export abstract class AiSdkProvider impleme seed: options.seed, tools: convertTools(options.tools), maxSteps: 1, // Single step - tool execution happens in AgentExecutor + abortSignal: options.signal, }; } diff --git a/src/providers/llm.ts b/src/providers/llm.ts index fc696256e..25d385edc 100644 --- a/src/providers/llm.ts +++ b/src/providers/llm.ts @@ -65,6 +65,17 @@ export interface GenerateOptions { tools?: ToolDefinition[]; toolChoice?: 'auto' | 'required' | 'none' | { type: 'function'; function: { name: string } }; seed?: number; + /** + * Cancels the request. Providers must reject promptly (with the signal's + * `reason`, normally an `AbortError`) once it is aborted. AgentExecutor + * sets this from `ExecuteOptions.signal`. + * + * @example + * ```ts + * await provider.generate({ messages, signal: AbortSignal.timeout(10_000) }); + * ``` + */ + signal?: AbortSignal; } /** diff --git a/src/providers/mock.ts b/src/providers/mock.ts index b836f375f..0adad3c3d 100644 --- a/src/providers/mock.ts +++ b/src/providers/mock.ts @@ -24,6 +24,24 @@ export interface MockProviderConfig extends LLMProviderConfig { errorMessage?: string; } +/** + * Waits `ms`, rejecting with the signal's reason as soon as it is aborted + * (LOU-V1), so a simulated slow call can be cancelled mid-delay. + */ +function abortableDelay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + /** * Mock LLM Provider */ @@ -46,12 +64,13 @@ export class MockLLMProvider implements LLMProvider { } async generate(options: GenerateOptions): Promise { + options.signal?.throwIfAborted(); if (this.simulateError) { throw new Error(this.errorMessage); } if (this.delay > 0) { - await new Promise(resolve => setTimeout(resolve, this.delay)); + await abortableDelay(this.delay, options.signal); } const text = this.getNextResponse(); @@ -70,6 +89,7 @@ export class MockLLMProvider implements LLMProvider { } async stream(options: GenerateOptions): Promise { + options.signal?.throwIfAborted(); if (this.simulateError) { throw new Error(this.errorMessage); } @@ -83,7 +103,7 @@ export class MockLLMProvider implements LLMProvider { ): AsyncGenerator { for (const word of words) { if (this.delay > 0) { - await new Promise(resolve => setTimeout(resolve, this.delay)); + await abortableDelay(this.delay, options.signal); } const chunk: StreamChunk = { diff --git a/src/tools/built-in/http.test.ts b/src/tools/built-in/http.test.ts index a05ca9c17..71beafd36 100644 --- a/src/tools/built-in/http.test.ts +++ b/src/tools/built-in/http.test.ts @@ -80,6 +80,24 @@ describe('makeHttpRequest', () => { expect(elapsed).toBeLessThan(2000); }); + it('LOU-V1: the tool passes its abortSignal to fetch and rejects with an AbortError', async () => { + server = http.createServer(() => { + // Never respond + }); + const baseUrl = await listen(server); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 20); + + const start = Date.now(); + await expect( + createHttpTool({ timeout: 10_000 }).tool.execute!( + { url: baseUrl, method: 'GET' }, + { toolCallId: 't', messages: [], abortSignal: controller.signal } + ) + ).rejects.toMatchObject({ name: 'AbortError', message: 'HTTP request was aborted' }); + expect(Date.now() - start).toBeLessThan(2000); + }); + it('throws when a redirect chain exceeds maxRedirects', async () => { server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://localhost'); diff --git a/src/tools/built-in/http.ts b/src/tools/built-in/http.ts index 95500a2cd..3bfe68054 100644 --- a/src/tools/built-in/http.ts +++ b/src/tools/built-in/http.ts @@ -193,6 +193,17 @@ function createSandboxTransport(sandbox: SandboxAdapter, validateSSL: boolean, t type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; +/** One outbound request, as makeHttpRequest()/makeHttpRequestViaSandbox() take it. */ +interface HttpRequestArgs { + url: string; + method: HttpMethod; + headers?: Record; + body?: string; + options?: HttpToolOptions; + /** Cancels the request (LOU-V1); it then rejects with an `AbortError`. */ + signal?: AbortSignal; +} + const DEFAULT_TIMEOUT_MS = 30000; const DEFAULT_MAX_REDIRECTS = 5; @@ -250,8 +261,35 @@ async function readResponseBody(response: Response): Promise { return await response.text(); } +/** + * Aborts `controller` when the caller's `signal` aborts (LOU-V1), so the + * run's cancellation reaches the in-flight fetch. Returns the unlink + * function to call once the request settles. + */ +function linkCallerSignal(controller: AbortController, signal: AbortSignal | undefined): () => void { + if (!signal) { + return () => undefined; + } + const onAbort = () => controller.abort(signal.reason); + if (signal.aborted) { + onAbort(); + } + signal.addEventListener('abort', onAbort, { once: true }); + return () => signal.removeEventListener('abort', onAbort); +} + +/** The error reported when the caller cancelled the request. */ +function abortedRequestError(): Error { + const error = new Error('HTTP request was aborted'); + error.name = 'AbortError'; + return error; +} + /** Map whatever a request threw to the error performHttpRequest() reports. */ -function toHttpRequestError(error: unknown, timeoutMs: number): Error { +function toHttpRequestError(error: unknown, timeoutMs: number, callerSignal?: AbortSignal): Error { + if (callerSignal?.aborted) { + return abortedRequestError(); + } if (error instanceof Error && error.name === 'AbortError') { return new Error(`Request timed out after ${timeoutMs}ms`); } @@ -273,13 +311,8 @@ async function performHttpRequest( headers, body, options = {}, - }: { - url: string; - method: HttpMethod; - headers?: Record; - body?: string; - options?: HttpToolOptions; - }, + signal, + }: HttpRequestArgs, transport: HttpTransport, getCleanup: () => (() => void | Promise) | void ): Promise { @@ -288,6 +321,7 @@ async function performHttpRequest( const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const unlinkSignal = linkCallerSignal(controller, signal); const cleanup = getCleanup(); try { @@ -313,9 +347,10 @@ async function performHttpRequest( return await readResponseBody(response); } catch (error) { clearTimeout(timeoutId); - throw toHttpRequestError(error, timeoutMs); + throw toHttpRequestError(error, timeoutMs, signal); } finally { clearTimeout(timeoutId); + unlinkSignal(); await cleanup?.(); } } @@ -327,13 +362,7 @@ async function performHttpRequest( * the HTTP tool's plain `execute()` - see makeHttpRequestViaSandbox() below * for the sandboxed path used by `sandboxExecute()`. */ -export async function makeHttpRequest(args: { - url: string; - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - headers?: Record; - body?: string; - options?: HttpToolOptions; -}): Promise { +export async function makeHttpRequest(args: HttpRequestArgs): Promise { const { transport, close } = createDirectTransport(args.options?.validateSSL !== false); return performHttpRequest(args, transport, () => close); } @@ -347,13 +376,7 @@ export async function makeHttpRequest(args: { * process. */ export async function makeHttpRequestViaSandbox( - args: { - url: string; - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - headers?: Record; - body?: string; - options?: HttpToolOptions; - }, + args: HttpRequestArgs, sandbox: SandboxAdapter ): Promise { const timeoutMs = args.options?.timeout ?? 30000; @@ -376,8 +399,10 @@ export function createHttpTool(options: HttpToolOptions = {}): ToolDescriptor { headers: z.record(z.string()).optional().describe('Optional headers to include in the request as key-value pairs'), body: z.string().optional().describe('The body of the request. For POST/PUT/PATCH, this should be a JSON string. Not used for GET/DELETE.'), }), - execute: async ({ url, method, headers, body }) => { - return makeHttpRequest({ url, method, headers, body, options }); + execute: async ({ url, method, headers, body }, executeOptions) => { + // `?.`: direct callers have historically passed no options object. + const signal = executeOptions?.abortSignal; + return makeHttpRequest({ url, method, headers, body, options, signal }); }, }), // LOU-K2: httpTool makes arbitrary, model-chosen outbound HTTP requests @@ -388,14 +413,12 @@ export function createHttpTool(options: HttpToolOptions = {}): ToolDescriptor { // left unchanged (still real, directly callable) for callers that // invoke descriptor.tool.execute() directly. requiresSandbox: true, - sandboxExecute: async (args, sandbox) => { - const { url, method, headers, body } = args as { - url: string; - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - headers?: Record; - body?: string; - }; - return makeHttpRequestViaSandbox({ url, method, headers, body, options }, sandbox); + sandboxExecute: async (args, sandbox, callOptions) => { + const { url, method, headers, body } = args as Omit; + return makeHttpRequestViaSandbox( + { url, method, headers, body, options, signal: callOptions?.abortSignal }, + sandbox + ); }, }; } diff --git a/src/types/tool.ts b/src/types/tool.ts index 62e33f990..3bd07b890 100644 --- a/src/types/tool.ts +++ b/src/types/tool.ts @@ -59,8 +59,16 @@ export interface ToolDescriptor { * AgentExecutor requires this to be defined and calls it instead of * `tool.execute()`; if it is missing, AgentExecutor throws rather than * silently falling back to unsandboxed in-process execution. + * + * LOU-V1: when the run has a cancellation signal, it is passed as the + * third argument (`{ abortSignal }`), the same name `tool.execute()` + * receives it under. */ - sandboxExecute?: (args: unknown, sandbox: SandboxAdapter) => Promise; + sandboxExecute?: ( + args: unknown, + sandbox: SandboxAdapter, + options?: { abortSignal?: AbortSignal } + ) => Promise; } /**