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
30 changes: 18 additions & 12 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,17 +127,24 @@ The mode is set at construction (`mode` on `CuaAgent`/`CuaAgentHarness`,
TUI's `/mode` command), which refreshes CUA-owned tools and the default
system prompt.

**Native tools.** `resolveCuaRuntimeSpec(model, { nativeTool })` drives an
Anthropic model through its provider-defined tool schema instead of the
canonical function tools: `computer_20260701` pairs with `computer` mode and
`browser_20260701` with `browser` mode (mismatches throw, mirroring the API's
own rejection of mixed frames). The spec routes the model to a CUA-owned api
id; the registered `anthropic` provider dispatches it to pi's builtin
`anthropic-messages` transport with the tool's `anthropic-beta` header, an
`onPayload` hook swaps the placeholder tool for the native declaration, and
`providers/anthropic/native.ts` maps incoming `tool_use` inputs onto the same
canonical actions the mode uses — so canonical vs native is purely a wire
format difference over one execution path.
**Native tools.** `resolveCuaRuntimeSpec(model, { nativeTool })` can drive an
Anthropic model through an allowlisted, Anthropic-API-only early-access tool
schema instead of the canonical function tools: `computer_20260701` pairs with
`computer` mode and `browser_20260701` with `browser` mode. Model and mode
mismatches throw locally before a browser is provisioned. The live-verified
model families are `claude-fable-5`, `claude-opus-4-8`, and `claude-sonnet-5`
for `computer_20260701`; only `claude-opus-4-8` and `claude-sonnet-5` support
`browser_20260701`. The API key's organization must also have the matching
beta entitlement.

The spec routes the model to a CUA-owned api id; the registered `anthropic`
provider dispatches it to pi's builtin `anthropic-messages` transport with the
tool-specific `anthropic-beta` header, an `onPayload` hook swaps the placeholder
tool for the native declaration, and `providers/anthropic/native.ts` maps
incoming `tool_use` inputs onto the same canonical actions the mode uses. The
runtime spec also carries the native tool's stop-on-first-failure result text,
which cua-agent applies without a provider conditional. Canonical vs native is
therefore a wire-format and turn-contract difference over one execution path.

## Layers

Expand Down Expand Up @@ -353,7 +360,6 @@ flowchart LR

| Feature | Status | Notes |
| -------------------------------------------------- | -------- | ------------------------------------------------- |
| Anthropic `hold_key` / `zoom` | deferred | Translator returns errors so the model adapts |
| `--local` Docker-backed browser | deferred | Remote Kernel cloud only |
| pi-tui `SelectList`-based session picker for `-r` | deferred | Plain readline picker today |
| Auto-compaction in the harness run loop | deferred | Manual `/compact` from the TUI; `shouldCompact` + `estimateContextTokens` are available from cua-agent re-exports for a future auto-trigger |
7 changes: 7 additions & 0 deletions packages/agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- Native Anthropic multi-action turns now stop after the first failed tool call.
Every remaining call in that assistant turn receives the provider-required
error result instead of executing against stale browser state. This applies
to both `CuaAgent` and `CuaAgentHarness` via provider-neutral runtime data.

## 0.7.0 - 2026-07-17

- `CuaAgent` and `CuaAgentHarness` support Moonshot Kimi K3
Expand Down
66 changes: 66 additions & 0 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ class CuaRuntimeController {
return this.runtimeSpec.mode;
}

get stopOnFirstToolFailureMessage(): string | undefined {
return this.runtimeSpec.stopOnFirstToolFailureMessage;
}

setMode(mode: CuaMode): void {
if (mode === this.runtimeSpec.mode) return;
this.beginSwitch(this.resolveSpec(this.runtimeSpec.model, mode));
Expand Down Expand Up @@ -509,9 +513,15 @@ export class CuaAgent extends Agent {
};
return retryingStream(model, context, optionsWithCuaRuntime);
};
const guardedToolHooks = stopToolTurnAfterFailure(
runtime.stopOnFirstToolFailureMessage,
agentOptions.beforeToolCall,
agentOptions.afterToolCall,
);

super({
...agentOptions,
...guardedToolHooks,
getApiKey: agentOptions.getApiKey ?? getCuaEnvApiKey,
streamFn: wrappedStreamFn,
transformContext: async (messages, signal) =>
Expand Down Expand Up @@ -654,6 +664,8 @@ export class CuaAgentHarness<
private requestedActiveToolNames?: string[];
private emptyResponseRecoveryAttempts = 0;
private hasPendingActiveQueue = false;
private toolTurnFailed = false;
private removeToolFailureGuard?: () => void;

constructor(options: CuaAgentHarnessOptions<TSkill, TPromptTemplate>) {
const {
Expand Down Expand Up @@ -705,6 +717,20 @@ export class CuaAgentHarness<

this.runtime = runtime;
this.requestedActiveToolNames = activeToolNames;
if (runtime.stopOnFirstToolFailureMessage) {
this.installToolFailureGuard(runtime.stopOnFirstToolFailureMessage);
this.subscribe((event) => {
if (event.type === "message_end" && event.message.role === "assistant") {
this.toolTurnFailed = false;
// Harness hooks are last-result-wins. Reinsert the guard after caller
// hooks at the start of each assistant tool turn so it cannot be
// accidentally overridden once a prior action has failed.
this.installToolFailureGuard(runtime.stopOnFirstToolFailureMessage!);
} else if (event.type === "tool_execution_end" && event.isError) {
this.toolTurnFailed = true;
}
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harness toolTurnFailed not reset

Medium Severity

CuaAgentHarness now tracks toolTurnFailed from subscribe() for native stop-on-first-failure, but it is only cleared on assistant message_end, not in before_agent_start. Reused harnesses can carry toolTurnFailed === true into the next prompt() after an aborted or incomplete turn, so the guard may block the first tool call of a new user prompt with the skip message even though no earlier action failed in that turn.

Fix in Cursor Fix in Web

Triggered by learned rule: Harness per-prompt state must be fully reset in before_agent_start

Reviewed by Cursor Bugbot for commit a88c3a3. Configure here.

if (recovery && recovery.maxAttempts > 0) {
this.on("before_agent_start", () => {
this.emptyResponseRecoveryAttempts = 0;
Expand All @@ -727,6 +753,13 @@ export class CuaAgentHarness<
});
}

private installToolFailureGuard(message: string): void {
this.removeToolFailureGuard?.();
this.removeToolFailureGuard = this.on("tool_call", () =>
this.toolTurnFailed ? { block: true, reason: message } : undefined,
);
}

private async recoverFromEmptyResponse(
recovery: CuaEmptyResponseRecoveryOptions,
signal?: AbortSignal,
Expand Down Expand Up @@ -809,3 +842,36 @@ function composeOnPayload(first: AgentOptions["onPayload"], second: AgentOptions
return second(afterFirst ?? payload, modelRef);
};
}

function stopToolTurnAfterFailure(
message: string | undefined,
before: AgentOptions["beforeToolCall"],
after: AgentOptions["afterToolCall"],
): Pick<AgentOptions, "beforeToolCall" | "afterToolCall"> {
if (!message) return { beforeToolCall: before, afterToolCall: after };
const failedTurns = new WeakSet<object>();

return {
beforeToolCall: async (context, signal) => {
if (failedTurns.has(context.assistantMessage)) return { block: true, reason: message };
try {
const result = await before?.(context, signal);
if (result?.block) failedTurns.add(context.assistantMessage);
return result;
} catch (error) {
failedTurns.add(context.assistantMessage);
throw error;
}
},
afterToolCall: async (context, signal) => {
try {
const result = await after?.(context, signal);
if (result?.isError ?? context.isError) failedTurns.add(context.assistantMessage);
return result;
} catch (error) {
failedTurns.add(context.assistantMessage);
throw error;
}
},
};
}
91 changes: 87 additions & 4 deletions packages/agent/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,11 @@ function createScriptedStream(texts: Array<string | undefined>, contexts?: Array
return { streamFn, calls: () => providerCalls };
}

function createModelsFromStream(streamFn: StreamFn) {
function createModelsFromStream(streamFn: StreamFn, provider = "openai") {
const models = createCuaModels();
models.setProvider({
id: "openai",
name: "scripted openai",
id: provider,
name: `scripted ${provider}`,
auth: {
apiKey: {
name: "test key",
Expand Down Expand Up @@ -259,7 +259,7 @@ describe("CuaAgent", () => {
client,
nativeTool: { type: "browser_20260701" },
initialState: {
model: "anthropic:claude-opus-4-5",
model: "anthropic:claude-opus-4-8",
},
});
expect(agent.getMode()).toBe("browser");
Expand Down Expand Up @@ -511,6 +511,47 @@ describe("CuaAgent", () => {
expect(fedBack!.content.some((block) => block.type === "image" && block.mimeType === "image/png")).toBe(true);
});

it("stops a native multi-action turn after its first failed action", async () => {
const contexts: Context[] = [];
let providerCalls = 0;
const streamFn: StreamFn = (model, context) => {
contexts.push({ ...context, messages: structuredClone(context.messages) });
const stream = createAssistantMessageEventStream();
const message = createAssistantMessage(model);
if (providerCalls++ === 0) {
message.content = [
{ type: "toolCall", id: "tool-1", name: "browser", arguments: { action: "warp" } },
{ type: "toolCall", id: "tool-2", name: "browser", arguments: { action: "navigate", url: "https://example.com" } },
];
message.stopReason = "toolUse";
} else {
message.content = [{ type: "text", text: "done" }];
}
stream.push({ type: "start", partial: message });
stream.push({ type: "done", reason: message.stopReason, message });
stream.end(message);
return stream;
};
const agent = new CuaAgent({
browser,
client,
streamFn,
nativeTool: { type: "browser_20260701" },
initialState: { model: "anthropic:claude-opus-4-8" },
});

await agent.prompt("run two browser actions");

const results = contexts[1]!.messages.filter((message) => message.role === "toolResult");
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({ toolCallId: "tool-1", isError: true });
expect(results[1]).toMatchObject({
toolCallId: "tool-2",
isError: true,
content: [{ type: "text", text: "Not executed: an earlier action in this turn failed." }],
});
});

it("applies screenshot projection after a caller context transform", async () => {
const history: AgentMessage[] = [];
for (let index = 1; index <= 5; index += 1) {
Expand Down Expand Up @@ -1160,6 +1201,48 @@ describe("CuaAgentHarness", () => {
expect(calls).toBe(2);
});

it("stops a native harness turn after its first failed action", async () => {
const contexts: Context[] = [];
let calls = 0;
const streamFn: StreamFn = (model, context) => {
contexts.push({ ...context, messages: structuredClone(context.messages) });
const stream = createAssistantMessageEventStream();
const message = createAssistantMessage(model);
if (calls++ === 0) {
message.content = [
{ type: "toolCall", id: "tool-1", name: "computer", arguments: { action: "warp" } },
{ type: "toolCall", id: "tool-2", name: "computer", arguments: { action: "screenshot" } },
];
message.stopReason = "toolUse";
} else {
message.content = [{ type: "text", text: "done" }];
}
stream.push({ type: "start", partial: message });
stream.push({ type: "done", reason: message.stopReason, message });
stream.end(message);
return stream;
};
const harness = new CuaAgentHarness({
...(await createHarnessServices()),
browser,
client,
model: "anthropic:claude-opus-4-8",
models: createModelsFromStream(streamFn, "anthropic"),
nativeTool: { type: "computer_20260701" },
});

await harness.prompt("run two computer actions");

const results = contexts[1]!.messages.filter((message) => message.role === "toolResult");
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({ toolCallId: "tool-1", isError: true });
expect(results[1]).toMatchObject({
toolCallId: "tool-2",
isError: true,
content: [{ type: "text", text: "Not executed: an earlier computer action in this turn failed." }],
});
});

it.each([[-1], [1.5], [Number.POSITIVE_INFINITY], [Number.NaN]])(
"rejects invalid maxAttempts %s",
async (maxAttempts) => {
Expand Down
17 changes: 15 additions & 2 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

- Fail locally when `computer_20260701` or `browser_20260701` is paired with
an Anthropic model that the live early-access API rejects. The verified
computer models are Claude Fable 5, Claude Opus 4.8, and Claude Sonnet 5;
the verified browser models are Claude Opus 4.8 and Claude Sonnet 5.
- Carry each native tool's stop-on-first-failure result contract through
`CuaRuntimeSpec`, allowing cua-agent to skip unsafe remaining actions without
a provider conditional.
- Add opt-in live integration coverage for the beta header, native declaration,
and pi-ai serialization path of both July 2026 tools.

## 0.7.0 - 2026-07-17

- Added Moonshot Kimi K3 computer-use support: `moonshotai:kimi-k3`
Expand Down Expand Up @@ -47,8 +59,9 @@ Introduces action planes (modes) and Anthropic native computer-use tools.
(`cuaToolNameForAction`), descriptions, schemas, and system prompts.
- New `nativeTool` option drives Anthropic models through their native
computer-use declarations: `computer_20260701` (computer mode, with
`enable_zoom`) and `browser_20260701` (browser mode) behind
`anthropic-beta: computer-use-2026-07-01`.
`enable_zoom`) behind `anthropic-beta: computer-use-2026-07-01`, and
`browser_20260701` (browser mode) behind
`anthropic-beta: browser-use-2026-07-01`.
- JavaScript execution is on by default: `browser_evaluate` is part of the
default browser/hybrid action sets, and native `browser_20260701`
declarations default `enable_javascript_exec` to true (an explicit value on
Expand Down
41 changes: 39 additions & 2 deletions packages/ai/src/native-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,29 @@ interface NativeToolInfo {
provider: "anthropic";
betaHeader: string;
defaultName: string;
/** Model families verified against the live early-access API. */
supportedModelFamilies: readonly string[];
/** Required error result for actions skipped after an earlier action failed. */
skippedAfterFailureMessage: string;
}

const NATIVE_TOOL_INFO: Record<CuaNativeToolType, NativeToolInfo> = {
computer_20260701: { mode: "computer", provider: "anthropic", betaHeader: "computer-use-2026-07-01", defaultName: "computer" },
browser_20260701: { mode: "browser", provider: "anthropic", betaHeader: "browser-use-2026-07-01", defaultName: "browser" },
computer_20260701: {
mode: "computer",
provider: "anthropic",
betaHeader: "computer-use-2026-07-01",
defaultName: "computer",
supportedModelFamilies: ["claude-fable-5", "claude-opus-4-8", "claude-sonnet-5"],
skippedAfterFailureMessage: "Not executed: an earlier computer action in this turn failed.",
},
browser_20260701: {
mode: "browser",
provider: "anthropic",
betaHeader: "browser-use-2026-07-01",
defaultName: "browser",
supportedModelFamilies: ["claude-opus-4-8", "claude-sonnet-5"],
skippedAfterFailureMessage: "Not executed: an earlier action in this turn failed.",
},
};

/** The {@link CuaMode} a native tool requires. */
Expand All @@ -85,6 +103,8 @@ export interface ResolvedCuaNativeTool {
name: string;
/** Required `anthropic-beta` header value. */
betaHeader: string;
/** Error result required for later tool calls after the first failure in a turn. */
skippedAfterFailureMessage: string;
mode: CuaMode;
}

Expand All @@ -101,6 +121,12 @@ export function resolveNativeTool(spec: CuaNativeToolSpec, model: Model<Api>, mo
if (model.provider !== info.provider) {
throw new Error(`native tool "${spec.type}" requires an ${info.provider} model paired with mode "${info.mode}"; got provider "${model.provider}"`);
}
if (!info.supportedModelFamilies.some((family) => isModelFamily(model.id, family))) {
throw new Error(
`native tool "${spec.type}" is an allowlisted Anthropic API beta and does not support model "${model.id}"; ` +
`supported model families: ${info.supportedModelFamilies.join(", ")}`,
);
}
if (mode !== info.mode) {
throw new Error(`native tool "${spec.type}" requires mode "${info.mode}"; got "${mode}"`);
}
Expand All @@ -110,6 +136,17 @@ export function resolveNativeTool(spec: CuaNativeToolSpec, model: Model<Api>, mo
declaration: { ...spec, name },
name,
betaHeader: info.betaHeader,
skippedAfterFailureMessage: info.skippedAfterFailureMessage,
mode,
};
}

function isModelFamily(modelId: string, family: string): boolean {
const id = modelId.toLowerCase();
if (id === family) return true;
if (!id.startsWith(`${family}-`)) return false;
return id
.slice(family.length + 1)
.split("-")
.every((segment) => /^\d+$/.test(segment));
}
2 changes: 1 addition & 1 deletion packages/ai/src/providers/anthropic/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function withAnthropicBetaHeader<T extends StreamOptions>(options: T | un
// The native tool's input schema is Anthropic-defined and validated
// server-side; the local placeholder schema stays permissive and the
// executor validates during mapping.
const NativeActionSchema = Type.Object({ action: Type.String() }, { additionalProperties: true });
const NativeActionSchema = Type.Object({ action: Type.Optional(Type.String()) }, { additionalProperties: true });

/**
* Build the single execution adapter for a native Anthropic tool: tool calls
Expand Down
Loading
Loading