support pi

This commit is contained in:
Franklin 2026-02-05 17:06:53 -05:00
parent cc5a9e0d73
commit 843498e9db
41 changed files with 2654 additions and 102 deletions

View file

@ -8,7 +8,7 @@ edition = "2021"
authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ]
license = "Apache-2.0"
repository = "https://github.com/rivet-dev/sandbox-agent"
description = "Universal API for automatic coding agents in sandboxes. Supprots Claude Code, Codex, OpenCode, and Amp."
description = "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Amp, and Pi."
[workspace.dependencies]
# Internal crates

View file

@ -5,7 +5,7 @@
<h3 align="center">Run Coding Agents in Sandboxes. Control Them Over HTTP.</h3>
<p align="center">
A server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, or Amp — streaming events, handling permissions, managing sessions.
A server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Amp, or Pi — streaming events, handling permissions, managing sessions.
</p>
<p align="center">
@ -20,13 +20,13 @@ Sandbox Agent solves three problems:
1. **Coding agents need sandboxes** — You can't let AI execute arbitrary code on your production servers. Coding agents need isolated environments, but existing SDKs assume local execution. Sandbox Agent is a server that runs inside the sandbox and exposes HTTP/SSE.
2. **Every coding agent is different** — Claude Code, Codex, OpenCode, and Amp each have proprietary APIs, event formats, and behaviors. Swapping agents means rewriting your integration. Sandbox Agent provides one HTTP API — write your code once, swap agents with a config change.
2. **Every coding agent is different** — Claude Code, Codex, OpenCode, Amp, and Pi each have proprietary APIs, event formats, and behaviors. Swapping agents means rewriting your integration. Sandbox Agent provides one HTTP API — write your code once, swap agents with a config change.
3. **Sessions are ephemeral** — Agent transcripts live in the sandbox. When the process ends, you lose everything. Sandbox Agent streams events in a universal schema to your storage. Persist to Postgres, ClickHouse, or [Rivet](https://rivet.dev). Replay later, audit everything.
## Features
- **Universal Agent API**: Single interface to control Claude Code, Codex, OpenCode, and Amp with full feature coverage
- **Universal Agent API**: Single interface to control Claude Code, Codex, OpenCode, Amp, and Pi with full feature coverage
- **Streaming Events**: Real-time SSE stream of everything the agent does — tool calls, permission requests, file edits, and more
- **Universal Session Schema**: [Standardized schema](https://sandboxagent.dev/docs/session-transcript-schema) that normalizes all agent event formats for storage and replay
- **Human-in-the-Loop**: Approve or deny tool executions and answer agent questions remotely over HTTP
@ -227,7 +227,7 @@ No, they're complementary. AI SDK is for building chat interfaces and calling LL
<details>
<summary><strong>Which coding agents are supported?</strong></summary>
Claude Code, Codex, OpenCode, and Amp. The SDK normalizes their APIs so you can swap between them without changing your code.
Claude Code, Codex, OpenCode, Amp, and Pi. The SDK normalizes their APIs so you can swap between them without changing your code.
</details>
<details>
@ -251,7 +251,7 @@ The server is a single Rust binary that runs anywhere with a curl install. If yo
<details>
<summary><strong>Can I use this with my personal API keys?</strong></summary>
Yes. Use `sandbox-agent credentials extract-env` to extract API keys from your local agent configs (Claude Code, Codex, OpenCode, Amp) and pass them to the sandbox environment.
Yes. Use `sandbox-agent credentials extract-env` to extract API keys from your local agent configs (Claude Code, Codex, OpenCode, Amp, Pi) and pass them to the sandbox environment.
</details>
<details>

View file

@ -61,7 +61,7 @@ sandbox-agent credentials extract [OPTIONS]
| Option | Description |
|--------|-------------|
| `-a, --agent <AGENT>` | Filter by agent (`claude`, `codex`, `opencode`, `amp`) |
| `-a, --agent <AGENT>` | Filter by agent (`claude`, `codex`, `opencode`, `amp`, `pi`) |
| `-p, --provider <PROVIDER>` | Filter by provider (`anthropic`, `openai`) |
| `-d, --home-dir <DIR>` | Custom home directory for credential search |
| `-r, --reveal` | Show full credential values (default: redacted) |

View file

@ -4,14 +4,14 @@ Source of truth: generated agent schemas in `resources/agent-schemas/artifacts/j
Identifiers
+----------------------+------------------------+------------------------------------------+-----------------------------+------------------------+
| Universal term | Claude | Codex (app-server) | OpenCode | Amp |
+----------------------+------------------------+------------------------------------------+-----------------------------+------------------------+
| session_id | n/a (daemon-only) | n/a (daemon-only) | n/a (daemon-only) | n/a (daemon-only) |
| native_session_id | none | threadId | sessionID | none |
| item_id | synthetic | ThreadItem.id | Message.id | StreamJSONMessage.id |
| native_item_id | none | ThreadItem.id | Message.id | StreamJSONMessage.id |
+----------------------+------------------------+------------------------------------------+-----------------------------+------------------------+
+----------------------+------------------------+------------------------------------------+-----------------------------+------------------------+----------------------+
| Universal term | Claude | Codex (app-server) | OpenCode | Amp | Pi (RPC) |
+----------------------+------------------------+------------------------------------------+-----------------------------+------------------------+----------------------+
| session_id | n/a (daemon-only) | n/a (daemon-only) | n/a (daemon-only) | n/a (daemon-only) | n/a (daemon-only) |
| native_session_id | none | threadId | sessionID | none | sessionId |
| item_id | synthetic | ThreadItem.id | Message.id | StreamJSONMessage.id | messageId/toolCallId |
| native_item_id | none | ThreadItem.id | Message.id | StreamJSONMessage.id | messageId/toolCallId |
+----------------------+------------------------+------------------------------------------+-----------------------------+------------------------+----------------------+
Notes:
- When a provider does not supply IDs (Claude), we synthesize item_id values and keep native_item_id null.
@ -24,22 +24,22 @@ Notes:
Events / Message Flow
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
| Universal term | Claude | Codex (app-server) | OpenCode | Amp |
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
| session.started | none | method=thread/started | type=session.created | none |
| session.ended | SDKMessage.type=result | no explicit session end (turn/completed) | no explicit session end (session.deleted)| type=done |
| message (user) | SDKMessage.type=user | item/completed (ThreadItem.type=userMessage)| message.updated (Message.role=user) | type=message |
| message (assistant) | SDKMessage.type=assistant | item/completed (ThreadItem.type=agentMessage)| message.updated (Message.role=assistant)| type=message |
| message.delta | stream_event (partial) or synthetic | method=item/agentMessage/delta | type=message.part.updated (delta) | synthetic |
| tool call | type=tool_use | method=item/mcpToolCall/progress | message.part.updated (part.type=tool) | type=tool_call |
| tool result | user.message.content.tool_result | item/completed (tool result ThreadItem variants) | message.part.updated (part.type=tool, state=completed) | type=tool_result |
| permission.requested | control_request.can_use_tool | none | type=permission.asked | none |
| permission.resolved | daemon reply to can_use_tool | none | type=permission.replied | none |
| question.requested | tool_use (AskUserQuestion) | experimental request_user_input (payload) | type=question.asked | none |
| question.resolved | tool_result (AskUserQuestion) | experimental request_user_input (payload) | type=question.replied / question.rejected | none |
| error | SDKResultMessage.error | method=error | type=session.error (or message error) | type=error |
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+----------------------------+
| Universal term | Claude | Codex (app-server) | OpenCode | Amp | Pi (RPC) |
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+----------------------------+
| session.started | none | method=thread/started | type=session.created | none | none |
| session.ended | SDKMessage.type=result | no explicit session end (turn/completed) | no explicit session end (session.deleted)| type=done | none (daemon synthetic) |
| message (user) | SDKMessage.type=user | item/completed (ThreadItem.type=userMessage)| message.updated (Message.role=user) | type=message | none (daemon synthetic) |
| message (assistant) | SDKMessage.type=assistant | item/completed (ThreadItem.type=agentMessage)| message.updated (Message.role=assistant)| type=message | message_start/message_end |
| message.delta | stream_event (partial) or synthetic | method=item/agentMessage/delta | type=message.part.updated (delta) | synthetic | message_update (text_delta/thinking_delta) |
| tool call | type=tool_use | method=item/mcpToolCall/progress | message.part.updated (part.type=tool) | type=tool_call | tool_execution_start |
| tool result | user.message.content.tool_result | item/completed (tool result ThreadItem variants) | message.part.updated (part.type=tool, state=completed) | type=tool_result | tool_execution_end |
| permission.requested | control_request.can_use_tool | none | type=permission.asked | none | none |
| permission.resolved | daemon reply to can_use_tool | none | type=permission.replied | none | none |
| question.requested | tool_use (AskUserQuestion) | experimental request_user_input (payload) | type=question.asked | none | none |
| question.resolved | tool_result (AskUserQuestion) | experimental request_user_input (payload) | type=question.replied / question.rejected | none | none |
| error | SDKResultMessage.error | method=error | type=session.error (or message error) | type=error | hook_error (status item) |
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+----------------------------+
Synthetics
@ -64,6 +64,7 @@ Delta handling
- Codex emits agent message and other deltas (e.g., item/agentMessage/delta).
- OpenCode emits part deltas via message.part.updated with a delta string.
- Claude can emit stream_event deltas when partial streaming is enabled; Amp does not emit deltas.
- Pi emits message_update deltas and cumulative tool_execution_update partialResult values (we diff to produce deltas).
Policy:
- Always emit item.delta across all providers.
@ -80,3 +81,7 @@ Message normalization notes
- OpenCode unrolling: message.updated creates/updates the parent message item; tool-related parts emit separate tool item events (item.started/ item.completed) with parent_id pointing to the message item.
- If a message.part.updated arrives before message.updated, we create a stub item.started (source=daemon) so deltas have a parent.
- Tool calls/results are always emitted as separate tool items to keep behavior consistent across agents.
- If Pi message_update events omit messageId, we synthesize a stable message id and emit a synthetic item.started before the first delta so streaming text stays grouped.
- Pi auto_compaction_start/auto_compaction_end and auto_retry_start/auto_retry_end events are mapped to status items (label `pi.*`).
- Pi extension_ui_request/extension_error events are mapped to status items.
- Pi RPC from pi-coding-agent does not include sessionId in events; we route events to the current Pi session (single-session semantics).

View file

@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "sandbox-agent",
"description": "Universal API for automatic coding agents in sandboxes. Supprots Claude Code, Codex, OpenCode, and Amp.",
"description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Amp, and Pi.",
"contact": {
"name": "Rivet Gaming, LLC",
"email": "developer@rivet.gg"

200
docs/pi-support-plan.md Normal file
View file

@ -0,0 +1,200 @@
# Pi Agent Support Plan (pi-mono)
## Investigation Summary
### Pi CLI modes and RPC protocol
- Pi supports multiple modes including interactive, print/JSON output, RPC, and SDK usage. JSON mode outputs a stream of JSON events suitable for parsing, and RPC mode is intended for programmatic control over stdin/stdout.
- RPC mode is started with `pi --mode rpc` and supports options like `--provider`, `--model`, `--no-session`, and `--session-dir`.
- The RPC protocol is newline-delimited JSON over stdin/stdout:
- Commands are JSON objects written to stdin.
- Responses are JSON objects with `type: "response"` and optional `id`.
- Events are JSON objects without `id`.
- `prompt` can include images using `ImageContent` (base64 or URL) alongside text.
- JSON/print mode (`pi -p` or `pi --print --mode json`) produces JSONL for non-interactive parsing and can resume sessions with a token.
### RPC commands
RPC commands listed in `rpc.md` include:
- `new_session`, `get_state`, `list_sessions`, `delete_session`, `rename_session`, `clear_session`
- `prompt`, `queue_message`, `abort`, `get_queued_messages`
### RPC event types
RPC events listed in `rpc.md` include:
- `agent_start`, `agent_end`
- `turn_start`, `turn_end`
- `message_start`, `message_update`, `message_end`
- `tool_execution_start`, `tool_execution_update`, `tool_execution_end`
- `auto_compaction`, `auto_retry`, `hook_error`
`message_update` uses `assistantMessageEvent` deltas such as:
- `start`, `text_start`, `text_delta`, `text_end`
- `thinking_start`, `thinking_delta`, `thinking_end`
- `toolcall_start`, `toolcall_delta`, `toolcall_end`
- `toolcall_args_start`, `toolcall_args_delta`, `toolcall_args_end`
- `done`, `error`
`tool_execution_update` includes `partialResult`, which is described as accumulated output so far.
### Schema source locations (pi-mono)
RPC types are documented as living in:
- `packages/ai/src/types.ts` (Model types)
- `packages/agent/src/types.ts` (AgentResponse types)
- `packages/coding-agent/src/core/messages.ts` (message types)
- `packages/coding-agent/src/modes/rpc/rpc-types.ts` (RPC protocol types)
### Distribution assets
Pi releases provide platform-specific binaries such as:
- `pi-darwin-arm64`, `pi-darwin-x64`
- `pi-linux-arm64`, `pi-linux-x64`
- `pi-win-x64.zip`
## Integration Decisions
- Follow the OpenCode pattern: a shared long-running process (stdio RPC) with session multiplexing.
- Primary integration path is RPC streaming (`pi --mode rpc`).
- JSON/print mode is a fallback only (diagnostics or non-interactive runs).
- Create sessions via `new_session`; store the returned `sessionId` as `native_session_id`.
- Use `get_state` as a re-sync path after server restarts.
- Use `prompt` for send-message, with optional image content.
- Convert Pi events into universal events; emit daemon synthetic `session.started` on session creation and `session.ended` only on errors/termination.
## Implementation Plan
### 1) Agent Identity + Capabilities
Files:
- `server/packages/agent-management/src/agents.rs`
- `server/packages/sandbox-agent/src/router.rs`
- `docs/cli.mdx`, `docs/conversion.mdx`, `docs/session-transcript-schema.mdx`
- `README.md`, `frontend/packages/website/src/components/FAQ.tsx`
Tasks:
- Add `AgentId::Pi` with string/binary name `"pi"` and parsing rules.
- Add Pi to `all_agents()` and agent lists.
- Define `AgentCapabilities` for Pi:
- `tool_calls=true`, `tool_results=true`
- `text_messages=true`, `streaming_deltas=true`, `item_started=true`
- `reasoning=true` (from `thinking_*` deltas)
- `images=true` (ImageContent in `prompt`)
- `permissions=false`, `questions=false`, `mcp_tools=false`
- `shared_process=true`, `session_lifecycle=false` (no native session events)
- `error_events=true` (hook_error)
- `command_execution=false`, `file_changes=false`, `file_attachments=false`
### 2) Installer and Binary Resolution
Files:
- `server/packages/agent-management/src/agents.rs`
Tasks:
- Add `install_pi()` that:
- Downloads the correct release asset per platform (`pi-<platform>`).
- Handles `.zip` on Windows and raw binaries elsewhere.
- Marks binary executable.
- Add Pi to `AgentManager::install`, `is_installed`, `version`.
- Version detection: try `--version`, `version`, `-V`.
### 3) Schema Extraction for Pi
Files:
- `resources/agent-schemas/src/pi.ts` (new)
- `resources/agent-schemas/src/index.ts`
- `resources/agent-schemas/artifacts/json-schema/pi.json`
- `server/packages/extracted-agent-schemas/build.rs`
- `server/packages/extracted-agent-schemas/src/lib.rs`
Tasks:
- Implement `extractPiSchema()`:
- Download pi-mono sources (zip/tarball) into a temp dir.
- Use `ts-json-schema-generator` against `packages/coding-agent/src/modes/rpc/rpc-types.ts`.
- Include dependent files per `rpc.md` (ai/types, agent/types, core/messages).
- Extract `RpcEvent`, `RpcResponse`, `RpcCommand` unions (exact type names from source).
- Add fallback schema if remote fetch fails (minimal union with event/response fields).
- Wire pi into extractor index and artifact generation.
### 4) Universal Schema Conversion (Pi -> Universal)
Files:
- `server/packages/universal-agent-schema/src/agents/pi.rs` (new)
- `server/packages/universal-agent-schema/src/agents/mod.rs`
- `server/packages/universal-agent-schema/src/lib.rs`
- `server/packages/sandbox-agent/src/router.rs`
Mapping rules:
- `message_start` -> `item.started` (kind=message, role=assistant, native_item_id=messageId)
- `message_update`:
- `text_*` -> `item.delta` (assistant text delta)
- `thinking_*` -> `item.delta` with `ContentPart::Reasoning` (visibility=Private)
- `toolcall_*` and `toolcall_args_*` -> ignore for now (tool_execution_* is authoritative)
- `error` -> `item.completed` with `ItemStatus::Failed` (if no later message_end)
- `message_end` -> `item.completed` (finalize assistant message)
- `tool_execution_start` -> `item.started` (kind=tool_call, ContentPart::ToolCall)
- `tool_execution_update` -> `item.delta` for a synthetic tool_result item:
- Maintain a per-toolCallId buffer to compute delta from accumulated `partialResult`.
- `tool_execution_end` -> `item.completed` (kind=tool_result, output from `result.content`)
- If `isError=true`, set item status to failed.
- `agent_start`, `turn_start`, `turn_end`, `agent_end`, `auto_compaction`, `auto_retry`, `hook_error`:
- Map to `ItemKind::Status` with a label like `pi.agent_start`, `pi.auto_retry`, etc.
- Do not emit `session.ended` for these events.
- If event parsing fails, emit `agent.unparsed` (source=daemon, synthetic=true) and fail tests.
### 5) Shared RPC Server Integration
Files:
- `server/packages/sandbox-agent/src/router.rs`
Tasks:
- Add a new managed stdio server type for Pi, similar to Codex:
- Create `PiServer` struct with:
- stdin sender
- pending request map keyed by request id
- per-session native session id mapping
- Extend `ManagedServerKind` to include Pi.
- Add `ensure_pi_server()` and `spawn_pi_server()` using `pi --mode rpc`.
- Add a `handle_pi_server_output()` loop to parse stdout lines into events/responses.
- Session creation:
- On `create_session`, ensure Pi server is running, send `new_session`, store sessionId.
- Register session with `server_manager.register_session` for native mapping.
- Sending messages:
- Use `prompt` command; include sessionId and optional images.
- Emit synthetic `item.started` only if Pi does not emit `message_start`.
### 6) Router + Streaming Path Changes
Files:
- `server/packages/sandbox-agent/src/router.rs`
Tasks:
- Add Pi handling to:
- `create_session` (new_session)
- `send_message` (prompt)
- `parse_agent_line` (Pi event conversion)
- `agent_modes` (default to `default` unless Pi exposes a mode list)
- `agent_supports_resume` (true if Pi supports session resume)
### 7) Tests
Files:
- `server/packages/sandbox-agent/tests/...`
- `server/packages/universal-agent-schema/tests/...` (if present)
Tasks:
- Unit tests for conversion:
- `message_start/update/end` -> item.started/delta/completed
- `tool_execution_*` -> tool call/result mapping with partialResult delta
- failure -> agent.unparsed
- Integration tests:
- Start Pi RPC server, create session, send prompt, stream events.
- Validate `native_session_id` mapping and event ordering.
- Update HTTP/SSE test coverage to include Pi agent if relevant.
## Risk Areas / Edge Cases
- `tool_execution_update.partialResult` is cumulative; must compute deltas.
- `message_update` may emit `done`/`error` without `message_end`; handle both paths.
- No native session lifecycle events; rely on daemon synthetic events.
- Session recovery after RPC server restart requires `get_state` + re-register sessions.
## Acceptance Criteria
- Pi appears in `/v1/agents`, CLI list, and docs.
- `create_session` returns `native_session_id` from Pi `new_session`.
- Streaming prompt yields universal events with proper ordering:
- message -> item.started/delta/completed
- tool execution -> tool call + tool result
- Tests pass and no synthetic data is used in test fixtures.
## Sources
- https://upd.dev/badlogic/pi-mono/src/commit/d36e0ea07303d8a76d51b4a7bd5f0d6d3c490860/packages/coding-agent/docs/rpc.md
- https://buildwithpi.ai/pi-cli
- https://takopi.dev/docs/pi-cli/
- https://upd.dev/badlogic/pi-mono/releases

View file

@ -12,25 +12,25 @@ The schema is defined in [OpenAPI format](https://github.com/rivet-dev/sandbox-a
This table shows which agent feature coverage appears in the universal event stream. All agents retain their full native feature coverage—this only reflects what's normalized into the schema.
| Feature | Claude | Codex | OpenCode | Amp |
|--------------------|:------:|:-----:|:------------:|:------------:|
| Stability | Stable | Stable| Experimental | Experimental |
| Text Messages | ✓ | ✓ | ✓ | ✓ |
| Tool Calls | ✓ | ✓ | ✓ | ✓ |
| Tool Results | ✓ | ✓ | ✓ | ✓ |
| Questions (HITL) | ✓ | | ✓ | |
| Permissions (HITL) | ✓ | ✓ | ✓ | - |
| Images | - | ✓ | ✓ | - |
| File Attachments | - | ✓ | ✓ | - |
| Session Lifecycle | - | ✓ | ✓ | - |
| Error Events | - | ✓ | ✓ | ✓ |
| Reasoning/Thinking | - | ✓ | - | - |
| Command Execution | - | ✓ | - | - |
| File Changes | - | ✓ | - | - |
| MCP Tools | - | ✓ | - | - |
| Streaming Deltas | ✓ | ✓ | ✓ | - |
| Feature | Claude | Codex | OpenCode | Amp | Pi (RPC) |
|--------------------|:------:|:-----:|:------------:|:------------:|:------------:|
| Stability | Stable | Stable| Experimental | Experimental | Experimental |
| Text Messages | ✓ | ✓ | ✓ | ✓ | ✓ |
| Tool Calls | ✓ | ✓ | ✓ | ✓ | ✓ |
| Tool Results | ✓ | ✓ | ✓ | ✓ | ✓ |
| Questions (HITL) | ✓ | | ✓ | | |
| Permissions (HITL) | ✓ | ✓ | ✓ | - | |
| Images | - | ✓ | ✓ | - | ✓ |
| File Attachments | - | ✓ | ✓ | - | |
| Session Lifecycle | - | ✓ | ✓ | - | |
| Error Events | - | ✓ | ✓ | ✓ | ✓ |
| Reasoning/Thinking | - | ✓ | - | - | ✓ |
| Command Execution | - | ✓ | - | - | |
| File Changes | - | ✓ | - | - | |
| MCP Tools | - | ✓ | - | - | |
| Streaming Deltas | ✓ | ✓ | ✓ | - | ✓ |
Agents: [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) · [Codex](https://github.com/openai/codex) · [OpenCode](https://github.com/opencode-ai/opencode) · [Amp](https://ampcode.com)
Agents: [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) · [Codex](https://github.com/openai/codex) · [OpenCode](https://github.com/opencode-ai/opencode) · [Amp](https://ampcode.com) · [Pi](https://buildwithpi.ai/pi-cli)
- ✓ = Appears in session events
- \- = Agent supports natively, schema conversion coming soon

View file

@ -19,7 +19,7 @@ import type { RequestLog } from "./types/requestLog";
import { buildCurl } from "./utils/http";
const logoUrl = `${import.meta.env.BASE_URL}logos/sandboxagent.svg`;
const defaultAgents = ["claude", "codex", "opencode", "amp", "mock"];
const defaultAgents = ["claude", "codex", "opencode", "amp", "pi", "mock"];
type ItemEventData = {
item: UniversalItem;
@ -31,6 +31,18 @@ type ItemDeltaEventData = {
delta: string;
};
const shouldHidePiStatusItem = (item: UniversalItem) => {
if (item.kind !== "status") return false;
const statusParts = (item.content ?? []).filter(
(part) => (part as { type?: string }).type === "status"
) as Array<{ label?: string }>;
if (statusParts.length === 0) return false;
return statusParts.every((part) => {
const label = part.label ?? "";
return label.startsWith("pi.turn_") || label.startsWith("pi.agent_");
});
};
const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => {
return {
item_id: itemId,
@ -734,7 +746,10 @@ export default function App() {
}
}
return entries;
return entries.filter((entry) => {
if (entry.kind !== "item" || !entry.item) return true;
return !shouldHidePiStatusItem(entry.item);
});
}, [events]);
useEffect(() => {
@ -841,6 +856,7 @@ export default function App() {
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
mock: "Mock"
};
const agentLabel = agentDisplayNames[agentId] ?? agentId;

View file

@ -45,6 +45,7 @@ const SessionSidebar = ({
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
mock: "Mock"
};

View file

@ -112,6 +112,7 @@ const ChatPanel = ({
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
mock: "Mock"
};

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
<!-- P shape: outer boundary clockwise, inner hole counter-clockwise -->
<path fill="#fff" fill-rule="evenodd" d="
M165.29 165.29
H517.36
V400
H400
V517.36
H282.65
V634.72
H165.29
Z
M282.65 282.65
V400
H400
V282.65
Z
"/>
<!-- i dot -->
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

View file

@ -7,7 +7,7 @@ import { ArrowRight, Terminal, Check } from 'lucide-react';
const CTA_TITLES = [
'Run coding agents in sandboxes. Control them over HTTP.',
'A server inside your sandbox. An API for your app.',
'Claude Code, Codex, OpenCode, Amp — one HTTP API.',
'Claude Code, Codex, OpenCode, Amp, Pi — one HTTP API.',
'Your app connects remotely. The coding agent runs isolated.',
'Streaming events. Handling permissions. Managing sessions.',
'Install with curl. Connect over HTTP. Control any coding agent.',

View file

@ -13,7 +13,7 @@ const faqs = [
{
question: 'Which coding agents are supported?',
answer:
'Claude Code, Codex, OpenCode, and Amp. The SDK normalizes their APIs so you can swap between them without changing your code.',
'Claude Code, Codex, OpenCode, Amp, and Pi. The SDK normalizes their APIs so you can swap between them without changing your code.',
},
{
question: 'How is session data persisted?',
@ -33,7 +33,7 @@ const faqs = [
{
question: 'Can I use this with my personal API keys?',
answer:
"Yes. Use <code>sandbox-agent credentials extract-env</code> to extract API keys from your local agent configs (Claude Code, Codex, OpenCode, Amp) and pass them to the sandbox environment.",
"Yes. Use <code>sandbox-agent credentials extract-env</code> to extract API keys from your local agent configs (Claude Code, Codex, OpenCode, Amp, Pi) and pass them to the sandbox environment.",
},
{
question: 'Why Rust and not [language]?',

View file

@ -38,7 +38,7 @@ export function FeatureGrid() {
<h4 className="text-sm font-medium uppercase tracking-wider text-white">Universal Agent API</h4>
</div>
<p className="text-zinc-400 leading-relaxed text-lg max-w-2xl">
Claude Code, Codex, OpenCode, and Amp each have different APIs. We provide a single,
Claude Code, Codex, OpenCode, Amp, and Pi each have different APIs. We provide a single,
unified interface to control them all.
</p>
</div>

View file

@ -4,10 +4,11 @@ import { useState, useEffect } from 'react';
import { Terminal, Check, ArrowRight } from 'lucide-react';
const ADAPTERS = [
{ label: 'Claude Code', color: '#D97757', x: 35, y: 70, logo: '/logos/claude.svg' },
{ label: 'Codex', color: '#10A37F', x: 185, y: 70, logo: 'openai' },
{ label: 'Amp', color: '#F59E0B', x: 35, y: 155, logo: '/logos/amp.svg' },
{ label: 'OpenCode', color: '#8B5CF6', x: 185, y: 155, logo: 'opencode' },
{ label: 'Claude Code', color: '#D97757', x: 35, y: 30, logo: '/logos/claude.svg' },
{ label: 'Codex', color: '#10A37F', x: 185, y: 30, logo: 'openai' },
{ label: 'Amp', color: '#F59E0B', x: 35, y: 115, logo: '/logos/amp.svg' },
{ label: 'OpenCode', color: '#8B5CF6', x: 185, y: 115, logo: 'opencode' },
{ label: 'Pi', color: '#38BDF8', x: 110, y: 200, logo: '/logos/pi.svg' },
];
function UniversalAPIDiagram() {
@ -187,7 +188,7 @@ export function Hero() {
<span className="text-zinc-400">Control Them Over HTTP.</span>
</h1>
<p className="mt-6 text-lg text-zinc-500 leading-relaxed max-w-xl mx-auto lg:mx-0">
The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, or Amp streaming events, handling permissions, managing sessions.
The Sandbox Agent SDK is a server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Amp, or Pi streaming events, handling permissions, managing sessions.
</p>
<div className="mt-10 flex flex-col items-center gap-4 sm:flex-row sm:justify-center lg:justify-start">

View file

@ -16,7 +16,7 @@ const frictions = [
number: '02',
title: 'Every Coding Agent is Different',
problem:
'Claude Code, Codex, OpenCode, and Amp each have proprietary APIs, event formats, and behaviors. Swapping coding agents means rewriting your entire integration.',
'Claude Code, Codex, OpenCode, Amp, and Pi each have proprietary APIs, event formats, and behaviors. Swapping coding agents means rewriting your entire integration.',
solution: 'One HTTP API. Write your code once, swap coding agents with a config change.',
accentColor: 'purple',
},

View file

@ -6,7 +6,7 @@ import { FeatureIcon } from './ui/FeatureIcon';
const problems = [
{
title: 'Universal Agent API',
desc: 'Claude Code, Codex, OpenCode, and Amp each have different APIs. We provide a single interface to control them all.',
desc: 'Claude Code, Codex, OpenCode, Amp, and Pi each have different APIs. We provide a single interface to control them all.',
icon: Workflow,
color: 'text-accent',
},

View file

@ -0,0 +1,124 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://sandbox-agent/schemas/pi.json",
"title": "Pi RPC Schema",
"definitions": {
"RpcEvent": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"sessionId": {
"type": "string"
},
"messageId": {
"type": "string"
},
"message": {
"$ref": "#/definitions/RpcMessage"
},
"assistantMessageEvent": {
"$ref": "#/definitions/AssistantMessageEvent"
},
"toolCallId": {
"type": "string"
},
"toolName": {
"type": "string"
},
"args": {},
"partialResult": {},
"result": {
"$ref": "#/definitions/ToolResult"
},
"isError": {
"type": "boolean"
},
"error": {}
},
"required": [
"type"
]
},
"RpcMessage": {
"type": "object",
"properties": {
"role": {
"type": "string"
},
"content": {}
}
},
"AssistantMessageEvent": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"delta": {
"type": "string"
},
"content": {},
"partial": {},
"messageId": {
"type": "string"
}
}
},
"ToolResult": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"content": {
"type": "string"
},
"text": {
"type": "string"
}
}
},
"RpcResponse": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "response"
},
"id": {
"type": "integer"
},
"success": {
"type": "boolean"
},
"data": {},
"error": {}
},
"required": [
"type"
]
},
"RpcCommand": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "command"
},
"id": {
"type": "integer"
},
"command": {
"type": "string"
},
"params": {}
},
"required": [
"type",
"command"
]
}
}
}

View file

@ -9,6 +9,7 @@
"extract:claude": "tsx src/index.ts --agent=claude",
"extract:codex": "tsx src/index.ts --agent=codex",
"extract:amp": "tsx src/index.ts --agent=amp",
"extract:pi": "tsx src/index.ts --agent=pi",
"extract:claude-events": "tsx src/claude-event-types.ts",
"extract:claude-events:sdk": "tsx src/claude-event-types-sdk.ts",
"extract:claude-events:cli": "tsx src/claude-event-types-cli.ts",

View file

@ -4,18 +4,20 @@ import { extractOpenCodeSchema } from "./opencode.js";
import { extractClaudeSchema } from "./claude.js";
import { extractCodexSchema } from "./codex.js";
import { extractAmpSchema } from "./amp.js";
import { extractPiSchema } from "./pi.js";
import { validateSchema, type NormalizedSchema } from "./normalize.js";
const RESOURCE_DIR = join(import.meta.dirname, "..");
const DIST_DIR = join(RESOURCE_DIR, "artifacts", "json-schema");
type AgentName = "opencode" | "claude" | "codex" | "amp";
type AgentName = "opencode" | "claude" | "codex" | "amp" | "pi";
const EXTRACTORS: Record<AgentName, () => Promise<NormalizedSchema>> = {
opencode: extractOpenCodeSchema,
claude: extractClaudeSchema,
codex: extractCodexSchema,
amp: extractAmpSchema,
pi: extractPiSchema,
};
function parseArgs(): { agents: AgentName[] } {

View file

@ -0,0 +1,191 @@
import { execSync } from "child_process";
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { createGenerator, type Config } from "ts-json-schema-generator";
import { createNormalizedSchema, type NormalizedSchema } from "./normalize.js";
import type { JSONSchema7 } from "json-schema";
const PI_SOURCE_URL = "https://codeload.github.com/badlogic/pi-mono/tar.gz/refs/heads/main";
const RPC_TYPES_PATH = "packages/coding-agent/src/modes/rpc/rpc-types.ts";
const TARGET_TYPES = ["RpcEvent", "RpcResponse", "RpcCommand"] as const;
export async function extractPiSchema(): Promise<NormalizedSchema> {
console.log("Extracting Pi schema from pi-mono sources...");
const tempDir = mkdtempSync(join(tmpdir(), "pi-schema-"));
try {
const archivePath = join(tempDir, "pi-mono.tar.gz");
await downloadToFile(PI_SOURCE_URL, archivePath);
execSync(`tar -xzf "${archivePath}" -C "${tempDir}"`, {
stdio: ["ignore", "ignore", "ignore"],
});
const repoRoot = findRepoRoot(tempDir);
const rpcTypesPath = join(repoRoot, RPC_TYPES_PATH);
if (!existsSync(rpcTypesPath)) {
throw new Error(`rpc-types.ts not found at ${rpcTypesPath}`);
}
const tsconfig = resolveTsconfig(repoRoot);
const definitions = generateDefinitions(rpcTypesPath, tsconfig);
if (Object.keys(definitions).length === 0) {
console.log(" [warn] No schemas extracted from source, using fallback");
return createFallbackSchema();
}
console.log(` [ok] Extracted ${Object.keys(definitions).length} types from source`);
return createNormalizedSchema("pi", "Pi RPC Schema", definitions);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(` [warn] Pi schema extraction failed: ${errorMessage}`);
console.log(" [fallback] Using embedded schema definitions");
return createFallbackSchema();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
async function downloadToFile(url: string, filePath: string): Promise<void> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
writeFileSync(filePath, buffer);
}
function findRepoRoot(root: string): string {
const entries = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory());
const repoDir = entries.find((entry) => entry.name.startsWith("pi-mono"));
if (!repoDir) {
throw new Error("pi-mono source directory not found after extraction");
}
return join(root, repoDir.name);
}
function resolveTsconfig(root: string): string | undefined {
const candidates = [
join(root, "tsconfig.json"),
join(root, "tsconfig.base.json"),
join(root, "packages", "coding-agent", "tsconfig.json"),
];
return candidates.find((path) => existsSync(path));
}
function generateDefinitions(
rpcTypesPath: string,
tsconfigPath?: string
): Record<string, JSONSchema7> {
const definitions: Record<string, JSONSchema7> = {};
for (const typeName of TARGET_TYPES) {
const config: Config = {
path: rpcTypesPath,
type: typeName,
expose: "all",
skipTypeCheck: false,
topRef: false,
...(tsconfigPath ? { tsconfig: tsconfigPath } : {}),
};
const schema = createGenerator(config).createSchema(typeName) as JSONSchema7;
mergeDefinitions(definitions, schema, typeName);
}
return definitions;
}
function mergeDefinitions(
target: Record<string, JSONSchema7>,
schema: JSONSchema7,
typeName: string
): void {
if (schema.definitions) {
for (const [name, def] of Object.entries(schema.definitions)) {
target[name] = def as JSONSchema7;
}
} else if (schema.$defs) {
for (const [name, def] of Object.entries(schema.$defs)) {
target[name] = def as JSONSchema7;
}
} else {
target[typeName] = schema;
}
if (!target[typeName]) {
target[typeName] = schema;
}
}
function createFallbackSchema(): NormalizedSchema {
const definitions: Record<string, JSONSchema7> = {
RpcEvent: {
type: "object",
properties: {
type: { type: "string" },
sessionId: { type: "string" },
messageId: { type: "string" },
message: { $ref: "#/definitions/RpcMessage" },
assistantMessageEvent: { $ref: "#/definitions/AssistantMessageEvent" },
toolCallId: { type: "string" },
toolName: { type: "string" },
args: {},
partialResult: {},
result: { $ref: "#/definitions/ToolResult" },
isError: { type: "boolean" },
error: {},
},
required: ["type"],
},
RpcMessage: {
type: "object",
properties: {
role: { type: "string" },
content: {},
},
},
AssistantMessageEvent: {
type: "object",
properties: {
type: { type: "string" },
delta: { type: "string" },
content: {},
partial: {},
messageId: { type: "string" },
},
},
ToolResult: {
type: "object",
properties: {
type: { type: "string" },
content: { type: "string" },
text: { type: "string" },
},
},
RpcResponse: {
type: "object",
properties: {
type: { type: "string", const: "response" },
id: { type: "integer" },
success: { type: "boolean" },
data: {},
error: {},
},
required: ["type"],
},
RpcCommand: {
type: "object",
properties: {
type: { type: "string", const: "command" },
id: { type: "integer" },
command: { type: "string" },
params: {},
},
required: ["type", "command"],
},
};
console.log(` [ok] Using fallback schema with ${Object.keys(definitions).length} definitions`);
return createNormalizedSchema("pi", "Pi RPC Schema", definitions);
}

View file

@ -7,7 +7,6 @@ use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitSta
use std::time::{Duration, Instant};
use flate2::read::GzDecoder;
use reqwest::blocking::Client;
use sandbox_agent_extracted_agent_schemas::codex as codex_schema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -21,6 +20,7 @@ pub enum AgentId {
Codex,
Opencode,
Amp,
Pi,
Mock,
}
@ -31,17 +31,55 @@ impl AgentId {
AgentId::Codex => "codex",
AgentId::Opencode => "opencode",
AgentId::Amp => "amp",
AgentId::Pi => "pi",
AgentId::Mock => "mock",
}
}
pub fn binary_name(self) -> &'static str {
match self {
AgentId::Claude => "claude",
AgentId::Codex => "codex",
AgentId::Opencode => "opencode",
AgentId::Amp => "amp",
AgentId::Mock => "mock",
AgentId::Claude => {
if cfg!(windows) {
"claude.exe"
} else {
"claude"
}
}
AgentId::Codex => {
if cfg!(windows) {
"codex.exe"
} else {
"codex"
}
}
AgentId::Opencode => {
if cfg!(windows) {
"opencode.exe"
} else {
"opencode"
}
}
AgentId::Amp => {
if cfg!(windows) {
"amp.exe"
} else {
"amp"
}
}
AgentId::Pi => {
if cfg!(windows) {
"pi.exe"
} else {
"pi"
}
}
AgentId::Mock => {
if cfg!(windows) {
"mock.exe"
} else {
"mock"
}
}
}
}
@ -51,6 +89,7 @@ impl AgentId {
"codex" => Some(AgentId::Codex),
"opencode" => Some(AgentId::Opencode),
"amp" => Some(AgentId::Amp),
"pi" => Some(AgentId::Pi),
"mock" => Some(AgentId::Mock),
_ => None,
}
@ -151,6 +190,7 @@ impl AgentManager {
install_opencode(&install_path, self.platform, options.version.as_deref())?
}
AgentId::Amp => install_amp(&install_path, self.platform, options.version.as_deref())?,
AgentId::Pi => install_pi(&install_path, self.platform, options.version.as_deref())?,
AgentId::Mock => {
if !install_path.exists() {
fs::write(&install_path, b"mock")?;
@ -284,6 +324,11 @@ impl AgentManager {
events,
});
}
AgentId::Pi => {
return Err(AgentError::UnsupportedAgent {
agent: agent.as_str().to_string(),
});
}
AgentId::Mock => {
return Err(AgentError::UnsupportedAgent {
agent: agent.as_str().to_string(),
@ -619,6 +664,11 @@ impl AgentManager {
AgentId::Amp => {
return Ok(build_amp_command(&path, &working_dir, options));
}
AgentId::Pi => {
return Err(AgentError::UnsupportedAgent {
agent: agent.as_str().to_string(),
});
}
AgentId::Mock => {
return Err(AgentError::UnsupportedAgent {
agent: agent.as_str().to_string(),
@ -940,6 +990,7 @@ fn extract_session_id(agent: AgentId, events: &[Value]) -> Option<String> {
return Some(id);
}
}
AgentId::Pi => {}
AgentId::Mock => {}
}
}
@ -1022,6 +1073,7 @@ fn extract_result_text(agent: AgentId, events: &[Value]) -> Option<String> {
Some(buffer)
}
}
AgentId::Pi => None,
AgentId::Mock => None,
}
}
@ -1200,7 +1252,7 @@ fn default_install_dir() -> PathBuf {
}
fn download_bytes(url: &Url) -> Result<Vec<u8>, AgentError> {
let client = Client::builder().build()?;
let client = crate::http_client::blocking_client_builder().build()?;
let mut response = client.get(url.clone()).send()?;
if !response.status().is_success() {
return Err(AgentError::DownloadFailed { url: url.clone() });
@ -1210,6 +1262,28 @@ fn download_bytes(url: &Url) -> Result<Vec<u8>, AgentError> {
Ok(bytes)
}
fn install_pi(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> {
let asset = match platform {
Platform::LinuxX64 | Platform::LinuxX64Musl => "pi-linux-x64",
Platform::LinuxArm64 => "pi-linux-arm64",
Platform::MacosArm64 => "pi-darwin-arm64",
Platform::MacosX64 => "pi-darwin-x64",
}
.to_string();
let url = match version {
Some(version) => Url::parse(&format!(
"https://upd.dev/badlogic/pi-mono/releases/download/{version}/{asset}"
))?,
None => Url::parse(&format!(
"https://upd.dev/badlogic/pi-mono/releases/latest/download/{asset}"
))?,
};
let bytes = download_bytes(&url)?;
write_executable(path, &bytes)?;
Ok(())
}
fn install_claude(
path: &Path,
platform: Platform,
@ -1329,7 +1403,7 @@ fn install_opencode(
};
install_zip_binary(path, &url, "opencode")
}
_ => {
Platform::LinuxX64 | Platform::LinuxX64Musl | Platform::LinuxArm64 => {
let platform_segment = match platform {
Platform::LinuxX64 => "linux-x64",
Platform::LinuxX64Musl => "linux-x64-musl",

View file

@ -0,0 +1,20 @@
use std::env;
use reqwest::blocking::ClientBuilder;
const NO_SYSTEM_PROXY_ENV: &str = "SANDBOX_AGENT_NO_SYSTEM_PROXY";
fn disable_system_proxy() -> bool {
env::var(NO_SYSTEM_PROXY_ENV)
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false)
}
pub(crate) fn blocking_client_builder() -> ClientBuilder {
let builder = reqwest::blocking::Client::builder();
if disable_system_proxy() {
builder.no_proxy()
} else {
builder
}
}

View file

@ -1,3 +1,4 @@
pub mod agents;
pub mod credentials;
mod http_client;
pub mod testing;

View file

@ -2,7 +2,6 @@ use std::env;
use std::path::PathBuf;
use std::time::Duration;
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use reqwest::StatusCode;
use thiserror::Error;
@ -36,6 +35,7 @@ pub enum TestAgentConfigError {
const AGENTS_ENV: &str = "SANDBOX_TEST_AGENTS";
const ANTHROPIC_ENV: &str = "SANDBOX_TEST_ANTHROPIC_API_KEY";
const OPENAI_ENV: &str = "SANDBOX_TEST_OPENAI_API_KEY";
const PI_ENV: &str = "SANDBOX_TEST_PI";
const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models";
const OPENAI_MODELS_URL: &str = "https://api.openai.com/v1/models";
const ANTHROPIC_VERSION: &str = "2023-06-01";
@ -63,6 +63,7 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr
AgentId::Codex,
AgentId::Opencode,
AgentId::Amp,
AgentId::Pi,
]);
continue;
}
@ -73,6 +74,12 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr
agents
};
let include_pi = pi_tests_enabled() && find_in_path(AgentId::Pi.binary_name());
if !include_pi && agents.iter().any(|agent| *agent == AgentId::Pi) {
eprintln!("Skipping Pi tests: set {PI_ENV}=1 and ensure pi is on PATH.");
}
agents.retain(|agent| *agent != AgentId::Pi || include_pi);
agents.sort_by(|a, b| a.as_str().cmp(b.as_str()));
agents.dedup();
@ -137,6 +144,21 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr
}
credentials_with(anthropic_cred.clone(), openai_cred.clone())
}
AgentId::Pi => {
if anthropic_cred.is_none() && openai_cred.is_none() {
return Err(TestAgentConfigError::MissingCredentials {
agent,
missing: format!("{ANTHROPIC_ENV} or {OPENAI_ENV}"),
});
}
if let Some(cred) = anthropic_cred.as_ref() {
ensure_anthropic_ok(&mut health_cache, cred)?;
}
if let Some(cred) = openai_cred.as_ref() {
ensure_openai_ok(&mut health_cache, cred)?;
}
credentials_with(anthropic_cred.clone(), openai_cred.clone())
}
AgentId::Mock => credentials_with(None, None),
};
configs.push(TestAgentConfig { agent, credentials });
@ -172,7 +194,7 @@ fn ensure_openai_ok(
fn health_check_anthropic(credentials: &ProviderCredentials) -> Result<(), TestAgentConfigError> {
let credentials = credentials.clone();
run_blocking_check("anthropic", move || {
let client = Client::builder()
let client = crate::http_client::blocking_client_builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|err| TestAgentConfigError::HealthCheckFailed {
@ -226,7 +248,7 @@ fn health_check_anthropic(credentials: &ProviderCredentials) -> Result<(), TestA
fn health_check_openai(credentials: &ProviderCredentials) -> Result<(), TestAgentConfigError> {
let credentials = credentials.clone();
run_blocking_check("openai", move || {
let client = Client::builder()
let client = crate::http_client::blocking_client_builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|err| TestAgentConfigError::HealthCheckFailed {
@ -298,12 +320,15 @@ where
}
fn detect_system_agents() -> Vec<AgentId> {
let candidates = [
let mut candidates = vec![
AgentId::Claude,
AgentId::Codex,
AgentId::Opencode,
AgentId::Amp,
];
if pi_tests_enabled() && find_in_path(AgentId::Pi.binary_name()) {
candidates.push(AgentId::Pi);
}
let install_dir = default_install_dir();
candidates
.into_iter()
@ -345,6 +370,15 @@ fn read_env_key(name: &str) -> Option<String> {
})
}
fn pi_tests_enabled() -> bool {
env::var(PI_ENV)
.map(|value| {
let value = value.trim().to_ascii_lowercase();
value == "1" || value == "true" || value == "yes"
})
.unwrap_or(false)
}
fn credentials_with(
anthropic_cred: Option<ProviderCredentials>,
openai_cred: Option<ProviderCredentials>,

View file

@ -11,6 +11,7 @@ fn main() {
("claude", "claude.json"),
("codex", "codex.json"),
("amp", "amp.json"),
("pi", "pi.json"),
];
for (name, file) in schemas {

View file

@ -5,6 +5,7 @@
//! - Claude Code SDK
//! - Codex SDK
//! - AMP Code SDK
//! - Pi RPC
pub mod opencode {
//! OpenCode SDK types extracted from OpenAPI 3.1.1 spec.
@ -25,3 +26,8 @@ pub mod amp {
//! AMP Code SDK types.
include!(concat!(env!("OUT_DIR"), "/amp.rs"));
}
pub mod pi {
//! Pi RPC types.
include!(concat!(env!("OUT_DIR"), "/pi.rs"));
}

View file

@ -0,0 +1,30 @@
use std::env;
use reqwest::blocking::ClientBuilder as BlockingClientBuilder;
use reqwest::ClientBuilder;
const NO_SYSTEM_PROXY_ENV: &str = "SANDBOX_AGENT_NO_SYSTEM_PROXY";
fn disable_system_proxy() -> bool {
env::var(NO_SYSTEM_PROXY_ENV)
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false)
}
pub fn client_builder() -> ClientBuilder {
let builder = reqwest::Client::builder();
if disable_system_proxy() {
builder.no_proxy()
} else {
builder
}
}
pub fn blocking_client_builder() -> BlockingClientBuilder {
let builder = reqwest::blocking::Client::builder();
if disable_system_proxy() {
builder.no_proxy()
} else {
builder
}
}

View file

@ -2,6 +2,7 @@
mod agent_server_logs;
pub mod credentials;
pub mod http_client;
pub mod router;
pub mod telemetry;
pub mod ui;

View file

@ -11,6 +11,7 @@ mod build_version {
}
use reqwest::blocking::Client as HttpClient;
use reqwest::Method;
use sandbox_agent::http_client;
use sandbox_agent::router::{build_router_with_state, shutdown_servers};
use sandbox_agent::router::{
AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest,
@ -687,6 +688,7 @@ enum CredentialAgent {
Codex,
Opencode,
Amp,
Pi,
}
fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput {
@ -806,6 +808,31 @@ fn select_token_for_agent(
)))
}
}
CredentialAgent::Pi => {
if let Some(provider) = provider {
return select_token_for_provider(credentials, provider);
}
if let Some(openai) = credentials.openai.as_ref() {
return Ok(openai.api_key.clone());
}
if let Some(anthropic) = credentials.anthropic.as_ref() {
return Ok(anthropic.api_key.clone());
}
if credentials.other.len() == 1 {
if let Some((_, cred)) = credentials.other.iter().next() {
return Ok(cred.api_key.clone());
}
}
let available = available_providers(credentials);
if available.is_empty() {
Err(CliError::Server("no credentials found for pi".to_string()))
} else {
Err(CliError::Server(format!(
"multiple providers available for pi: {} (use --provider)",
available.join(", ")
)))
}
}
}
}
@ -919,7 +946,7 @@ impl ClientContext {
} else {
cli.token.clone()
};
let client = HttpClient::builder().build()?;
let client = http_client::blocking_client_builder().build()?;
Ok(Self {
endpoint,
token,

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,8 @@ use serde::Serialize;
use time::OffsetDateTime;
use tokio::time::Instant;
use crate::http_client;
const TELEMETRY_URL: &str = "https://tc.rivet.dev";
const TELEMETRY_ENV_DEBUG: &str = "SANDBOX_AGENT_TELEMETRY_DEBUG";
const TELEMETRY_ID_FILE: &str = "telemetry_id";
@ -77,7 +79,7 @@ pub fn log_enabled_message() {
pub fn spawn_telemetry_task() {
tokio::spawn(async move {
let client = match Client::builder()
let client = match http_client::client_builder()
.timeout(Duration::from_millis(TELEMETRY_TIMEOUT_MS))
.build()
{

View file

@ -5,3 +5,4 @@ mod agent_permission_flow;
mod agent_question_flow;
mod agent_termination;
mod agent_tool_flow;
mod pi_rpc_integration;

View file

@ -0,0 +1,61 @@
// Pi RPC integration tests (gated via SANDBOX_TEST_PI + PATH).
include!("../common/http.rs");
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pi_rpc_session_and_stream() {
let configs = match test_agents_from_env() {
Ok(configs) => configs,
Err(err) => {
eprintln!("Skipping Pi RPC integration test: {err}");
return;
}
};
let Some(config) = configs.iter().find(|config| config.agent == AgentId::Pi) else {
return;
};
let app = TestApp::new();
let _guard = apply_credentials(&config.credentials);
install_agent(&app.app, config.agent).await;
let session_id = "pi-rpc-session".to_string();
let (status, payload) = send_json(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}"),
Some(json!({
"agent": "pi",
"permissionMode": test_permission_mode(AgentId::Pi),
})),
)
.await;
assert_eq!(status, StatusCode::OK, "create pi session");
let native_session_id = payload
.get("native_session_id")
.and_then(Value::as_str)
.unwrap_or("");
assert!(
!native_session_id.is_empty(),
"expected native_session_id for pi session"
);
let events = read_turn_stream_events(&app.app, &session_id, Duration::from_secs(120)).await;
assert!(!events.is_empty(), "no events from pi stream");
assert!(
!events.iter().any(is_unparsed_event),
"agent.unparsed event encountered"
);
let mut last_sequence = 0u64;
for event in events {
let sequence = event
.get("sequence")
.and_then(Value::as_u64)
.expect("missing sequence");
assert!(
sequence > last_sequence,
"sequence did not increase (prev {last_sequence}, next {sequence})"
);
last_sequence = sequence;
}
}

View file

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::env;
use sandbox_agent_agent_management::agents::{
AgentError, AgentId, AgentManager, InstallOptions, SpawnOptions,
@ -29,6 +30,29 @@ fn prompt_ok(label: &str) -> String {
format!("Respond with exactly the text {label} and nothing else.")
}
fn pi_tests_enabled() -> bool {
env::var("SANDBOX_TEST_PI")
.map(|value| {
let value = value.trim().to_ascii_lowercase();
value == "1" || value == "true" || value == "yes"
})
.unwrap_or(false)
}
fn pi_on_path() -> bool {
let binary = AgentId::Pi.binary_name();
let path_var = match env::var_os("PATH") {
Some(path) => path,
None => return false,
};
for path in env::split_paths(&path_var) {
if path.join(binary).exists() {
return true;
}
}
false
}
#[test]
fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
@ -36,12 +60,15 @@ fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>>
let env = build_env();
assert!(!env.is_empty(), "expected credentials to be available");
let agents = [
let mut agents = vec![
AgentId::Claude,
AgentId::Codex,
AgentId::Opencode,
AgentId::Amp,
];
if pi_tests_enabled() && pi_on_path() {
agents.push(AgentId::Pi);
}
for agent in agents {
let install = manager.install(agent, InstallOptions::default())?;
assert!(install.path.exists(), "expected install for {agent}");

View file

@ -178,7 +178,7 @@ async fn install_agent(app: &Router, agent: AgentId) {
/// while other agents support "bypass" which skips tool approval.
fn test_permission_mode(agent: AgentId) -> &'static str {
match agent {
AgentId::Opencode => "default",
AgentId::Opencode | AgentId::Pi => "default",
_ => "bypass",
}
}

View file

@ -182,7 +182,7 @@ pub async fn create_session_with_mode(
pub fn test_permission_mode(agent: AgentId) -> &'static str {
match agent {
AgentId::Opencode => "default",
AgentId::Opencode | AgentId::Pi => "default",
_ => "bypass",
}
}

View file

@ -2,3 +2,4 @@ pub mod amp;
pub mod claude;
pub mod codex;
pub mod opencode;
pub mod pi;

View file

@ -0,0 +1,674 @@
use std::collections::{HashMap, HashSet};
use serde_json::Value;
use crate::pi as schema;
use crate::{
ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus,
ReasoningVisibility, UniversalEventData, UniversalEventType, UniversalItem,
};
#[derive(Default)]
pub struct PiEventConverter {
tool_result_buffers: HashMap<String, String>,
tool_result_started: HashSet<String>,
message_errors: HashSet<String>,
message_reasoning: HashMap<String, String>,
message_text: HashMap<String, String>,
last_message_id: Option<String>,
message_started: HashSet<String>,
message_counter: u64,
}
impl PiEventConverter {
fn next_synthetic_message_id(&mut self) -> String {
self.message_counter += 1;
format!("pi_msg_{}", self.message_counter)
}
fn ensure_message_id(&mut self, message_id: Option<String>) -> String {
if let Some(id) = message_id {
self.last_message_id = Some(id.clone());
return id;
}
if let Some(id) = self.last_message_id.clone() {
return id;
}
let id = self.next_synthetic_message_id();
self.last_message_id = Some(id.clone());
id
}
fn ensure_message_started(&mut self, message_id: &str) -> Option<EventConversion> {
if !self.message_started.insert(message_id.to_string()) {
return None;
}
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(message_id.to_string()),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: Vec::new(),
status: ItemStatus::InProgress,
};
Some(
EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item }),
)
.synthetic(),
)
}
fn clear_last_message_id(&mut self, message_id: Option<&str>) {
if message_id.is_none() || self.last_message_id.as_deref() == message_id {
self.last_message_id = None;
}
}
pub fn event_to_universal(
&mut self,
event: &schema::RpcEvent,
) -> Result<Vec<EventConversion>, String> {
let raw = serde_json::to_value(event).map_err(|err| err.to_string())?;
let event_type = raw
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| "missing event type".to_string())?;
let native_session_id = extract_session_id(&raw);
let conversions = match event_type {
"message_start" => self.message_start(&raw),
"message_update" => self.message_update(&raw),
"message_end" => self.message_end(&raw),
"tool_execution_start" => self.tool_execution_start(&raw),
"tool_execution_update" => self.tool_execution_update(&raw),
"tool_execution_end" => self.tool_execution_end(&raw),
"agent_start"
| "agent_end"
| "turn_start"
| "turn_end"
| "auto_compaction"
| "auto_compaction_start"
| "auto_compaction_end"
| "auto_retry"
| "auto_retry_start"
| "auto_retry_end"
| "hook_error" => Ok(vec![status_event(event_type, &raw)]),
"extension_ui_request" | "extension_ui_response" | "extension_error" => {
Ok(vec![status_event(event_type, &raw)])
}
other => Err(format!("unsupported Pi event type: {other}")),
}?;
Ok(conversions
.into_iter()
.map(|conversion| attach_metadata(conversion, &native_session_id, &raw))
.collect())
}
fn message_start(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
let message = raw.get("message");
if is_user_role(message) {
return Ok(Vec::new());
}
let message_id = self.ensure_message_id(extract_message_id(raw));
self.message_started.insert(message_id.clone());
let content = message.and_then(parse_message_content).unwrap_or_default();
let entry = self.message_text.entry(message_id.clone()).or_default();
for part in &content {
if let ContentPart::Text { text } = part {
entry.push_str(text);
}
}
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(message_id),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content,
status: ItemStatus::InProgress,
};
Ok(vec![EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item }),
)])
}
fn message_update(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
let assistant_event = raw
.get("assistantMessageEvent")
.or_else(|| raw.get("assistant_message_event"))
.ok_or_else(|| "missing assistantMessageEvent".to_string())?;
let event_type = assistant_event
.get("type")
.and_then(Value::as_str)
.unwrap_or("");
let message_id = extract_message_id(raw)
.or_else(|| extract_message_id(assistant_event))
.or_else(|| self.last_message_id.clone());
match event_type {
"start" => {
if let Some(id) = message_id {
self.last_message_id = Some(id);
}
Ok(Vec::new())
}
"text_start" | "text_delta" | "text_end" => {
let Some(delta) = extract_delta_text(assistant_event) else {
return Ok(Vec::new());
};
let message_id = self.ensure_message_id(message_id);
let entry = self.message_text.entry(message_id.clone()).or_default();
entry.push_str(&delta);
let mut conversions = Vec::new();
if let Some(start) = self.ensure_message_started(&message_id) {
conversions.push(start);
}
conversions.push(item_delta(Some(message_id), delta));
Ok(conversions)
}
"thinking_start" | "thinking_delta" | "thinking_end" => {
let Some(delta) = extract_delta_text(assistant_event) else {
return Ok(Vec::new());
};
let message_id = self.ensure_message_id(message_id);
let entry = self
.message_reasoning
.entry(message_id.clone())
.or_default();
entry.push_str(&delta);
let mut conversions = Vec::new();
if let Some(start) = self.ensure_message_started(&message_id) {
conversions.push(start);
}
conversions.push(item_delta(Some(message_id), delta));
Ok(conversions)
}
"toolcall_start"
| "toolcall_delta"
| "toolcall_end"
| "toolcall_args_start"
| "toolcall_args_delta"
| "toolcall_args_end" => Ok(Vec::new()),
"done" => {
let message_id = self.ensure_message_id(message_id);
if self.message_errors.remove(&message_id) {
self.message_text.remove(&message_id);
self.message_reasoning.remove(&message_id);
self.message_started.remove(&message_id);
self.clear_last_message_id(Some(&message_id));
return Ok(Vec::new());
}
let message = raw
.get("message")
.or_else(|| assistant_event.get("message"));
let conversion = self.complete_message(Some(message_id.clone()), message);
self.clear_last_message_id(Some(&message_id));
Ok(vec![conversion])
}
"error" => {
let message_id = self.ensure_message_id(message_id);
let error_text = assistant_event
.get("error")
.or_else(|| raw.get("error"))
.map(value_to_string)
.unwrap_or_else(|| "Pi message error".to_string());
self.message_reasoning.remove(&message_id);
self.message_text.remove(&message_id);
self.message_errors.insert(message_id.clone());
self.message_started.remove(&message_id);
self.clear_last_message_id(Some(&message_id));
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(message_id),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::Text { text: error_text }],
status: ItemStatus::Failed,
};
Ok(vec![EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)])
}
other => Err(format!("unsupported assistantMessageEvent: {other}")),
}
}
fn message_end(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
let message = raw.get("message");
if is_user_role(message) {
return Ok(Vec::new());
}
let message_id = self
.ensure_message_id(extract_message_id(raw).or_else(|| self.last_message_id.clone()));
if self.message_errors.remove(&message_id) {
self.message_text.remove(&message_id);
self.message_reasoning.remove(&message_id);
self.message_started.remove(&message_id);
self.clear_last_message_id(Some(&message_id));
return Ok(Vec::new());
}
let conversion = self.complete_message(Some(message_id.clone()), message);
self.clear_last_message_id(Some(&message_id));
Ok(vec![conversion])
}
fn complete_message(
&mut self,
message_id: Option<String>,
message: Option<&Value>,
) -> EventConversion {
let mut content = message.and_then(parse_message_content).unwrap_or_default();
if let Some(id) = message_id.clone() {
if content.is_empty() {
if let Some(text) = self.message_text.remove(&id) {
if !text.is_empty() {
content.push(ContentPart::Text { text });
}
}
} else {
self.message_text.remove(&id);
}
if let Some(reasoning) = self.message_reasoning.remove(&id) {
if !reasoning.trim().is_empty()
&& !content
.iter()
.any(|part| matches!(part, ContentPart::Reasoning { .. }))
{
content.push(ContentPart::Reasoning {
text: reasoning,
visibility: ReasoningVisibility::Private,
});
}
}
self.message_started.remove(&id);
}
let item = UniversalItem {
item_id: String::new(),
native_item_id: message_id,
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content,
status: ItemStatus::Completed,
};
EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)
}
fn tool_execution_start(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
let tool_call_id =
extract_tool_call_id(raw).ok_or_else(|| "missing toolCallId".to_string())?;
let tool_name = extract_tool_name(raw).unwrap_or_else(|| "tool".to_string());
let arguments = raw
.get("args")
.or_else(|| raw.get("arguments"))
.map(value_to_string)
.unwrap_or_else(|| "{}".to_string());
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(tool_call_id.clone()),
parent_id: None,
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: tool_name,
arguments,
call_id: tool_call_id,
}],
status: ItemStatus::InProgress,
};
Ok(vec![EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item }),
)])
}
fn tool_execution_update(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
let tool_call_id = match extract_tool_call_id(raw) {
Some(id) => id,
None => return Ok(Vec::new()),
};
let partial = match raw
.get("partialResult")
.or_else(|| raw.get("partial_result"))
{
Some(value) => value_to_string(value),
None => return Ok(Vec::new()),
};
let prior = self
.tool_result_buffers
.get(&tool_call_id)
.cloned()
.unwrap_or_default();
let delta = delta_from_partial(&prior, &partial);
self.tool_result_buffers
.insert(tool_call_id.clone(), partial);
let mut conversions = Vec::new();
if self.tool_result_started.insert(tool_call_id.clone()) {
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(tool_call_id.clone()),
parent_id: None,
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: vec![ContentPart::ToolResult {
call_id: tool_call_id.clone(),
output: String::new(),
}],
status: ItemStatus::InProgress,
};
conversions.push(
EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item }),
)
.synthetic(),
);
}
if !delta.is_empty() {
conversions.push(
EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: Some(tool_call_id.clone()),
delta,
}),
)
.synthetic(),
);
}
Ok(conversions)
}
fn tool_execution_end(&mut self, raw: &Value) -> Result<Vec<EventConversion>, String> {
let tool_call_id =
extract_tool_call_id(raw).ok_or_else(|| "missing toolCallId".to_string())?;
self.tool_result_buffers.remove(&tool_call_id);
self.tool_result_started.remove(&tool_call_id);
let output = raw
.get("result")
.and_then(extract_result_content)
.unwrap_or_default();
let is_error = raw.get("isError").and_then(Value::as_bool).unwrap_or(false);
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(tool_call_id.clone()),
parent_id: None,
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: vec![ContentPart::ToolResult {
call_id: tool_call_id,
output,
}],
status: if is_error {
ItemStatus::Failed
} else {
ItemStatus::Completed
},
};
Ok(vec![EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)])
}
}
pub fn event_to_universal(event: &schema::RpcEvent) -> Result<Vec<EventConversion>, String> {
PiEventConverter::default().event_to_universal(event)
}
fn attach_metadata(
conversion: EventConversion,
native_session_id: &Option<String>,
raw: &Value,
) -> EventConversion {
conversion
.with_native_session(native_session_id.clone())
.with_raw(Some(raw.clone()))
}
fn status_event(label: &str, raw: &Value) -> EventConversion {
let detail = raw
.get("error")
.or_else(|| raw.get("message"))
.map(value_to_string);
let item = UniversalItem {
item_id: String::new(),
native_item_id: None,
parent_id: None,
kind: ItemKind::Status,
role: Some(ItemRole::System),
content: vec![ContentPart::Status {
label: format!("pi.{label}"),
detail,
}],
status: ItemStatus::Completed,
};
EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)
}
fn item_delta(message_id: Option<String>, delta: String) -> EventConversion {
EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: message_id,
delta,
}),
)
}
fn is_user_role(message: Option<&Value>) -> bool {
message
.and_then(|msg| msg.get("role"))
.and_then(Value::as_str)
.is_some_and(|role| role == "user")
}
fn extract_session_id(value: &Value) -> Option<String> {
extract_string(value, &["sessionId"])
.or_else(|| extract_string(value, &["session_id"]))
.or_else(|| extract_string(value, &["session", "id"]))
.or_else(|| extract_string(value, &["message", "sessionId"]))
}
fn extract_message_id(value: &Value) -> Option<String> {
extract_string(value, &["messageId"])
.or_else(|| extract_string(value, &["message_id"]))
.or_else(|| extract_string(value, &["message", "id"]))
.or_else(|| extract_string(value, &["message", "messageId"]))
.or_else(|| extract_string(value, &["assistantMessageEvent", "messageId"]))
}
fn extract_tool_call_id(value: &Value) -> Option<String> {
extract_string(value, &["toolCallId"]).or_else(|| extract_string(value, &["tool_call_id"]))
}
fn extract_tool_name(value: &Value) -> Option<String> {
extract_string(value, &["toolName"]).or_else(|| extract_string(value, &["tool_name"]))
}
fn extract_string(value: &Value, path: &[&str]) -> Option<String> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_str().map(|value| value.to_string())
}
fn extract_delta_text(event: &Value) -> Option<String> {
if let Some(value) = event.get("delta") {
return Some(value_to_string(value));
}
if let Some(value) = event.get("text") {
return Some(value_to_string(value));
}
if let Some(value) = event.get("partial") {
return extract_text_from_value(value);
}
if let Some(value) = event.get("content") {
return extract_text_from_value(value);
}
None
}
fn extract_text_from_value(value: &Value) -> Option<String> {
if let Some(text) = value.as_str() {
return Some(text.to_string());
}
if let Some(text) = value.get("text").and_then(Value::as_str) {
return Some(text.to_string());
}
if let Some(text) = value.get("content").and_then(Value::as_str) {
return Some(text.to_string());
}
None
}
fn extract_result_content(value: &Value) -> Option<String> {
let content = value.get("content").and_then(Value::as_str);
let text = value.get("text").and_then(Value::as_str);
content
.or(text)
.map(|value| value.to_string())
.or_else(|| Some(value_to_string(value)))
}
fn parse_message_content(message: &Value) -> Option<Vec<ContentPart>> {
if let Some(text) = message.as_str() {
return Some(vec![ContentPart::Text {
text: text.to_string(),
}]);
}
let content_value = message
.get("content")
.or_else(|| message.get("text"))
.or_else(|| message.get("value"))?;
let mut parts = Vec::new();
match content_value {
Value::String(text) => parts.push(ContentPart::Text { text: text.clone() }),
Value::Array(items) => {
for item in items {
if let Some(part) = content_part_from_value(item) {
parts.push(part);
}
}
}
Value::Object(_) => {
if let Some(part) = content_part_from_value(content_value) {
parts.push(part);
}
}
_ => {}
}
Some(parts)
}
fn content_part_from_value(value: &Value) -> Option<ContentPart> {
if let Some(text) = value.as_str() {
return Some(ContentPart::Text {
text: text.to_string(),
});
}
let part_type = value.get("type").and_then(Value::as_str);
match part_type {
Some("text") | Some("markdown") => {
extract_text_from_value(value).map(|text| ContentPart::Text { text })
}
Some("thinking") | Some("reasoning") => {
extract_text_from_value(value).map(|text| ContentPart::Reasoning {
text,
visibility: ReasoningVisibility::Private,
})
}
Some("image") => value
.get("path")
.or_else(|| value.get("url"))
.and_then(|path| {
path.as_str().map(|path| ContentPart::Image {
path: path.to_string(),
mime: value
.get("mime")
.or_else(|| value.get("mimeType"))
.and_then(Value::as_str)
.map(|mime| mime.to_string()),
})
}),
Some("tool_call") | Some("toolcall") => {
let name = value
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string();
let arguments = value
.get("arguments")
.or_else(|| value.get("args"))
.map(value_to_string)
.unwrap_or_else(|| "{}".to_string());
let call_id = value
.get("call_id")
.or_else(|| value.get("callId"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Some(ContentPart::ToolCall {
name,
arguments,
call_id,
})
}
Some("tool_result") => {
let call_id = value
.get("call_id")
.or_else(|| value.get("callId"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let output = value
.get("output")
.or_else(|| value.get("content"))
.map(value_to_string)
.unwrap_or_default();
Some(ContentPart::ToolResult { call_id, output })
}
_ => Some(ContentPart::Json {
json: value.clone(),
}),
}
}
fn value_to_string(value: &Value) -> String {
if let Some(text) = value.as_str() {
text.to_string()
} else {
value.to_string()
}
}
fn delta_from_partial(previous: &str, next: &str) -> String {
if next.starts_with(previous) {
next[previous.len()..].to_string()
} else {
next.to_string()
}
}

View file

@ -3,13 +3,13 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::ToSchema;
pub use sandbox_agent_extracted_agent_schemas::{amp, claude, codex, opencode};
pub use sandbox_agent_extracted_agent_schemas::{amp, claude, codex, opencode, pi};
pub mod agents;
pub use agents::{
amp as convert_amp, claude as convert_claude, codex as convert_codex,
opencode as convert_opencode,
amp as convert_amp, claude as convert_claude, codex as convert_codex, opencode as convert_opencode,
pi as convert_pi,
};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@ -204,7 +204,7 @@ pub enum ItemKind {
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ItemRole {
User,
@ -213,7 +213,7 @@ pub enum ItemRole {
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ItemStatus {
InProgress,

View file

@ -0,0 +1,264 @@
use sandbox_agent_universal_agent_schema::convert_pi::PiEventConverter;
use sandbox_agent_universal_agent_schema::pi as pi_schema;
use sandbox_agent_universal_agent_schema::{
ContentPart, ItemKind, ItemRole, ItemStatus, UniversalEventData, UniversalEventType,
};
use serde_json::json;
fn parse_event(value: serde_json::Value) -> pi_schema::RpcEvent {
serde_json::from_value(value).expect("pi event")
}
#[test]
fn pi_message_flow_converts() {
let mut converter = PiEventConverter::default();
let start_event = parse_event(json!({
"type": "message_start",
"sessionId": "session-1",
"messageId": "msg-1",
"message": {
"role": "assistant",
"content": [{ "type": "text", "text": "Hello" }]
}
}));
let start_events = converter
.event_to_universal(&start_event)
.expect("start conversions");
assert_eq!(start_events[0].event_type, UniversalEventType::ItemStarted);
if let UniversalEventData::Item(item) = &start_events[0].data {
assert_eq!(item.item.kind, ItemKind::Message);
assert_eq!(item.item.role, Some(ItemRole::Assistant));
assert_eq!(item.item.status, ItemStatus::InProgress);
} else {
panic!("expected item event");
}
let update_event = parse_event(json!({
"type": "message_update",
"sessionId": "session-1",
"messageId": "msg-1",
"assistantMessageEvent": { "type": "text_delta", "delta": " world" }
}));
let update_events = converter
.event_to_universal(&update_event)
.expect("update conversions");
assert_eq!(update_events[0].event_type, UniversalEventType::ItemDelta);
let end_event = parse_event(json!({
"type": "message_end",
"sessionId": "session-1",
"messageId": "msg-1",
"message": {
"role": "assistant",
"content": [{ "type": "text", "text": "Hello world" }]
}
}));
let end_events = converter
.event_to_universal(&end_event)
.expect("end conversions");
assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted);
if let UniversalEventData::Item(item) = &end_events[0].data {
assert_eq!(item.item.kind, ItemKind::Message);
assert_eq!(item.item.role, Some(ItemRole::Assistant));
assert_eq!(item.item.status, ItemStatus::Completed);
} else {
panic!("expected item event");
}
}
#[test]
fn pi_user_message_echo_is_skipped() {
let mut converter = PiEventConverter::default();
// Pi may echo the user message as a message_start with role "user".
// The daemon already records synthetic user events, so the converter
// must skip these to avoid a duplicate assistant-looking bubble.
let start_event = parse_event(json!({
"type": "message_start",
"sessionId": "session-1",
"messageId": "user-msg-1",
"message": {
"role": "user",
"content": [{ "type": "text", "text": "hello!" }]
}
}));
let events = converter
.event_to_universal(&start_event)
.expect("user message_start should not error");
assert!(
events.is_empty(),
"user message_start should produce no events, got {}",
events.len()
);
let end_event = parse_event(json!({
"type": "message_end",
"sessionId": "session-1",
"messageId": "user-msg-1",
"message": {
"role": "user",
"content": [{ "type": "text", "text": "hello!" }]
}
}));
let events = converter
.event_to_universal(&end_event)
.expect("user message_end should not error");
assert!(
events.is_empty(),
"user message_end should produce no events, got {}",
events.len()
);
// A subsequent assistant message should still work normally.
let assistant_start = parse_event(json!({
"type": "message_start",
"sessionId": "session-1",
"messageId": "msg-1",
"message": {
"role": "assistant",
"content": [{ "type": "text", "text": "Hello! How can I help?" }]
}
}));
let events = converter
.event_to_universal(&assistant_start)
.expect("assistant message_start");
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, UniversalEventType::ItemStarted);
if let UniversalEventData::Item(item) = &events[0].data {
assert_eq!(item.item.role, Some(ItemRole::Assistant));
} else {
panic!("expected item event");
}
}
#[test]
fn pi_tool_execution_converts_with_partial_deltas() {
let mut converter = PiEventConverter::default();
let start_event = parse_event(json!({
"type": "tool_execution_start",
"sessionId": "session-1",
"toolCallId": "call-1",
"toolName": "bash",
"args": { "command": "ls" }
}));
let start_events = converter
.event_to_universal(&start_event)
.expect("tool start");
assert_eq!(start_events[0].event_type, UniversalEventType::ItemStarted);
if let UniversalEventData::Item(item) = &start_events[0].data {
assert_eq!(item.item.kind, ItemKind::ToolCall);
assert_eq!(item.item.role, Some(ItemRole::Assistant));
match &item.item.content[0] {
ContentPart::ToolCall { name, .. } => assert_eq!(name, "bash"),
_ => panic!("expected tool call content"),
}
}
let update_event = parse_event(json!({
"type": "tool_execution_update",
"sessionId": "session-1",
"toolCallId": "call-1",
"partialResult": "foo"
}));
let update_events = converter
.event_to_universal(&update_event)
.expect("tool update");
assert!(update_events
.iter()
.any(|event| event.event_type == UniversalEventType::ItemDelta));
let update_event2 = parse_event(json!({
"type": "tool_execution_update",
"sessionId": "session-1",
"toolCallId": "call-1",
"partialResult": "foobar"
}));
let update_events2 = converter
.event_to_universal(&update_event2)
.expect("tool update 2");
let delta = update_events2
.iter()
.find_map(|event| match &event.data {
UniversalEventData::ItemDelta(data) => Some(data.delta.clone()),
_ => None,
})
.unwrap_or_default();
assert_eq!(delta, "bar");
let end_event = parse_event(json!({
"type": "tool_execution_end",
"sessionId": "session-1",
"toolCallId": "call-1",
"result": { "type": "text", "content": "done" },
"isError": false
}));
let end_events = converter.event_to_universal(&end_event).expect("tool end");
assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted);
if let UniversalEventData::Item(item) = &end_events[0].data {
assert_eq!(item.item.kind, ItemKind::ToolResult);
assert_eq!(item.item.role, Some(ItemRole::Tool));
match &item.item.content[0] {
ContentPart::ToolResult { output, .. } => assert_eq!(output, "done"),
_ => panic!("expected tool result content"),
}
}
}
#[test]
fn pi_unknown_event_returns_error() {
let mut converter = PiEventConverter::default();
let event = parse_event(json!({
"type": "unknown_event",
"sessionId": "session-1"
}));
assert!(converter.event_to_universal(&event).is_err());
}
#[test]
fn pi_message_done_completes_without_message_end() {
let mut converter = PiEventConverter::default();
let start_event = parse_event(json!({
"type": "message_start",
"sessionId": "session-1",
"messageId": "msg-1",
"message": {
"role": "assistant",
"content": [{ "type": "text", "text": "Hello" }]
}
}));
let _start_events = converter
.event_to_universal(&start_event)
.expect("start conversions");
let update_event = parse_event(json!({
"type": "message_update",
"sessionId": "session-1",
"messageId": "msg-1",
"assistantMessageEvent": { "type": "text_delta", "delta": " world" }
}));
let _update_events = converter
.event_to_universal(&update_event)
.expect("update conversions");
let done_event = parse_event(json!({
"type": "message_update",
"sessionId": "session-1",
"messageId": "msg-1",
"assistantMessageEvent": { "type": "done" }
}));
let done_events = converter
.event_to_universal(&done_event)
.expect("done conversions");
assert_eq!(done_events[0].event_type, UniversalEventType::ItemCompleted);
if let UniversalEventData::Item(item) = &done_events[0].data {
assert_eq!(item.item.status, ItemStatus::Completed);
assert!(
matches!(item.item.content.get(0), Some(ContentPart::Text { text }) if text == "Hello world")
);
} else {
panic!("expected item event");
}
}