diff --git a/README.md b/README.md index a649dc533..dcf664f3f 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 983a71cfe..4bf32b7bf 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 3bd62533d..c7214fd70 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -47,9 +47,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; } /** @@ -70,13 +85,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 71afe4d68..31be10e8d 100644 --- a/src/execution/AgentExecutor.ts +++ b/src/execution/AgentExecutor.ts @@ -26,6 +26,7 @@ import { AgentRunState, addUsage, loadRunState, + pushCancelledToolResults, saveStepCheckpoint, toExecutionResult, } from './agentRunState'; @@ -42,7 +43,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 @@ -61,13 +80,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; } /** @@ -231,6 +256,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; } /** @@ -245,7 +296,7 @@ export interface ExecutionResult { completionTokens: number; totalTokens: number; }; - finishReason: string; + finishReason: ExecutionFinishReason; steps: number; approvalId?: string; } @@ -294,7 +345,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, { @@ -309,29 +360,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; + } } /** @@ -415,9 +488,11 @@ export class AgentExecutor { try { return await generateInSpan(options, generateRequest, state.messages, agentSpanId); } catch (generateError) { - // A cancellation is not a provider failure: rethrow it untouched so - // the caller that aborted still sees its own error (see isAbortError()). - if (isAbortError(generateError)) { + // A cancellation is not a provider failure - never compact it or fold + // it into the conversation for a retry. With `signal` (LOU-V1) the + // loop turns it into an 'aborted' result; an AbortError thrown without + // one (see isAbortError()) reaches the caller untouched. + if (options.signal?.aborted || isAbortError(generateError)) { throw generateError; } @@ -473,7 +548,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(), @@ -534,6 +614,7 @@ export class AgentExecutor { sessionId, exporter, redactContent = false, + signal, } = options; return withSpan( @@ -551,7 +632,8 @@ export class AgentExecutor { sandbox, hooks, sessionId, - state.messages + state.messages, + signal ); const parsedArgs = executed.args === undefined @@ -618,6 +700,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. @@ -679,7 +790,8 @@ export class AgentExecutor { sandbox: SandboxAdapter = NoopSandbox, hooks?: HookRegistry, sessionId?: string, - messages: Message[] = [] + messages: Message[] = [], + signal?: AbortSignal ): Promise { return runToolCall(toolCall, { agent, @@ -690,6 +802,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 b507d5121..6d9ed28ad 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, @@ -273,7 +279,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 6bd8f774d..6973234e8 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 9ac0672ed..d7267929a 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 03e3dc199..5a76e57e2 100644 --- a/src/providers/aiSdkProvider.ts +++ b/src/providers/aiSdkProvider.ts @@ -268,6 +268,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 edaacd20a..ef8892f81 100644 --- a/src/providers/llm.ts +++ b/src/providers/llm.ts @@ -82,6 +82,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; } /**