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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- README revamped into a short front page (LOU-D52); the details it dropped moved to `docs/` (new pages: tools, approvals, providers, cli, flows, guardrails, utilities).

### Changed
- The execute context is our own type (LOU-D23, not breaking): the new exported `ToolExecutionContext` (`{ toolCallId, messages: readonly Message[], abortSignal?, sessionId?, onDelegatedUsage? }`, `Message` from `@loushy/build-ai-agent`) replaces the `ai` SDK's `ToolExecutionOptions` as the type of the second argument of `defineTool()`'s `execute`, `ToolDescriptor.execute`, `DefinedTool.execute` and the third argument of `sandboxExecute`, and is what `buildToolRunContext()` returns; the public types no longer import `ToolExecutionOptions` from `ai`. A function typed for `ai`'s options that only reads `toolCallId` or `abortSignal` stays assignable. `ToolRunContext` is now a deprecated alias of the optional fields of `ToolExecutionContext` (it already existed with `onDelegatedUsage` and `toolCallId`). `AgentExecutionOptions.messages` is typed `Message[]` instead of the `ai` SDK's `CoreMessage[]` (the field is unused by the SDK). The legacy `ToolDescriptor.tool` (`ai` v4 `Tool`) is unchanged. See docs/tools.md#execute-context.
- BREAKING (small): `SDKError.code` values are now the `LOUSHY_*` codes (LOU-D2): `AGENT_EXECUTION_ERROR` -> `LOUSHY_AGENT_EXECUTION_FAILED`, `TOOL_EXECUTION_ERROR` -> `LOUSHY_TOOL_EXECUTION_FAILED`, `LLM_PROVIDER_ERROR` -> `LOUSHY_PROVIDER_REQUEST_FAILED`, `SESSION_AWAITING_APPROVAL` -> `LOUSHY_SESSION_AWAITING_APPROVAL`, `FLOW_EXECUTION_ERROR` -> `LOUSHY_FLOW_EXECUTION_FAILED`, `CONFIGURATION_ERROR` -> `LOUSHY_CONFIG_INVALID`, `VALIDATION_ERROR` -> `LOUSHY_VALIDATION_FAILED`, `TIMEOUT_ERROR` -> `LOUSHY_OPERATION_TIMEOUT`, `RATE_LIMIT_ERROR` -> `LOUSHY_PROVIDER_RATE_LIMITED`; `code` is no longer optional (`new SDKError(message)` gets `LOUSHY_GENERIC_ERROR`). No class was renamed. The messages of `SDKError`s other than `ToolExecutionError`, `LLMProviderError`, `TimeoutError` and `RateLimitError` gain a second line with the code, hint and docs link, and `loadSpec()` now rejects a spec whose top-level field is a likely typo of a spec field (`LOUSHY_SPEC_UNKNOWN_FIELD`; other unknown fields are still ignored). **Migration:** compare `code` against the new values (or use `instanceof`); match messages with `toContain` / a regex, or compare `error.detail`, instead of exact equality.
- BREAKING (small): tool failures that did not go through the thrown-error path now use the shared shape (LOU-U14), so `error` is an error name instead of a message. Before: an unknown tool or a missing registry gave `{ error: "Tool 'x' not found" }` / `{ error: 'No tool registry available' }`; a rejected approval gave `{ error: 'Tool execution was rejected by the reviewer', note }`; a tool that threw after an approval gave `{ error: '<message>' }`; a call left without a result on resume gave `{ error: 'Tool call was not run: ...' }`. Now each is `{ error: '<ToolNotFoundError | ToolRejectedError | <thrown error name> | ToolNotRunError>', toolName, message: '<the old text>', kind, ... }`. A tool approved but missing from the registry on resume used to reject `resumeAfterApproval()` with `Tool 'x' not found in registry`; it now resolves with a `not-found` error result. The sandbox guard throws `SandboxRequiredError` (same message, still an `Error`). **Migration:** read `message` instead of `error` when you want the text, and branch on `kind`. `ToolArgumentsValidationError.toToolResult()` now also returns `kind: 'validation'`.
- Background sub-agents still running when the lead run ends are now cancelled (LOU-Y4.2) instead of running to completion on their own; a lead run that pauses for approval cancels them too (the resumed run starts with none). **Migration:** to keep them, have the lead collect them with `agent_await` before its final answer, or set `subagentOptions: { awaitBackgroundOnFinish: true }`.
Expand Down
5 changes: 4 additions & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ descriptors that set neither `inputSchema` nor `execute`.

## Execute context

`execute(args, ctx)` always gets a real second argument, on every path that
`execute(args, ctx)` always gets a real second argument, typed
`ToolExecutionContext` (exported from the package root; it replaces the `ai`
SDK's `ToolExecutionOptions`), on every path that
runs a tool: the main loop, the call that runs after an approval, tools that
run in a sandbox (`sandboxExecute(args, sandbox, ctx)`) and a flow's tool-call
node.
Expand All @@ -78,6 +80,7 @@ node.
| `ctx.toolCallId` | The model's id for this call. It stays the same when the call is re-run after a crash. A call with no model turn behind it (a flow node) gets a generated id. |
| `ctx.messages` | A read-only copy of the transcript the model had seen before it made the call: no system prompt and not the assistant turn that made the call. Empty for a flow node. |
| `ctx.abortSignal` | The run's `AbortSignal`, set when the run has one. |
| `ctx.sessionId` | Reserved: the type has it and `buildToolRunContext()` passes it through, but the executor does not set it yet. |

A `sandboxExecute(args, sandbox)` that ignores the third argument keeps working.

Expand Down
5 changes: 4 additions & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5908,7 +5908,9 @@ descriptors that set neither `inputSchema` nor `execute`.

## Execute context

`execute(args, ctx)` always gets a real second argument, on every path that
`execute(args, ctx)` always gets a real second argument, typed
`ToolExecutionContext` (exported from the package root; it replaces the `ai`
SDK's `ToolExecutionOptions`), on every path that
runs a tool: the main loop, the call that runs after an approval, tools that
run in a sandbox (`sandboxExecute(args, sandbox, ctx)`) and a flow's tool-call
node.
Expand All @@ -5918,6 +5920,7 @@ node.
| `ctx.toolCallId` | The model's id for this call. It stays the same when the call is re-run after a crash. A call with no model turn behind it (a flow node) gets a generated id. |
| `ctx.messages` | A read-only copy of the transcript the model had seen before it made the call: no system prompt and not the assistant turn that made the call. Empty for a flow node. |
| `ctx.abortSignal` | The run's `AbortSignal`, set when the run has one. |
| `ctx.sessionId` | Reserved: the type has it and `buildToolRunContext()` passes it through, but the executor does not set it yet. |

A `sandboxExecute(args, sandbox)` that ignores the third argument keeps working.

Expand Down
13 changes: 6 additions & 7 deletions src/execution/toolRunContext.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { describe, it, expect } from 'vitest';
import type { ToolExecutionOptions } from 'ai';
import { AgentExecutor } from './AgentExecutor';
import { resumeAfterApproval } from './resume';
import type { ApprovalStore, ExecutionSnapshot, PendingApproval } from './ApprovalGate';
import { executeToolWithSandboxGuard } from './sandboxGuard';
import { buildToolRunContext } from './toolRunContext';
import { ToolRegistry } from '../tools';
import { AgentBuilder } from '../core';
import { AgentType } from '../types';
import { AgentType, type ToolExecutionContext } from '../types';
import type { SandboxAdapter } from '../security/sandboxCore';
import { NoopSandbox } from '../security/sandboxCore';
import { mockModel } from '../testing';

/** What a tool saw as its execute context. */
interface Seen {
ctx: ToolExecutionOptions;
ctx: ToolExecutionContext;
}

function inMemoryApprovalStore(): ApprovalStore {
Expand All @@ -38,20 +37,20 @@ const sandbox: SandboxAdapter = {
};

function register(registry: ToolRegistry, seen: Partial<Seen>, flags: { sandboxed: boolean; approval: boolean }) {
const record = (ctx: ToolExecutionOptions) => {
const record = (ctx: ToolExecutionContext) => {
seen.ctx = ctx;
return { ok: true };
};
registry.register('probe', {
displayName: 'Probe',
tool: { description: 'probe', parameters: {}, execute: async (_a: unknown, ctx: ToolExecutionOptions) => record(ctx) } as never,
tool: { description: 'probe', parameters: {}, execute: async (_a: unknown, ctx: ToolExecutionContext) => record(ctx) } as never,
needsApproval: flags.approval,
...(flags.sandboxed && {
requiresSandbox: true,
// Records the third argument, as a tool author would read it.
sandboxExecute: async (_args: unknown, sb: SandboxAdapter, ctx?: ToolExecutionOptions) => {
sandboxExecute: async (_args: unknown, sb: SandboxAdapter, ctx?: ToolExecutionContext) => {
expect(sb).toBe(sandbox);
return record(ctx as ToolExecutionOptions);
return record(ctx as ToolExecutionContext);
},
}),
});
Expand Down
36 changes: 16 additions & 20 deletions src/execution/toolRunContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,25 @@
* The one builder of the context a tool's `execute(args, ctx)` receives
* (LOU-U15). Every path that runs a tool - the main loop, resume after an
* approval, the sandbox route and FlowExecutor's tool-call node - goes
* through {@link buildToolRunContext}, so the `ToolExecutionOptions` the
* through {@link buildToolRunContext}, so the `ToolExecutionContext` the
* type promises (`toolCallId`, `messages`, `abortSignal`) are always real.
*/

import { newId } from '../utils/id';
import type { ToolExecutionOptions } from 'ai';
import type { Message } from '../providers';
import type { RunUsage } from '../models/usage';
import type { ToolExecutionContext } from '../types/tool';
import { bindToolCallScope, type ToolCallScope } from './subagentRuntime';

/** Extra context the executor hands a tool next to the 'ai' SDK's own execute options (LOU-V5). */
export interface ToolRunContext {
/** Called by the delegate tool with a finished child run's usage, so the parent run adds it to its totals. */
onDelegatedUsage?: (usage: RunUsage) => void;
/**
* LOU-U9: the model's id for this tool call - unchanged when a call that
* was running when the process died is re-run on resume, so tools can use
* it as an idempotency key.
*/
toolCallId?: string;
}
/**
* @deprecated Use {@link ToolExecutionContext}, the one public execute-context
* type. Kept as an alias of its fields (all optional, `messages` left out, as
* before) so existing imports compile and 'ai' `tool()` execute options still
* fit it.
*/
export type ToolRunContext = Partial<Omit<ToolExecutionContext, 'messages'>>;

/** What {@link buildToolRunContext} builds the context from. */
export interface ToolRunInput extends ToolRunContext {
export interface ToolRunInput extends Pick<ToolRunContext, 'toolCallId' | 'sessionId' | 'onDelegatedUsage'> {
/** The run's transcript. The tool gets the part before the model turn that made this call. */
messages?: readonly Message[];
/** The run's cancellation signal; the tool gets it as `abortSignal`. */
Expand All @@ -38,7 +33,7 @@ export interface ToolRunInput extends ToolRunContext {
* A read-only copy of what the model had seen when it made `toolCallId`'s
* call: the transcript without the system prompt and without the assistant
* turn that made the call (or anything after it) - the same meaning
* `ToolExecutionOptions.messages` has in the 'ai' SDK.
* `ToolExecutionContext.messages` has (as in the 'ai' SDK).
*/
function transcriptBefore(messages: readonly Message[], toolCallId: string): readonly Message[] {
const turn = messages.findIndex((m) => m.toolCalls?.some((call) => call.id === toolCallId));
Expand All @@ -48,17 +43,18 @@ function transcriptBefore(messages: readonly Message[], toolCallId: string): rea

/**
* The context for one tool call: `{ toolCallId, messages, abortSignal }`
* plus the SDK's own {@link ToolRunContext} fields. `toolCallId` falls back
* plus the optional `sessionId` and `onDelegatedUsage`. `toolCallId` falls back
* to a generated id for callers with no model turn behind the call.
*/
export function buildToolRunContext(input: ToolRunInput): ToolExecutionOptions & ToolRunContext {
export function buildToolRunContext(input: ToolRunInput): ToolExecutionContext {
const toolCallId = input.toolCallId ?? newId('call');
const ctx = {
const ctx: ToolExecutionContext = {
toolCallId,
messages: transcriptBefore(input.messages ?? [], toolCallId),
abortSignal: input.signal,
onDelegatedUsage: input.onDelegatedUsage,
} as unknown as ToolExecutionOptions & ToolRunContext;
...(input.sessionId !== undefined && { sessionId: input.sessionId }),
};
bindToolCallScope(ctx, input.scope);
return ctx;
}
39 changes: 35 additions & 4 deletions src/tools/defineTool.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, it, expectTypeOf } from 'vitest';
import { z } from 'zod';
import type { ToolExecutionOptions } from 'ai';
import { tool as aiTool } from 'ai';
import type { Message } from '../providers/llm';
import { defineTool, type DefinedTool, type ToolInput, type ToolOutput } from './defineTool';
import { createAgent } from '../createAgent';
import { createMockProvider } from '../providers/mock';
import { ToolRegistry } from './ToolRegistry';
import type { ToolDescriptor, ToolExecutionContext } from '../types';

const sendEmail = defineTool({
name: 'send_email',
Expand All @@ -19,7 +21,7 @@ const sendEmail = defineTool({
async execute(args, ctx) {
expectTypeOf(args.to).toBeString();
expectTypeOf(args.count).toBeNumber();
expectTypeOf(ctx).toEqualTypeOf<ToolExecutionOptions>();
expectTypeOf(ctx).toEqualTypeOf<ToolExecutionContext>();
// @ts-expect-error - `nope` is not an argument of this tool
void args.nope;
return { messageId: 'x', sent: args.count };
Expand Down Expand Up @@ -48,10 +50,39 @@ describe('defineTool types', () => {
const t = defineTool({ name: 't', description: 'd', input, execute: async () => ({ ok: true }) });
expectTypeOf(t.inputSchema).toEqualTypeOf<typeof input>();
expectTypeOf(t.execute).parameter(0).toEqualTypeOf<{ to: string; count: number }>();
expectTypeOf(t.execute).parameter(1).toEqualTypeOf<ToolExecutionOptions>();
expectTypeOf(t.execute).parameter(1).toEqualTypeOf<ToolExecutionContext>();
expectTypeOf(t.execute).returns.toEqualTypeOf<Promise<{ ok: boolean }>>();
// @ts-expect-error - `count` must be a number
void t.execute({ to: 'a@b.c', count: 'x' }, {} as ToolExecutionOptions);
void t.execute({ to: 'a@b.c', count: 'x' }, {} as ToolExecutionContext);
});

it('types ctx as our ToolExecutionContext, not ai\'s options (LOU-D23)', () => {
defineTool({
name: 'ctx_probe',
description: 'd',
input: z.object({ q: z.string() }),
execute: async ({ q }, ctx) => {
expectTypeOf(q).toBeString();
expectTypeOf(ctx).toEqualTypeOf<ToolExecutionContext>();
expectTypeOf(ctx.toolCallId).toBeString();
expectTypeOf(ctx.messages).toEqualTypeOf<readonly Message[]>();
expectTypeOf(ctx.abortSignal).toEqualTypeOf<AbortSignal | undefined>();
expectTypeOf(ctx.sessionId).toEqualTypeOf<string | undefined>();
return q;
},
});
expectTypeOf<ToolDescriptor['execute']>().parameter(1).toEqualTypeOf<ToolExecutionContext>();
expectTypeOf<NonNullable<ToolDescriptor['sandboxExecute']>>()
.parameter(2)
.toEqualTypeOf<ToolExecutionContext | undefined>();
});

it('still registers a legacy ai tool() descriptor', () => {
const legacy: ToolDescriptor = {
displayName: 'legacy',
tool: aiTool({ description: 'd', parameters: z.object({ a: z.string() }), execute: async ({ a }) => a }),
};
new ToolRegistry().register('legacy', legacy);
});

it('rejects execute args that do not match the schema', () => {
Expand Down
12 changes: 6 additions & 6 deletions src/tools/defineTool.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { ToolExecutionOptions } from 'ai';
import type { z } from 'zod';
import type { ToolDescriptor } from '../types';
import type { ToolDescriptor, ToolExecutionContext } from '../types';
import type { SandboxAdapter } from '../security/sandboxCore';

/** Tool names must satisfy the constraint LLM providers impose on function names. */
Expand Down Expand Up @@ -28,7 +27,7 @@ export interface DefineToolOptions<S extends z.ZodTypeAny, R> {
/** See {@link ToolDescriptor.injectStreamingController}. */
injectStreamingController?: ToolDescriptor['injectStreamingController'];
/** Runs the tool. Arguments are typed from `input`; the return type is preserved on the result. */
execute: (args: z.output<S>, ctx: ToolExecutionOptions) => R | Promise<R>;
execute: (args: z.output<S>, ctx: ToolExecutionContext) => R | Promise<R>;
}

/**
Expand All @@ -45,7 +44,7 @@ export interface DefinedTool<S extends z.ZodTypeAny = z.ZodTypeAny, O = unknown>
readonly input: S;
/** Same schema as `input`. */
readonly inputSchema: S;
execute(args: z.output<S>, ctx: ToolExecutionOptions): Promise<O>;
execute(args: z.output<S>, ctx: ToolExecutionContext): Promise<O>;
/** Type-only marker; never set at runtime. */
readonly _types?: { input: z.output<S>; output: O };
}
Expand Down Expand Up @@ -131,13 +130,14 @@ export function defineTool<S extends z.ZodTypeAny, R>(
): DefinedTool<S, Awaited<R>> {
assertValidOptions(opts);

const execute = async (args: z.output<S>, ctx: ToolExecutionOptions): Promise<Awaited<R>> =>
const execute = async (args: z.output<S>, ctx: ToolExecutionContext): Promise<Awaited<R>> =>
await opts.execute(args, ctx);
// Hand-built `ai` v4 Tool shape (its `tool()` is the identity function).
const legacyTool: ToolDescriptor['tool'] = {
description: opts.description,
parameters: opts.input,
execute,
// legacy (.tool): `ai` v4 types `messages` as CoreMessage[]; the SDK always passes ours. Removed in D26.
execute: execute as ToolDescriptor['tool']['execute'],
};
const defined: DefinedTool<S, Awaited<R>> = {
name: opts.name,
Expand Down
4 changes: 2 additions & 2 deletions src/types/agent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CoreMessage } from 'ai';
import type { Message } from '../providers/llm';
import { ToolConfiguration } from './tool';
import { AgentFlow } from './flow';

Expand Down Expand Up @@ -52,7 +52,7 @@ export interface AgentConfig {
export interface AgentExecutionOptions {
streaming?: boolean;
sessionId: string;
messages: CoreMessage[];
messages: Message[];
attachments?: any[];
locale?: string;
timezone?: string;
Expand Down
Loading
Loading