Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
2,313 changes: 458 additions & 1,855 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
"typecheck": "tsc -b"
},
"dependencies": {
"@earendil-works/pi-agent-core": "0.80.3",
"@earendil-works/pi-ai": "0.80.3",
"@earendil-works/pi-agent-core": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-agent-core-0.80.7-anchor.3d8f743.tgz",
"@earendil-works/pi-ai": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz",
"@onkernel/cua-ai": "0.6.0",
"@onkernel/sdk": "0.49.0",
"sharp": "^0.34.5"
Expand Down
35 changes: 25 additions & 10 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ export class CuaAgentHarness<
private requestedActiveToolNames?: string[];
private emptyResponseRecoveryAttempts = 0;
private hasPendingActiveQueue = false;
private toolMutation = Promise.resolve();

constructor(options: CuaAgentHarnessOptions<TSkill, TPromptTemplate>) {
const {
Expand Down Expand Up @@ -730,6 +731,16 @@ export class CuaAgentHarness<
this.emptyResponseRecoveryAttempts += 1;
}

/** Serialize tool-list changes with model switches. */
async mutateTools<T>(mutation: () => Promise<T>): Promise<T> {
const result = this.toolMutation.then(mutation);
this.toolMutation = result.then(
() => {},
() => {},
);
return result;
}

/**
* Mirror pi `AgentHarness.setModel()` while accepting CUA model refs.
*
Expand All @@ -740,16 +751,20 @@ export class CuaAgentHarness<
override async setModel(model: CuaRuntimeInput): Promise<void> {
this.runtime.setModel(model);
const tools = this.runtime.tools();
try {
await super.setTools(tools, this.requestedActiveToolNames ?? tools.map((tool) => tool.name));
} catch (err) {
// The pre-switch tools stay exposed, so restore the runtime they are
// bound to — including its still-live translator.
this.runtime.rollbackSwitch();
throw err;
}
this.runtime.commitSwitch();
await super.setModel(this.runtime.model);
const resolvedModel = this.runtime.model;
await this.mutateTools(async () => {
try {
await super.setTools(
tools,
this.requestedActiveToolNames ?? tools.map((tool) => tool.name),
);
} catch (err) {
this.runtime.rollbackSwitch();
throw err;
}
this.runtime.commitSwitch();
});
await super.setModel(resolvedModel);
Comment thread
cursor[bot] marked this conversation as resolved.
}

override async setActiveTools(toolNames: string[]): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"test:integration": "vitest --run --config vitest.integration.config.ts"
},
"dependencies": {
"@earendil-works/pi-ai": "0.80.3",
"@earendil-works/pi-ai": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz",
"@tzafon/lightcone": "^0.7.0",
"openai": "^6.26.0"
},
Expand Down
215 changes: 215 additions & 0 deletions packages/ai/test/dynamic-tool-payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentTool, Context, Message, Model } from "@earendil-works/pi-ai";
import { createServer } from "node:http";
import { cuaModels, getCuaModel, resolveCuaRuntimeSpec } from "../src/index";
import { ANTHROPIC_NATIVE_BROWSER_MESSAGES_API } from "../src/providers/anthropic/native";

const { openaiCreate } = vi.hoisted(() => ({
openaiCreate: vi.fn(),
}));

vi.mock("openai", () => ({
default: class {
responses = {
create: (...args: unknown[]) => {
openaiCreate(...args);
return {
withResponse: async () => {
throw new Error("stop after payload capture");
},
};
},
};
},
}));

const dynamicTool: AgentTool = {
name: "learned_tool",
label: "Learned tool",
description: "learned",
parameters: { type: "object", properties: {} },
execute: async () => ({ content: [], details: {} }),
};
const addTool: AgentTool = { ...dynamicTool, name: "add_tool", label: "Add tool" };

function addMessages(api: string, responseId?: string): Message[] {
return [
{
role: "assistant",
content: [{ type: "toolCall", id: "add_1", name: "add_tool", arguments: {} }],
api,
provider: api.startsWith("openai") ? "openai" : "anthropic",
model: api.startsWith("openai") ? "gpt-5.5" : "claude-sonnet-4-5",
responseId,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "toolUse",
timestamp: 0,
},
{
role: "toolResult",
toolCallId: "add_1",
toolName: "add_tool",
content: [{ type: "text", text: "added" }],
addedToolNames: ["learned_tool"],
isError: false,
timestamp: 0,
},
];
}

async function capturePayload(
model: Model<any>,
context: Context,
transform?: (payload: unknown, model: Model<any>) => unknown | Promise<unknown>,
): Promise<Record<string, any>> {
let payload: Record<string, any> | undefined;
await cuaModels()
.streamSimple(model, context, {
apiKey: "test",
onPayload: async (value, payloadModel) => {
const transformed = transform
? ((await transform(value, payloadModel)) ?? value)
: value;
payload = transformed as Record<string, any>;
return transformed;
},
})
.result();
if (!payload) throw new Error("payload was not captured");
return payload;
}

describe("message-anchored dynamic tool payloads", () => {
it("uses OpenAI tool search while preserving response threading", async () => {
const model = getCuaModel("openai:gpt-5.5");
const payload = await capturePayload(model, {
systemPrompt: "test",
tools: [addTool, dynamicTool],
messages: addMessages(model.api, "resp_add"),
});
expect(payload.store).toBe(true);
expect(payload.previous_response_id).toBe("resp_add");
const searchOutput = payload.input.find((item: any) => item.type === "tool_search_output");
expect(searchOutput.tools).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "learned_tool", defer_loading: true })]),
);
expect(payload.input).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "tool_search_call" }),
expect.objectContaining({ type: "tool_search_output" }),
]),
);
});

it("eagerly declares the tool after the add marker is pruned by the next response anchor", async () => {
const model = getCuaModel("openai:gpt-5.5");
const messages = addMessages(model.api, "resp_add");
messages.push(
{
role: "assistant",
content: [{ type: "toolCall", id: "learn_1", name: "learned_tool", arguments: {} }],
api: model.api,
provider: "openai",
model: model.id,
responseId: "resp_learned",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "toolUse",
timestamp: 0,
},
{ role: "toolResult", toolCallId: "learn_1", toolName: "learned_tool", content: [], isError: false, timestamp: 0 },
);
const payload = await capturePayload(model, { systemPrompt: "test", tools: [addTool, dynamicTool], messages });
expect(payload.previous_response_id).toBe("resp_learned");
expect(payload.tools).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "learned_tool" })]),
);
expect(payload.tools.find((tool: any) => tool.name === "learned_tool").defer_loading).toBeUndefined();
});

it("uses Anthropic references for supported models and eager tools for Haiku", async () => {
const supported = getCuaModel("anthropic:claude-sonnet-4-5");
const supportedPayload = await capturePayload(supported, {
systemPrompt: "test",
tools: [addTool, dynamicTool],
messages: addMessages(supported.api),
});
expect(supportedPayload.tools).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "learned_tool", defer_loading: true })]),
);
expect(JSON.stringify(supportedPayload.messages)).toContain("tool_reference");

const haiku = {
...supported,
id: "claude-haiku-4",
compat: { ...supported.compat, supportsToolReferences: false },
};
const haikuPayload = await capturePayload(haiku, {
systemPrompt: "test",
tools: [addTool, dynamicTool],
messages: addMessages(haiku.api),
});
expect(JSON.stringify(haikuPayload.messages)).not.toContain("tool_reference");
expect(haikuPayload.tools).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "learned_tool" })]),
);
});

it("preserves dynamic tools and beta headers through Anthropic native routing", async () => {
let requestHeaders: Record<string, string | string[] | undefined> = {};
const server = createServer((request, response) => {
requestHeaders = request.headers;
response.writeHead(500, { "content-type": "application/json" });
response.end('{"error":{"message":"captured"}}');
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
try {
const address = server.address();
if (!address || typeof address === "string")
throw new Error("test server did not bind");
const spec = resolveCuaRuntimeSpec(
"anthropic:claude-sonnet-4-5",
{ nativeTool: { type: "browser_20260701" } },
);
const model = {
...spec.model,
api: ANTHROPIC_NATIVE_BROWSER_MESSAGES_API,
baseUrl: `http://127.0.0.1:${address.port}`,
} as Model<any>;
const nativePlaceholder = {
...dynamicTool,
...spec.toolDefinitions[0],
} as AgentTool;
const payload = await capturePayload(
model,
{
systemPrompt: "test",
tools: [nativePlaceholder, addTool, dynamicTool],
messages: addMessages("anthropic-messages"),
},
spec.onPayload,
);
expect(payload.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "learned_tool",
defer_loading: true,
}),
]),
);
expect(payload.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "browser_20260701" }),
]),
);
expect(requestHeaders["anthropic-beta"]).toContain(
"browser-use-2026-07-01",
);
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
});
});
10 changes: 6 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ cua do "buy a pair of socks on amazon" --max-steps 20
cua models
cua models -p openai
cua --print --model openai:gpt-5.5 "..."
cua --print --model anthropic:claude-opus-4-7 "..."
cua --print --model anthropic:claude-opus-4-8 "..."
cua --print --model google:gemini-3-flash-preview "..."
cua --print --model yutori:n1.5-latest "..."

Expand Down Expand Up @@ -170,9 +170,11 @@ loader are appended to the system prompt and listed in the TUI's
context files still load, since they describe the project rather than
add agent capabilities.

pi *extensions* are not executed by `cua`: extensions bind into pi's
`AgentSession`, and `cua` drives the lower-level `AgentHarness`
directly. Installed-package skills and context still load.
Project and user pi extensions load at startup and can be refreshed with
`/reload`. `--self-extend` additionally exposes `add_tool`, which accepts one
constrained tool definition, persists it under `.agents/extensions`, and makes
it callable on the next model turn in the same run. Authored execute code is
trusted local code, not sandboxed.

## Image protocol

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
"typecheck": "tsc -b"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "0.80.3",
"@earendil-works/pi-tui": "0.80.3",
"@earendil-works/pi-coding-agent": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-coding-agent-0.80.7-anchor.3d8f743.tgz",
"@earendil-works/pi-tui": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-tui-0.80.7-anchor.3d8f743.tgz",
"@onkernel/cua-agent": "0.6.0",
"@onkernel/cua-ai": "0.6.0",
"@onkernel/sdk": "0.49.0"
Expand Down
14 changes: 2 additions & 12 deletions packages/cli/src/action/harness-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,24 +107,14 @@ export async function runAction(
}

async function maybeInitialScreenshot(opts: HarnessRunOptions): Promise<ImageContent[] | undefined> {
// `skipInitialScreenshot` is decided once at startup (before extensions load),
// so an extension's startup message can't suppress the user's first-turn frame.
if (opts.skipInitialScreenshot) return undefined;
const hasPriorTurn = await sessionHasPriorTurn(opts.session);
if (hasPriorTurn) return undefined;
const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id);
if (!png) return undefined;
return [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }];
}

async function sessionHasPriorTurn(session: Session): Promise<boolean> {
const entries = await session.getBranch();
for (const entry of entries) {
if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) {
return true;
}
}
return false;
}

function textFromAssistant(message: AssistantMessage): string {
const parts: string[] = [];
for (const block of message.content) {
Expand Down
Loading
Loading