Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/tidy-moons-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/codemode": patch
---

Add client-side tool calls to codemode via durable pause/resolve. A tool annotated with `resolution: "client"` pauses the run like an approval, but is never executed server-side: the host supplies its result with the new `resolve({ executionId, seq, result })` on the runtime handle (or the standalone `resolveCodemode`), and the run resumes with the code seeing the client's value. `ToolSetConnector` gains a `clientTools: "pause"` option that exposes execute-less AI SDK tools (client-side / provider-executed) as client-resolved tools instead of skipping them.
48 changes: 48 additions & 0 deletions docs/codemode/approvals.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,14 @@ type PendingAction = {
connector: string;
method: string;
args: unknown;
resolution?: "approval" | "client";
};
```

Every outcome also carries `calls` — the execution's [tool-call log](./runtime.md#the-tool-call-log) as it stands at the end of the pass: each connector call and `codemode.step`, with args, recorded result, approval requirement, and state. Render it to show the user what a run actually did (and what is still pending) without a separate `executions()` round trip.

`resolution` tells the UI what kind of answer the pending action needs: `"approval"` (the default) is a yes/no — approve and the host executes the tool server-side; `"client"` means the tool never executes server-side and the run stays paused until the host supplies the tool's result via `resolve()` — see [Client-resolved tools](#client-resolved-tools).

## Resolving approvals

The agent drives resolution through the runtime handle:
Expand All @@ -102,6 +105,10 @@ await runtime.pending();
// Approve the pending action(s) and continue
await runtime.approve({ executionId });

// Supply a client-resolved call's result and continue — for pending actions
// with resolution: "client" (see below); approve() cannot satisfy these.
await runtime.resolve({ executionId, seq, result });

// Reject — ends the execution. Does NOT undo actions already applied earlier
// in the same run; call rollback() for that. Returns false if the action was
// no longer pending (approved/rejected elsewhere) — check it before telling
Expand Down Expand Up @@ -132,6 +139,47 @@ export class Chat extends AIChatAgent<Env> {
}
```

## Client-resolved tools

Some tools can't execute server-side at all — their result comes from the client (a browser API like `getUserTimezone`, an `ask_user` prompt, a device sensor). Mark them `resolution: "client"`: calling one pauses the run durably, exactly like an approval, but instead of approving you **supply the result**:

```ts
protected tools() {
return {
get_user_timezone: {
description: "The user's IANA timezone, read from the browser.",
requiresApproval: true,
resolution: "client",
execute: () => {
throw new Error("client-resolved — supplied via resolve()");
}
}
};
}
```

For an AI SDK `ToolSet` wrapped in a `ToolSetConnector`, execute-less tools are skipped by default (advertising a method the sandbox can't call would send the model down a dead end). Opt them in instead with `clientTools: "pause"` — they're then exposed in the sandbox and the generated types as client-resolved tools:

```ts
new ToolSetConnector(ctx, { name: "tools", tools, clientTools: "pause" });
```

The flow mirrors approvals, with `resolve()` in place of `approve()`:

```
Model code calls tools.get_user_timezone()
→ run pauses; pending action carries resolution: "client"
→ agent surfaces it to the client (same channel as approvals)
→ client computes the value and calls back
→ runtime.resolve({ executionId, seq, result: "Europe/London" })
→ the result is recorded as the call's value; the run replays and continues —
the model's code sees it as the call's ordinary return value
```

`approve()` can never satisfy a client-resolved call — on such a run it just returns the same `paused` outcome again. `resolve()` has the same safety properties as `reject()`: it only lands on an action that is still pending on a paused run, and a stale/duplicate resolve returns an error outcome rather than throwing or double-applying. A client that never answers is covered by [`expirePaused`](./runtime.md#retention), same as an approval nobody answers.

The client-supplied result is recorded in the durable log and replayed as ground truth — it is **trusted**. Validate it at the agent boundary (the `@callable` method) if the client isn't.

## Rollback

Rollback reverts **all** applied actions that have a `revert` — not only approval-gated ones — in reverse order. Define `revert` on the tool (or override `revertAction`); it returns whether a revert actually ran, and the runtime marks only those entries as reverted:
Expand Down
3 changes: 2 additions & 1 deletion docs/codemode/connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ type ConnectorTool = {
inputSchema?: JSONSchema7; // defaults to an open object
outputSchema?: JSONSchema7;
requiresApproval?: boolean; // omit to execute immediately
resolution?: "approval" | "client"; // how a paused call is satisfied
execute: (
args: unknown,
ctx?: ToolExecuteContext
Expand All @@ -81,7 +82,7 @@ type ConnectorTool = {

`execute`/`revert` receive an optional `ctx` carrying the `executionId` of the run they belong to. The id is stable across a run's pause/resume passes, so it's the key to use for any resource scoped to the whole execution (see [Per-execution resources](#per-execution-resources)).

`requiresApproval: true` pauses the run for [approval](./approvals.md). `revert` enables [rollback](./runtime.md#rollback). Everything else executes immediately and is recorded in the durable log.
`requiresApproval: true` pauses the run for [approval](./approvals.md). `resolution: "client"` marks a [client-resolved tool](./approvals.md#client-resolved-tools) — it never executes server-side; the run pauses until the host supplies the result via `resolve()`. `revert` enables [rollback](./runtime.md#rollback). Everything else executes immediately and is recorded in the durable log.

AI SDK tools are shape-compatible — an existing `ToolSet` can be returned from `tools()` directly:

Expand Down
2 changes: 2 additions & 0 deletions docs/codemode/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const runtime = createCodemodeRuntime({
| `runtime.tool(options?)` | The single model-facing AI SDK tool, `codemode({ code })` |
| `runtime.pending(executionId?)` | Actions awaiting approval — drives approval UIs; no id aggregates all paused runs |
| `runtime.approve({ executionId })` | Approve the pending action and continue via replay |
| `runtime.resolve({ executionId, seq, result })` | Supply a [client-resolved](./approvals.md#client-resolved-tools) call's result and continue via replay |
| `runtime.reject({ seq, executionId })` | Reject a pending action; ends the execution. Returns `false` if it was a no-op (action no longer pending — approved or rejected elsewhere) |
| `runtime.rollback({ executionId })` | Revert applied actions in reverse order via each tool's `revert` |
| `runtime.expirePaused({ maxAgeMs? })` | Expire stale awaiting-approval runs and reclaim their resources |
Expand Down Expand Up @@ -132,6 +133,7 @@ type ToolLogEntry = {
args: unknown;
result?: unknown; // recorded for replay (never for ephemeral entries)
requiresApproval: boolean;
resolution?: "approval" | "client"; // how a pending entry is satisfied
ephemeral?: boolean; // replay: "reexecute" — re-runs instead of replaying
state: "executing" | "applied" | "pending" | "reverted";
};
Expand Down
12 changes: 10 additions & 2 deletions packages/codemode/src/connectors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export type ConnectorTool = {
outputSchema?: JSONSchema7;
/** Pause for user approval before executing. Omit to execute immediately. */
requiresApproval?: boolean;
/**
* How the paused call is satisfied. `"approval"` (the default when
* `requiresApproval` is set) executes the tool server-side once approved;
* `"client"` keeps the run paused until the host supplies the result via
* `resolve()` — the tool's `execute` never runs server-side.
*/
resolution?: "approval" | "client";
/**
* Replay policy for the durable log. `"reexecute"` marks the call ephemeral:
* its result is never stored durably, and a replay re-executes the call
Expand Down Expand Up @@ -167,10 +174,11 @@ export abstract class CodemodeConnector<
inputSchema: toolInputSchema(t),
outputSchema: t.outputSchema
};
if (t.requiresApproval || t.replay === "reexecute") {
if (t.requiresApproval || t.replay === "reexecute" || t.resolution) {
annotations[name] = {
...(t.requiresApproval ? { requiresApproval: true } : {}),
...(t.replay === "reexecute" ? { replay: "reexecute" as const } : {})
...(t.replay === "reexecute" ? { replay: "reexecute" as const } : {}),
...(t.resolution ? { resolution: t.resolution } : {})
};
}
}
Expand Down
86 changes: 79 additions & 7 deletions packages/codemode/src/connectors/toolset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export interface ToolSetConnectorOptions {
instructions?: string;
/** The AI SDK tools to expose. */
tools: ToolSet;
/**
* What to do with execute-less tools (client-side / provider-executed).
* `"skip"` (the default) excludes them from bindings and types — calling
* one from the sandbox is impossible. `"pause"` includes them as
* client-resolved tools: calling one pauses the run durably until the host
* supplies the result via `resolve()` (they never execute server-side).
*/
clientTools?: "skip" | "pause";
}

export class ToolSetConnector extends CodemodeConnector {
Expand All @@ -45,9 +53,11 @@ export class ToolSetConnector extends CodemodeConnector {

/**
* Only tools with an `execute` function can run inside the sandbox.
* Execute-less tools (client-side / provider-executed) are excluded from
* both the runtime bindings and the generated types — advertising a method
* the sandbox can't call would send the model down a dead end.
* By default (`clientTools: "skip"`), execute-less tools (client-side /
* provider-executed) are excluded from both the runtime bindings and the
* generated types — advertising a method the sandbox can't call would send
* the model down a dead end. With `clientTools: "pause"` they are exposed
* as client-resolved tools instead (see `#clientTools`).
*/
#executableTools(): ToolSet {
const executable: ToolSet = {};
Expand All @@ -59,7 +69,11 @@ export class ToolSetConnector extends CodemodeConnector {
skipped.push(toolName);
}
}
if (skipped.length > 0 && !this.#warnedSkipped) {
if (
skipped.length > 0 &&
this.#options.clientTools !== "pause" &&
!this.#warnedSkipped
) {
this.#warnedSkipped = true;
console.warn(
`[codemode] ToolSetConnector "${this.name()}" skipped tools without ` +
Expand All @@ -70,6 +84,22 @@ export class ToolSetConnector extends CodemodeConnector {
return executable;
}

/**
* Execute-less tools exposed as client-resolved (only with
* `clientTools: "pause"`). Calling one pauses the run durably; the host
* supplies the result via `resolve()`.
*/
#clientTools(): ToolSet {
if (this.#options.clientTools !== "pause") return {};
const client: ToolSet = {};
for (const [toolName, t] of Object.entries(this.#options.tools)) {
if (!("execute" in t && typeof t.execute === "function")) {
client[toolName] = t;
}
}
return client;
}

override name(): string {
return this.#options.name ?? "tools";
}
Expand Down Expand Up @@ -123,18 +153,60 @@ export class ToolSetConnector extends CodemodeConnector {
: (args: unknown) => execute(args)
};
}

// Client-resolved tools (clientTools: "pause"): included so the sandbox
// can call them, but they never execute server-side — the call pauses
// durably and the host supplies the result via resolve(). The execute here
// is a safety net for a path that should be unreachable.
for (const [toolName, t] of Object.entries(this.#clientTools())) {
const name = sanitizeToolName(toolName);
const existing = sources.get(name);
if (existing !== undefined) {
throw new Error(
`Tools "${existing}" and "${toolName}" on ${this.name()} both ` +
`map to "${name}" — rename one of them.`
);
}
sources.set(name, toolName);

const rawSchema =
"inputSchema" in t
? t.inputSchema
: (t as Record<string, unknown>).parameters;
const schema =
rawSchema != null
? asSchema(rawSchema as Parameters<typeof asSchema>[0])
: undefined;

out[name] = {
description: t.description,
inputSchema: schema?.jsonSchema as JSONSchema7 | undefined,
requiresApproval: true,
resolution: "client",
execute: () => {
throw new Error(
`Tool "${toolName}" on ${this.name()} is client-resolved and ` +
`cannot execute server-side — supply its result via resolve().`
);
}
};
}
return out;
}

/**
* Generate the sandbox type block from the original AI SDK schemas (Zod or
* `jsonSchema()` wrappers) rather than the converted JSON Schema, preserving
* field descriptions as `@param` lines. Restricted to the same executable
* subset that `tools()` exposes, so the types never advertise a method the
* field descriptions as `@param` lines. Restricted to the same subset that
* `tools()` exposes — executable tools plus, with `clientTools: "pause"`,
* the client-resolved ones — so the types never advertise a method the
* sandbox can't call.
*/
override async getTypeScriptTypes(): Promise<string> {
return generateTypes(this.#executableTools(), this.name());
return generateTypes(
{ ...this.#executableTools(), ...this.#clientTools() },
this.name()
);
}
}

Expand Down
9 changes: 9 additions & 0 deletions packages/codemode/src/connectors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import type { JsonSchemaToolDescriptors } from "../json-schema-types";
export type ToolAnnotations = {
/** Requires user approval before executing. Unannotated methods execute immediately. */
requiresApproval?: boolean;
/**
* How a paused call is satisfied. Both values imply the run pauses at the
* call. `"approval"` (the default when `requiresApproval` is set) means the
* host executes the tool server-side once approved. `"client"` means the
* tool never executes server-side: the run stays paused until the host
* supplies the tool's result via `resolve()` (e.g. a client-side tool whose
* result comes from the browser).
*/
resolution?: "approval" | "client";
/**
* Replay policy for the durable log. `"reexecute"` marks the call ephemeral:
* its result is never stored, and a replay re-executes the call instead of
Expand Down
1 change: 1 addition & 0 deletions packages/codemode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
type CodemodeRuntimeHandle,
type CodemodeRuntimeToolOptions,
type CodemodeApproveOptions,
type CodemodeResolveOptions,
type CodemodeRejectOptions,
type CodemodeRollbackOptions,
type CodemodeExpireOptions
Expand Down
Loading
Loading