From 0fbf6272b16f26864959983c7b85cba557cf493f Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 25 Jan 2026 03:04:12 -0800 Subject: [PATCH] feat: refresh docs and agent schema --- CLAUDE.md | 28 + README.md | 95 +- docs/agent-compatibility.mdx | 26 + docs/architecture.mdx | 45 + docs/cli.mdx | 111 ++ docs/deployments/cloudflare-sandboxes.mdx | 21 + docs/deployments/daytona.mdx | 21 + docs/deployments/docker.mdx | 27 + docs/deployments/e2b.mdx | 25 + docs/deployments/vercel-sandboxes.mdx | 21 + docs/docs.json | 82 + docs/favicon.svg | 19 + docs/frontend.mdx | 20 + docs/http-api.mdx | 157 ++ docs/index.mdx | 68 + docs/logo/dark.svg | 21 + docs/logo/light.svg | 21 + docs/quickstart.mdx | 75 + docs/theme.css | 72 + docs/typescript-sdk.mdx | 100 + docs/universal-api.mdx | 30 + engine/packages/sandbox-agent/src/main.rs | 285 ++- engine/packages/sandbox-agent/src/router.rs | 106 +- .../universal-agent-schema/src/agents/amp.rs | 155 ++ .../src/agents/claude.rs | 239 +++ .../src/agents/codex.rs | 375 ++++ .../universal-agent-schema/src/agents/mod.rs | 4 + .../src/agents/opencode.rs | 958 ++++++++++ .../universal-agent-schema/src/lib.rs | 1678 +---------------- resources/agent-schemas/package.json | 10 +- spec.md | 14 +- .../agent-schemas/src => src/agents}/amp.ts | 0 .../agent-schemas/src => src/agents}/cache.ts | 9 +- .../src => src/agents}/claude.ts | 4 +- .../agent-schemas/src => src/agents}/codex.ts | 4 +- .../agent-schemas/src => src/agents}/index.ts | 3 +- .../src => src/agents}/normalize.ts | 0 .../src => src/agents}/opencode.ts | 0 todo.md | 4 +- 39 files changed, 3127 insertions(+), 1806 deletions(-) create mode 100644 docs/agent-compatibility.mdx create mode 100644 docs/architecture.mdx create mode 100644 docs/cli.mdx create mode 100644 docs/deployments/cloudflare-sandboxes.mdx create mode 100644 docs/deployments/daytona.mdx create mode 100644 docs/deployments/docker.mdx create mode 100644 docs/deployments/e2b.mdx create mode 100644 docs/deployments/vercel-sandboxes.mdx create mode 100644 docs/docs.json create mode 100644 docs/favicon.svg create mode 100644 docs/frontend.mdx create mode 100644 docs/http-api.mdx create mode 100644 docs/index.mdx create mode 100644 docs/logo/dark.svg create mode 100644 docs/logo/light.svg create mode 100644 docs/quickstart.mdx create mode 100644 docs/theme.css create mode 100644 docs/typescript-sdk.mdx create mode 100644 docs/universal-api.mdx create mode 100644 engine/packages/universal-agent-schema/src/agents/amp.rs create mode 100644 engine/packages/universal-agent-schema/src/agents/claude.rs create mode 100644 engine/packages/universal-agent-schema/src/agents/codex.rs create mode 100644 engine/packages/universal-agent-schema/src/agents/mod.rs create mode 100644 engine/packages/universal-agent-schema/src/agents/opencode.rs rename {resources/agent-schemas/src => src/agents}/amp.ts (100%) rename {resources/agent-schemas/src => src/agents}/cache.ts (95%) rename {resources/agent-schemas/src => src/agents}/claude.ts (96%) rename {resources/agent-schemas/src => src/agents}/codex.ts (96%) rename {resources/agent-schemas/src => src/agents}/index.ts (95%) rename {resources/agent-schemas/src => src/agents}/normalize.ts (100%) rename {resources/agent-schemas/src => src/agents}/opencode.ts (100%) diff --git a/CLAUDE.md b/CLAUDE.md index abc7b8f..2aef7e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,34 @@ Universal schema guidance: - When changing the HTTP API, update the TypeScript SDK and CLI together. - Do not make breaking changes to API endpoints. +### CLI ⇄ HTTP endpoint map (keep in sync) + +- `sandbox-agent agents list` ↔ `GET /v1/agents` +- `sandbox-agent agents install` ↔ `POST /v1/agents/{agent}/install` +- `sandbox-agent agents modes` ↔ `GET /v1/agents/{agent}/modes` +- `sandbox-agent sessions create` ↔ `POST /v1/sessions/{sessionId}` +- `sandbox-agent sessions send-message` ↔ `POST /v1/sessions/{sessionId}/messages` +- `sandbox-agent sessions events` / `get-messages` ↔ `GET /v1/sessions/{sessionId}/events` +- `sandbox-agent sessions events-sse` ↔ `GET /v1/sessions/{sessionId}/events/sse` +- `sandbox-agent sessions reply-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` +- `sandbox-agent sessions reject-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` +- `sandbox-agent sessions reply-permission` ↔ `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` + +### Default port references (update when CLI default changes) + +- `frontend/packages/web/src/App.tsx` +- `README.md` +- `docs/cli.mdx` +- `docs/frontend.mdx` +- `docs/index.mdx` +- `docs/quickstart.mdx` +- `docs/typescript-sdk.mdx` +- `docs/deployments/cloudflare-sandboxes.mdx` +- `docs/deployments/daytona.mdx` +- `docs/deployments/docker.mdx` +- `docs/deployments/e2b.mdx` +- `docs/deployments/vercel-sandboxes.mdx` + ## Git Commits - Do not include any co-authors in commit messages (no `Co-Authored-By` lines) diff --git a/README.md b/README.md index 0f1a9c7..e887657 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,15 @@ # Sandbox Agent SDK -Run inside sandboxes to provide support +Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes. -- **Any coding agent**: Universal API to interact with all agents with full feature coverage -- **Server Mode**: Run as HTTP server from any sandbox provider or as TypeScript & Python SDK -- **Universal session schema**: Universal schema to store agent transcripts -- **Supports your sandbox provider**: Daytona, E2B, Vercel Sandboxes, [add your own](TODO) -- **Lightweight, portable Rust binary**: Install anywhere with 1 curl command -- **Compatible with Vercel AI SDK**: TODO +Documentation lives in `docs/` (Mintlify). Start with: -## Quickstart +- `docs/index.mdx` for the overview +- `docs/quickstart.mdx` to run the daemon +- `docs/http-api.mdx` and `docs/cli.mdx` for API references -Start with the SDK: +Quickstart (local dev): +```bash +sandbox-agent --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 8787 ``` -TODO -``` - -To run this in server mode, install with: - -``` -TODO -``` - -And run with: - -``` -TODO -``` - -See the example for your provider of choice: - -- TODO -- [Add your own](TODO) - -## Security - -TODO: Tokens -TODO: Using a gateawy -TODO: BYO tokens with extractor - -## Demo Frontend - -TODO: Screenshot - -This project provides a demo frontend for testing the connection. Run it with: - -``` -TODO -``` - -## Agent Compatibility Matrix - -TODO - -## Reference - -### TypeScript SDK - -TODO - -### HTTP API - -TODO - -### CLI - -TODO - -## FAQ - -TODO - -- Why not use PTY? This is the recommended option for XXXX -- Why not use ? -- Does it support ? -- Can I use this with my personal OpenAPI & Claude tokens? - -## Project Scope - -This project aims to solve 3 problems with agents: - -- **Universal Coding Agent API**: Claude Code, Codex, Amp, and OpenCode all have put a lot of work in to the agent scaffold. Each have respective pros and cons and need to be easy to be swapped between. -- **Agents In Sandboxes**: There are many complications with running agents inside of sandbox providers. This lets you run a simple curl command to spawn an HTTP server for using any agent from within the sandbox. -- **Agent Transcript**: Maintaining agent transcripts is difficult since the agent manages its own sessions. This provides a simpler way to read and retrieve agent transcripts in your system. - -Features out of scope: - -- **Storage of sessions on disk**: Sessions are already stored by the respective coding agents on disk. It's assumed that the consumer is streaming data from this machine to an extral storage, such as Postgres, ClickHouse, or Rivet. -- **Direct LLM wrappers**: Use the [Vercel AI SDK](https://ai-sdk.dev/docs/introduction) if you want to impelment your own agent from scratch -- **Git Repo Management**: Just use git commands or the features provided by your sandbox provider of choice. -- **Sandbox Provider API**: Sandbox providers have many nuanced differences in their API, it does not make sense for us to try to provide a custom layer. Instead, we opt to provide skills that lets you integrate this project with sandbox providers. - diff --git a/docs/agent-compatibility.mdx b/docs/agent-compatibility.mdx new file mode 100644 index 0000000..cd805d5 --- /dev/null +++ b/docs/agent-compatibility.mdx @@ -0,0 +1,26 @@ +--- +title: "Agent Compatibility" +description: "Supported agents, install methods, and streaming formats." +--- + +## Compatibility matrix + +| Agent | Provider | Binary | Install method | Session ID | Streaming format | +|-------|----------|--------|----------------|------------|------------------| +| Claude Code | Anthropic | `claude` | curl raw binary from GCS | `session_id` | JSONL via stdout | +| Codex | OpenAI | `codex` | curl tarball from GitHub releases | `thread_id` | JSONL via stdout | +| OpenCode | Multi-provider | `opencode` | curl tarball from GitHub releases | `session_id` | SSE or JSONL | +| Amp | Sourcegraph | `amp` | curl raw binary from GCS | `session_id` | JSONL via stdout | + +## Agent modes + +- **OpenCode**: discovered via the server API. +- **Claude Code / Codex / Amp**: hardcoded modes (typically `build`, `plan`, or `custom`). + +## Capability notes + +- **Questions / permissions**: OpenCode natively supports these workflows. Claude plan approval is normalized into a question event. +- **Streaming**: all agents stream events; OpenCode uses SSE while others use JSONL. +- **Files and images**: normalized via `UniversalMessagePart` with `File` and `Image` parts. + +See [Universal API](/universal-api) for feature coverage details. diff --git a/docs/architecture.mdx b/docs/architecture.mdx new file mode 100644 index 0000000..3773bd4 --- /dev/null +++ b/docs/architecture.mdx @@ -0,0 +1,45 @@ +--- +title: "Architecture" +description: "How the daemon, schemas, and agents fit together." +--- + +Sandbox Agent SDK is built around a single daemon that runs inside the sandbox and exposes a universal HTTP API. Clients use the API (or the TypeScript SDK / CLI) to create sessions, send messages, and stream events. + +## Components + +- **Daemon**: Rust HTTP server that manages agent processes and streaming. +- **Universal schema**: Shared input/output types for messages and events. +- **SDKs & CLI**: Convenience wrappers around the HTTP API. + +## Session model + +- **Session ID**: Client-provided primary session identifier. +- **Agent session ID**: Underlying ID from the agent (thread/session). This is surfaced in events but is not the primary key. + +## Event streaming + +- Events are stored in memory per session and assigned a monotonically increasing `id`. +- `/events` returns a slice of events by offset/limit. +- `/events/sse` streams new events from the same offset semantics. + +## Agent integration strategies + +### Subprocess per session + +Claude Code, Codex, and Amp run as subprocesses. The daemon reads JSONL output from stdout and converts each event into a UniversalEvent. + +### Shared server (OpenCode) + +OpenCode runs as a shared server. The daemon connects via HTTP and SSE, then converts OpenCode events to UniversalEvents. + +## Human-in-the-loop + +Questions and permission prompts are normalized into the universal schema: + +- Question events surface as `questionAsked` with selectable options. +- Permission events surface as `permissionAsked` with `reply: once | always | reject`. +- Claude plan approval is normalized into a question event (approve/reject). + +## Authentication + +The daemon uses a **global token** configured at startup. All HTTP and CLI operations reuse the same token and are validated against the `Authorization` header (`Bearer` or `Token`) or `x-sandbox-token`. diff --git a/docs/cli.mdx b/docs/cli.mdx new file mode 100644 index 0000000..227d171 --- /dev/null +++ b/docs/cli.mdx @@ -0,0 +1,111 @@ +--- +title: "CLI" +description: "CLI reference and server flags." +--- + +The `sandbox-agent` CLI mirrors the HTTP API so you can script everything without writing client code. + +## Server flags + +```bash +sandbox-agent --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 8787 +``` + +- `--token`: global token for all requests. +- `--no-token`: disable auth (local dev only). +- `--host`, `--port`: bind address. +- `--cors-allow-origin`, `--cors-allow-method`, `--cors-allow-header`, `--cors-allow-credentials`: configure CORS. + +## Agent commands + +
+agents list + +```bash +sandbox-agent agents list --endpoint http://127.0.0.1:8787 +``` +
+ +
+agents install + +```bash +sandbox-agent agents install claude --reinstall --endpoint http://127.0.0.1:8787 +``` +
+ +
+agents modes + +```bash +sandbox-agent agents modes claude --endpoint http://127.0.0.1:8787 +``` +
+ +## Session commands + +
+sessions create + +```bash +sandbox-agent sessions create my-session \ + --agent claude \ + --agent-mode build \ + --permission-mode default \ + --endpoint http://127.0.0.1:8787 +``` +
+ +
+sessions send-message + +```bash +sandbox-agent sessions send-message my-session \ + --message "Summarize the repository" \ + --endpoint http://127.0.0.1:8787 +``` +
+ +
+sessions events + +```bash +sandbox-agent sessions events my-session --offset 0 --limit 50 --endpoint http://127.0.0.1:8787 +``` +
+ +
+sessions events-sse + +```bash +sandbox-agent sessions events-sse my-session --offset 0 --endpoint http://127.0.0.1:8787 +``` +
+ +
+sessions reply-question + +```bash +sandbox-agent sessions reply-question my-session QUESTION_ID \ + --answers "yes" \ + --endpoint http://127.0.0.1:8787 +``` +
+ +
+sessions reject-question + +```bash +sandbox-agent sessions reject-question my-session QUESTION_ID --endpoint http://127.0.0.1:8787 +``` +
+ +
+sessions reply-permission + +```bash +sandbox-agent sessions reply-permission my-session PERMISSION_ID \ + --reply once \ + --endpoint http://127.0.0.1:8787 +``` +
diff --git a/docs/deployments/cloudflare-sandboxes.mdx b/docs/deployments/cloudflare-sandboxes.mdx new file mode 100644 index 0000000..e52b872 --- /dev/null +++ b/docs/deployments/cloudflare-sandboxes.mdx @@ -0,0 +1,21 @@ +--- +title: "Cloudflare Sandboxes" +description: "Deploy the daemon in Cloudflare Sandboxes." +--- + +## Steps + +1. Create a Cloudflare Sandbox with a Linux runtime. +2. Install the agent binaries and the sandbox-agent daemon. +3. Start the daemon and expose the HTTP port. + +```bash +export SANDBOX_TOKEN="..." + +cargo run -p sandbox-agent -- \ + --token "$SANDBOX_TOKEN" \ + --host 0.0.0.0 \ + --port 8787 +``` + +4. Connect your client to the sandbox endpoint. diff --git a/docs/deployments/daytona.mdx b/docs/deployments/daytona.mdx new file mode 100644 index 0000000..e6518a7 --- /dev/null +++ b/docs/deployments/daytona.mdx @@ -0,0 +1,21 @@ +--- +title: "Daytona" +description: "Run the daemon in a Daytona workspace." +--- + +## Steps + +1. Create a Daytona workspace with Rust and curl available. +2. Install or build the sandbox-agent binary. +3. Start the daemon and expose port `8787` (or your preferred port). + +```bash +export SANDBOX_TOKEN="..." + +cargo run -p sandbox-agent -- \ + --token "$SANDBOX_TOKEN" \ + --host 0.0.0.0 \ + --port 8787 +``` + +4. Use your Daytona port forwarding to reach the daemon from your client. diff --git a/docs/deployments/docker.mdx b/docs/deployments/docker.mdx new file mode 100644 index 0000000..7e21130 --- /dev/null +++ b/docs/deployments/docker.mdx @@ -0,0 +1,27 @@ +--- +title: "Docker (dev)" +description: "Build and run the daemon in a Docker container." +--- + +## Build the binary + +Use the release Dockerfile to build a static binary: + +```bash +docker build -f docker/release/linux-x86_64.Dockerfile -t sandbox-agent-build . + +docker run --rm -v "$PWD/artifacts:/artifacts" sandbox-agent-build +``` + +The binary will be written to `./artifacts/sandbox-agent-x86_64-unknown-linux-musl`. + +## Run the daemon + +```bash +docker run --rm -p 8787:8787 \ + -v "$PWD/artifacts:/artifacts" \ + debian:bookworm-slim \ + /artifacts/sandbox-agent-x86_64-unknown-linux-musl --token "$SANDBOX_TOKEN" --host 0.0.0.0 --port 8787 +``` + +You can now access the API at `http://localhost:8787`. diff --git a/docs/deployments/e2b.mdx b/docs/deployments/e2b.mdx new file mode 100644 index 0000000..9cb9214 --- /dev/null +++ b/docs/deployments/e2b.mdx @@ -0,0 +1,25 @@ +--- +title: "E2B" +description: "Deploy the daemon inside an E2B sandbox." +--- + +## Steps + +1. Start an E2B sandbox with network access. +2. Install the agent binaries you need (Claude, Codex, OpenCode, Amp). +3. Run the daemon and expose its port. + +Example startup script: + +```bash +export SANDBOX_TOKEN="..." + +# Install sandbox-agent binary (or build from source) +# TODO: replace with release download once published +cargo run -p sandbox-agent -- \ + --token "$SANDBOX_TOKEN" \ + --host 0.0.0.0 \ + --port 8787 +``` + +4. Configure your client to connect to the sandbox endpoint. diff --git a/docs/deployments/vercel-sandboxes.mdx b/docs/deployments/vercel-sandboxes.mdx new file mode 100644 index 0000000..c09941a --- /dev/null +++ b/docs/deployments/vercel-sandboxes.mdx @@ -0,0 +1,21 @@ +--- +title: "Vercel Sandboxes" +description: "Run the daemon inside Vercel Sandboxes." +--- + +## Steps + +1. Provision a Vercel Sandbox with network access and storage. +2. Install the agent binaries you need. +3. Run the daemon and expose the port. + +```bash +export SANDBOX_TOKEN="..." + +cargo run -p sandbox-agent -- \ + --token "$SANDBOX_TOKEN" \ + --host 0.0.0.0 \ + --port 8787 +``` + +4. Configure your client to use the sandbox URL. diff --git a/docs/docs.json b/docs/docs.json new file mode 100644 index 0000000..7302b66 --- /dev/null +++ b/docs/docs.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://mintlify.com/docs.json", + "theme": "dark", + "name": "Sandbox Agent SDK", + "colors": { + "primary": "#ff4f00", + "light": "#ff6a2a", + "dark": "#cc3f00", + "background": "#000000", + "card": "#1c1c1e", + "border": "#2c2c2e", + "inputBackground": "#2c2c2e", + "inputBorder": "#3a3a3c", + "text": "#ffffff", + "muted": "#8e8e93", + "success": "#30d158", + "warning": "#ff4f00", + "danger": "#ff3b30", + "purple": "#bf5af2" + }, + "favicon": "/favicon.svg", + "logo": { + "light": "/logo/light.svg", + "dark": "/logo/dark.svg" + }, + "navigation": { + "tabs": [ + { + "tab": "Guides", + "groups": [ + { + "group": "Getting started", + "pages": [ + "index", + "quickstart", + "architecture", + "agent-compatibility", + "universal-api" + ] + }, + { + "group": "Operations", + "pages": [ + "frontend" + ] + } + ] + }, + { + "tab": "Reference", + "groups": [ + { + "group": "Interfaces", + "pages": [ + "cli", + "http-api", + "typescript-sdk" + ] + } + ] + }, + { + "tab": "Deployments", + "groups": [ + { + "group": "Examples", + "pages": [ + "deployments/docker", + "deployments/e2b", + "deployments/daytona", + "deployments/vercel-sandboxes", + "deployments/cloudflare-sandboxes" + ] + } + ] + } + ] + }, + "styles": [ + "/theme.css" + ] +} diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..b785c73 --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/docs/frontend.mdx b/docs/frontend.mdx new file mode 100644 index 0000000..147c5dc --- /dev/null +++ b/docs/frontend.mdx @@ -0,0 +1,20 @@ +--- +title: "Frontend Demo" +description: "Run the Vite + React UI for testing the daemon." +--- + +The demo frontend lives at `frontend/packages/web`. + +## Run locally + +```bash +pnpm install +pnpm --filter @sandbox-agent/web dev +``` + +The UI expects: + +- Endpoint (e.g. `http://127.0.0.1:8787`) +- Optional token + +If you see CORS errors, enable CORS on the daemon with `--cors-allow-origin` and related flags. diff --git a/docs/http-api.mdx b/docs/http-api.mdx new file mode 100644 index 0000000..9466e38 --- /dev/null +++ b/docs/http-api.mdx @@ -0,0 +1,157 @@ +--- +title: "HTTP API" +description: "Endpoint reference for the sandbox agent daemon." +--- + +All endpoints are under `/v1`. Authentication uses the daemon-level token via `Authorization: Bearer ` or `x-sandbox-token`. + +## Sessions + +
+POST /v1/sessions/{sessionId} - Create session + +Request: + +```json +{ + "agent": "claude", + "agentMode": "build", + "permissionMode": "default", + "model": "claude-3-5-sonnet", + "variant": "high", + "agentVersion": "latest" +} +``` + +Response: + +```json +{ + "healthy": true, + "agentSessionId": "..." +} +``` +
+ +
+POST /v1/sessions/{sessionId}/messages - Send message + +Request: + +```json +{ + "message": "Describe the repository." +} +``` +
+ +
+GET /v1/sessions/{sessionId}/events - Fetch events + +Query params: + +- `offset`: last-seen event id (exclusive) +- `limit`: max number of events + +Response: + +```json +{ + "events": [ + { + "id": 1, + "timestamp": "2026-01-25T10:00:00Z", + "sessionId": "my-session", + "agent": "claude", + "agentSessionId": "...", + "data": { "message": { "role": "assistant", "parts": [{ "type": "text", "text": "..." }] } } + } + ], + "hasMore": false +} +``` +
+ +
+GET /v1/sessions/{sessionId}/events/sse - Stream events (SSE) + +Query params: + +- `offset`: last-seen event id (exclusive) + +SSE payloads are `UniversalEvent` JSON. +
+ +
+POST /v1/sessions/{sessionId}/questions/{questionId}/reply + +Request: + +```json +{ "answers": [["Option A"], ["Option B", "Option C"]] } +``` +
+ +
+POST /v1/sessions/{sessionId}/questions/{questionId}/reject + +Request: + +```json +{} +``` +
+ +
+POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply + +Request: + +```json +{ "reply": "once" } +``` +
+ +## Agents + +
+GET /v1/agents - List agents + +Response: + +```json +{ + "agents": [ + { "id": "claude", "installed": true, "version": "...", "path": "/usr/local/bin/claude" } + ] +} +``` +
+ +
+POST /v1/agents/{agentId}/install - Install agent + +Request: + +```json +{ "reinstall": false } +``` +
+ +
+GET /v1/agents/{agentId}/modes - List modes + +Response: + +```json +{ + "modes": [ + { "id": "build", "name": "Build", "description": "Default coding mode" } + ] +} +``` +
+ +## Error handling + +All errors use RFC 7807 Problem Details and stable `type` strings (e.g. `urn:sandbox-agent:error:session_not_found`). diff --git a/docs/index.mdx b/docs/index.mdx new file mode 100644 index 0000000..258897f --- /dev/null +++ b/docs/index.mdx @@ -0,0 +1,68 @@ +--- +title: "Overview" +description: "Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes." +--- + +Sandbox Agent SDK is a universal API and daemon for running coding agents inside sandboxes. It standardizes agent sessions, events, and human-in-the-loop workflows across Claude Code, Codex, OpenCode, and Amp. + +## At a glance + +- Universal HTTP API and TypeScript SDK +- Runs inside sandboxes with a lightweight Rust daemon +- Streams events in a shared UniversalEvent schema +- Supports questions and permission workflows +- Designed for multi-provider sandbox environments + +## Quickstart + +Run the daemon locally: + +```bash +sandbox-agent --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 8787 +``` + +Send a message: + +```bash +curl -X POST "http://127.0.0.1:8787/v1/sessions/my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"agent":"claude"}' + +curl -X POST "http://127.0.0.1:8787/v1/sessions/my-session/messages" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"message":"Explain the repo structure."}' +``` + +See the full quickstart in [Quickstart](/quickstart). + +## What this project solves + +- **Universal Coding Agent API**: standardize tool calls, messages, and events across agents. +- **Agents in sandboxes**: run a single HTTP daemon inside any sandbox provider. +- **Agent transcripts**: stream or persist a universal event log in your own storage. + +## Project scope + +**In scope** + +- Agent session orchestration inside a sandbox +- Streaming events in a universal schema +- Human-in-the-loop questions and permissions +- TypeScript SDK and CLI wrappers + +**Out of scope** + +- Persistent storage of sessions on disk +- Building custom LLM agents (use Vercel AI SDK for that) +- Sandbox provider APIs (use provider SDKs or custom glue) +- Git repo management + +## Next steps + +- Read the [Architecture](/architecture) overview +- Review [Agent compatibility](/agent-compatibility) +- See the [HTTP API](/http-api) and [CLI](/cli) +- Run the [Frontend demo](/frontend) +- Use the [TypeScript SDK](/typescript-sdk) diff --git a/docs/logo/dark.svg b/docs/logo/dark.svg new file mode 100644 index 0000000..8b343cd --- /dev/null +++ b/docs/logo/dark.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/logo/light.svg b/docs/logo/light.svg new file mode 100644 index 0000000..03e62bf --- /dev/null +++ b/docs/logo/light.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx new file mode 100644 index 0000000..d6fea23 --- /dev/null +++ b/docs/quickstart.mdx @@ -0,0 +1,75 @@ +--- +title: "Quickstart" +description: "Start the daemon and send your first message." +--- + +## 1. Run the daemon + +Use the installed binary, or `cargo run` in development. + +```bash +sandbox-agent --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 8787 +``` + +If you want to run without auth (local dev only): + +```bash +sandbox-agent --no-token --host 127.0.0.1 --port 8787 +``` + +### CORS (frontend usage) + +If you are calling the daemon from a browser, enable CORS explicitly: + +```bash +sandbox-agent \ + --token "$SANDBOX_TOKEN" \ + --cors-allow-origin "http://localhost:5173" \ + --cors-allow-method "GET" \ + --cors-allow-method "POST" \ + --cors-allow-header "Authorization" \ + --cors-allow-header "Content-Type" \ + --cors-allow-credentials +``` + +## 2. Create a session + +```bash +curl -X POST "http://127.0.0.1:8787/v1/sessions/my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"agent":"claude","agentMode":"build","permissionMode":"default"}' +``` + +## 3. Send a message + +```bash +curl -X POST "http://127.0.0.1:8787/v1/sessions/my-session/messages" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"message":"Summarize the repository and suggest next steps."}' +``` + +## 4. Read events + +```bash +curl "http://127.0.0.1:8787/v1/sessions/my-session/events?offset=0&limit=50" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` + +For streaming output, use SSE: + +```bash +curl "http://127.0.0.1:8787/v1/sessions/my-session/events/sse?offset=0" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` + +## 5. CLI shortcuts + +The CLI mirrors the HTTP API: + +```bash +sandbox-agent sessions create my-session --agent claude --endpoint http://127.0.0.1:8787 --token "$SANDBOX_TOKEN" + +sandbox-agent sessions send-message my-session --message "Hello" --endpoint http://127.0.0.1:8787 --token "$SANDBOX_TOKEN" +``` diff --git a/docs/theme.css b/docs/theme.css new file mode 100644 index 0000000..4286d2c --- /dev/null +++ b/docs/theme.css @@ -0,0 +1,72 @@ +:root { + color-scheme: dark; + --sa-primary: #ff4f00; + --sa-bg: #000000; + --sa-card: #1c1c1e; + --sa-border: #2c2c2e; + --sa-input-bg: #2c2c2e; + --sa-input-border: #3a3a3c; + --sa-text: #ffffff; + --sa-muted: #8e8e93; + --sa-success: #30d158; + --sa-warning: #ff4f00; + --sa-danger: #ff3b30; + --sa-purple: #bf5af2; +} + +html, +body { + background-color: var(--sa-bg); + color: var(--sa-text); +} + +a { + color: var(--sa-primary); +} + +a:hover { + color: var(--sa-warning); +} + +hr { + border-color: var(--sa-border); +} + +input, +textarea, +select { + background-color: var(--sa-input-bg); + border: 1px solid var(--sa-input-border); + color: var(--sa-text); +} + +code, +pre { + background-color: var(--sa-card); + border: 1px solid var(--sa-border); + color: var(--sa-text); +} + +.card, +.mintlify-card, +.docs-card { + background-color: var(--sa-card); + border: 1px solid var(--sa-border); +} + +.muted, +.text-muted { + color: var(--sa-muted); +} + +.alert-success { + border-color: var(--sa-success); +} + +.alert-warning { + border-color: var(--sa-warning); +} + +.alert-danger { + border-color: var(--sa-danger); +} diff --git a/docs/typescript-sdk.mdx b/docs/typescript-sdk.mdx new file mode 100644 index 0000000..5ced7ee --- /dev/null +++ b/docs/typescript-sdk.mdx @@ -0,0 +1,100 @@ +--- +title: "TypeScript SDK" +description: "Generated types and a thin fetch-based client." +--- + +The TypeScript SDK is generated from the OpenAPI spec produced by the Rust server. + +## Generate types + +```bash +pnpm --filter @sandbox-agent/typescript-sdk generate +``` + +This runs: + +- `cargo run -p sandbox-agent-openapi-gen` to emit OpenAPI JSON +- `openapi-typescript` to generate types + +## Usage + +```ts +import { SandboxDaemonClient } from "@sandbox-agent/typescript-sdk"; + +const client = new SandboxDaemonClient({ + baseUrl: "http://127.0.0.1:8787", + token: process.env.SANDBOX_TOKEN, +}); + +await client.createSession("my-session", { agent: "claude" }); +await client.postMessage("my-session", { message: "Hello" }); +const events = await client.getEvents("my-session", { offset: 0, limit: 50 }); +``` + +## Endpoint mapping + +
+client.listAgents() + +Maps to `GET /v1/agents`. +
+ +
+client.installAgent(agentId, body) + +Maps to `POST /v1/agents/{agentId}/install`. +
+ +
+client.getAgentModes(agentId) + +Maps to `GET /v1/agents/{agentId}/modes`. +
+ +
+client.createSession(sessionId, body) + +Maps to `POST /v1/sessions/{sessionId}`. +
+ +
+client.postMessage(sessionId, body) + +Maps to `POST /v1/sessions/{sessionId}/messages`. +
+ +
+client.getEvents(sessionId, params) + +Maps to `GET /v1/sessions/{sessionId}/events`. +
+ +
+client.getEventsSse(sessionId, params) + +Maps to `GET /v1/sessions/{sessionId}/events/sse` (raw SSE response). +
+ +
+client.streamEvents(sessionId, params) + +Helper that parses SSE into `UniversalEvent` objects. +
+ +
+client.replyQuestion(sessionId, questionId, body) + +Maps to `POST /v1/sessions/{sessionId}/questions/{questionId}/reply`. +
+ +
+client.rejectQuestion(sessionId, questionId) + +Maps to `POST /v1/sessions/{sessionId}/questions/{questionId}/reject`. +
+ +
+client.replyPermission(sessionId, permissionId, body) + +Maps to `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply`. +
diff --git a/docs/universal-api.mdx b/docs/universal-api.mdx new file mode 100644 index 0000000..c4f494f --- /dev/null +++ b/docs/universal-api.mdx @@ -0,0 +1,30 @@ +--- +title: "Universal API" +description: "Feature checklist and normalization rules." +--- + +## Feature checklist + +- [x] Session creation and lifecycle events +- [x] Message streaming (assistant and tool messages) +- [x] Tool call and tool result normalization +- [x] File and image parts +- [x] Human-in-the-loop questions +- [x] Permission prompts and replies +- [x] Plan approval normalization (Claude -> question) +- [x] Event streaming over SSE +- [ ] Persistent storage (out of scope) + +## Normalization rules + +- **Session ID** is always the client-provided ID. +- **Agent session ID** is surfaced in events but never replaces the primary session ID. +- **Tool calls** map to `UniversalMessagePart::ToolCall` and results to `ToolResult`. +- **File and image parts** map to `AttachmentSource` with `Path`, `Url`, or base64 `Data`. + +## Agent mode vs permission mode + +- **agentMode**: behavior or system prompt strategy (build/plan/custom). +- **permissionMode**: capability restrictions (default/plan/bypass). + +These are separate concepts and must be configured independently. diff --git a/engine/packages/sandbox-agent/src/main.rs b/engine/packages/sandbox-agent/src/main.rs index f1dadf6..8ec7458 100644 --- a/engine/packages/sandbox-agent/src/main.rs +++ b/engine/packages/sandbox-agent/src/main.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; @@ -5,6 +6,10 @@ use clap::{Args, Parser, Subcommand}; use reqwest::blocking::Client as HttpClient; use reqwest::Method; use sandbox_agent_agent_management::agents::AgentManager; +use sandbox_agent_agent_management::credentials::{ + extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials, + ProviderCredentials, +}; use sandbox_agent_core::router::{ AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest, PermissionReply, PermissionReplyRequest, QuestionReplyRequest, @@ -26,35 +31,39 @@ struct Cli { #[command(subcommand)] command: Option, - #[arg(long, default_value = "127.0.0.1")] + #[arg(long, short = 'H', default_value = "127.0.0.1")] host: String, - #[arg(long, default_value_t = 8787)] + #[arg(long, short = 'p', default_value_t = 2468)] port: u16, - #[arg(long)] + #[arg(long, short = 't')] token: Option, - #[arg(long)] + #[arg(long, short = 'n')] no_token: bool, - #[arg(long = "cors-allow-origin")] + #[arg(long = "cors-allow-origin", short = 'O')] cors_allow_origin: Vec, - #[arg(long = "cors-allow-method")] + #[arg(long = "cors-allow-method", short = 'M')] cors_allow_method: Vec, - #[arg(long = "cors-allow-header")] + #[arg(long = "cors-allow-header", short = 'A')] cors_allow_header: Vec, - #[arg(long = "cors-allow-credentials")] + #[arg(long = "cors-allow-credentials", short = 'C')] cors_allow_credentials: bool, } #[derive(Subcommand, Debug)] enum Command { + /// Manage installed agents and their modes. Agents(AgentsArgs), + /// Create sessions and interact with session events. Sessions(SessionsArgs), + /// Inspect locally discovered credentials. + Credentials(CredentialsArgs), } #[derive(Args, Debug)] @@ -69,42 +78,65 @@ struct SessionsArgs { command: SessionsCommand, } +#[derive(Args, Debug)] +struct CredentialsArgs { + #[command(subcommand)] + command: CredentialsCommand, +} + #[derive(Subcommand, Debug)] enum AgentsCommand { + /// List all agents and install status. List(ClientArgs), + /// Install or reinstall an agent. Install(InstallAgentArgs), + /// Show available modes for an agent. Modes(AgentModesArgs), } +#[derive(Subcommand, Debug)] +enum CredentialsCommand { + /// Extract credentials using local discovery rules. + Extract(CredentialsExtractArgs), +} + #[derive(Subcommand, Debug)] enum SessionsCommand { + /// Create a new session for an agent. Create(CreateSessionArgs), #[command(name = "send-message")] + /// Send a message to an existing session. SendMessage(SessionMessageArgs), #[command(name = "get-messages")] + /// Alias for events; returns session events. GetMessages(SessionEventsArgs), #[command(name = "events")] + /// Fetch session events with offset/limit. Events(SessionEventsArgs), #[command(name = "events-sse")] + /// Stream session events over SSE. EventsSse(SessionEventsSseArgs), #[command(name = "reply-question")] + /// Reply to a question event. ReplyQuestion(QuestionReplyArgs), #[command(name = "reject-question")] + /// Reject a question event. RejectQuestion(QuestionRejectArgs), #[command(name = "reply-permission")] + /// Reply to a permission request. ReplyPermission(PermissionReplyArgs), } #[derive(Args, Debug, Clone)] struct ClientArgs { - #[arg(long)] + #[arg(long, short = 'e')] endpoint: Option, } #[derive(Args, Debug)] struct InstallAgentArgs { agent: String, - #[arg(long)] + #[arg(long, short = 'r')] reinstall: bool, #[command(flatten)] client: ClientArgs, @@ -120,17 +152,17 @@ struct AgentModesArgs { #[derive(Args, Debug)] struct CreateSessionArgs { session_id: String, - #[arg(long)] + #[arg(long, short = 'a')] agent: String, - #[arg(long)] + #[arg(long, short = 'g')] agent_mode: Option, - #[arg(long)] + #[arg(long, short = 'p')] permission_mode: Option, - #[arg(long)] + #[arg(long, short = 'm')] model: Option, - #[arg(long)] + #[arg(long, short = 'v')] variant: Option, - #[arg(long)] + #[arg(long, short = 'A')] agent_version: Option, #[command(flatten)] client: ClientArgs, @@ -139,7 +171,7 @@ struct CreateSessionArgs { #[derive(Args, Debug)] struct SessionMessageArgs { session_id: String, - #[arg(long)] + #[arg(long, short = 'm')] message: String, #[command(flatten)] client: ClientArgs, @@ -148,9 +180,9 @@ struct SessionMessageArgs { #[derive(Args, Debug)] struct SessionEventsArgs { session_id: String, - #[arg(long)] + #[arg(long, short = 'o')] offset: Option, - #[arg(long)] + #[arg(long, short = 'l')] limit: Option, #[command(flatten)] client: ClientArgs, @@ -159,7 +191,7 @@ struct SessionEventsArgs { #[derive(Args, Debug)] struct SessionEventsSseArgs { session_id: String, - #[arg(long)] + #[arg(long, short = 'o')] offset: Option, #[command(flatten)] client: ClientArgs, @@ -169,7 +201,7 @@ struct SessionEventsSseArgs { struct QuestionReplyArgs { session_id: String, question_id: String, - #[arg(long)] + #[arg(long, short = 'a')] answers: String, #[command(flatten)] client: ClientArgs, @@ -187,12 +219,26 @@ struct QuestionRejectArgs { struct PermissionReplyArgs { session_id: String, permission_id: String, - #[arg(long)] + #[arg(long, short = 'r')] reply: PermissionReply, #[command(flatten)] client: ClientArgs, } +#[derive(Args, Debug)] +struct CredentialsExtractArgs { + #[arg(long, short = 'a', value_enum)] + agent: Option, + #[arg(long, short = 'p')] + provider: Option, + #[arg(long, short = 'd')] + home_dir: Option, + #[arg(long, short = 'n')] + no_oauth: bool, + #[arg(long, short = 'r')] + reveal: bool, +} + #[derive(Debug, Error)] enum CliError { #[error("missing --token or --no-token for server mode")] @@ -280,6 +326,7 @@ fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> { match command { Command::Agents(subcommand) => run_agents(&subcommand.command, cli), Command::Sessions(subcommand) => run_sessions(&subcommand.command, cli), + Command::Credentials(subcommand) => run_credentials(&subcommand.command), } } @@ -380,6 +427,200 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> { } } +fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> { + match command { + CredentialsCommand::Extract(args) => { + let mut options = CredentialExtractionOptions::new(); + if let Some(home_dir) = args.home_dir.clone() { + options.home_dir = Some(home_dir); + } + if args.no_oauth { + options.include_oauth = false; + } + + let credentials = extract_all_credentials(&options); + if let Some(agent) = args.agent.clone() { + let token = select_token_for_agent(&credentials, agent, args.provider.as_deref())?; + write_stdout_line(&token)?; + return Ok(()); + } + if let Some(provider) = args.provider.as_deref() { + let token = select_token_for_provider(&credentials, provider)?; + write_stdout_line(&token)?; + return Ok(()); + } + + let output = credentials_to_output(credentials, args.reveal); + let pretty = serde_json::to_string_pretty(&output)?; + write_stdout_line(&pretty)?; + Ok(()) + } + } +} + +#[derive(Serialize)] +struct CredentialsOutput { + anthropic: Option, + openai: Option, + other: HashMap, +} + +#[derive(Serialize)] +struct CredentialSummary { + provider: String, + source: String, + auth_type: String, + api_key: String, + redacted: bool, +} + +#[derive(clap::ValueEnum, Clone, Debug)] +enum CredentialAgent { + Claude, + Codex, + Opencode, + Amp, +} + +fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput { + CredentialsOutput { + anthropic: credentials.anthropic.map(|cred| summarize_credential(&cred, reveal)), + openai: credentials.openai.map(|cred| summarize_credential(&cred, reveal)), + other: credentials + .other + .into_iter() + .map(|(key, cred)| (key, summarize_credential(&cred, reveal))) + .collect(), + } +} + +fn summarize_credential(credential: &ProviderCredentials, reveal: bool) -> CredentialSummary { + let api_key = if reveal { + credential.api_key.clone() + } else { + redact_key(&credential.api_key) + }; + CredentialSummary { + provider: credential.provider.clone(), + source: credential.source.clone(), + auth_type: match credential.auth_type { + AuthType::ApiKey => "api_key".to_string(), + AuthType::Oauth => "oauth".to_string(), + }, + api_key, + redacted: !reveal, + } +} + +fn redact_key(key: &str) -> String { + let trimmed = key.trim(); + let len = trimmed.len(); + if len <= 8 { + return "****".to_string(); + } + let prefix = &trimmed[..4]; + let suffix = &trimmed[len - 4..]; + format!("{prefix}...{suffix}") +} + +fn select_token_for_agent( + credentials: &ExtractedCredentials, + agent: CredentialAgent, + provider: Option<&str>, +) -> Result { + match agent { + CredentialAgent::Claude | CredentialAgent::Amp => { + if let Some(provider) = provider { + if provider != "anthropic" { + return Err(CliError::Server(format!( + "agent {:?} only supports provider anthropic", + agent + ))); + } + } + select_token_for_provider(credentials, "anthropic") + } + CredentialAgent::Codex => { + if let Some(provider) = provider { + if provider != "openai" { + return Err(CliError::Server(format!( + "agent {:?} only supports provider openai", + agent + ))); + } + } + select_token_for_provider(credentials, "openai") + } + CredentialAgent::Opencode => { + 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 opencode".to_string(), + )) + } else { + Err(CliError::Server(format!( + "multiple providers available for opencode: {} (use --provider)", + available.join(", ") + ))) + } + } + } +} + +fn select_token_for_provider( + credentials: &ExtractedCredentials, + provider: &str, +) -> Result { + if let Some(cred) = provider_credential(credentials, provider) { + Ok(cred.api_key.clone()) + } else { + Err(CliError::Server(format!( + "no credentials found for provider {provider}" + ))) + } +} + +fn provider_credential<'a>( + credentials: &'a ExtractedCredentials, + provider: &str, +) -> Option<&'a ProviderCredentials> { + match provider { + "openai" => credentials.openai.as_ref(), + "anthropic" => credentials.anthropic.as_ref(), + _ => credentials.other.get(provider), + } +} + +fn available_providers(credentials: &ExtractedCredentials) -> Vec { + let mut providers = Vec::new(); + if credentials.openai.is_some() { + providers.push("openai".to_string()); + } + if credentials.anthropic.is_some() { + providers.push("anthropic".to_string()); + } + for key in credentials.other.keys() { + providers.push(key.clone()); + } + providers.sort(); + providers.dedup(); + providers +} + fn build_cors_layer(cli: &Cli) -> Result, CliError> { let has_config = !cli.cors_allow_origin.is_empty() || !cli.cors_allow_method.is_empty() diff --git a/engine/packages/sandbox-agent/src/router.rs b/engine/packages/sandbox-agent/src/router.rs index 7b455fb..85fe62d 100644 --- a/engine/packages/sandbox-agent/src/router.rs +++ b/engine/packages/sandbox-agent/src/router.rs @@ -184,6 +184,9 @@ struct SessionState { model: Option, variant: Option, agent_session_id: Option, + ended: bool, + ended_exit_code: Option, + ended_message: Option, next_event_id: u64, events: Vec, pending_questions: HashSet, @@ -213,6 +216,9 @@ impl SessionState { model: request.model.clone(), variant: request.variant.clone(), agent_session_id: None, + ended: false, + ended_exit_code: None, + ended_message: None, next_event_id: 0, events: Vec::new(), pending_questions: HashSet::new(), @@ -290,6 +296,23 @@ impl SessionState { fn take_permission(&mut self, permission_id: &str) -> bool { self.pending_permissions.remove(permission_id) } + + fn mark_ended(&mut self, exit_code: Option, message: String) { + self.ended = true; + self.ended_exit_code = exit_code; + self.ended_message = Some(message); + } + + fn ended_error(&self) -> Option { + if !self.ended { + return None; + } + Some(SandboxError::AgentProcessExited { + agent: self.agent.as_str().to_string(), + exit_code: self.ended_exit_code, + stderr: self.ended_message.clone(), + }) + } } #[derive(Debug)] @@ -408,7 +431,7 @@ impl SessionManager { session_id: String, message: String, ) -> Result<(), SandboxError> { - let session_snapshot = self.session_snapshot(&session_id).await?; + let session_snapshot = self.session_snapshot(&session_id, false).await?; if session_snapshot.agent == AgentId::Opencode { self.ensure_opencode_stream(session_id.clone()).await?; self.send_opencode_prompt(&session_snapshot, &message).await?; @@ -511,6 +534,9 @@ impl SessionManager { let session = sessions.get_mut(session_id).ok_or_else(|| SandboxError::SessionNotFound { session_id: session_id.to_string(), })?; + if let Some(err) = session.ended_error() { + return Err(err); + } if !session.take_question(question_id) { return Err(SandboxError::InvalidRequest { message: format!("unknown question id: {question_id}"), @@ -542,6 +568,9 @@ impl SessionManager { let session = sessions.get_mut(session_id).ok_or_else(|| SandboxError::SessionNotFound { session_id: session_id.to_string(), })?; + if let Some(err) = session.ended_error() { + return Err(err); + } if !session.take_question(question_id) { return Err(SandboxError::InvalidRequest { message: format!("unknown question id: {question_id}"), @@ -574,6 +603,9 @@ impl SessionManager { let session = sessions.get_mut(session_id).ok_or_else(|| SandboxError::SessionNotFound { session_id: session_id.to_string(), })?; + if let Some(err) = session.ended_error() { + return Err(err); + } if !session.take_permission(permission_id) { return Err(SandboxError::InvalidRequest { message: format!("unknown permission id: {permission_id}"), @@ -595,11 +627,20 @@ impl SessionManager { Ok(()) } - async fn session_snapshot(&self, session_id: &str) -> Result { + async fn session_snapshot( + &self, + session_id: &str, + allow_ended: bool, + ) -> Result { let sessions = self.sessions.lock().await; let session = sessions.get(session_id).ok_or_else(|| SandboxError::SessionNotFound { session_id: session_id.to_string(), })?; + if !allow_ended { + if let Some(err) = session.ended_error() { + return Err(err); + } + } Ok(SessionSnapshot::from(session)) } @@ -641,26 +682,47 @@ impl SessionManager { Ok(Ok(status)) if status.success() => {} Ok(Ok(status)) => { let message = format!("agent exited with status {:?}", status); - self.record_error(&session_id, message, Some("process_exit".to_string()), None) + self.record_error( + &session_id, + message.clone(), + Some("process_exit".to_string()), + None, + ) + .await; + self.mark_session_ended(&session_id, status.code(), &message) .await; } Ok(Err(err)) => { + let message = format!("failed to wait for agent: {err}"); self.record_error( &session_id, - format!("failed to wait for agent: {err}"), + message.clone(), Some("process_wait_failed".to_string()), None, ) .await; + self.mark_session_ended( + &session_id, + None, + &message, + ) + .await; } Err(err) => { + let message = format!("failed to join agent task: {err}"); self.record_error( &session_id, - format!("failed to join agent task: {err}"), + message.clone(), Some("process_wait_failed".to_string()), None, ) .await; + self.mark_session_ended( + &session_id, + None, + &message, + ) + .await; } } } @@ -707,6 +769,16 @@ impl SessionManager { .await; } + async fn mark_session_ended(&self, session_id: &str, exit_code: Option, message: &str) { + let mut sessions = self.sessions.lock().await; + if let Some(session) = sessions.get_mut(session_id) { + if session.ended { + return; + } + session.mark_ended(exit_code, message.to_string()); + } + } + async fn ensure_opencode_stream(self: &Arc, session_id: String) -> Result<(), SandboxError> { let agent_session_id = { let mut sessions = self.sessions.lock().await; @@ -744,6 +816,12 @@ impl SessionManager { None, ) .await; + self.mark_session_ended( + &session_id, + None, + "opencode server unavailable", + ) + .await; return; } }; @@ -759,6 +837,12 @@ impl SessionManager { None, ) .await; + self.mark_session_ended( + &session_id, + None, + "opencode sse connection failed", + ) + .await; return; } }; @@ -773,6 +857,12 @@ impl SessionManager { None, ) .await; + self.mark_session_ended( + &session_id, + None, + "opencode sse error", + ) + .await; return; } @@ -789,6 +879,12 @@ impl SessionManager { None, ) .await; + self.mark_session_ended( + &session_id, + None, + "opencode sse stream error", + ) + .await; return; } }; diff --git a/engine/packages/universal-agent-schema/src/agents/amp.rs b/engine/packages/universal-agent-schema/src/agents/amp.rs new file mode 100644 index 0000000..a4a3255 --- /dev/null +++ b/engine/packages/universal-agent-schema/src/agents/amp.rs @@ -0,0 +1,155 @@ +use crate::{ + message_from_parts, + message_from_text, + text_only_from_parts, + ConversionError, + CrashInfo, + EventConversion, + UniversalEventData, + UniversalMessage, + UniversalMessageParsed, + UniversalMessagePart, +}; +use crate::amp as schema; +use serde_json::{Map, Value}; + +pub fn event_to_universal(event: &schema::StreamJsonMessage) -> EventConversion { + let schema::StreamJsonMessage { + content, + error, + id, + tool_call, + type_, + } = event; + match type_ { + schema::StreamJsonMessageType::Message => { + let text = content.clone().unwrap_or_default(); + let mut message = message_from_text("assistant", text); + if let UniversalMessage::Parsed(parsed) = &mut message { + parsed.id = id.clone(); + } + EventConversion::new(UniversalEventData::Message { message }) + } + schema::StreamJsonMessageType::ToolCall => { + let tool_call = tool_call.as_ref(); + let part = if let Some(tool_call) = tool_call { + let schema::ToolCall { arguments, id, name } = tool_call; + let input = match arguments { + schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()), + schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()), + }; + UniversalMessagePart::ToolCall { + id: Some(id.clone()), + name: name.clone(), + input, + } + } else { + UniversalMessagePart::Unknown { raw: Value::Null } + }; + let mut message = message_from_parts("assistant", vec![part]); + if let UniversalMessage::Parsed(parsed) = &mut message { + parsed.id = id.clone(); + } + EventConversion::new(UniversalEventData::Message { message }) + } + schema::StreamJsonMessageType::ToolResult => { + let output = content + .clone() + .map(Value::String) + .unwrap_or(Value::Null); + let part = UniversalMessagePart::ToolResult { + id: id.clone(), + name: None, + output, + is_error: None, + }; + let message = message_from_parts("tool", vec![part]); + EventConversion::new(UniversalEventData::Message { message }) + } + schema::StreamJsonMessageType::Error => { + let message = error.clone().unwrap_or_else(|| "amp error".to_string()); + let crash = CrashInfo { + message, + kind: Some("amp".to_string()), + details: serde_json::to_value(event).ok(), + }; + EventConversion::new(UniversalEventData::Error { error: crash }) + } + schema::StreamJsonMessageType::Done => EventConversion::new(UniversalEventData::Unknown { + raw: serde_json::to_value(event).unwrap_or(Value::Null), + }), + } +} + +pub fn universal_event_to_amp(event: &UniversalEventData) -> Result { + match event { + UniversalEventData::Message { message } => { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let content = text_only_from_parts(&parsed.parts)?; + Ok(schema::StreamJsonMessage { + content: Some(content), + error: None, + id: parsed.id.clone(), + tool_call: None, + type_: schema::StreamJsonMessageType::Message, + }) + } + _ => Err(ConversionError::Unsupported("amp event")), + } +} + +pub fn message_to_universal(message: &schema::Message) -> UniversalMessage { + let schema::Message { + role, + content, + tool_calls, + } = message; + let mut parts = vec![UniversalMessagePart::Text { + text: content.clone(), + }]; + for call in tool_calls { + let schema::ToolCall { arguments, id, name } = call; + let input = match arguments { + schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()), + schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()), + }; + parts.push(UniversalMessagePart::ToolCall { + id: Some(id.clone()), + name: name.clone(), + input, + }); + } + UniversalMessage::Parsed(UniversalMessageParsed { + role: role.to_string(), + id: None, + metadata: Map::new(), + parts, + }) +} + +pub fn universal_message_to_message( + message: &UniversalMessage, +) -> Result { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let content = text_only_from_parts(&parsed.parts)?; + Ok(schema::Message { + role: match parsed.role.as_str() { + "user" => schema::MessageRole::User, + "assistant" => schema::MessageRole::Assistant, + "system" => schema::MessageRole::System, + _ => schema::MessageRole::User, + }, + content, + tool_calls: vec![], + }) +} diff --git a/engine/packages/universal-agent-schema/src/agents/claude.rs b/engine/packages/universal-agent-schema/src/agents/claude.rs new file mode 100644 index 0000000..13fa2da --- /dev/null +++ b/engine/packages/universal-agent-schema/src/agents/claude.rs @@ -0,0 +1,239 @@ +use crate::{ + message_from_parts, + message_from_text, + text_only_from_parts, + ConversionError, + EventConversion, + QuestionInfo, + QuestionOption, + QuestionRequest, + UniversalEventData, + UniversalMessage, + UniversalMessageParsed, + UniversalMessagePart, +}; +use serde_json::{Map, Value}; + +pub fn event_to_universal_with_session( + event: &Value, + session_id: String, +) -> EventConversion { + let event_type = event.get("type").and_then(Value::as_str).unwrap_or(""); + match event_type { + "assistant" => assistant_event_to_universal(event), + "tool_use" => tool_use_event_to_universal(event, session_id), + "tool_result" => tool_result_event_to_universal(event), + "result" => result_event_to_universal(event), + _ => EventConversion::new(UniversalEventData::Unknown { raw: event.clone() }), + } +} + +pub fn universal_event_to_claude(event: &UniversalEventData) -> Result { + match event { + UniversalEventData::Message { message } => { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let text = text_only_from_parts(&parsed.parts)?; + Ok(Value::Object(Map::from_iter([ + ("type".to_string(), Value::String("assistant".to_string())), + ( + "message".to_string(), + Value::Object(Map::from_iter([( + "content".to_string(), + Value::Array(vec![Value::Object(Map::from_iter([( + "type".to_string(), + Value::String("text".to_string()), + ), ( + "text".to_string(), + Value::String(text), + )]))]), + )])), + ), + ]))) + } + _ => Err(ConversionError::Unsupported("claude event")), + } +} + +pub fn prompt_to_universal(prompt: &str) -> UniversalMessage { + message_from_text("user", prompt.to_string()) +} + +pub fn universal_message_to_prompt(message: &UniversalMessage) -> Result { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + text_only_from_parts(&parsed.parts) +} + +fn assistant_event_to_universal(event: &Value) -> EventConversion { + let content = event + .get("message") + .and_then(|msg| msg.get("content")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut parts = Vec::new(); + for block in content { + let block_type = block.get("type").and_then(Value::as_str).unwrap_or(""); + match block_type { + "text" => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + parts.push(UniversalMessagePart::Text { + text: text.to_string(), + }); + } + } + "tool_use" => { + if let Some(name) = block.get("name").and_then(Value::as_str) { + let input = block.get("input").cloned().unwrap_or(Value::Null); + let id = block.get("id").and_then(Value::as_str).map(|s| s.to_string()); + parts.push(UniversalMessagePart::ToolCall { + id, + name: name.to_string(), + input, + }); + } + } + _ => parts.push(UniversalMessagePart::Unknown { raw: block }), + } + } + let message = UniversalMessage::Parsed(UniversalMessageParsed { + role: "assistant".to_string(), + id: None, + metadata: Map::new(), + parts, + }); + EventConversion::new(UniversalEventData::Message { message }) +} + +fn tool_use_event_to_universal(event: &Value, session_id: String) -> EventConversion { + let tool_use = event.get("tool_use"); + let name = tool_use + .and_then(|tool| tool.get("name")) + .and_then(Value::as_str) + .unwrap_or(""); + let input = tool_use + .and_then(|tool| tool.get("input")) + .cloned() + .unwrap_or(Value::Null); + let id = tool_use + .and_then(|tool| tool.get("id")) + .and_then(Value::as_str) + .map(|s| s.to_string()); + + if name == "AskUserQuestion" { + if let Some(question) = + question_from_claude_input(&input, id.clone(), session_id.clone()) + { + return EventConversion::new(UniversalEventData::QuestionAsked { + question_asked: question, + }); + } + } + + let message = message_from_parts( + "assistant", + vec![UniversalMessagePart::ToolCall { + id, + name: name.to_string(), + input, + }], + ); + EventConversion::new(UniversalEventData::Message { message }) +} + +fn tool_result_event_to_universal(event: &Value) -> EventConversion { + let tool_result = event.get("tool_result"); + let output = tool_result + .and_then(|tool| tool.get("content")) + .cloned() + .unwrap_or(Value::Null); + let is_error = tool_result + .and_then(|tool| tool.get("is_error")) + .and_then(Value::as_bool); + let id = tool_result + .and_then(|tool| tool.get("id")) + .and_then(Value::as_str) + .map(|s| s.to_string()); + + let message = message_from_parts( + "tool", + vec![UniversalMessagePart::ToolResult { + id, + name: None, + output, + is_error, + }], + ); + EventConversion::new(UniversalEventData::Message { message }) +} + +fn result_event_to_universal(event: &Value) -> EventConversion { + let result_text = event + .get("result") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let session_id = event + .get("session_id") + .and_then(Value::as_str) + .map(|s| s.to_string()); + let message = message_from_text("assistant", result_text); + EventConversion::new(UniversalEventData::Message { message }).with_session(session_id) +} + +fn question_from_claude_input( + input: &Value, + tool_id: Option, + session_id: String, +) -> Option { + let questions = input.get("questions").and_then(Value::as_array)?; + let mut parsed_questions = Vec::new(); + for question in questions { + let question_text = question.get("question")?.as_str()?.to_string(); + let header = question + .get("header") + .and_then(Value::as_str) + .map(|s| s.to_string()); + let multi_select = question + .get("multiSelect") + .and_then(Value::as_bool); + let options = question + .get("options") + .and_then(Value::as_array) + .map(|options| { + options + .iter() + .filter_map(|option| { + let label = option.get("label")?.as_str()?.to_string(); + let description = option + .get("description") + .and_then(Value::as_str) + .map(|s| s.to_string()); + Some(QuestionOption { label, description }) + }) + .collect::>() + })?; + parsed_questions.push(QuestionInfo { + question: question_text, + header, + options, + multi_select, + custom: None, + }); + } + Some(QuestionRequest { + id: tool_id.unwrap_or_else(|| "claude-question".to_string()), + session_id, + questions: parsed_questions, + tool: None, + }) +} diff --git a/engine/packages/universal-agent-schema/src/agents/codex.rs b/engine/packages/universal-agent-schema/src/agents/codex.rs new file mode 100644 index 0000000..dca7a3a --- /dev/null +++ b/engine/packages/universal-agent-schema/src/agents/codex.rs @@ -0,0 +1,375 @@ +use crate::{ + extract_message_from_value, + text_only_from_parts, + AttachmentSource, + ConversionError, + CrashInfo, + EventConversion, + Started, + UniversalEventData, + UniversalMessage, + UniversalMessageParsed, + UniversalMessagePart, +}; +use crate::codex as schema; +use serde_json::{Map, Value}; + +pub fn event_to_universal(event: &schema::ThreadEvent) -> EventConversion { + let schema::ThreadEvent { + error, + item, + thread_id, + type_, + } = event; + match type_ { + schema::ThreadEventType::ThreadCreated | schema::ThreadEventType::ThreadUpdated => { + let started = Started { + message: Some(type_.to_string()), + details: serde_json::to_value(event).ok(), + }; + EventConversion::new(UniversalEventData::Started { started }) + .with_session(thread_id.clone()) + } + schema::ThreadEventType::ItemCreated | schema::ThreadEventType::ItemUpdated => { + if let Some(item) = item.as_ref() { + let message = thread_item_to_message(item); + EventConversion::new(UniversalEventData::Message { message }) + .with_session(thread_id.clone()) + } else { + EventConversion::new(UniversalEventData::Unknown { + raw: serde_json::to_value(event).unwrap_or(Value::Null), + }) + } + } + schema::ThreadEventType::Error => { + let message = extract_message_from_value(&Value::Object(error.clone())) + .unwrap_or_else(|| "codex error".to_string()); + let crash = CrashInfo { + message, + kind: Some("error".to_string()), + details: Some(Value::Object(error.clone())), + }; + EventConversion::new(UniversalEventData::Error { error: crash }) + .with_session(thread_id.clone()) + } + } +} + +pub fn universal_event_to_codex(event: &UniversalEventData) -> Result { + match event { + UniversalEventData::Message { message } => { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let id = parsed.id.clone().ok_or(ConversionError::MissingField("message.id"))?; + let content = text_only_from_parts(&parsed.parts)?; + let role = match parsed.role.as_str() { + "user" => Some(schema::ThreadItemRole::User), + "assistant" => Some(schema::ThreadItemRole::Assistant), + "system" => Some(schema::ThreadItemRole::System), + _ => None, + }; + let item = schema::ThreadItem { + content: Some(schema::ThreadItemContent::Variant0(content)), + id, + role, + status: None, + type_: schema::ThreadItemType::Message, + }; + Ok(schema::ThreadEvent { + error: Map::new(), + item: Some(item), + thread_id: None, + type_: schema::ThreadEventType::ItemCreated, + }) + } + _ => Err(ConversionError::Unsupported("codex event")), + } +} + +pub fn message_to_universal(message: &schema::Message) -> UniversalMessage { + let schema::Message { role, content } = message; + UniversalMessage::Parsed(UniversalMessageParsed { + role: role.to_string(), + id: None, + metadata: Map::new(), + parts: vec![UniversalMessagePart::Text { + text: content.clone(), + }], + }) +} + +pub fn universal_message_to_message( + message: &UniversalMessage, +) -> Result { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let content = text_only_from_parts(&parsed.parts)?; + Ok(schema::Message { + role: match parsed.role.as_str() { + "user" => schema::MessageRole::User, + "assistant" => schema::MessageRole::Assistant, + "system" => schema::MessageRole::System, + _ => schema::MessageRole::User, + }, + content, + }) +} + +pub fn inputs_to_universal_message(inputs: &[schema::Input], role: &str) -> UniversalMessage { + let parts = inputs.iter().map(input_to_universal_part).collect(); + UniversalMessage::Parsed(UniversalMessageParsed { + role: role.to_string(), + id: None, + metadata: Map::new(), + parts, + }) +} + +pub fn input_to_universal_part(input: &schema::Input) -> UniversalMessagePart { + let schema::Input { + content, + mime_type, + path, + type_, + } = input; + let raw = serde_json::to_value(input).unwrap_or(Value::Null); + match type_ { + schema::InputType::Text => match content { + Some(content) => UniversalMessagePart::Text { + text: content.clone(), + }, + None => UniversalMessagePart::Unknown { raw }, + }, + schema::InputType::File => { + let source = if let Some(path) = path { + AttachmentSource::Path { path: path.clone() } + } else if let Some(content) = content { + AttachmentSource::Data { + data: content.clone(), + encoding: None, + } + } else { + return UniversalMessagePart::Unknown { raw }; + }; + UniversalMessagePart::File { + source, + mime_type: mime_type.clone(), + filename: None, + raw: Some(raw), + } + } + schema::InputType::Image => { + let source = if let Some(path) = path { + AttachmentSource::Path { path: path.clone() } + } else if let Some(content) = content { + AttachmentSource::Data { + data: content.clone(), + encoding: None, + } + } else { + return UniversalMessagePart::Unknown { raw }; + }; + UniversalMessagePart::Image { + source, + mime_type: mime_type.clone(), + alt: None, + raw: Some(raw), + } + } + } +} + +pub fn universal_message_to_inputs( + message: &UniversalMessage, +) -> Result, ConversionError> { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + universal_parts_to_inputs(&parsed.parts) +} + +pub fn universal_parts_to_inputs( + parts: &[UniversalMessagePart], +) -> Result, ConversionError> { + let mut inputs = Vec::new(); + for part in parts { + match part { + UniversalMessagePart::Text { text } => inputs.push(schema::Input { + content: Some(text.clone()), + mime_type: None, + path: None, + type_: schema::InputType::Text, + }), + UniversalMessagePart::File { + source, + mime_type, + .. + } => inputs.push(input_from_attachment(source, mime_type.as_ref(), schema::InputType::File)?), + UniversalMessagePart::Image { + source, mime_type, .. + } => inputs.push(input_from_attachment( + source, + mime_type.as_ref(), + schema::InputType::Image, + )?), + UniversalMessagePart::ToolCall { .. } + | UniversalMessagePart::ToolResult { .. } + | UniversalMessagePart::FunctionCall { .. } + | UniversalMessagePart::FunctionResult { .. } + | UniversalMessagePart::Error { .. } + | UniversalMessagePart::Unknown { .. } => { + return Err(ConversionError::Unsupported("unsupported part")) + } + } + } + if inputs.is_empty() { + return Err(ConversionError::MissingField("parts")); + } + Ok(inputs) +} + +fn input_from_attachment( + source: &AttachmentSource, + mime_type: Option<&String>, + input_type: schema::InputType, +) -> Result { + match source { + AttachmentSource::Path { path } => Ok(schema::Input { + content: None, + mime_type: mime_type.cloned(), + path: Some(path.clone()), + type_: input_type, + }), + AttachmentSource::Data { data, encoding } => { + if let Some(encoding) = encoding.as_deref() { + if encoding != "base64" { + return Err(ConversionError::Unsupported("codex data encoding")); + } + } + Ok(schema::Input { + content: Some(data.clone()), + mime_type: mime_type.cloned(), + path: None, + type_: input_type, + }) + } + AttachmentSource::Url { .. } => Err(ConversionError::Unsupported("codex input url")), + } +} + +fn thread_item_to_message(item: &schema::ThreadItem) -> UniversalMessage { + let schema::ThreadItem { + content, + id, + role, + status, + type_, + } = item; + let mut metadata = Map::new(); + metadata.insert("itemType".to_string(), Value::String(type_.to_string())); + if let Some(status) = status { + metadata.insert("status".to_string(), Value::String(status.to_string())); + } + let role = role + .as_ref() + .map(|role| role.to_string()) + .unwrap_or_else(|| "assistant".to_string()); + let parts = match type_ { + schema::ThreadItemType::Message => message_parts_from_codex_content(content), + schema::ThreadItemType::FunctionCall => vec![function_call_part_from_codex(id, content)], + schema::ThreadItemType::FunctionResult => vec![function_result_part_from_codex(id, content)], + }; + UniversalMessage::Parsed(UniversalMessageParsed { + role, + id: Some(id.clone()), + metadata, + parts, + }) +} + +fn message_parts_from_codex_content( + content: &Option, +) -> Vec { + match content { + Some(schema::ThreadItemContent::Variant0(text)) => { + vec![UniversalMessagePart::Text { text: text.clone() }] + } + Some(schema::ThreadItemContent::Variant1(raw)) => { + vec![UniversalMessagePart::Unknown { + raw: serde_json::to_value(raw).unwrap_or(Value::Null), + }] + } + None => Vec::new(), + } +} + +fn function_call_part_from_codex( + item_id: &str, + content: &Option, +) -> UniversalMessagePart { + let raw = thread_item_content_to_value(content); + let name = extract_object_field(&raw, "name"); + let arguments = extract_object_value(&raw, "arguments").unwrap_or_else(|| raw.clone()); + UniversalMessagePart::FunctionCall { + id: Some(item_id.to_string()), + name, + arguments, + raw: Some(raw), + } +} + +fn function_result_part_from_codex( + item_id: &str, + content: &Option, +) -> UniversalMessagePart { + let raw = thread_item_content_to_value(content); + let name = extract_object_field(&raw, "name"); + let result = extract_object_value(&raw, "result") + .or_else(|| extract_object_value(&raw, "output")) + .or_else(|| extract_object_value(&raw, "content")) + .unwrap_or_else(|| raw.clone()); + UniversalMessagePart::FunctionResult { + id: Some(item_id.to_string()), + name, + result, + is_error: None, + raw: Some(raw), + } +} + +fn thread_item_content_to_value(content: &Option) -> Value { + match content { + Some(schema::ThreadItemContent::Variant0(text)) => Value::String(text.clone()), + Some(schema::ThreadItemContent::Variant1(raw)) => { + Value::Array(raw.iter().cloned().map(Value::Object).collect()) + } + None => Value::Null, + } +} + +fn extract_object_field(raw: &Value, field: &str) -> Option { + extract_object_value(raw, field) + .and_then(|value| value.as_str().map(|s| s.to_string())) +} + +fn extract_object_value(raw: &Value, field: &str) -> Option { + match raw { + Value::Object(map) => map.get(field).cloned(), + Value::Array(values) => values + .first() + .and_then(|value| value.as_object()) + .and_then(|map| map.get(field).cloned()), + _ => None, + } +} diff --git a/engine/packages/universal-agent-schema/src/agents/mod.rs b/engine/packages/universal-agent-schema/src/agents/mod.rs new file mode 100644 index 0000000..8108098 --- /dev/null +++ b/engine/packages/universal-agent-schema/src/agents/mod.rs @@ -0,0 +1,4 @@ +pub mod amp; +pub mod claude; +pub mod codex; +pub mod opencode; diff --git a/engine/packages/universal-agent-schema/src/agents/opencode.rs b/engine/packages/universal-agent-schema/src/agents/opencode.rs new file mode 100644 index 0000000..c27a8dd --- /dev/null +++ b/engine/packages/universal-agent-schema/src/agents/opencode.rs @@ -0,0 +1,958 @@ +use crate::{ + extract_message_from_value, + AttachmentSource, + ConversionError, + CrashInfo, + EventConversion, + PermissionRequest, + PermissionToolRef, + QuestionInfo, + QuestionOption, + QuestionRequest, + QuestionToolRef, + Started, + UniversalEventData, + UniversalMessage, + UniversalMessageParsed, + UniversalMessagePart, +}; +use crate::opencode as schema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub fn event_to_universal(event: &schema::Event) -> EventConversion { + match event { + schema::Event::MessageUpdated(updated) => { + let schema::EventMessageUpdated { properties, type_: _ } = updated; + let schema::EventMessageUpdatedProperties { info } = properties; + let (message, session_id) = message_from_opencode(info); + EventConversion::new(UniversalEventData::Message { message }) + .with_session(session_id) + } + schema::Event::MessagePartUpdated(updated) => { + let schema::EventMessagePartUpdated { properties, type_: _ } = updated; + let schema::EventMessagePartUpdatedProperties { part, delta } = properties; + let (message, session_id) = part_to_message(part, delta.as_ref()); + EventConversion::new(UniversalEventData::Message { message }) + .with_session(session_id) + } + schema::Event::QuestionAsked(asked) => { + let schema::EventQuestionAsked { properties, type_: _ } = asked; + let question = question_request_from_opencode(properties); + let session_id = question.session_id.clone(); + EventConversion::new(UniversalEventData::QuestionAsked { question_asked: question }) + .with_session(Some(session_id)) + } + schema::Event::PermissionAsked(asked) => { + let schema::EventPermissionAsked { properties, type_: _ } = asked; + let permission = permission_request_from_opencode(properties); + let session_id = permission.session_id.clone(); + EventConversion::new(UniversalEventData::PermissionAsked { permission_asked: permission }) + .with_session(Some(session_id)) + } + schema::Event::SessionCreated(created) => { + let schema::EventSessionCreated { properties, type_: _ } = created; + let schema::EventSessionCreatedProperties { info } = properties; + let details = serde_json::to_value(info).ok(); + let started = Started { + message: Some("session.created".to_string()), + details, + }; + EventConversion::new(UniversalEventData::Started { started }) + } + schema::Event::SessionError(error) => { + let schema::EventSessionError { properties, type_: _ } = error; + let schema::EventSessionErrorProperties { + error: _error, + session_id, + } = properties; + let message = extract_message_from_value(&serde_json::to_value(properties).unwrap_or(Value::Null)) + .unwrap_or_else(|| "opencode session error".to_string()); + let crash = CrashInfo { + message, + kind: Some("session.error".to_string()), + details: serde_json::to_value(properties).ok(), + }; + EventConversion::new(UniversalEventData::Error { error: crash }) + .with_session(session_id.clone()) + } + _ => EventConversion::new(UniversalEventData::Unknown { + raw: serde_json::to_value(event).unwrap_or(Value::Null), + }), + } +} + +pub fn universal_event_to_opencode(event: &UniversalEventData) -> Result { + match event { + UniversalEventData::QuestionAsked { question_asked } => { + let properties = question_request_to_opencode(question_asked)?; + Ok(schema::Event::QuestionAsked(schema::EventQuestionAsked { + properties, + type_: "question.asked".to_string(), + })) + } + UniversalEventData::PermissionAsked { permission_asked } => { + let properties = permission_request_to_opencode(permission_asked)?; + Ok(schema::Event::PermissionAsked(schema::EventPermissionAsked { + properties, + type_: "permission.asked".to_string(), + })) + } + _ => Err(ConversionError::Unsupported("opencode event")), + } +} + +pub fn universal_message_to_parts( + message: &UniversalMessage, +) -> Result, ConversionError> { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let mut parts = Vec::new(); + for part in &parsed.parts { + match part { + UniversalMessagePart::Text { text } => { + parts.push(text_part_input_from_text(text)); + } + UniversalMessagePart::ToolCall { .. } + | UniversalMessagePart::ToolResult { .. } + | UniversalMessagePart::FunctionCall { .. } + | UniversalMessagePart::FunctionResult { .. } + | UniversalMessagePart::File { .. } + | UniversalMessagePart::Image { .. } + | UniversalMessagePart::Error { .. } + | UniversalMessagePart::Unknown { .. } => { + return Err(ConversionError::Unsupported("non-text part")) + } + } + } + if parts.is_empty() { + return Err(ConversionError::MissingField("parts")); + } + Ok(parts) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum OpencodePartInput { + Text(schema::TextPartInput), + File(schema::FilePartInput), +} + +pub fn universal_message_to_part_inputs( + message: &UniversalMessage, +) -> Result, ConversionError> { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + universal_parts_to_part_inputs(&parsed.parts) +} + +pub fn universal_parts_to_part_inputs( + parts: &[UniversalMessagePart], +) -> Result, ConversionError> { + let mut inputs = Vec::new(); + for part in parts { + inputs.push(universal_part_to_opencode_input(part)?); + } + if inputs.is_empty() { + return Err(ConversionError::MissingField("parts")); + } + Ok(inputs) +} + +pub fn universal_part_to_opencode_input( + part: &UniversalMessagePart, +) -> Result { + match part { + UniversalMessagePart::Text { text } => Ok(OpencodePartInput::Text( + text_part_input_from_text(text), + )), + UniversalMessagePart::File { + source, + mime_type, + filename, + .. + } => Ok(OpencodePartInput::File(file_part_input_from_universal( + source, + mime_type.as_deref(), + filename.as_ref(), + )?)), + UniversalMessagePart::Image { + source, mime_type, .. + } => Ok(OpencodePartInput::File(file_part_input_from_universal( + source, + mime_type.as_deref(), + None, + )?)), + UniversalMessagePart::ToolCall { .. } + | UniversalMessagePart::ToolResult { .. } + | UniversalMessagePart::FunctionCall { .. } + | UniversalMessagePart::FunctionResult { .. } + | UniversalMessagePart::Error { .. } + | UniversalMessagePart::Unknown { .. } => { + Err(ConversionError::Unsupported("unsupported part")) + } + } +} + +fn text_part_input_from_text(text: &str) -> schema::TextPartInput { + schema::TextPartInput { + id: None, + ignored: None, + metadata: Map::new(), + synthetic: None, + text: text.to_string(), + time: None, + type_: "text".to_string(), + } +} + +pub fn text_part_input_to_universal(part: &schema::TextPartInput) -> UniversalMessage { + let schema::TextPartInput { + id, + ignored, + metadata, + synthetic, + text, + time, + type_, + } = part; + let mut metadata = metadata.clone(); + if let Some(id) = id { + metadata.insert("partId".to_string(), Value::String(id.clone())); + } + if let Some(ignored) = ignored { + metadata.insert("ignored".to_string(), Value::Bool(*ignored)); + } + if let Some(synthetic) = synthetic { + metadata.insert("synthetic".to_string(), Value::Bool(*synthetic)); + } + if let Some(time) = time { + metadata.insert( + "time".to_string(), + serde_json::to_value(time).unwrap_or(Value::Null), + ); + } + metadata.insert("type".to_string(), Value::String(type_.clone())); + UniversalMessage::Parsed(UniversalMessageParsed { + role: "user".to_string(), + id: None, + metadata, + parts: vec![UniversalMessagePart::Text { text: text.clone() }], + }) +} + +fn file_part_input_from_universal( + source: &AttachmentSource, + mime_type: Option<&str>, + filename: Option<&String>, +) -> Result { + let mime = mime_type.ok_or(ConversionError::MissingField("mime_type"))?; + let url = attachment_source_to_opencode_url(source, mime)?; + Ok(schema::FilePartInput { + filename: filename.cloned(), + id: None, + mime: mime.to_string(), + source: None, + type_: "file".to_string(), + url, + }) +} + +fn attachment_source_to_opencode_url( + source: &AttachmentSource, + mime_type: &str, +) -> Result { + match source { + AttachmentSource::Url { url } => Ok(url.clone()), + AttachmentSource::Path { path } => Ok(format!("file://{}", path)), + AttachmentSource::Data { data, encoding } => { + let encoding = encoding.as_deref().unwrap_or("base64"); + if encoding != "base64" { + return Err(ConversionError::Unsupported("opencode data encoding")); + } + Ok(format!("data:{};base64,{}", mime_type, data)) + } + } +} + +fn message_from_opencode(message: &schema::Message) -> (UniversalMessage, Option) { + match message { + schema::Message::UserMessage(user) => { + let schema::UserMessage { + agent, + id, + model, + role, + session_id, + summary, + system, + time, + tools, + variant, + } = user; + let mut metadata = Map::new(); + metadata.insert("agent".to_string(), Value::String(agent.clone())); + metadata.insert( + "model".to_string(), + serde_json::to_value(model).unwrap_or(Value::Null), + ); + metadata.insert( + "time".to_string(), + serde_json::to_value(time).unwrap_or(Value::Null), + ); + metadata.insert( + "tools".to_string(), + serde_json::to_value(tools).unwrap_or(Value::Null), + ); + if let Some(summary) = summary { + metadata.insert( + "summary".to_string(), + serde_json::to_value(summary).unwrap_or(Value::Null), + ); + } + if let Some(system) = system { + metadata.insert("system".to_string(), Value::String(system.clone())); + } + if let Some(variant) = variant { + metadata.insert("variant".to_string(), Value::String(variant.clone())); + } + let parsed = UniversalMessageParsed { + role: role.clone(), + id: Some(id.clone()), + metadata, + parts: Vec::new(), + }; + ( + UniversalMessage::Parsed(parsed), + Some(session_id.clone()), + ) + } + schema::Message::AssistantMessage(assistant) => { + let schema::AssistantMessage { + agent, + cost, + error, + finish, + id, + mode, + model_id, + parent_id, + path, + provider_id, + role, + session_id, + summary, + time, + tokens, + } = assistant; + let mut metadata = Map::new(); + metadata.insert("agent".to_string(), Value::String(agent.clone())); + metadata.insert( + "cost".to_string(), + serde_json::to_value(cost).unwrap_or(Value::Null), + ); + metadata.insert("mode".to_string(), Value::String(mode.clone())); + metadata.insert("modelId".to_string(), Value::String(model_id.clone())); + metadata.insert("providerId".to_string(), Value::String(provider_id.clone())); + metadata.insert("parentId".to_string(), Value::String(parent_id.clone())); + metadata.insert( + "path".to_string(), + serde_json::to_value(path).unwrap_or(Value::Null), + ); + metadata.insert( + "tokens".to_string(), + serde_json::to_value(tokens).unwrap_or(Value::Null), + ); + metadata.insert( + "time".to_string(), + serde_json::to_value(time).unwrap_or(Value::Null), + ); + if let Some(error) = error { + metadata.insert( + "error".to_string(), + serde_json::to_value(error).unwrap_or(Value::Null), + ); + } + if let Some(finish) = finish { + metadata.insert("finish".to_string(), Value::String(finish.clone())); + } + if let Some(summary) = summary { + metadata.insert( + "summary".to_string(), + serde_json::to_value(summary).unwrap_or(Value::Null), + ); + } + let parsed = UniversalMessageParsed { + role: role.clone(), + id: Some(id.clone()), + metadata, + parts: Vec::new(), + }; + ( + UniversalMessage::Parsed(parsed), + Some(session_id.clone()), + ) + } + } +} + +fn part_to_message(part: &schema::Part, delta: Option<&String>) -> (UniversalMessage, Option) { + match part { + schema::Part::Variant0(text_part) => { + let schema::TextPart { + id, + ignored, + message_id, + metadata, + session_id, + synthetic, + text, + time, + type_, + } = text_part; + let mut part_metadata = base_part_metadata(message_id, id, delta); + part_metadata.insert("type".to_string(), Value::String(type_.clone())); + if let Some(ignored) = ignored { + part_metadata.insert("ignored".to_string(), Value::Bool(*ignored)); + } + if let Some(synthetic) = synthetic { + part_metadata.insert("synthetic".to_string(), Value::Bool(*synthetic)); + } + if let Some(time) = time { + part_metadata.insert( + "time".to_string(), + serde_json::to_value(time).unwrap_or(Value::Null), + ); + } + if !metadata.is_empty() { + part_metadata.insert( + "partMetadata".to_string(), + Value::Object(metadata.clone()), + ); + } + let parsed = UniversalMessageParsed { + role: "assistant".to_string(), + id: Some(message_id.clone()), + metadata: part_metadata, + parts: vec![UniversalMessagePart::Text { text: text.clone() }], + }; + (UniversalMessage::Parsed(parsed), Some(session_id.clone())) + } + schema::Part::Variant1 { + agent: _agent, + command: _command, + description: _description, + id, + message_id, + model: _model, + prompt: _prompt, + session_id, + type_: _type, + } => unknown_part_message(message_id, id, session_id, serde_json::to_value(part).unwrap_or(Value::Null), delta), + schema::Part::Variant2(reasoning_part) => { + let schema::ReasoningPart { + id, + message_id, + metadata: _metadata, + session_id, + text: _text, + time: _time, + type_: _type, + } = reasoning_part; + unknown_part_message( + message_id, + id, + session_id, + serde_json::to_value(reasoning_part).unwrap_or(Value::Null), + delta, + ) + } + schema::Part::Variant3(file_part) => { + let schema::FilePart { + filename: _filename, + id, + message_id, + mime: _mime, + session_id, + source: _source, + type_: _type, + url: _url, + } = file_part; + let part_metadata = base_part_metadata(message_id, id, delta); + let part = file_part_to_universal_part(file_part); + let parsed = UniversalMessageParsed { + role: "assistant".to_string(), + id: Some(message_id.clone()), + metadata: part_metadata, + parts: vec![part], + }; + (UniversalMessage::Parsed(parsed), Some(session_id.clone())) + } + schema::Part::Variant4(tool_part) => { + let schema::ToolPart { + call_id, + id, + message_id, + metadata, + session_id, + state, + tool, + type_, + } = tool_part; + let mut part_metadata = base_part_metadata(message_id, id, delta); + part_metadata.insert("type".to_string(), Value::String(type_.clone())); + part_metadata.insert("callId".to_string(), Value::String(call_id.clone())); + part_metadata.insert("tool".to_string(), Value::String(tool.clone())); + if !metadata.is_empty() { + part_metadata.insert( + "partMetadata".to_string(), + Value::Object(metadata.clone()), + ); + } + let (mut parts, state_meta) = tool_state_to_parts(call_id, tool, state); + if let Some(state_meta) = state_meta { + part_metadata.insert("toolState".to_string(), state_meta); + } + let parsed = UniversalMessageParsed { + role: "assistant".to_string(), + id: Some(message_id.clone()), + metadata: part_metadata, + parts: parts.drain(..).collect(), + }; + (UniversalMessage::Parsed(parsed), Some(session_id.clone())) + } + schema::Part::Variant5(step_start) => { + let schema::StepStartPart { + id, + message_id, + session_id, + snapshot: _snapshot, + type_: _type, + } = step_start; + unknown_part_message( + message_id, + id, + session_id, + serde_json::to_value(step_start).unwrap_or(Value::Null), + delta, + ) + } + schema::Part::Variant6(step_finish) => { + let schema::StepFinishPart { + cost: _cost, + id, + message_id, + reason: _reason, + session_id, + snapshot: _snapshot, + tokens: _tokens, + type_: _type, + } = step_finish; + unknown_part_message( + message_id, + id, + session_id, + serde_json::to_value(step_finish).unwrap_or(Value::Null), + delta, + ) + } + schema::Part::Variant7(snapshot_part) => { + let schema::SnapshotPart { + id, + message_id, + session_id, + snapshot: _snapshot, + type_: _type, + } = snapshot_part; + unknown_part_message( + message_id, + id, + session_id, + serde_json::to_value(snapshot_part).unwrap_or(Value::Null), + delta, + ) + } + schema::Part::Variant8(patch_part) => { + let schema::PatchPart { + files: _files, + hash: _hash, + id, + message_id, + session_id, + type_: _type, + } = patch_part; + unknown_part_message( + message_id, + id, + session_id, + serde_json::to_value(patch_part).unwrap_or(Value::Null), + delta, + ) + } + schema::Part::Variant9(agent_part) => { + let schema::AgentPart { + id, + message_id, + name: _name, + session_id, + source: _source, + type_: _type, + } = agent_part; + unknown_part_message( + message_id, + id, + session_id, + serde_json::to_value(agent_part).unwrap_or(Value::Null), + delta, + ) + } + schema::Part::Variant10(retry_part) => { + let schema::RetryPart { + attempt: _attempt, + error: _error, + id, + message_id, + session_id, + time: _time, + type_: _type, + } = retry_part; + unknown_part_message( + message_id, + id, + session_id, + serde_json::to_value(retry_part).unwrap_or(Value::Null), + delta, + ) + } + schema::Part::Variant11(compaction_part) => { + let schema::CompactionPart { + auto: _auto, + id, + message_id, + session_id, + type_: _type, + } = compaction_part; + unknown_part_message( + message_id, + id, + session_id, + serde_json::to_value(compaction_part).unwrap_or(Value::Null), + delta, + ) + } + } +} + +fn base_part_metadata(message_id: &str, part_id: &str, delta: Option<&String>) -> Map { + let mut metadata = Map::new(); + metadata.insert("messageId".to_string(), Value::String(message_id.to_string())); + metadata.insert("partId".to_string(), Value::String(part_id.to_string())); + if let Some(delta) = delta { + metadata.insert("delta".to_string(), Value::String(delta.clone())); + } + metadata +} + +fn unknown_part_message( + message_id: &str, + part_id: &str, + session_id: &str, + raw: Value, + delta: Option<&String>, +) -> (UniversalMessage, Option) { + let metadata = base_part_metadata(message_id, part_id, delta); + let parsed = UniversalMessageParsed { + role: "assistant".to_string(), + id: Some(message_id.to_string()), + metadata, + parts: vec![UniversalMessagePart::Unknown { raw }], + }; + (UniversalMessage::Parsed(parsed), Some(session_id.to_string())) +} + +fn file_part_to_universal_part(file_part: &schema::FilePart) -> UniversalMessagePart { + let schema::FilePart { + filename, + id: _id, + message_id: _message_id, + mime, + session_id: _session_id, + source: _source, + type_: _type, + url, + } = file_part; + let raw = serde_json::to_value(file_part).unwrap_or(Value::Null); + let source = AttachmentSource::Url { url: url.clone() }; + if mime.starts_with("image/") { + UniversalMessagePart::Image { + source, + mime_type: Some(mime.clone()), + alt: filename.clone(), + raw: Some(raw), + } + } else { + UniversalMessagePart::File { + source, + mime_type: Some(mime.clone()), + filename: filename.clone(), + raw: Some(raw), + } + } +} + +fn tool_state_to_parts( + call_id: &str, + tool: &str, + state: &schema::ToolState, +) -> (Vec, Option) { + match state { + schema::ToolState::Pending(state) => { + let schema::ToolStatePending { input, raw, status } = state; + let mut meta = Map::new(); + meta.insert("status".to_string(), Value::String(status.clone())); + meta.insert("raw".to_string(), Value::String(raw.clone())); + meta.insert("input".to_string(), Value::Object(input.clone())); + ( + vec![UniversalMessagePart::ToolCall { + id: Some(call_id.to_string()), + name: tool.to_string(), + input: Value::Object(input.clone()), + }], + Some(Value::Object(meta)), + ) + } + schema::ToolState::Running(state) => { + let schema::ToolStateRunning { + input, + metadata, + status, + time, + title, + } = state; + let mut meta = Map::new(); + meta.insert("status".to_string(), Value::String(status.clone())); + meta.insert("input".to_string(), Value::Object(input.clone())); + meta.insert("metadata".to_string(), Value::Object(metadata.clone())); + meta.insert( + "time".to_string(), + serde_json::to_value(time).unwrap_or(Value::Null), + ); + if let Some(title) = title { + meta.insert("title".to_string(), Value::String(title.clone())); + } + ( + vec![UniversalMessagePart::ToolCall { + id: Some(call_id.to_string()), + name: tool.to_string(), + input: Value::Object(input.clone()), + }], + Some(Value::Object(meta)), + ) + } + schema::ToolState::Completed(state) => { + let schema::ToolStateCompleted { + attachments, + input, + metadata, + output, + status, + time, + title, + } = state; + let mut meta = Map::new(); + meta.insert("status".to_string(), Value::String(status.clone())); + meta.insert("input".to_string(), Value::Object(input.clone())); + meta.insert("metadata".to_string(), Value::Object(metadata.clone())); + meta.insert( + "time".to_string(), + serde_json::to_value(time).unwrap_or(Value::Null), + ); + meta.insert("title".to_string(), Value::String(title.clone())); + if !attachments.is_empty() { + meta.insert( + "attachments".to_string(), + serde_json::to_value(attachments).unwrap_or(Value::Null), + ); + } + let mut parts = vec![UniversalMessagePart::ToolResult { + id: Some(call_id.to_string()), + name: Some(tool.to_string()), + output: Value::String(output.clone()), + is_error: Some(false), + }]; + for attachment in attachments { + parts.push(file_part_to_universal_part(attachment)); + } + (parts, Some(Value::Object(meta))) + } + schema::ToolState::Error(state) => { + let schema::ToolStateError { + error, + input, + metadata, + status, + time, + } = state; + let mut meta = Map::new(); + meta.insert("status".to_string(), Value::String(status.clone())); + meta.insert("error".to_string(), Value::String(error.clone())); + meta.insert("input".to_string(), Value::Object(input.clone())); + meta.insert("metadata".to_string(), Value::Object(metadata.clone())); + meta.insert( + "time".to_string(), + serde_json::to_value(time).unwrap_or(Value::Null), + ); + ( + vec![UniversalMessagePart::ToolResult { + id: Some(call_id.to_string()), + name: Some(tool.to_string()), + output: Value::String(error.clone()), + is_error: Some(true), + }], + Some(Value::Object(meta)), + ) + } + } +} + +fn question_request_from_opencode(request: &schema::QuestionRequest) -> QuestionRequest { + let schema::QuestionRequest { + id, + questions, + session_id, + tool, + } = request; + QuestionRequest { + id: id.clone().into(), + session_id: session_id.clone().into(), + questions: questions + .iter() + .map(|question| { + let schema::QuestionInfo { + custom, + header, + multiple, + options, + question, + } = question; + QuestionInfo { + question: question.clone(), + header: Some(header.clone()), + options: options + .iter() + .map(|opt| { + let schema::QuestionOption { description, label } = opt; + QuestionOption { + label: label.clone(), + description: Some(description.clone()), + } + }) + .collect(), + multi_select: *multiple, + custom: *custom, + } + }) + .collect(), + tool: tool.as_ref().map(|tool| { + let schema::QuestionRequestTool { message_id, call_id } = tool; + QuestionToolRef { + message_id: message_id.clone(), + call_id: call_id.clone(), + } + }), + } +} + +fn permission_request_from_opencode(request: &schema::PermissionRequest) -> PermissionRequest { + let schema::PermissionRequest { + always, + id, + metadata, + patterns, + permission, + session_id, + tool, + } = request; + PermissionRequest { + id: id.clone().into(), + session_id: session_id.clone().into(), + permission: permission.clone(), + patterns: patterns.clone(), + metadata: metadata.clone(), + always: always.clone(), + tool: tool.as_ref().map(|tool| { + let schema::PermissionRequestTool { message_id, call_id } = tool; + PermissionToolRef { + message_id: message_id.clone(), + call_id: call_id.clone(), + } + }), + } +} + +fn question_request_to_opencode(request: &QuestionRequest) -> Result { + let id = schema::QuestionRequestId::try_from(request.id.as_str()) + .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; + let session_id = schema::QuestionRequestSessionId::try_from(request.session_id.as_str()) + .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; + let questions = request + .questions + .iter() + .map(|question| schema::QuestionInfo { + question: question.question.clone(), + header: question + .header + .clone() + .unwrap_or_else(|| "Question".to_string()), + options: question + .options + .iter() + .map(|opt| schema::QuestionOption { + label: opt.label.clone(), + description: opt.description.clone().unwrap_or_default(), + }) + .collect(), + multiple: question.multi_select, + custom: question.custom, + }) + .collect(); + + Ok(schema::QuestionRequest { + id, + session_id, + questions, + tool: request.tool.as_ref().map(|tool| schema::QuestionRequestTool { + message_id: tool.message_id.clone(), + call_id: tool.call_id.clone(), + }), + }) +} + +fn permission_request_to_opencode( + request: &PermissionRequest, +) -> Result { + let id = schema::PermissionRequestId::try_from(request.id.as_str()) + .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; + let session_id = schema::PermissionRequestSessionId::try_from(request.session_id.as_str()) + .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; + Ok(schema::PermissionRequest { + id, + session_id, + permission: request.permission.clone(), + patterns: request.patterns.clone(), + metadata: request.metadata.clone(), + always: request.always.clone(), + tool: request.tool.as_ref().map(|tool| schema::PermissionRequestTool { + message_id: tool.message_id.clone(), + call_id: tool.call_id.clone(), + }), + }) +} diff --git a/engine/packages/universal-agent-schema/src/lib.rs b/engine/packages/universal-agent-schema/src/lib.rs index 0b055d4..91ec826 100644 --- a/engine/packages/universal-agent-schema/src/lib.rs +++ b/engine/packages/universal-agent-schema/src/lib.rs @@ -6,6 +6,10 @@ use utoipa::ToSchema; pub use sandbox_agent_agent_schema::{amp, claude, codex, opencode}; +pub mod agents; + +pub use agents::{amp as convert_amp, claude as convert_claude, codex as convert_codex, opencode as convert_opencode}; + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UniversalEvent { @@ -319,1680 +323,6 @@ fn extract_message_from_value(value: &Value) -> Option { None } -pub mod convert_opencode { - use super::*; - pub fn event_to_universal(event: &opencode::Event) -> EventConversion { - match event { - opencode::Event::MessageUpdated(updated) => { - let opencode::EventMessageUpdated { properties, type_: _ } = updated; - let opencode::EventMessageUpdatedProperties { info } = properties; - let (message, session_id) = message_from_opencode(info); - EventConversion::new(UniversalEventData::Message { message }) - .with_session(session_id) - } - opencode::Event::MessagePartUpdated(updated) => { - let opencode::EventMessagePartUpdated { properties, type_: _ } = updated; - let opencode::EventMessagePartUpdatedProperties { part, delta } = properties; - let (message, session_id) = part_to_message(part, delta.as_ref()); - EventConversion::new(UniversalEventData::Message { message }) - .with_session(session_id) - } - opencode::Event::QuestionAsked(asked) => { - let opencode::EventQuestionAsked { properties, type_: _ } = asked; - let question = question_request_from_opencode(properties); - let session_id = question.session_id.clone(); - EventConversion::new(UniversalEventData::QuestionAsked { question_asked: question }) - .with_session(Some(session_id)) - } - opencode::Event::PermissionAsked(asked) => { - let opencode::EventPermissionAsked { properties, type_: _ } = asked; - let permission = permission_request_from_opencode(properties); - let session_id = permission.session_id.clone(); - EventConversion::new(UniversalEventData::PermissionAsked { permission_asked: permission }) - .with_session(Some(session_id)) - } - opencode::Event::SessionCreated(created) => { - let opencode::EventSessionCreated { properties, type_: _ } = created; - let opencode::EventSessionCreatedProperties { info } = properties; - let details = serde_json::to_value(info).ok(); - let started = Started { - message: Some("session.created".to_string()), - details, - }; - EventConversion::new(UniversalEventData::Started { started }) - } - opencode::Event::SessionError(error) => { - let opencode::EventSessionError { properties, type_: _ } = error; - let opencode::EventSessionErrorProperties { - error: _error, - session_id, - } = properties; - let message = extract_message_from_value(&serde_json::to_value(properties).unwrap_or(Value::Null)) - .unwrap_or_else(|| "opencode session error".to_string()); - let crash = CrashInfo { - message, - kind: Some("session.error".to_string()), - details: serde_json::to_value(properties).ok(), - }; - EventConversion::new(UniversalEventData::Error { error: crash }) - .with_session(session_id.clone()) - } - _ => EventConversion::new(UniversalEventData::Unknown { - raw: serde_json::to_value(event).unwrap_or(Value::Null), - }), - } - } - pub fn universal_event_to_opencode(event: &UniversalEventData) -> Result { - match event { - UniversalEventData::QuestionAsked { question_asked } => { - let properties = question_request_to_opencode(question_asked)?; - Ok(opencode::Event::QuestionAsked(opencode::EventQuestionAsked { - properties, - type_: "question.asked".to_string(), - })) - } - UniversalEventData::PermissionAsked { permission_asked } => { - let properties = permission_request_to_opencode(permission_asked)?; - Ok(opencode::Event::PermissionAsked(opencode::EventPermissionAsked { - properties, - type_: "permission.asked".to_string(), - })) - } - _ => Err(ConversionError::Unsupported("opencode event")), - } - } - pub fn universal_message_to_parts( - message: &UniversalMessage, - ) -> Result, ConversionError> { - let parsed = match message { - UniversalMessage::Parsed(parsed) => parsed, - UniversalMessage::Unparsed { .. } => { - return Err(ConversionError::Unsupported("unparsed message")) - } - }; - let mut parts = Vec::new(); - for part in &parsed.parts { - match part { - UniversalMessagePart::Text { text } => { - parts.push(text_part_input_from_text(text)); - } - UniversalMessagePart::ToolCall { .. } - | UniversalMessagePart::ToolResult { .. } - | UniversalMessagePart::FunctionCall { .. } - | UniversalMessagePart::FunctionResult { .. } - | UniversalMessagePart::File { .. } - | UniversalMessagePart::Image { .. } - | UniversalMessagePart::Error { .. } - | UniversalMessagePart::Unknown { .. } => { - return Err(ConversionError::Unsupported("non-text part")) - } - } - } - if parts.is_empty() { - return Err(ConversionError::MissingField("parts")); - } - Ok(parts) - } - - #[derive(Debug, Clone, Serialize, Deserialize)] - #[serde(untagged)] - pub enum OpencodePartInput { - Text(opencode::TextPartInput), - File(opencode::FilePartInput), - } - - pub fn universal_message_to_part_inputs( - message: &UniversalMessage, - ) -> Result, ConversionError> { - let parsed = match message { - UniversalMessage::Parsed(parsed) => parsed, - UniversalMessage::Unparsed { .. } => { - return Err(ConversionError::Unsupported("unparsed message")) - } - }; - universal_parts_to_part_inputs(&parsed.parts) - } - - pub fn universal_parts_to_part_inputs( - parts: &[UniversalMessagePart], - ) -> Result, ConversionError> { - let mut inputs = Vec::new(); - for part in parts { - inputs.push(universal_part_to_opencode_input(part)?); - } - if inputs.is_empty() { - return Err(ConversionError::MissingField("parts")); - } - Ok(inputs) - } - - pub fn universal_part_to_opencode_input( - part: &UniversalMessagePart, - ) -> Result { - match part { - UniversalMessagePart::Text { text } => Ok(OpencodePartInput::Text( - text_part_input_from_text(text), - )), - UniversalMessagePart::File { - source, - mime_type, - filename, - .. - } => Ok(OpencodePartInput::File(file_part_input_from_universal( - source, - mime_type.as_deref(), - filename.as_ref(), - )?)), - UniversalMessagePart::Image { - source, mime_type, .. - } => Ok(OpencodePartInput::File(file_part_input_from_universal( - source, - mime_type.as_deref(), - None, - )?)), - UniversalMessagePart::ToolCall { .. } - | UniversalMessagePart::ToolResult { .. } - | UniversalMessagePart::FunctionCall { .. } - | UniversalMessagePart::FunctionResult { .. } - | UniversalMessagePart::Error { .. } - | UniversalMessagePart::Unknown { .. } => { - Err(ConversionError::Unsupported("unsupported part")) - } - } - } - - fn text_part_input_from_text(text: &str) -> opencode::TextPartInput { - opencode::TextPartInput { - id: None, - ignored: None, - metadata: Map::new(), - synthetic: None, - text: text.to_string(), - time: None, - type_: "text".to_string(), - } - } - - pub fn text_part_input_to_universal(part: &opencode::TextPartInput) -> UniversalMessage { - let opencode::TextPartInput { - id, - ignored, - metadata, - synthetic, - text, - time, - type_, - } = part; - let mut metadata = metadata.clone(); - if let Some(id) = id { - metadata.insert("partId".to_string(), Value::String(id.clone())); - } - if let Some(ignored) = ignored { - metadata.insert("ignored".to_string(), Value::Bool(*ignored)); - } - if let Some(synthetic) = synthetic { - metadata.insert("synthetic".to_string(), Value::Bool(*synthetic)); - } - if let Some(time) = time { - metadata.insert( - "time".to_string(), - serde_json::to_value(time).unwrap_or(Value::Null), - ); - } - metadata.insert("type".to_string(), Value::String(type_.clone())); - UniversalMessage::Parsed(UniversalMessageParsed { - role: "user".to_string(), - id: None, - metadata, - parts: vec![UniversalMessagePart::Text { text: text.clone() }], - }) - } - - fn file_part_input_from_universal( - source: &AttachmentSource, - mime_type: Option<&str>, - filename: Option<&String>, - ) -> Result { - let mime = mime_type.ok_or(ConversionError::MissingField("mime_type"))?; - let url = attachment_source_to_opencode_url(source, mime)?; - Ok(opencode::FilePartInput { - filename: filename.cloned(), - id: None, - mime: mime.to_string(), - source: None, - type_: "file".to_string(), - url, - }) - } - - fn attachment_source_to_opencode_url( - source: &AttachmentSource, - mime_type: &str, - ) -> Result { - match source { - AttachmentSource::Url { url } => Ok(url.clone()), - AttachmentSource::Path { path } => Ok(format!("file://{}", path)), - AttachmentSource::Data { data, encoding } => { - let encoding = encoding.as_deref().unwrap_or("base64"); - if encoding != "base64" { - return Err(ConversionError::Unsupported("opencode data encoding")); - } - Ok(format!("data:{};base64,{}", mime_type, data)) - } - } - } - - fn message_from_opencode(message: &opencode::Message) -> (UniversalMessage, Option) { - match message { - opencode::Message::UserMessage(user) => { - let opencode::UserMessage { - agent, - id, - model, - role, - session_id, - summary, - system, - time, - tools, - variant, - } = user; - let mut metadata = Map::new(); - metadata.insert("agent".to_string(), Value::String(agent.clone())); - metadata.insert( - "model".to_string(), - serde_json::to_value(model).unwrap_or(Value::Null), - ); - metadata.insert( - "time".to_string(), - serde_json::to_value(time).unwrap_or(Value::Null), - ); - metadata.insert( - "tools".to_string(), - serde_json::to_value(tools).unwrap_or(Value::Null), - ); - if let Some(summary) = summary { - metadata.insert( - "summary".to_string(), - serde_json::to_value(summary).unwrap_or(Value::Null), - ); - } - if let Some(system) = system { - metadata.insert("system".to_string(), Value::String(system.clone())); - } - if let Some(variant) = variant { - metadata.insert("variant".to_string(), Value::String(variant.clone())); - } - let parsed = UniversalMessageParsed { - role: role.clone(), - id: Some(id.clone()), - metadata, - parts: Vec::new(), - }; - ( - UniversalMessage::Parsed(parsed), - Some(session_id.clone()), - ) - } - opencode::Message::AssistantMessage(assistant) => { - let opencode::AssistantMessage { - agent, - cost, - error, - finish, - id, - mode, - model_id, - parent_id, - path, - provider_id, - role, - session_id, - summary, - time, - tokens, - } = assistant; - let mut metadata = Map::new(); - metadata.insert("agent".to_string(), Value::String(agent.clone())); - metadata.insert( - "cost".to_string(), - serde_json::to_value(cost).unwrap_or(Value::Null), - ); - metadata.insert("mode".to_string(), Value::String(mode.clone())); - metadata.insert("modelId".to_string(), Value::String(model_id.clone())); - metadata.insert("providerId".to_string(), Value::String(provider_id.clone())); - metadata.insert("parentId".to_string(), Value::String(parent_id.clone())); - metadata.insert( - "path".to_string(), - serde_json::to_value(path).unwrap_or(Value::Null), - ); - metadata.insert( - "tokens".to_string(), - serde_json::to_value(tokens).unwrap_or(Value::Null), - ); - metadata.insert( - "time".to_string(), - serde_json::to_value(time).unwrap_or(Value::Null), - ); - if let Some(error) = error { - metadata.insert( - "error".to_string(), - serde_json::to_value(error).unwrap_or(Value::Null), - ); - } - if let Some(finish) = finish { - metadata.insert("finish".to_string(), Value::String(finish.clone())); - } - if let Some(summary) = summary { - metadata.insert( - "summary".to_string(), - serde_json::to_value(summary).unwrap_or(Value::Null), - ); - } - let parsed = UniversalMessageParsed { - role: role.clone(), - id: Some(id.clone()), - metadata, - parts: Vec::new(), - }; - ( - UniversalMessage::Parsed(parsed), - Some(session_id.clone()), - ) - } - } - } - - fn part_to_message(part: &opencode::Part, delta: Option<&String>) -> (UniversalMessage, Option) { - match part { - opencode::Part::Variant0(text_part) => { - let opencode::TextPart { - id, - ignored, - message_id, - metadata, - session_id, - synthetic, - text, - time, - type_, - } = text_part; - let mut part_metadata = base_part_metadata(message_id, id, delta); - part_metadata.insert("type".to_string(), Value::String(type_.clone())); - if let Some(ignored) = ignored { - part_metadata.insert("ignored".to_string(), Value::Bool(*ignored)); - } - if let Some(synthetic) = synthetic { - part_metadata.insert("synthetic".to_string(), Value::Bool(*synthetic)); - } - if let Some(time) = time { - part_metadata.insert( - "time".to_string(), - serde_json::to_value(time).unwrap_or(Value::Null), - ); - } - if !metadata.is_empty() { - part_metadata.insert( - "partMetadata".to_string(), - Value::Object(metadata.clone()), - ); - } - let parsed = UniversalMessageParsed { - role: "assistant".to_string(), - id: Some(message_id.clone()), - metadata: part_metadata, - parts: vec![UniversalMessagePart::Text { text: text.clone() }], - }; - (UniversalMessage::Parsed(parsed), Some(session_id.clone())) - } - opencode::Part::Variant1 { - agent: _agent, - command: _command, - description: _description, - id, - message_id, - model: _model, - prompt: _prompt, - session_id, - type_: _type, - } => unknown_part_message(message_id, id, session_id, serde_json::to_value(part).unwrap_or(Value::Null), delta), - opencode::Part::Variant2(reasoning_part) => { - let opencode::ReasoningPart { - id, - message_id, - metadata: _metadata, - session_id, - text: _text, - time: _time, - type_: _type, - } = reasoning_part; - unknown_part_message( - message_id, - id, - session_id, - serde_json::to_value(reasoning_part).unwrap_or(Value::Null), - delta, - ) - } - opencode::Part::Variant3(file_part) => { - let opencode::FilePart { - filename: _filename, - id, - message_id, - mime: _mime, - session_id, - source: _source, - type_: _type, - url: _url, - } = file_part; - let part_metadata = base_part_metadata(message_id, id, delta); - let part = file_part_to_universal_part(file_part); - let parsed = UniversalMessageParsed { - role: "assistant".to_string(), - id: Some(message_id.clone()), - metadata: part_metadata, - parts: vec![part], - }; - (UniversalMessage::Parsed(parsed), Some(session_id.clone())) - } - opencode::Part::Variant4(tool_part) => { - let opencode::ToolPart { - call_id, - id, - message_id, - metadata, - session_id, - state, - tool, - type_, - } = tool_part; - let mut part_metadata = base_part_metadata(message_id, id, delta); - part_metadata.insert("type".to_string(), Value::String(type_.clone())); - part_metadata.insert("callId".to_string(), Value::String(call_id.clone())); - part_metadata.insert("tool".to_string(), Value::String(tool.clone())); - if !metadata.is_empty() { - part_metadata.insert( - "partMetadata".to_string(), - Value::Object(metadata.clone()), - ); - } - let (mut parts, state_meta) = tool_state_to_parts(call_id, tool, state); - if let Some(state_meta) = state_meta { - part_metadata.insert("toolState".to_string(), state_meta); - } - let parsed = UniversalMessageParsed { - role: "assistant".to_string(), - id: Some(message_id.clone()), - metadata: part_metadata, - parts: parts.drain(..).collect(), - }; - (UniversalMessage::Parsed(parsed), Some(session_id.clone())) - } - opencode::Part::Variant5(step_start) => { - let opencode::StepStartPart { - id, - message_id, - session_id, - snapshot: _snapshot, - type_: _type, - } = step_start; - unknown_part_message( - message_id, - id, - session_id, - serde_json::to_value(step_start).unwrap_or(Value::Null), - delta, - ) - } - opencode::Part::Variant6(step_finish) => { - let opencode::StepFinishPart { - cost: _cost, - id, - message_id, - reason: _reason, - session_id, - snapshot: _snapshot, - tokens: _tokens, - type_: _type, - } = step_finish; - unknown_part_message( - message_id, - id, - session_id, - serde_json::to_value(step_finish).unwrap_or(Value::Null), - delta, - ) - } - opencode::Part::Variant7(snapshot_part) => { - let opencode::SnapshotPart { - id, - message_id, - session_id, - snapshot: _snapshot, - type_: _type, - } = snapshot_part; - unknown_part_message( - message_id, - id, - session_id, - serde_json::to_value(snapshot_part).unwrap_or(Value::Null), - delta, - ) - } - opencode::Part::Variant8(patch_part) => { - let opencode::PatchPart { - files: _files, - hash: _hash, - id, - message_id, - session_id, - type_: _type, - } = patch_part; - unknown_part_message( - message_id, - id, - session_id, - serde_json::to_value(patch_part).unwrap_or(Value::Null), - delta, - ) - } - opencode::Part::Variant9(agent_part) => { - let opencode::AgentPart { - id, - message_id, - name: _name, - session_id, - source: _source, - type_: _type, - } = agent_part; - unknown_part_message( - message_id, - id, - session_id, - serde_json::to_value(agent_part).unwrap_or(Value::Null), - delta, - ) - } - opencode::Part::Variant10(retry_part) => { - let opencode::RetryPart { - attempt: _attempt, - error: _error, - id, - message_id, - session_id, - time: _time, - type_: _type, - } = retry_part; - unknown_part_message( - message_id, - id, - session_id, - serde_json::to_value(retry_part).unwrap_or(Value::Null), - delta, - ) - } - opencode::Part::Variant11(compaction_part) => { - let opencode::CompactionPart { - auto: _auto, - id, - message_id, - session_id, - type_: _type, - } = compaction_part; - unknown_part_message( - message_id, - id, - session_id, - serde_json::to_value(compaction_part).unwrap_or(Value::Null), - delta, - ) - } - } - } - - fn base_part_metadata(message_id: &str, part_id: &str, delta: Option<&String>) -> Map { - let mut metadata = Map::new(); - metadata.insert("messageId".to_string(), Value::String(message_id.to_string())); - metadata.insert("partId".to_string(), Value::String(part_id.to_string())); - if let Some(delta) = delta { - metadata.insert("delta".to_string(), Value::String(delta.clone())); - } - metadata - } - - fn unknown_part_message( - message_id: &str, - part_id: &str, - session_id: &str, - raw: Value, - delta: Option<&String>, - ) -> (UniversalMessage, Option) { - let metadata = base_part_metadata(message_id, part_id, delta); - let parsed = UniversalMessageParsed { - role: "assistant".to_string(), - id: Some(message_id.to_string()), - metadata, - parts: vec![UniversalMessagePart::Unknown { raw }], - }; - (UniversalMessage::Parsed(parsed), Some(session_id.to_string())) - } - - fn file_part_to_universal_part(file_part: &opencode::FilePart) -> UniversalMessagePart { - let opencode::FilePart { - filename, - id: _id, - message_id: _message_id, - mime, - session_id: _session_id, - source: _source, - type_: _type, - url, - } = file_part; - let raw = serde_json::to_value(file_part).unwrap_or(Value::Null); - let source = AttachmentSource::Url { url: url.clone() }; - if mime.starts_with("image/") { - UniversalMessagePart::Image { - source, - mime_type: Some(mime.clone()), - alt: filename.clone(), - raw: Some(raw), - } - } else { - UniversalMessagePart::File { - source, - mime_type: Some(mime.clone()), - filename: filename.clone(), - raw: Some(raw), - } - } - } - - fn tool_state_to_parts( - call_id: &str, - tool: &str, - state: &opencode::ToolState, - ) -> (Vec, Option) { - match state { - opencode::ToolState::Pending(state) => { - let opencode::ToolStatePending { input, raw, status } = state; - let mut meta = Map::new(); - meta.insert("status".to_string(), Value::String(status.clone())); - meta.insert("raw".to_string(), Value::String(raw.clone())); - meta.insert("input".to_string(), Value::Object(input.clone())); - ( - vec![UniversalMessagePart::ToolCall { - id: Some(call_id.to_string()), - name: tool.to_string(), - input: Value::Object(input.clone()), - }], - Some(Value::Object(meta)), - ) - } - opencode::ToolState::Running(state) => { - let opencode::ToolStateRunning { - input, - metadata, - status, - time, - title, - } = state; - let mut meta = Map::new(); - meta.insert("status".to_string(), Value::String(status.clone())); - meta.insert("input".to_string(), Value::Object(input.clone())); - meta.insert("metadata".to_string(), Value::Object(metadata.clone())); - meta.insert( - "time".to_string(), - serde_json::to_value(time).unwrap_or(Value::Null), - ); - if let Some(title) = title { - meta.insert("title".to_string(), Value::String(title.clone())); - } - ( - vec![UniversalMessagePart::ToolCall { - id: Some(call_id.to_string()), - name: tool.to_string(), - input: Value::Object(input.clone()), - }], - Some(Value::Object(meta)), - ) - } - opencode::ToolState::Completed(state) => { - let opencode::ToolStateCompleted { - attachments, - input, - metadata, - output, - status, - time, - title, - } = state; - let mut meta = Map::new(); - meta.insert("status".to_string(), Value::String(status.clone())); - meta.insert("input".to_string(), Value::Object(input.clone())); - meta.insert("metadata".to_string(), Value::Object(metadata.clone())); - meta.insert( - "time".to_string(), - serde_json::to_value(time).unwrap_or(Value::Null), - ); - meta.insert("title".to_string(), Value::String(title.clone())); - if !attachments.is_empty() { - meta.insert( - "attachments".to_string(), - serde_json::to_value(attachments).unwrap_or(Value::Null), - ); - } - let mut parts = vec![UniversalMessagePart::ToolResult { - id: Some(call_id.to_string()), - name: Some(tool.to_string()), - output: Value::String(output.clone()), - is_error: Some(false), - }]; - for attachment in attachments { - parts.push(file_part_to_universal_part(attachment)); - } - (parts, Some(Value::Object(meta))) - } - opencode::ToolState::Error(state) => { - let opencode::ToolStateError { - error, - input, - metadata, - status, - time, - } = state; - let mut meta = Map::new(); - meta.insert("status".to_string(), Value::String(status.clone())); - meta.insert("error".to_string(), Value::String(error.clone())); - meta.insert("input".to_string(), Value::Object(input.clone())); - meta.insert("metadata".to_string(), Value::Object(metadata.clone())); - meta.insert( - "time".to_string(), - serde_json::to_value(time).unwrap_or(Value::Null), - ); - ( - vec![UniversalMessagePart::ToolResult { - id: Some(call_id.to_string()), - name: Some(tool.to_string()), - output: Value::String(error.clone()), - is_error: Some(true), - }], - Some(Value::Object(meta)), - ) - } - } - } - - fn question_request_from_opencode(request: &opencode::QuestionRequest) -> QuestionRequest { - let opencode::QuestionRequest { - id, - questions, - session_id, - tool, - } = request; - QuestionRequest { - id: id.clone().into(), - session_id: session_id.clone().into(), - questions: questions - .iter() - .map(|question| { - let opencode::QuestionInfo { - custom, - header, - multiple, - options, - question, - } = question; - QuestionInfo { - question: question.clone(), - header: Some(header.clone()), - options: options - .iter() - .map(|opt| { - let opencode::QuestionOption { description, label } = opt; - QuestionOption { - label: label.clone(), - description: Some(description.clone()), - } - }) - .collect(), - multi_select: *multiple, - custom: *custom, - } - }) - .collect(), - tool: tool.as_ref().map(|tool| { - let opencode::QuestionRequestTool { message_id, call_id } = tool; - QuestionToolRef { - message_id: message_id.clone(), - call_id: call_id.clone(), - } - }), - } - } - - fn permission_request_from_opencode(request: &opencode::PermissionRequest) -> PermissionRequest { - let opencode::PermissionRequest { - always, - id, - metadata, - patterns, - permission, - session_id, - tool, - } = request; - PermissionRequest { - id: id.clone().into(), - session_id: session_id.clone().into(), - permission: permission.clone(), - patterns: patterns.clone(), - metadata: metadata.clone(), - always: always.clone(), - tool: tool.as_ref().map(|tool| { - let opencode::PermissionRequestTool { message_id, call_id } = tool; - PermissionToolRef { - message_id: message_id.clone(), - call_id: call_id.clone(), - } - }), - } - } - - fn question_request_to_opencode(request: &QuestionRequest) -> Result { - let id = opencode::QuestionRequestId::try_from(request.id.as_str()) - .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; - let session_id = opencode::QuestionRequestSessionId::try_from(request.session_id.as_str()) - .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; - let questions = request - .questions - .iter() - .map(|question| opencode::QuestionInfo { - question: question.question.clone(), - header: question - .header - .clone() - .unwrap_or_else(|| "Question".to_string()), - options: question - .options - .iter() - .map(|opt| opencode::QuestionOption { - label: opt.label.clone(), - description: opt.description.clone().unwrap_or_default(), - }) - .collect(), - multiple: question.multi_select, - custom: question.custom, - }) - .collect(); - - Ok(opencode::QuestionRequest { - id, - session_id, - questions, - tool: request.tool.as_ref().map(|tool| opencode::QuestionRequestTool { - message_id: tool.message_id.clone(), - call_id: tool.call_id.clone(), - }), - }) - } - - fn permission_request_to_opencode( - request: &PermissionRequest, - ) -> Result { - let id = opencode::PermissionRequestId::try_from(request.id.as_str()) - .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; - let session_id = opencode::PermissionRequestSessionId::try_from(request.session_id.as_str()) - .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; - Ok(opencode::PermissionRequest { - id, - session_id, - permission: request.permission.clone(), - patterns: request.patterns.clone(), - metadata: request.metadata.clone(), - always: request.always.clone(), - tool: request.tool.as_ref().map(|tool| opencode::PermissionRequestTool { - message_id: tool.message_id.clone(), - call_id: tool.call_id.clone(), - }), - }) - } -} - -pub mod convert_codex { - use super::*; - - pub fn event_to_universal(event: &codex::ThreadEvent) -> EventConversion { - let codex::ThreadEvent { - error, - item, - thread_id, - type_, - } = event; - match type_ { - codex::ThreadEventType::ThreadCreated | codex::ThreadEventType::ThreadUpdated => { - let started = Started { - message: Some(type_.to_string()), - details: serde_json::to_value(event).ok(), - }; - EventConversion::new(UniversalEventData::Started { started }) - .with_session(thread_id.clone()) - } - codex::ThreadEventType::ItemCreated | codex::ThreadEventType::ItemUpdated => { - if let Some(item) = item.as_ref() { - let message = thread_item_to_message(item); - EventConversion::new(UniversalEventData::Message { message }) - .with_session(thread_id.clone()) - } else { - EventConversion::new(UniversalEventData::Unknown { - raw: serde_json::to_value(event).unwrap_or(Value::Null), - }) - } - } - codex::ThreadEventType::Error => { - let message = extract_message_from_value(&Value::Object(error.clone())) - .unwrap_or_else(|| "codex error".to_string()); - let crash = CrashInfo { - message, - kind: Some("error".to_string()), - details: Some(Value::Object(error.clone())), - }; - EventConversion::new(UniversalEventData::Error { error: crash }) - .with_session(thread_id.clone()) - } - } - } - - pub fn universal_event_to_codex(event: &UniversalEventData) -> Result { - match event { - UniversalEventData::Message { message } => { - let parsed = match message { - UniversalMessage::Parsed(parsed) => parsed, - UniversalMessage::Unparsed { .. } => { - return Err(ConversionError::Unsupported("unparsed message")) - } - }; - let id = parsed.id.clone().ok_or(ConversionError::MissingField("message.id"))?; - let content = text_only_from_parts(&parsed.parts)?; - let role = match parsed.role.as_str() { - "user" => Some(codex::ThreadItemRole::User), - "assistant" => Some(codex::ThreadItemRole::Assistant), - "system" => Some(codex::ThreadItemRole::System), - _ => None, - }; - let item = codex::ThreadItem { - content: Some(codex::ThreadItemContent::Variant0(content)), - id, - role, - status: None, - type_: codex::ThreadItemType::Message, - }; - Ok(codex::ThreadEvent { - error: Map::new(), - item: Some(item), - thread_id: None, - type_: codex::ThreadEventType::ItemCreated, - }) - } - _ => Err(ConversionError::Unsupported("codex event")), - } - } - - pub fn message_to_universal(message: &codex::Message) -> UniversalMessage { - let codex::Message { role, content } = message; - UniversalMessage::Parsed(UniversalMessageParsed { - role: role.to_string(), - id: None, - metadata: Map::new(), - parts: vec![UniversalMessagePart::Text { - text: content.clone(), - }], - }) - } - - pub fn universal_message_to_message( - message: &UniversalMessage, - ) -> Result { - let parsed = match message { - UniversalMessage::Parsed(parsed) => parsed, - UniversalMessage::Unparsed { .. } => { - return Err(ConversionError::Unsupported("unparsed message")) - } - }; - let content = text_only_from_parts(&parsed.parts)?; - Ok(codex::Message { - role: match parsed.role.as_str() { - "user" => codex::MessageRole::User, - "assistant" => codex::MessageRole::Assistant, - "system" => codex::MessageRole::System, - _ => codex::MessageRole::User, - }, - content, - }) - } - - pub fn inputs_to_universal_message(inputs: &[codex::Input], role: &str) -> UniversalMessage { - let parts = inputs.iter().map(input_to_universal_part).collect(); - UniversalMessage::Parsed(UniversalMessageParsed { - role: role.to_string(), - id: None, - metadata: Map::new(), - parts, - }) - } - - pub fn input_to_universal_part(input: &codex::Input) -> UniversalMessagePart { - let codex::Input { - content, - mime_type, - path, - type_, - } = input; - let raw = serde_json::to_value(input).unwrap_or(Value::Null); - match type_ { - codex::InputType::Text => match content { - Some(content) => UniversalMessagePart::Text { - text: content.clone(), - }, - None => UniversalMessagePart::Unknown { raw }, - }, - codex::InputType::File => { - let source = if let Some(path) = path { - AttachmentSource::Path { path: path.clone() } - } else if let Some(content) = content { - AttachmentSource::Data { - data: content.clone(), - encoding: None, - } - } else { - return UniversalMessagePart::Unknown { raw }; - }; - UniversalMessagePart::File { - source, - mime_type: mime_type.clone(), - filename: None, - raw: Some(raw), - } - } - codex::InputType::Image => { - let source = if let Some(path) = path { - AttachmentSource::Path { path: path.clone() } - } else if let Some(content) = content { - AttachmentSource::Data { - data: content.clone(), - encoding: None, - } - } else { - return UniversalMessagePart::Unknown { raw }; - }; - UniversalMessagePart::Image { - source, - mime_type: mime_type.clone(), - alt: None, - raw: Some(raw), - } - } - } - } - - pub fn universal_message_to_inputs( - message: &UniversalMessage, - ) -> Result, ConversionError> { - let parsed = match message { - UniversalMessage::Parsed(parsed) => parsed, - UniversalMessage::Unparsed { .. } => { - return Err(ConversionError::Unsupported("unparsed message")) - } - }; - universal_parts_to_inputs(&parsed.parts) - } - - pub fn universal_parts_to_inputs( - parts: &[UniversalMessagePart], - ) -> Result, ConversionError> { - let mut inputs = Vec::new(); - for part in parts { - match part { - UniversalMessagePart::Text { text } => inputs.push(codex::Input { - content: Some(text.clone()), - mime_type: None, - path: None, - type_: codex::InputType::Text, - }), - UniversalMessagePart::File { - source, - mime_type, - .. - } => inputs.push(input_from_attachment(source, mime_type.as_ref(), codex::InputType::File)?), - UniversalMessagePart::Image { - source, mime_type, .. - } => inputs.push(input_from_attachment( - source, - mime_type.as_ref(), - codex::InputType::Image, - )?), - UniversalMessagePart::ToolCall { .. } - | UniversalMessagePart::ToolResult { .. } - | UniversalMessagePart::FunctionCall { .. } - | UniversalMessagePart::FunctionResult { .. } - | UniversalMessagePart::Error { .. } - | UniversalMessagePart::Unknown { .. } => { - return Err(ConversionError::Unsupported("unsupported part")) - } - } - } - if inputs.is_empty() { - return Err(ConversionError::MissingField("parts")); - } - Ok(inputs) - } - - fn input_from_attachment( - source: &AttachmentSource, - mime_type: Option<&String>, - input_type: codex::InputType, - ) -> Result { - match source { - AttachmentSource::Path { path } => Ok(codex::Input { - content: None, - mime_type: mime_type.cloned(), - path: Some(path.clone()), - type_: input_type, - }), - AttachmentSource::Data { data, encoding } => { - if let Some(encoding) = encoding.as_deref() { - if encoding != "base64" { - return Err(ConversionError::Unsupported("codex data encoding")); - } - } - Ok(codex::Input { - content: Some(data.clone()), - mime_type: mime_type.cloned(), - path: None, - type_: input_type, - }) - } - AttachmentSource::Url { .. } => Err(ConversionError::Unsupported("codex input url")), - } - } - - fn thread_item_to_message(item: &codex::ThreadItem) -> UniversalMessage { - let codex::ThreadItem { - content, - id, - role, - status, - type_, - } = item; - let mut metadata = Map::new(); - metadata.insert("itemType".to_string(), Value::String(type_.to_string())); - if let Some(status) = status { - metadata.insert("status".to_string(), Value::String(status.to_string())); - } - let role = role - .as_ref() - .map(|role| role.to_string()) - .unwrap_or_else(|| "assistant".to_string()); - let parts = match type_ { - codex::ThreadItemType::Message => message_parts_from_codex_content(content), - codex::ThreadItemType::FunctionCall => vec![function_call_part_from_codex(id, content)], - codex::ThreadItemType::FunctionResult => vec![function_result_part_from_codex(id, content)], - }; - UniversalMessage::Parsed(UniversalMessageParsed { - role, - id: Some(id.clone()), - metadata, - parts, - }) - } - - fn message_parts_from_codex_content( - content: &Option, - ) -> Vec { - match content { - Some(codex::ThreadItemContent::Variant0(text)) => { - vec![UniversalMessagePart::Text { text: text.clone() }] - } - Some(codex::ThreadItemContent::Variant1(raw)) => { - vec![UniversalMessagePart::Unknown { - raw: serde_json::to_value(raw).unwrap_or(Value::Null), - }] - } - None => Vec::new(), - } - } - - fn function_call_part_from_codex( - item_id: &str, - content: &Option, - ) -> UniversalMessagePart { - let raw = thread_item_content_to_value(content); - let name = extract_object_field(&raw, "name"); - let arguments = extract_object_value(&raw, "arguments").unwrap_or_else(|| raw.clone()); - UniversalMessagePart::FunctionCall { - id: Some(item_id.to_string()), - name, - arguments, - raw: Some(raw), - } - } - - fn function_result_part_from_codex( - item_id: &str, - content: &Option, - ) -> UniversalMessagePart { - let raw = thread_item_content_to_value(content); - let name = extract_object_field(&raw, "name"); - let result = extract_object_value(&raw, "result") - .or_else(|| extract_object_value(&raw, "output")) - .or_else(|| extract_object_value(&raw, "content")) - .unwrap_or_else(|| raw.clone()); - UniversalMessagePart::FunctionResult { - id: Some(item_id.to_string()), - name, - result, - is_error: None, - raw: Some(raw), - } - } - - fn thread_item_content_to_value(content: &Option) -> Value { - match content { - Some(codex::ThreadItemContent::Variant0(text)) => Value::String(text.clone()), - Some(codex::ThreadItemContent::Variant1(raw)) => { - Value::Array(raw.iter().cloned().map(Value::Object).collect()) - } - None => Value::Null, - } - } - - fn extract_object_field(raw: &Value, field: &str) -> Option { - extract_object_value(raw, field) - .and_then(|value| value.as_str().map(|s| s.to_string())) - } - - fn extract_object_value(raw: &Value, field: &str) -> Option { - match raw { - Value::Object(map) => map.get(field).cloned(), - Value::Array(values) => values - .first() - .and_then(|value| value.as_object()) - .and_then(|map| map.get(field).cloned()), - _ => None, - } - } -} - -pub mod convert_amp { - use super::*; - - pub fn event_to_universal(event: &::StreamJsonMessage) -> EventConversion { - let amp::StreamJsonMessage { - content, - error, - id, - tool_call, - type_, - } = event; - match type_ { - amp::StreamJsonMessageType::Message => { - let text = content.clone().unwrap_or_default(); - let mut message = message_from_text("assistant", text); - if let UniversalMessage::Parsed(parsed) = &mut message { - parsed.id = id.clone(); - } - EventConversion::new(UniversalEventData::Message { message }) - } - amp::StreamJsonMessageType::ToolCall => { - let tool_call = tool_call.as_ref(); - let part = if let Some(tool_call) = tool_call { - let amp::ToolCall { arguments, id, name } = tool_call; - let input = match arguments { - amp::ToolCallArguments::Variant0(text) => Value::String(text.clone()), - amp::ToolCallArguments::Variant1(map) => Value::Object(map.clone()), - }; - UniversalMessagePart::ToolCall { - id: Some(id.clone()), - name: name.clone(), - input, - } - } else { - UniversalMessagePart::Unknown { raw: Value::Null } - }; - let mut message = message_from_parts("assistant", vec![part]); - if let UniversalMessage::Parsed(parsed) = &mut message { - parsed.id = id.clone(); - } - EventConversion::new(UniversalEventData::Message { message }) - } - amp::StreamJsonMessageType::ToolResult => { - let output = content - .clone() - .map(Value::String) - .unwrap_or(Value::Null); - let part = UniversalMessagePart::ToolResult { - id: id.clone(), - name: None, - output, - is_error: None, - }; - let message = message_from_parts("tool", vec![part]); - EventConversion::new(UniversalEventData::Message { message }) - } - amp::StreamJsonMessageType::Error => { - let message = error.clone().unwrap_or_else(|| "amp error".to_string()); - let crash = CrashInfo { - message, - kind: Some("amp".to_string()), - details: serde_json::to_value(event).ok(), - }; - EventConversion::new(UniversalEventData::Error { error: crash }) - } - amp::StreamJsonMessageType::Done => EventConversion::new(UniversalEventData::Unknown { - raw: serde_json::to_value(event).unwrap_or(Value::Null), - }), - } - } - - pub fn universal_event_to_amp(event: &UniversalEventData) -> Result { - match event { - UniversalEventData::Message { message } => { - let parsed = match message { - UniversalMessage::Parsed(parsed) => parsed, - UniversalMessage::Unparsed { .. } => { - return Err(ConversionError::Unsupported("unparsed message")) - } - }; - let content = text_only_from_parts(&parsed.parts)?; - Ok(amp::StreamJsonMessage { - content: Some(content), - error: None, - id: parsed.id.clone(), - tool_call: None, - type_: amp::StreamJsonMessageType::Message, - }) - } - _ => Err(ConversionError::Unsupported("amp event")), - } - } - - pub fn message_to_universal(message: &::Message) -> UniversalMessage { - let amp::Message { - role, - content, - tool_calls, - } = message; - let mut parts = vec![UniversalMessagePart::Text { - text: content.clone(), - }]; - for call in tool_calls { - let amp::ToolCall { arguments, id, name } = call; - let input = match arguments { - amp::ToolCallArguments::Variant0(text) => Value::String(text.clone()), - amp::ToolCallArguments::Variant1(map) => Value::Object(map.clone()), - }; - parts.push(UniversalMessagePart::ToolCall { - id: Some(id.clone()), - name: name.clone(), - input, - }); - } - UniversalMessage::Parsed(UniversalMessageParsed { - role: role.to_string(), - id: None, - metadata: Map::new(), - parts, - }) - } - - pub fn universal_message_to_message( - message: &UniversalMessage, - ) -> Result { - let parsed = match message { - UniversalMessage::Parsed(parsed) => parsed, - UniversalMessage::Unparsed { .. } => { - return Err(ConversionError::Unsupported("unparsed message")) - } - }; - let content = text_only_from_parts(&parsed.parts)?; - Ok(amp::Message { - role: match parsed.role.as_str() { - "user" => amp::MessageRole::User, - "assistant" => amp::MessageRole::Assistant, - "system" => amp::MessageRole::System, - _ => amp::MessageRole::User, - }, - content, - tool_calls: vec![], - }) - } -} - -pub mod convert_claude { - use super::*; - - pub fn event_to_universal_with_session( - event: &Value, - session_id: String, - ) -> EventConversion { - let event_type = event.get("type").and_then(Value::as_str).unwrap_or(""); - match event_type { - "assistant" => assistant_event_to_universal(event), - "tool_use" => tool_use_event_to_universal(event, session_id), - "tool_result" => tool_result_event_to_universal(event), - "result" => result_event_to_universal(event), - _ => EventConversion::new(UniversalEventData::Unknown { raw: event.clone() }), - } - } - - pub fn universal_event_to_claude(event: &UniversalEventData) -> Result { - match event { - UniversalEventData::Message { message } => { - let parsed = match message { - UniversalMessage::Parsed(parsed) => parsed, - UniversalMessage::Unparsed { .. } => { - return Err(ConversionError::Unsupported("unparsed message")) - } - }; - let text = text_only_from_parts(&parsed.parts)?; - Ok(Value::Object(Map::from_iter([ - ("type".to_string(), Value::String("assistant".to_string())), - ( - "message".to_string(), - Value::Object(Map::from_iter([( - "content".to_string(), - Value::Array(vec![Value::Object(Map::from_iter([( - "type".to_string(), - Value::String("text".to_string()), - ), ( - "text".to_string(), - Value::String(text), - )]))]), - )])), - ), - ]))) - } - _ => Err(ConversionError::Unsupported("claude event")), - } - } - - pub fn prompt_to_universal(prompt: &str) -> UniversalMessage { - message_from_text("user", prompt.to_string()) - } - - pub fn universal_message_to_prompt(message: &UniversalMessage) -> Result { - let parsed = match message { - UniversalMessage::Parsed(parsed) => parsed, - UniversalMessage::Unparsed { .. } => { - return Err(ConversionError::Unsupported("unparsed message")) - } - }; - text_only_from_parts(&parsed.parts) - } - - fn assistant_event_to_universal(event: &Value) -> EventConversion { - let content = event - .get("message") - .and_then(|msg| msg.get("content")) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let mut parts = Vec::new(); - for block in content { - let block_type = block.get("type").and_then(Value::as_str).unwrap_or(""); - match block_type { - "text" => { - if let Some(text) = block.get("text").and_then(Value::as_str) { - parts.push(UniversalMessagePart::Text { - text: text.to_string(), - }); - } - } - "tool_use" => { - if let Some(name) = block.get("name").and_then(Value::as_str) { - let input = block.get("input").cloned().unwrap_or(Value::Null); - let id = block.get("id").and_then(Value::as_str).map(|s| s.to_string()); - parts.push(UniversalMessagePart::ToolCall { - id, - name: name.to_string(), - input, - }); - } - } - _ => parts.push(UniversalMessagePart::Unknown { raw: block }), - } - } - let message = UniversalMessage::Parsed(UniversalMessageParsed { - role: "assistant".to_string(), - id: None, - metadata: Map::new(), - parts, - }); - EventConversion::new(UniversalEventData::Message { message }) - } - - fn tool_use_event_to_universal(event: &Value, session_id: String) -> EventConversion { - let tool_use = event.get("tool_use"); - let name = tool_use - .and_then(|tool| tool.get("name")) - .and_then(Value::as_str) - .unwrap_or(""); - let input = tool_use - .and_then(|tool| tool.get("input")) - .cloned() - .unwrap_or(Value::Null); - let id = tool_use - .and_then(|tool| tool.get("id")) - .and_then(Value::as_str) - .map(|s| s.to_string()); - - if name == "AskUserQuestion" { - if let Some(question) = - question_from_claude_input(&input, id.clone(), session_id.clone()) - { - return EventConversion::new(UniversalEventData::QuestionAsked { - question_asked: question, - }); - } - } - - let message = message_from_parts( - "assistant", - vec![UniversalMessagePart::ToolCall { - id, - name: name.to_string(), - input, - }], - ); - EventConversion::new(UniversalEventData::Message { message }) - } - - fn tool_result_event_to_universal(event: &Value) -> EventConversion { - let tool_result = event.get("tool_result"); - let output = tool_result - .and_then(|tool| tool.get("content")) - .cloned() - .unwrap_or(Value::Null); - let is_error = tool_result - .and_then(|tool| tool.get("is_error")) - .and_then(Value::as_bool); - let id = tool_result - .and_then(|tool| tool.get("id")) - .and_then(Value::as_str) - .map(|s| s.to_string()); - - let message = message_from_parts( - "tool", - vec![UniversalMessagePart::ToolResult { - id, - name: None, - output, - is_error, - }], - ); - EventConversion::new(UniversalEventData::Message { message }) - } - - fn result_event_to_universal(event: &Value) -> EventConversion { - let result_text = event - .get("result") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - let session_id = event - .get("session_id") - .and_then(Value::as_str) - .map(|s| s.to_string()); - let message = message_from_text("assistant", result_text); - EventConversion::new(UniversalEventData::Message { message }).with_session(session_id) - } - - fn question_from_claude_input( - input: &Value, - tool_id: Option, - session_id: String, - ) -> Option { - let questions = input.get("questions").and_then(Value::as_array)?; - let mut parsed_questions = Vec::new(); - for question in questions { - let question_text = question.get("question")?.as_str()?.to_string(); - let header = question - .get("header") - .and_then(Value::as_str) - .map(|s| s.to_string()); - let multi_select = question - .get("multiSelect") - .and_then(Value::as_bool); - let options = question - .get("options") - .and_then(Value::as_array) - .map(|options| { - options - .iter() - .filter_map(|option| { - let label = option.get("label")?.as_str()?.to_string(); - let description = option - .get("description") - .and_then(Value::as_str) - .map(|s| s.to_string()); - Some(QuestionOption { label, description }) - }) - .collect::>() - })?; - parsed_questions.push(QuestionInfo { - question: question_text, - header, - options, - multi_select, - custom: None, - }); - } - Some(QuestionRequest { - id: tool_id.unwrap_or_else(|| "claude-question".to_string()), - session_id, - questions: parsed_questions, - tool: None, - }) - } -} diff --git a/resources/agent-schemas/package.json b/resources/agent-schemas/package.json index 3707816..206e6bb 100644 --- a/resources/agent-schemas/package.json +++ b/resources/agent-schemas/package.json @@ -4,11 +4,11 @@ "type": "module", "license": "Apache-2.0", "scripts": { - "extract": "tsx src/index.ts", - "extract:opencode": "tsx src/index.ts --agent=opencode", - "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": "tsx ../../src/agents/index.ts", + "extract:opencode": "tsx ../../src/agents/index.ts --agent=opencode", + "extract:claude": "tsx ../../src/agents/index.ts --agent=claude", + "extract:codex": "tsx ../../src/agents/index.ts --agent=codex", + "extract:amp": "tsx ../../src/agents/index.ts --agent=amp" }, "dependencies": { "ts-json-schema-generator": "^2.4.0", diff --git a/spec.md b/spec.md index 68f6577..3a767b5 100644 --- a/spec.md +++ b/spec.md @@ -468,25 +468,27 @@ build typescript examples of how to deploy this to the given providres: these should each have a vitest unit test to test. cloudflaer is trickier since it requires a more complex setup. -## readme docs +## docs -write a readme that doubles as docs for: +Docs live in the `docs/` folder (Mintlify). The root `README.md` should stay brief and link to the docs site or local docs. + +Write docs that cover: - architecture - agent compatibility -- deployemnt guide (these should be links to working examples) +- deployment guide (link to working examples) - docker (for dev) - e2b - daytona - vercel sandboxes - cloudflare sandboxes - universal agent api feature checklist - - quesitons + - questions - approve plan - - etc (ie you need to infer what features are required to imeplment and what is optional) + - etc (infer what features are required vs optional) - cli - http api - running the example frontend - typescript sdk -use the collapsible github sections for things like each api endpoint or each typescript sdk endpoint to collapse more info. this keeps the page readable. +Use collapsible sections for each API endpoint or TypeScript SDK endpoint to keep the page readable. diff --git a/resources/agent-schemas/src/amp.ts b/src/agents/amp.ts similarity index 100% rename from resources/agent-schemas/src/amp.ts rename to src/agents/amp.ts diff --git a/resources/agent-schemas/src/cache.ts b/src/agents/cache.ts similarity index 95% rename from resources/agent-schemas/src/cache.ts rename to src/agents/cache.ts index 414c1a2..dbd2d16 100644 --- a/resources/agent-schemas/src/cache.ts +++ b/src/agents/cache.ts @@ -2,7 +2,14 @@ import { createHash } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs"; import { join } from "path"; -const CACHE_DIR = join(import.meta.dirname, "..", ".cache"); +const CACHE_DIR = join( + import.meta.dirname, + "..", + "..", + "resources", + "agent-schemas", + ".cache" +); const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours interface CacheEntry { diff --git a/resources/agent-schemas/src/claude.ts b/src/agents/claude.ts similarity index 96% rename from resources/agent-schemas/src/claude.ts rename to src/agents/claude.ts index 6acb8bb..58ae0e1 100644 --- a/resources/agent-schemas/src/claude.ts +++ b/src/agents/claude.ts @@ -28,7 +28,7 @@ const TARGET_TYPES = [ ]; function findTypesPath(): string | null { - const baseDir = join(import.meta.dirname, ".."); + const baseDir = join(import.meta.dirname, "..", "..", "resources", "agent-schemas"); for (const relativePath of POSSIBLE_PATHS) { const fullPath = join(baseDir, relativePath); @@ -54,7 +54,7 @@ export async function extractClaudeSchema(): Promise { const config: Config = { path: typesPath, - tsconfig: join(import.meta.dirname, "..", "tsconfig.json"), + tsconfig: join(import.meta.dirname, "..", "..", "resources", "agent-schemas", "tsconfig.json"), type: "*", skipTypeCheck: true, topRef: false, diff --git a/resources/agent-schemas/src/codex.ts b/src/agents/codex.ts similarity index 96% rename from resources/agent-schemas/src/codex.ts rename to src/agents/codex.ts index 93d9da9..bee5d86 100644 --- a/resources/agent-schemas/src/codex.ts +++ b/src/agents/codex.ts @@ -24,7 +24,7 @@ const TARGET_TYPES = [ ]; function findTypesPath(): string | null { - const baseDir = join(import.meta.dirname, ".."); + const baseDir = join(import.meta.dirname, "..", "..", "resources", "agent-schemas"); for (const relativePath of POSSIBLE_PATHS) { const fullPath = join(baseDir, relativePath); @@ -50,7 +50,7 @@ export async function extractCodexSchema(): Promise { const config: Config = { path: typesPath, - tsconfig: join(import.meta.dirname, "..", "tsconfig.json"), + tsconfig: join(import.meta.dirname, "..", "..", "resources", "agent-schemas", "tsconfig.json"), type: "*", skipTypeCheck: true, topRef: false, diff --git a/resources/agent-schemas/src/index.ts b/src/agents/index.ts similarity index 95% rename from resources/agent-schemas/src/index.ts rename to src/agents/index.ts index 93d1a00..c2f73bc 100644 --- a/resources/agent-schemas/src/index.ts +++ b/src/agents/index.ts @@ -6,7 +6,8 @@ import { extractCodexSchema } from "./codex.js"; import { extractAmpSchema } from "./amp.js"; import { validateSchema, type NormalizedSchema } from "./normalize.js"; -const DIST_DIR = join(import.meta.dirname, "..", "dist"); +const RESOURCE_DIR = join(import.meta.dirname, "..", "..", "resources", "agent-schemas"); +const DIST_DIR = join(RESOURCE_DIR, "dist"); type AgentName = "opencode" | "claude" | "codex" | "amp"; diff --git a/resources/agent-schemas/src/normalize.ts b/src/agents/normalize.ts similarity index 100% rename from resources/agent-schemas/src/normalize.ts rename to src/agents/normalize.ts diff --git a/resources/agent-schemas/src/opencode.ts b/src/agents/opencode.ts similarity index 100% rename from resources/agent-schemas/src/opencode.ts rename to src/agents/opencode.ts diff --git a/todo.md b/todo.md index 8bcbc5f..f0d1aa2 100644 --- a/todo.md +++ b/todo.md @@ -22,8 +22,9 @@ ## CLI - [x] Implement clap CLI flags: `--token`, `--no-token`, `--host`, `--port`, CORS flags - [x] Implement a CLI endpoint for every HTTP endpoint -- [ ] Update `CLAUDE.md` to keep CLI endpoints in sync with HTTP API changes +- [x] Update `CLAUDE.md` to keep CLI endpoints in sync with HTTP API changes - [x] Prefix CLI API requests with `/v1` +- [x] Add CLI credentials extractor subcommand ## HTTP API Endpoints - [x] POST `/agents/{}/install` with `reinstall` handling @@ -96,3 +97,4 @@ - [x] implement release pipeline - implement e2b example - implement typescript "start locally" by pulling form server using version +- [x] Move agent schema sources to src/agents