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
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,9 @@ catalog:
- Ordinary function tools stay ordinary.
- Anthropic native browser/computer declarations replace only their own
placeholders and merge required beta headers with caller headers.
- OpenAI native computer uses a CUA-owned Responses adapter and can coexist with
ordinary functions.
- OpenAI streams through pi's builtin Responses transport and its automatic
prompt caching; a CUA-owned adapter handles OpenAI's native computer tool and
tool-search namespace round-trips.
- Tzafon replaces only the selected computer identity and fills declaration
dimensions from the actual viewport.
- Anthropic's native browser tool falls back to an equivalent function-tool
Expand Down
12 changes: 12 additions & 0 deletions packages/agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 0.11.0 - 2026-08-13

- `responseThreading` (`CuaAgentOptions`/`CuaAgentHarnessOptions`) no longer
affects OpenAI models: OpenAI now streams through pi-ai's builtin Responses
transport and its automatic prompt caching regardless of this flag. The
option still governs Google and Tzafon's `previous_response_id`-style
continuation.
- Exempt OpenAI's native computer tool from the tool-result image replay
limit, alongside Tzafon: its `computer_call_output` items must each carry a
screenshot, and stateless replay no longer leaves them in provider-stored
state.

## 0.10.0 - 2026-08-04

Breaking: upgrade `@earendil-works/pi-agent-core` and `@earendil-works/pi-ai`
Expand Down
21 changes: 14 additions & 7 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export type CuaAgentOptions = Omit<AgentOptions, "initialState" | "streamFn"> &
streamFn?: AgentOptions["streamFn"];
emptyResponseRecovery?: CuaEmptyResponseRecoveryOptions;
toolResultImageReplayLimit?: ToolResultImageReplayLimit;
/** Governs Google and Tzafon's `previous_response_id`-style continuation. Every other provider streams through pi's transports and their automatic prompt caching regardless of this flag. Defaults to `true`. */
responseThreading?: boolean;
retry?: CuaRetryOptions;
};
Expand All @@ -112,6 +113,7 @@ type CuaAgentHarnessOptionsBase<
onPayload?: SimpleStreamOptions["onPayload"];
emptyResponseRecovery?: CuaEmptyResponseRecoveryOptions;
toolResultImageReplayLimit?: ToolResultImageReplayLimit;
/** Governs Google and Tzafon's `previous_response_id`-style continuation. Every other provider streams through pi's transports and their automatic prompt caching regardless of this flag. Defaults to `true`. */
responseThreading?: boolean;
retry?: CuaRetryOptions;
};
Expand Down Expand Up @@ -203,7 +205,7 @@ export class CuaAgent {
transformContext: async (messages, signal) => projectToolResultImages(
transformContext ? await transformContext(messages, signal) : messages,
imageReplayLimit,
manager.catalog.incoming.tzafonComputerName,
requiredImageToolNames(manager.catalog.incoming),
),
prepareNextTurnWithContext: async (context, signal) => {
const update = prepareNextTurnWithContext
Expand Down Expand Up @@ -483,7 +485,7 @@ function withCatalogModels(
const contextFor = (context: Context) => projectModelContext(
context,
imageReplayLimit,
manager.catalog.incoming.tzafonComputerName,
requiredImageToolNames(manager.catalog.incoming),
);
const optionsFor = <T extends SimpleStreamOptions | undefined>(options: T): T => {
const catalog = manager.catalog;
Expand Down Expand Up @@ -525,23 +527,28 @@ function resolveToolResultImageReplayLimit(limit: ToolResultImageReplayLimit | u
return limit;
}

/** Native computer tool names whose screenshot history the provider protocol requires in full, regardless of the image replay limit. */
function requiredImageToolNames(incoming: CuaIncomingToolPlan): ReadonlySet<string> {
return new Set([incoming.tzafonComputerName, incoming.openaiComputerName].filter((name): name is string => !!name));
}

function projectToolResultImages<TMessage extends AgentMessage>(
messages: TMessage[],
limit: ToolResultImageReplayLimit,
requiredImageToolName?: string,
requiredToolNames: ReadonlySet<string> = new Set(),
): TMessage[] {
if (limit === false) return messages;
let imageCount = 0;
for (const message of messages) {
if (message.role === "toolResult" && message.toolName !== requiredImageToolName) {
if (message.role === "toolResult" && !requiredToolNames.has(message.toolName)) {
imageCount += message.content.filter((block) => block.type === "image").length;
}
}
if (imageCount <= limit) return messages;
const firstRetainedImage = Math.max(0, imageCount - limit);
let imageOrdinal = 0;
return messages.map((message) => {
if (message.role !== "toolResult" || message.toolName === requiredImageToolName) return message;
if (message.role !== "toolResult" || requiredToolNames.has(message.toolName)) return message;
let changed = false;
let markerInserted = false;
const content = [] as typeof message.content;
Expand All @@ -563,9 +570,9 @@ function projectToolResultImages<TMessage extends AgentMessage>(
function projectModelContext(
context: Context,
imageReplayLimit: ToolResultImageReplayLimit,
requiredImageToolName?: string,
requiredToolNames: ReadonlySet<string>,
): Context {
const messages = projectToolResultImages(context.messages, imageReplayLimit, requiredImageToolName);
const messages = projectToolResultImages(context.messages, imageReplayLimit, requiredToolNames);
return messages === context.messages ? context : { ...context, messages };
}

Expand Down
28 changes: 28 additions & 0 deletions packages/agent/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,34 @@ describe("CuaAgent explicit tools", () => {
expect(results[1]!.content).toEqual([{ type: "text", text: "[stale tool-result images omitted]" }]);
});

it("retains protocol-required OpenAI native computer screenshot results outside the image replay limit", async () => {
const contexts: Context[] = [];
const model = getCuaModel("openai:gpt-5.5");
const nativeComputer = cua.providers.openai.tools.computer();
const ordinaryTool = callerTool("ordinary");
const image = { type: "image" as const, data: "c2NyZWVuc2hvdA==", mimeType: "image/png" };
const messages: AgentMessage[] = [
assistant(model, [{ type: "toolCall", id: "native-shot", name: "computer", arguments: { action: { type: "screenshot" } } }], "toolUse"),
{ role: "toolResult", toolCallId: "native-shot", toolName: "computer", content: [image], isError: false, timestamp: 1 },
assistant(model, [{ type: "toolCall", id: "ordinary-shot", name: "ordinary", arguments: {} }], "toolUse"),
{ role: "toolResult", toolCallId: "ordinary-shot", toolName: "ordinary", content: [image], isError: false, timestamp: 2 },
];
const agent = new CuaAgent({
browser,
client,
tools: [nativeComputer, ordinaryTool],
streamFn: scriptedStream([(selectedModel) => assistant(selectedModel)], contexts),
toolResultImageReplayLimit: 0,
initialState: { model, messages },
});

await agent.prompt("continue");

const results = contexts[0]!.messages.filter((message) => message.role === "toolResult");
expect(results[0]!.content).toEqual([image]);
expect(results[1]!.content).toEqual([{ type: "text", text: "[stale tool-result images omitted]" }]);
});

it("installs exact native-only, native-plus-CUA, Playwright-only, and browser-act-only catalogs", () => {
const custom = callerTool("customer_lookup");
const cases = [
Expand Down
1 change: 0 additions & 1 deletion packages/agent/test/openai-deferred-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ describe("OpenAI deferred tool namespace continuation", () => {
tools: [loader],
initialState: { model: "openai:gpt-5.5" },
getApiKey: () => "test",
responseThreading: false,
});

await agent.prompt("load and run the added tool");
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ export default defineConfig({
globals: true,
environment: "node",
testTimeout: 30000,
// pi-ai ships real ESM and imports "openai" itself; both need to run
// through Vitest's module graph (not Node's native loader) for
// vi.mock("openai") to intercept requests pi's builtin transport makes.
server: { deps: { inline: [/@earendil-works\/pi-ai/, /^openai$/] } },
},
});
33 changes: 33 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## 0.11.0 - 2026-08-13

Breaking: OpenAI models no longer carry a CUA-owned api id.

- OpenAI models now resolve to pi-ai's builtin `"openai-responses"` api
instead of the removed `openai-cua-responses`, and stream through pi's
builtin Responses transport (`store: false`, automatic prompt-cache-key
matching) by default. `OPENAI_CUA_RESPONSES_API` and the OpenAI adapter's
`previous_response_id` threading are removed; `previous_response_id` and
`store: true` no longer appear on any OpenAI request.
- The CUA-owned OpenAI adapter is retained but now dispatches on request
shape rather than a rerouted api id: it only intercepts requests that select
OpenAI's native computer tool, or whose transcript carries a deferred
tool-search addition or a replayed function-call namespace (pi-ai 0.83.0's
builtin transport does not round-trip either).
- OpenAI's native computer adapter now sends the same `prompt_cache_key`,
`prompt_cache_retention`, `prompt_cache_options`, and session-affinity
headers as the function-tool path. It previously relied on stored response
state for context reuse and sent no cache key of its own.
- Remove the xAI and Meta Responses forks. Both existed only to thread
`previous_response_id` and to set `parallel_tool_calls: false`, which the tool
catalog already emits for those providers. `xai-cua-responses` and
`meta-responses` are gone: Grok streams through pi's builtin xAI provider, and
Meta registers pi's builtin Responses transport against its own base URL and
credentials. `XAI_CUA_RESPONSES_API`, `META_RESPONSES_API`,
`streamXaiResponses`, `streamSimpleXaiResponses`, `streamMetaResponses`, and
`streamSimpleMetaResponses` are no longer exported. Meta also regains pi's
stateless encrypted-reasoning replay, which the fork deleted because it relied
on stored response state.
- Google and Tzafon keep their continuation protocols: each threads a
provider-specific field with no builtin equivalent, and the shared helpers in
`providers/common.ts` are unchanged for them.

## 0.10.0 - 2026-08-04

Breaking: upgrade `@earendil-works/pi-ai` to 0.83.0.
Expand Down
5 changes: 3 additions & 2 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,9 @@ additions through pi's active-tool change entries.

## Provider behavior

- **OpenAI**: CUA-owned Responses transport for native computer plus ordinary
function composition and response threading.
- **OpenAI**: streams through pi's builtin Responses transport and its
automatic prompt caching by default. A CUA-owned adapter is used only for
OpenAI's native computer tool and for tool-search namespace round-trips.
- **Anthropic**: exact native declarations, beta-header composition, and
adaptive model preparation.
- **Google**: a CUA-owned Interactions API adapter plus the current predefined
Expand Down
7 changes: 0 additions & 7 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,15 @@ export {
createCuaModels,
cuaModels,
GOOGLE_CUA_INTERACTIONS_API,
META_RESPONSES_API,
OPENAI_CUA_RESPONSES_API,
streamGoogleInteractions,
streamMetaResponses,
streamOpenAIResponses,
streamSimpleGoogleInteractions,
streamSimpleMetaResponses,
streamSimpleOpenAIResponses,
streamSimpleTzafonResponses,
streamSimpleXaiResponses,
streamSimpleYutori,
streamTzafonResponses,
streamXaiResponses,
streamYutori,
TZAFON_RESPONSES_API,
XAI_CUA_RESPONSES_API,
YUTORI_CHAT_COMPLETIONS_API,
} from "./providers";
export * from "./models";
Expand Down
19 changes: 6 additions & 13 deletions packages/ai/src/models.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import { getBuiltinModel, getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
import { GOOGLE_CUA_INTERACTIONS_API } from "./providers/google/provider";
import { META_RESPONSES_API } from "./providers/meta/provider";
import { OPENAI_CUA_RESPONSES_API } from "./providers/openai/provider";
import { XAI_CUA_RESPONSES_API } from "./providers/xai/provider";

/** Providers with curated computer-use model support. */
export type CuaProvider = "openai" | "anthropic" | "google" | "meta" | "xai" | "moonshotai" | "openrouter" | "tzafon" | "yutori";
Expand Down Expand Up @@ -229,22 +226,18 @@ export function getCuaModel(ref: CuaModelRef): Model<Api> {
throw new Error(`CUA model "${ref}" is supported but not registered. Add it to pi-ai (models.dev) or CUA_MODEL_OVERRIDES.`);
}

// Route CUA models to provider-specific transports. Registry-resolved models
// otherwise carry pi-ai's builtin API ids.
// Route CUA models to provider-specific transports. OpenAI keeps pi-ai's
// builtin "openai-responses" api and streams through its automatic prompt
// caching; the CUA adapter dispatches on request shape (see
// requiresCuaOpenAIAdapter), not on a rerouted api id. Other registry-resolved
// models otherwise carry pi-ai's builtin API ids too.
export function routeCuaApi(model: Model<Api>): Model<Api> {
if (model.provider === "google" && model.api !== GOOGLE_CUA_INTERACTIONS_API) {
return { ...model, api: GOOGLE_CUA_INTERACTIONS_API };
}
if (model.provider === "openai" && model.api !== OPENAI_CUA_RESPONSES_API) {
return { ...model, api: OPENAI_CUA_RESPONSES_API };
}
if (model.provider === "meta" && model.api !== META_RESPONSES_API) {
return { ...model, api: META_RESPONSES_API };
}
if (model.provider === "xai" && model.id === "grok-4.5") {
return {
...model,
api: XAI_CUA_RESPONSES_API,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "high" },
cost: {
...model.cost,
Expand Down Expand Up @@ -328,7 +321,7 @@ function cuaModel(provider: "meta" | "tzafon" | "yutori", id: string, name: stri
// computer-use cookbook configures 128,000 maximum output tokens.
return {
...base,
api: META_RESPONSES_API,
api: "openai-responses",
baseUrl: "https://api.meta.ai/v1",
thinkingLevelMap: { off: null, xhigh: "xhigh" },
cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
Expand Down
Loading
Loading