Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 18 additions & 2 deletions src/createAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExecutionResult>;
send: (message: string, options?: SendOptions) => Promise<ExecutionResult>;
}

/**
Expand All @@ -70,13 +85,14 @@ export function createAgent(config: CreateAgentConfig): SimpleAgent {
.build();

return {
async send(message: string): Promise<ExecutionResult> {
async send(message: string, options: SendOptions = {}): Promise<ExecutionResult> {
return AgentExecutor.execute({
agent,
input: message,
provider: config.provider,
toolRegistry,
maxSteps: config.maxSteps,
signal: options.signal,
});
},
};
Expand Down
167 changes: 140 additions & 27 deletions src/execution/AgentExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
AgentRunState,
addUsage,
loadRunState,
pushCancelledToolResults,
saveStepCheckpoint,
toExecutionResult,
} from './agentRunState';
Expand All @@ -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
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -245,7 +296,7 @@ export interface ExecutionResult {
completionTokens: number;
totalTokens: number;
};
finishReason: string;
finishReason: ExecutionFinishReason;
steps: number;
approvalId?: string;
}
Expand Down Expand Up @@ -294,7 +345,7 @@ export class AgentExecutor {
options: ExecuteOptions,
agentSpanId: string
): Promise<ExecutionResult> {
const { agent, toolRegistry, maxSteps = 10, onEvent } = options;
const { agent, toolRegistry, maxSteps = 10, onEvent, signal } = options;

// Emit start event
this.emitEvent(onEvent, {
Expand All @@ -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;
}
}

/**
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -534,6 +614,7 @@ export class AgentExecutor {
sessionId,
exporter,
redactContent = false,
signal,
} = options;

return withSpan(
Expand All @@ -551,7 +632,8 @@ export class AgentExecutor {
sandbox,
hooks,
sessionId,
state.messages
state.messages,
signal
);
const parsedArgs =
executed.args === undefined
Expand Down Expand Up @@ -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<ExecutionResult> {
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.
Expand Down Expand Up @@ -679,7 +790,8 @@ export class AgentExecutor {
sandbox: SandboxAdapter = NoopSandbox,
hooks?: HookRegistry,
sessionId?: string,
messages: Message[] = []
messages: Message[] = [],
signal?: AbortSignal
): Promise<ToolCallOutcome> {
return runToolCall(toolCall, {
agent,
Expand All @@ -690,6 +802,7 @@ export class AgentExecutor {
hooks,
sessionId,
messages,
signal,
});
}

Expand Down
Loading
Loading