Skip to content

Unify Anthropic and OpenAI runtimes on pi-agent-core - #3246

Closed
youknowriad wants to merge 5 commits into
trunkfrom
unify-ai-runtimes-on-pi
Closed

Unify Anthropic and OpenAI runtimes on pi-agent-core#3246
youknowriad wants to merge 5 commits into
trunkfrom
unify-ai-runtimes-on-pi

Conversation

@youknowriad

Copy link
Copy Markdown
Contributor

Related issues

How AI was used in this PR

Substantial AI-generated migration. The plan, the runtime rewrite, the test updates, the documentation prose, and the commit message were all produced by Claude Opus 4.7 working from a detailed exploration spec. A human reviewed every file before commit, but the diff is large and AI-shaped.

The user explicitly opted for a direct cutover (no feature flag), so Studio runs every model — Claude Sonnet/Opus and GPT 5.5 — through the new unified runtime starting on first launch with this branch.

This is a Proof of Concept PR per the AGENTS.md guidance for substantial vibe-coded changes — opening it as draft + labeled `Proof of Concept` so the team can evaluate the architecture before considering merge. Expect rounds of refinement on cache hit rate parity, the wpcom auth path, and tuning the Claude-side skill appendix narrowing before this is merge-ready.

Proposed Changes

  • Single agent loop. `apps/cli/ai/runtimes/pi/` is now the only runtime. `runtimes/anthropic/` is gone. `agent.ts` no longer dispatches by model family — `piRuntime` handles everything and resolves the family internally to pick between pi-ai's `anthropic-messages` and `openai-completions` providers.
  • `@anthropic-ai/claude-agent-sdk` dropped. Removed from `apps/cli/package.json` dependencies. The forge build no longer needs the vendor-binary cleanup pass on Windows code-sign. The local `apps/cli/ai/sdk-message-types.ts` mirrors the SDKMessage and `SdkMcpToolDefinition` shapes the rest of the codebase consumes; the new `apps/cli/ai/tools/define-tool.ts` provides the `tool()` helper with the same authoring signature so all 24 tool files keep working unchanged.
  • `studio mcp` standalone server. Re-implemented on top of the MCP SDK's `McpServer` directly (was using the agent SDK's `createSdkMcpServer` wrapper). Same tool catalog, same registration shape.
  • Real result-message accounting. The synthetic `result` SDKMessage now carries `total_cost_usd` (summed from pi-ai's per-stream `Usage.cost.total`), per-model `modelUsage` (input/output/cache tokens), `num_turns` (counted from `turn_end` events), and `permission_denials` (populated from path-gated `beforeToolCall` blocks). The CLI's "Thought for Xs · N turns" footer reflects real numbers across both families instead of zeros for OpenAI.
  • Thinking blocks survive translation. Reasoning content now becomes a proper `thinking` content block on the assistant SDKMessage (was folded into text on the OpenAI side). The desktop UI's "Claude is thinking" affordance keeps working; the empty-turn fallback still skips silently when there's no text, no tool call, and no thinking either.
  • `beforeToolCall` for permission gating. Path-gated tools (Read/Write/Edit/Bash/NotebookEdit) flow through `security.promptForApproval` via pi-agent-core's `beforeToolCall` hook. Denials become `{ block: true, reason }` on pi's side and surface as `permission_denials` in the result message. `AskUserQuestion` is registered as a regular tool now (was an SDK preset interception path), so the question UX comes for free through the same channel.
  • Sidecar persistence. The cross-fork transcript file moves from `.openai-state.json` → `.pi-state.json`. The loader still reads the legacy suffix as a one-time fallback so resumed sessions don't lose pi memory across the migration. Delete sweeps both.
  • Anthropic env-resolution. `ANTHROPIC_AUTH_TOKEN` (wpcom proxy) and `ANTHROPIC_API_KEY` (direct API key) both populate the apiKey; `ANTHROPIC_BASE_URL` overrides the default `https://api.anthropic.com\`; `ANTHROPIC_CUSTOM_HEADERS` is parsed from the colon-delimited format `providers.ts` writes and merged into `Model.headers` so the wpcom `X-WPCOM-AI-Feature` header lands at the provider.
  • Tests. `runtimes/openai/tests/` → `runtimes/pi/tests/` (with persistence test extended for the new + legacy suffix matrix). `tests/openai-runtime.test.ts` → `tests/pi-runtime.test.ts` with a new case verifying Claude dispatch builds an `anthropic-messages` Model. `tests/agent.test.ts` rewritten to assert each family routes to the right pi-ai `Model.api` rather than mocking the SDK `query()`. 1401 tests pass (up from 1396 before this branch's additions).

Testing Instructions

CLI smoke (after `npm run cli:build && node apps/cli/dist/cli/main.mjs ai`):

  • Pick `gpt-5.5` from `/model` and run a turn — assistant text should render, the footer should show non-zero cost.
  • Pick `claude-sonnet-4-6` from `/model` and run a turn — same checks. With Opus 4.7, watch for thinking blocks rendering as their own UI segment, not folded into text.
  • Mid-session, swap families with `/model` (gpt-5.5 ↔ claude-sonnet-4-6). The conversation should continue (the new Agent rebuilds with the existing transcript), and the previous turn's tool results should still be visible to the model.
  • Open the desktop UI's family-switch confirm dialog (apps/ui composer) — confirm the swap, run a turn, repeat. The cross-family resume guard is gone; both families share session ids now.

Local site interactions:

  • Ask the agent to Read/Write a file outside the trusted root. Decline once → `permission_denials` shows up in the synthetic result. Allow always → the second access in the same directory doesn't re-prompt.
  • Ask the agent to use `AskUserQuestion` (e.g. "ask me a question with three options"). The question picker should appear and the answer should land back in the assistant transcript.

Standalone MCP:

  • Run `node apps/cli/dist/cli/main.mjs mcp` (non-TTY) and verify the stdio server starts and responds to a manual MCP `tools/list` request with the full Studio tool catalog.

Resume across forks (desktop):

  • In the desktop app, start a new chat, run a turn, close + reopen — the `*.pi-state.json` sidecar should be present next to the JSONL and the agent should remember the prior turn.
  • Resume an older session that has a `.openai-state.json` sidecar from before this branch — the legacy fallback should rehydrate it; the next turn writes a new `.pi-state.json`.

Pre-merge Checklist

  • No TypeScript, React, or console errors (`npm run typecheck` clean).
  • `npm test` passes (1401 tests).
  • Cache-hit rate parity verified against the old Anthropic runtime — pi-ai's anthropic provider sets `cache_control: { type: 'ephemeral' }` on system + tools + last user content; check the wpcom proxy logs / `AssistantMessage.usage.cacheRead` are non-zero for follow-up turns.
  • wpcom auth path verified end-to-end — the pi-ai anthropic provider has an OAuth token detection heuristic (looks for `sk-ant-oat`) that flips the auth header style; our wpcom access token doesn't match the heuristic, so the API-key path with our `X-WPCOM-AI-Feature` header should fire. Confirm by hitting the wpcom proxy and inspecting the request headers logged on the proxy side.
  • Thinking-block UI rendering verified on a real Claude turn (Opus 4.7 with reasoning) and a real GPT 5.5 turn — both should produce visible "thinking" segments that don't overlap with the assistant text or get rendered as raw text.
  • Skill appendix narrowing for Claude — `AUTO_APPLY_SKILLS` is currently `{ 'site-spec' }`, narrowed for the OpenAI side because GPT eagerly auto-runs tool-shaped skills. For Claude this is probably too restrictive; revisit in a follow-up tuning pass once we have signal from real Claude turns.
  • `ALLOWED_TOOLS` still references `WebFetch`, `WebSearch`, `TodoRead`, `NotebookRead` from the SDK preset era — these are no-ops now (the unified runtime doesn't register them). Documented inline; tracked as a follow-up to either re-implement or drop from the list.

🤖 Generated with Claude Code

youknowriad and others added 4 commits April 25, 2026 23:12
Introduces a dual-runtime architecture so the WordPress.com provider can
serve OpenAI models (currently GPT 5.5) alongside Claude. Routes by model
family at dispatch time, splits the runtime per family under
apps/cli/ai/runtimes/{anthropic,openai}, and reorganizes Studio tools into
one-file-per-tool. UI gets a cross-family confirmation dialog on /model
swap, and the recorder side learns to clean up runtime sidecars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI lint flagged the SdkMcpToolDefinition type import as out of order —
external-package types must come before local-path types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s split

- AI_MODELS is now an array of `{id, label, family}` so `getAiModelFamily`
  is a lookup instead of a string-prefix heuristic. New `getAiModelLabel`
  helper replaces direct `AI_MODELS[id]` access at all call sites; new
  models become a one-line data change instead of two.
- The cross-family confirmation dialog moves from `SessionView` into the
  composer (where the model picker lives) — composer owns the orchestration
  and renders the dialog inline; `FamilySwitchConfirmDialog` extracted to
  its own file for testability.
- Single-caller helpers in `apps/cli/ai/tools/utils.ts` move to where
  they're used: `splitCommandArgs` → `wp-cli.ts` (private), and
  `formatInvalidBlocks` → `validate-blocks.ts` (private). The four
  preview tools share `runPreviewCommand` via a new `preview-helpers.ts`
  rather than the general utils grab-bag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run every model — Claude Opus/Sonnet and GPT 5.5 — through a single
agent loop backed by @mariozechner/pi-agent-core. The runtime picks
between pi-ai's anthropic-messages and openai-completions providers
based on the active model id, so the family-specific dispatch is now
an internal detail rather than a runtime fork.

Why: the parallel Anthropic-via-Claude-Agent-SDK + OpenAI-via-pi
runtimes meant duplicating tool registration, transcript persistence,
permission gating, and result-message synthesis. Two different cross-
family resume guards. Two SDK message-shape contracts to keep in sync
with the recorder JSONL. Adding a third model family would have meant
adding a third runtime. One pi-based loop closes that gap.

What changes for users / the desktop UI:
- Cost reporting in the "Thought for Xs · N turns" footer now shows
  real numbers for both families instead of zeros — pi-ai reports
  per-stream Usage with cost.total via the model registry; the
  synthetic SDKMessage `result` carries those through.
- Reasoning content surfaces as proper `thinking` content blocks on
  the assistant SDKMessage so the UI's "Claude is thinking" affordance
  works on Claude turns and on the GPT-5+ thinking-only fallback path.
- Sidecar transcripts move from .openai-state.json → .pi-state.json
  next to each session JSONL. The loader still reads the legacy suffix
  so resumed sessions don't lose pi memory across the migration; the
  writer always uses the new name. Delete sweeps both.
- @anthropic-ai/claude-agent-sdk drops from apps/cli/package.json and
  the forge build no longer needs to prune its vendor binaries on
  Windows code-sign.

Tool authoring is unchanged: each tool file still imports a `tool()`
helper with the same `(name, description, zodInputSchema, handler)`
signature. The local `apps/cli/ai/tools/define-tool.ts` produces the
same `SdkMcpToolDefinition` shape the pi adapter expects, and the
`studio mcp` standalone server registers tools directly through the
MCP SDK instead of going through the agent SDK's createSdkMcpServer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chubes4

chubes4 commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

I think this is a really great idea. It could eventually allow users the freedom to use any model they want.

@youknowriad

Copy link
Copy Markdown
Contributor Author

Yes, but we need to ensure that there's no regressions. The pi based custom hardness has a different feeling that the agent SDK based one. I'm going to need a bit of testing to be able to land this.

Reconciles the unified pi runtime with trunk's `permissionMode: 'auto'` /
ad-hoc-gating-removal change (#3242). pi-agent-core ships no equivalent to
the SDK's auto classifier, so this merge takes the middle path:

- Drops the allow-once / allow-always / deny prompt machinery from
  `apps/cli/ai/security.ts` (matches trunk's "drop ad-hoc gating" intent
  while keeping a stable trust-root contract on the pi side).
- Keeps a minimal static path allowlist — `STUDIO_ROOT`, `os.tmpdir()`,
  `/tmp` — enforced via pi's `beforeToolCall` hook in `runtimes/pi/`.
  Tool calls whose path arguments land outside the allowlist are blocked
  silently with `ACCESS_DENIED_MESSAGE`. Bash is intentionally not gated
  (its `command` arg isn't a structured path).
- Removes `autoApprove` from `AgentRuntimeConfig` and `AiAgentConfig`
  (no consumers; the new gate has no escape hatch by design).
- Rewrites `tests/security.test.ts` for the trimmed surface
  (`isPathGatedTool`, `resolveToolPath`, `isPathWithinTrustedRoot`,
  `findFirstPathOutsideTrustedRoots`).
- Resolves `tests/agent.test.ts` to keep the pi-runtime dispatch tests
  (Claude → anthropic-messages, GPT → openai-completions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chubes4

chubes4 commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

I tested this locally through the Studio eval runner with a Homeboy cross-rig bench comparing trunk/Claude Agent SDK against this branch/pi-agent-core.

The direction looks promising, but I found three pi adapter issues that made the initial result look faster while producing no usable assistant output on the WP.com Anthropic path:

  1. The WP.com Anthropic proxy path needs Authorization: Bearer <token> in the pi model headers. Passing the token only as the Anthropic API key produced a pi assistant error: 401 Sorry, you are not allowed to do that.
  2. pi assistant messages with stopReason: "error" need to be translated into an SDK result with subtype: "error_during_execution". Otherwise auth/provider failures look like successful empty runs.
  3. Some successful pi responses surfaced assistant text on turn_end.message without a preceding message_end. The SDK compatibility layer should translate that message too, and dedupe when both events carry the same assistant message.

I patched those locally and added focused tests around auth headers, pi error messages, turn_end.message, and deduping. With those fixes, a no-tool identity prompt now works through both runtimes using claude-sonnet-4-6:

  • Prompt: In one short sentence, tell me who you are. Do not call any tools.
  • SDK runs: 7518, 7552, 8410 ms
  • pi runs: 3310, 3000, 3384 ms
  • Both paths: success_rate=1, turn_count=1, assistant_message_count=1, tool_call_count=0
  • pi p50 was about 56% faster for this narrow no-tool case.

I would treat that as encouraging, not conclusive. Before landing this, I think we should also cover at least one tool-call scenario, one denial/error scenario, and one small site-building scenario, because the current proof only validates the simplest assistant-message path.

The local files I changed are apps/cli/ai/runtimes/pi/index.ts and apps/cli/ai/tests/pi-runtime.test.ts. Happy to turn that into a patch/PR if you want.

Edit: posted by Franklin, Chris's clanker agent, on Chris's behalf.

@youknowriad

Copy link
Copy Markdown
Contributor Author

Closing this one, starting with less disruptive #3328

@youknowriad youknowriad closed this May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants