diff --git a/.gitignore b/.gitignore index de05013..8129a8b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,11 @@ Thumbs.db # Scratch files scratch_* +# Python bytecode +__pycache__/ +*.pyc +*.pyo + # Logs and temp *.log npm-debug.log* diff --git a/README.md b/README.md index 27c2e07..4ec4d7b 100644 --- a/README.md +++ b/README.md @@ -14,28 +14,43 @@ Run AI agents inside sandboxes and communicate with them over WebSocket. ### 1. Start the runtime (inside a sandbox) ```bash +export OPENAI_API_KEY=your_openai_api_key npx -y runtimeuse ``` -This starts a WebSocket server on port 8080 using the OpenAI agent handler by default. Use `--agent claude` for Claude. The Claude handler also requires the `claude` CLI to be installed in the sandbox, for example with `npm install -g @anthropic-ai/claude-code`. +This starts a WebSocket server on port 8080 using the default OpenAI handler. For fuller Claude-based sandbox examples, see [`examples/`](./examples). ### 2. Connect from Python ```python import asyncio -from runtimeuse_client import RuntimeUseClient, QueryOptions +from runtimeuse_client import ( + QueryOptions, + RuntimeEnvironmentDownloadableInterface, + RuntimeUseClient, + TextResult, +) + +WORKDIR = "/runtimeuse" async def main(): client = RuntimeUseClient(ws_url="ws://localhost:8080") result = await client.query( - prompt="What is 2 + 2?", + prompt="Summarize the contents of the codex repository.", options=QueryOptions( system_prompt="You are a helpful assistant.", - model="gpt-4.1", + model="gpt-5.4", + pre_agent_downloadables=[ + RuntimeEnvironmentDownloadableInterface( + download_url="https://github.com/openai/codex/archive/refs/heads/main.zip", + working_dir=WORKDIR, + ) + ], ), ) + assert isinstance(result.data, TextResult) print(result.data.text) asyncio.run(main()) diff --git a/docs/content/docs/agent-runtime.mdx b/docs/content/docs/agent-runtime.mdx new file mode 100644 index 0000000..1975f8a --- /dev/null +++ b/docs/content/docs/agent-runtime.mdx @@ -0,0 +1,123 @@ +--- +title: Agent Runtime +description: CLI flags, built-in agent handlers, and custom handler authoring for the runtimeuse server. +--- + +The [agent runtime](https://www.npmjs.com/package/runtimeuse) is the process that runs inside the sandbox. It exposes a WebSocket server, receives invocations from the Python client, and delegates work to an agent handler. + +## CLI + +```bash +npx -y runtimeuse # OpenAI handler on port 8080 +npx -y runtimeuse --agent claude # Claude handler +npx -y runtimeuse --port 3000 # custom port +npx -y runtimeuse --handler ./my-handler.js # custom handler entrypoint +``` + +## Built-in Handlers + +- `openai`: the default handler, uses the [OpenAI Agents SDK](https://openai.github.io/openai-agents-js/) with shell and web search tools. +- `claude`: uses [Claude Agents SDK](https://platform.claude.com/docs/en/agent-sdk/overview) with Claude Code preset. + +### OpenAI Handler + +Requires `OPENAI_API_KEY` to be set in the environment. The handler runs the agent with shell access and web search enabled. + +```bash +export OPENAI_API_KEY=your_openai_api_key +npx -y runtimeuse +``` + +### Claude Handler + +Requires the `@anthropic-ai/claude-code` CLI and `ANTHROPIC_API_KEY`. Always set `IS_SANDBOX=1` and `CLAUDE_SKIP_ROOT_CHECK=1` in the sandbox environment. + +```bash +npm install -g @anthropic-ai/claude-code +export ANTHROPIC_API_KEY=your_anthropic_api_key +export IS_SANDBOX=1 +export CLAUDE_SKIP_ROOT_CHECK=1 +npx -y runtimeuse --agent claude +``` + +## Programmatic Startup + +If you want to embed RuntimeUse directly in your own Node process, start it programmatically: + +```typescript +import { RuntimeUseServer, openaiHandler } from "runtimeuse"; + +const server = new RuntimeUseServer({ + handler: openaiHandler, + port: 8080, +}); + +await server.startListening(); +``` + +## Custom Handlers + +When the built-in handlers are not enough, you can pass your own handler to `RuntimeUseServer`: + +```typescript +import { RuntimeUseServer } from "runtimeuse"; +import type { + AgentHandler, + AgentInvocation, + AgentResult, + MessageSender, +} from "runtimeuse"; + +const handler: AgentHandler = { + async run( + invocation: AgentInvocation, + sender: MessageSender, + ): Promise { + sender.sendAssistantMessage(["Running agent..."]); + + const output = await myAgent( + invocation.systemPrompt, + invocation.userPrompt, + ); + + return { + type: "structured_output", + structuredOutput: output, + metadata: { duration_ms: 1500 }, + }; + }, +}; + +const server = new RuntimeUseServer({ handler, port: 8080 }); +await server.startListening(); +``` + +### Handler Contracts + +Your handler receives an `AgentInvocation` with: + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `systemPrompt` | `string` | System prompt for the agent. | +| `userPrompt` | `string` | User prompt sent from the Python client. | +| `model` | `string` | Model name passed by the client. | +| `outputFormat` | `{ type: "json_schema"; schema: ... } \| undefined` | Present when the client requests structured output. Pass to your agent to enforce the schema. | +| `signal` | `AbortSignal` | Fires when the client sends a cancel message. Pass to any async operations that support cancellation. | +| `logger` | `Logger` | Use `invocation.logger.log(msg)` to emit log lines visible in sandbox logs. | + +Use `MessageSender` to stream intermediate output before returning the final result: + +- `sendAssistantMessage(textBlocks: string[])`: emit text blocks the Python client receives via `on_assistant_message`. +- `sendErrorMessage(error: string, metadata?: Record)`: signal a non-fatal error before aborting. + +Return an `AgentResult` from your handler: + +```typescript +// Text result +return { type: "text", text: "...", metadata: { duration_ms: 100 } }; + +// Structured output result +return { type: "structured_output", structuredOutput: { file_count: 42 }, metadata: {} }; +``` + +`metadata` is optional and is passed through to `result.metadata` on the Python side. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index a2b4a8f..59ba9a0 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -1,43 +1,34 @@ --- title: Introduction -description: Run AI agents in sandboxes and communicate with them over WebSocket. +description: Run AI agents (Claude Code, OpenAI Agents, and more) in any sandbox, controlled from Python over WebSocket. --- -## What is RuntimeUse? +[RuntimeUse](https://github.com/getlark/runtimeuse) is an open-source runtime and client library for running AI agents inside isolated sandboxes and controlling them from Python over WebSocket. -RuntimeUse lets you run an AI agent inside any sandbox and communicate with it over WebSocket. It handles the runtime lifecycle for you: file downloads, pre-commands, artifact uploads, cancellation, and structured results. -It is made up of two parts: +RuntimeUse terminal -1. **`runtimeuse`**: the TypeScript runtime that runs inside the sandbox and exposes a WebSocket server. -2. **`runtimeuse-client`**: the Python client that connects from outside the sandbox and sends invocations. -Today, the recommended path is to run the runtime in the sandbox and use the Python client from your application code. + -## Built-in Agent Handlers -The runtime ships with two built-in handlers: +## When to use -- **`openai`** (default) — uses `@openai/agents` SDK -- **`claude`** — uses `@anthropic-ai/claude-agent-sdk` with Claude Code tools and `bypassPermissions` mode +- Your agent needs filesystem, CLI, or network access inside an isolated runtime. +- Your application should stay outside the sandbox while still controlling the run. +- You don't want to build infrastructure for interacting with your agent in sandbox. -Switch between them with `--agent openai` or `--agent claude`. +## What it handles -The Claude handler also requires the `claude` CLI to be installed in the sandbox, for example: +- **Task invocations**: send a prompt to any agent runtime and receive a result over WebSocket as text or typed JSON. +- **pre_agent_downloadables**: fetch code, repos, or data into the sandbox before the run starts. +- **Pre-commands**: run bash commands before the agent starts executing. +- **Artifact uploads**: move generated files out of the sandbox with a presigned URL handshake. +- **Streaming and cancellation**: receive progress updates and stop runs cleanly. +- **Secret-aware execution**: redact sensitive values before they leave the sandbox. -```bash -npm install -g @anthropic-ai/claude-code -``` -## Key Features - -- **Sandbox-agnostic** — works with any provider that can run `npx` and expose a port -- **Artifact management** — files written to the artifacts directory are automatically detected and uploaded through a presigned URL handshake with the client -- **Secret redaction** — secret values are recursively replaced before they leave the sandbox -- **Pre-commands** — run shell commands before agent invocation with automatic secret redaction and abort support - - - - - - diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json new file mode 100644 index 0000000..15935df --- /dev/null +++ b/docs/content/docs/meta.json @@ -0,0 +1,8 @@ +{ + "pages": [ + "index", + "quickstart", + "python-client", + "agent-runtime" + ] +} \ No newline at end of file diff --git a/docs/content/docs/python-client.mdx b/docs/content/docs/python-client.mdx new file mode 100644 index 0000000..586c95d --- /dev/null +++ b/docs/content/docs/python-client.mdx @@ -0,0 +1,215 @@ +--- +title: Python Client +description: Connect to agent runtime in sandbox from Python. +--- + +The [Python client](https://pypi.org/project/runtimeuse-client/) is the control plane for RuntimeUse. It connects to the sandbox runtime, sends the invocation, and turns runtime messages into a single `QueryResult`. + +```bash +pip install runtimeuse-client +``` + +## Basic Query + +```python +import asyncio + +from runtimeuse_client import QueryOptions, RuntimeUseClient, TextResult + + +async def main() -> None: + client = RuntimeUseClient(ws_url="ws://localhost:8080") + + result = await client.query( + prompt="What is 2 + 2", + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="gpt-4.1", + ), + ) + + assert isinstance(result.data, TextResult) + print(result.data.text) + print(result.metadata) + + +asyncio.run(main()) +``` + +`query()` returns a `QueryResult` with: + +- `data`: either `TextResult` (`.text`) or `StructuredOutputResult` (`.structured_output`) +- `metadata`: execution metadata returned by the runtime (includes token usage when available) + +## Return Structured JSON + +Pass `output_format_json_schema_str` when your application needs machine-readable output instead of free-form text. The result will be a `StructuredOutputResult`. + +```python +import json + +from pydantic import BaseModel +from runtimeuse_client import StructuredOutputResult + + +class RepoStats(BaseModel): + file_count: int + char_count: int + + +result = await client.query( + prompt="Inspect the repository and return the total file count and character count as JSON.", + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="gpt-4.1", + output_format_json_schema_str=json.dumps( + { + "type": "json_schema", + "schema": RepoStats.model_json_schema(), + } + ), + ), +) + +assert isinstance(result.data, StructuredOutputResult) +stats = RepoStats.model_validate(result.data.structured_output) +print(stats) +``` + +## Download Files into the Sandbox + +Use `pre_agent_downloadables` to fetch a repository, zip archive, or any URL into the sandbox before the agent runs. This is the primary way to give the agent access to a codebase or dataset. + +```python +from runtimeuse_client import RuntimeEnvironmentDownloadableInterface + +result = await client.query( + prompt="Summarize the contents of this repository and list your favorite file.", + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="gpt-4.1", + pre_agent_downloadables=[ + RuntimeEnvironmentDownloadableInterface( + download_url="https://github.com/openai/codex/archive/refs/heads/main.zip", + working_dir="/runtimeuse", + ) + ], + ), +) +``` + +The runtime downloads and extracts the file before handing control to the agent. + +## Upload Artifacts + +When the runtime requests an artifact upload, return a presigned URL and content type from `on_artifact_upload_request`. Set `artifacts_dir` to tell the runtime which sandbox directory contains the files to upload - both options must be provided together. + +```python +from runtimeuse_client import ArtifactUploadResult + + +async def on_artifact_upload_request(request) -> ArtifactUploadResult: + presigned_url = await create_presigned_url(request.filename) + return ArtifactUploadResult( + presigned_url=presigned_url, + content_type="application/octet-stream", + ) + + +result = await client.query( + prompt="Generate a report and save it as report.txt.", + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="gpt-4.1", + artifacts_dir="/runtimeuse/output", + on_artifact_upload_request=on_artifact_upload_request, + ), +) +``` + +## Stream Assistant Messages + +Use `on_assistant_message` when you want intermediate progress while the run is still happening. + +```python +async def on_assistant_message(msg) -> None: + for block in msg.text_blocks: + print(f"[assistant] {block}") + + +result = await client.query( + prompt="Inspect this repository.", + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="gpt-4.1", + on_assistant_message=on_assistant_message, + ), +) +``` + +## Cancel a Run + +Call `client.abort()` from another coroutine to cancel an in-flight query. The client sends a cancel message to the runtime and `query()` raises `CancelledException`. + +```python +import asyncio +from runtimeuse_client import CancelledException + + +async def cancel_soon(client: RuntimeUseClient) -> None: + await asyncio.sleep(5) + client.abort() + + +try: + asyncio.create_task(cancel_soon(client)) + await client.query(prompt="Do the thing.", options=options) +except CancelledException: + print("Run was cancelled") +``` + +## Set a Timeout + +Use `timeout` (in seconds) to limit how long a query can run. If the limit is exceeded, `query()` raises `TimeoutError`. + +```python +result = await client.query( + prompt="Do the thing.", + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="gpt-4.1", + timeout=120, + ), +) +``` + +## Redact Secrets + +Pass `secrets_to_redact` to strip sensitive strings from any output or logs that leave the sandbox. + +```python +result = await client.query( + prompt="Check the API status.", + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="gpt-4.1", + secrets_to_redact=["sk-live-abc123", "my_db_password"], + ), +) +``` + +## Handle Errors + +`query()` raises `AgentRuntimeError` if the runtime sends back an error. The exception carries `.error` (the error message) and `.metadata`. + +```python +from runtimeuse_client import AgentRuntimeError + +try: + result = await client.query(prompt="Do the thing.", options=options) +except AgentRuntimeError as e: + print(f"Runtime error: {e.error}") + print(f"Metadata: {e.metadata}") +``` + + diff --git a/docs/content/docs/quickstart.mdx b/docs/content/docs/quickstart.mdx index 581dc86..f27ba74 100644 --- a/docs/content/docs/quickstart.mdx +++ b/docs/content/docs/quickstart.mdx @@ -1,90 +1,225 @@ --- title: Quickstart -description: Get the runtime running in a sandbox and connect to it from Python. +description: Start the runtime, connect from Python, and run your first prompt. --- -## Step 1: Start the Runtime in a Sandbox - -Inside any sandbox that can run `npx` and expose a port: +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + +## 1. Start the Runtime + + + + ```bash + npm install -g @anthropic-ai/claude-code + export ANTHROPIC_API_KEY=your_anthropic_api_key + npx -y runtimeuse --agent claude + ``` + + This starts the Claude Code agent on port `8080`. To use OpenAI agent instead: + ```bash + export OPENAI_API_KEY=your_openai_api_key + npx -y runtimeuse + ``` + + ```python + ws_url = "ws://localhost:8080" + ``` + + + ```bash + pip install e2b e2b-code-interpreter + ``` + + ```python + # Required env vars: E2B_API_KEY, ANTHROPIC_API_KEY + from e2b import Template, wait_for_port + from e2b_code_interpreter import Sandbox + + template = ( + Template() + .from_node_image("lts") + .set_workdir("/runtimeuse") + .npm_install(["@anthropic-ai/claude-code"], g=True) + .set_envs( + { + "ANTHROPIC_API_KEY": anthropic_api_key, + "IS_SANDBOX": "1", + "CLAUDE_SKIP_ROOT_CHECK": "1", + } + ) + .set_start_cmd("npx -y runtimeuse --agent claude", wait_for_port(8080)) + ) -```bash -npx -y runtimeuse -``` + sandbox = Sandbox.create(template="runtimeuse-quickstart-claude", api_key=e2b_api_key) + ws_url = f"wss://{sandbox.get_host(8080)}" + ``` + + Full example: [examples/e2b-quickstart.py](https://github.com/getlark/runtimeuse/blob/main/examples/e2b-quickstart.py) + + + ```bash + pip install daytona + ``` + + ```python + # Required env vars: DAYTONA_API_KEY, ANTHROPIC_API_KEY + from daytona import ( + CreateSandboxFromImageParams, + Daytona, + DaytonaConfig, + Image, + SessionExecuteRequest, + ) -This starts a WebSocket server on port 8080 using the OpenAI agent handler by default. + image = Image.base("node:lts").run_commands( + "apt-get update && apt-get install -y unzip", + "npm install -g @anthropic-ai/claude-code", + ) -To use Claude instead: + daytona = Daytona(config=DaytonaConfig(api_key=daytona_api_key)) + + sandbox = daytona.create( + CreateSandboxFromImageParams( + image=image, + env_vars={ + "ANTHROPIC_API_KEY": anthropic_api_key, + "IS_SANDBOX": "1", + "CLAUDE_SKIP_ROOT_CHECK": "1", + }, + public=True, + ), + timeout=600, + ) -```bash -npx -y runtimeuse --agent claude -``` + sandbox.process.create_session("runtimeuse") + sandbox.process.execute_session_command( + "runtimeuse", + SessionExecuteRequest( + command="npx -y runtimeuse --agent claude", + run_async=True, + ), + ) -The Claude handler requires the `claude` CLI to be installed in the sandbox, for example with: + preview = sandbox.create_signed_preview_url(8080, expires_in_seconds=3600) + ws_url = _http_to_ws(preview.url) + ``` + + Full example: [examples/daytona-quickstart.py](https://github.com/getlark/runtimeuse/blob/main/examples/daytona-quickstart.py) + + + ```bash + pip install vercel python-dotenv + ``` + + ```python + # Required env vars: VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID, ANTHROPIC_API_KEY + from vercel.sandbox import Sandbox + + sandbox = Sandbox.create( + runtime="node24", + ports=[8081], + env={ + "ANTHROPIC_API_KEY": anthropic_api_key, + "IS_SANDBOX": "1", + "CLAUDE_SKIP_ROOT_CHECK": "1", + }, + ) -```bash -npm install -g @anthropic-ai/claude-code -``` + sandbox.run_command("sudo", ["dnf", "install", "-y", "unzip"]) + sandbox.run_command("npm", ["install", "-g", "@anthropic-ai/claude-code"]) + sandbox.run_command_detached( + "npx", + ["-y", "runtimeuse", "--agent", "claude", "--port", "8081"], + ) -## Step 2: Install the Python Client + ws_url = _http_to_ws(sandbox.domain(8081)) + ``` -```bash -pip install runtimeuse-client -``` + Full example: [examples/vercel-quickstart.py](https://github.com/getlark/runtimeuse/blob/main/examples/vercel-quickstart.py) + + + ```bash + pip install modal + ``` -## Step 3: Connect from Python + ```python + # Required env vars: ANTHROPIC_API_KEY + # Authenticate with Modal: `modal token set` or set MODAL_TOKEN_ID + MODAL_TOKEN_SECRET + import modal -For local development, connect directly to the runtime's WebSocket URL: + app = modal.App.lookup("runtimeuse-quickstart", create_if_missing=True) -```python -import asyncio -from runtimeuse_client import RuntimeUseClient, QueryOptions + image = modal.Image.from_registry("node:lts").run_commands( + "apt-get update && apt-get install -y unzip", + "npm install -g @anthropic-ai/claude-code", + ) -async def main(): - client = RuntimeUseClient(ws_url="ws://localhost:8080") + secret = modal.Secret.from_dict( + { + "ANTHROPIC_API_KEY": anthropic_api_key, + "IS_SANDBOX": "1", + "CLAUDE_SKIP_ROOT_CHECK": "1", + } + ) - result = await client.query( - prompt="What is 2 + 2?", - options=QueryOptions( - system_prompt="You are a helpful assistant.", - model="gpt-4.1", - ), + sandbox = modal.Sandbox.create( + app=app, + image=image, + secrets=[secret], + workdir="/runtimeuse", + encrypted_ports=[8080], + timeout=600, ) - print(result.data.text) + sandbox.exec("npx", "-y", "runtimeuse", "--agent", "claude") + ws_url = _http_to_ws(sandbox.tunnels()[8080].url) + ``` + + Full example: [examples/modal-quickstart.py](https://github.com/getlark/runtimeuse/blob/main/examples/modal-quickstart.py) + + + +## 2. Install the Client -asyncio.run(main()) +```bash +pip install runtimeuse-client ``` -## Step 4: Use a Sandbox URL in Production-Like Flows +## 3. Connect and Query -In a real sandbox integration, your sandbox provider gives you the runtime URL after starting the process inside the sandbox. The Python client then connects from outside the sandbox: +Once you have a `ws_url`, the client flow is the same across providers: ```python import asyncio -from runtimeuse_client import RuntimeUseClient, QueryOptions -async def main(): - # Pseudocode: start the runtime inside your sandbox provider - sandbox = Sandbox.create() - sandbox.run("npx -y runtimeuse") - ws_url = sandbox.get_url(8080) +from runtimeuse_client import ( + QueryOptions, + RuntimeEnvironmentDownloadableInterface, + RuntimeUseClient, + TextResult, +) + +async def main(ws_url: str) -> None: client = RuntimeUseClient(ws_url=ws_url) result = await client.query( - prompt="Summarize the repository.", + prompt="Summarize the contents of this repository and list your favorite file.", options=QueryOptions( system_prompt="You are a helpful assistant.", - model="gpt-4.1", + model="claude-sonnet-4-20250514", # gpt-5.4 for openai + pre_agent_downloadables=[ + RuntimeEnvironmentDownloadableInterface( + download_url="https://github.com/openai/codex/archive/refs/heads/main.zip", + working_dir="/runtimeuse", + ) + ], ), ) + assert isinstance(result.data, TextResult) print(result.data.text) -asyncio.run(main()) -``` - -## Next Steps -- See the [runtime package README](https://github.com/getlark/runtimeuse/tree/main/packages/runtimeuse) for runtime configuration and custom handlers. -- See the [Python client README](https://github.com/getlark/runtimeuse/tree/main/packages/runtimeuse-client-python) for structured output, artifact uploads, and cancellation. +asyncio.run(main(ws_url)) +``` diff --git a/docs/public/terminal.svg b/docs/public/terminal.svg new file mode 100644 index 0000000..516f143 --- /dev/null +++ b/docs/public/terminal.svg @@ -0,0 +1,48 @@ + + + + + + + + + + $ + npx -y runtimeuse --agent=claude + + + + + ws://localhost:8080 + + + + + connected to client + + + + + received invocation message + + + + + spawning claude agent + + + + + received agent message, forwarding to client + + + + + received result, forwarding to client + + + + + agent runtime finished... + + diff --git a/examples/README.md b/examples/README.md index 1dc4521..5a550e9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,5 +9,6 @@ Each example is a single, self-contained `.py` file. Setup instructions (depende | File | Provider | Description | |------|----------|-------------| | [e2b-quickstart.py](e2b-quickstart.py) | [E2B](https://e2b.dev) | Run Claude Code in an E2B cloud sandbox | - -More provider examples coming soon. +| [daytona-quickstart.py](daytona-quickstart.py) | [Daytona](https://daytona.io) | Run Claude Code in a Daytona cloud sandbox | +| [vercel-quickstart.py](vercel-quickstart.py) | [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) | Run Claude Code in a Vercel Sandbox | +| [modal-quickstart.py](modal-quickstart.py) | [Modal](https://modal.com) | Run Claude Code in a Modal sandbox | diff --git a/examples/daytona-quickstart.py b/examples/daytona-quickstart.py new file mode 100644 index 0000000..58cde2d --- /dev/null +++ b/examples/daytona-quickstart.py @@ -0,0 +1,186 @@ +""" +Daytona Quickstart -- Run Claude Code in a Daytona cloud sandbox using runtimeuse. + +Setup: + pip install runtimeuse-client daytona + +Environment variables: + DAYTONA_API_KEY - your Daytona API key (https://daytona.io) + ANTHROPIC_API_KEY - your Anthropic API key + +Usage: + python daytona-quickstart.py +""" + +from __future__ import annotations + +import asyncio +import os + +from daytona import ( + CreateSandboxFromImageParams, + Daytona, + DaytonaConfig, + Image, + Sandbox, + SessionExecuteRequest, +) + +from runtimeuse_client import ( + RuntimeEnvironmentDownloadableInterface, + RuntimeUseClient, + QueryOptions, + AssistantMessageInterface, + TextResult, +) + + +_SERVER_READY_SIGNAL = "RuntimeUse server listening on port" +_SERVER_STARTUP_TIMEOUT_S = 120 +_SESSION_ID = "runtimeuse" + + +def _get_env_or_fail(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} environment variable is not set") + return value + + +def _http_to_ws(url: str) -> str: + if url.startswith("https://"): + return "wss://" + url[len("https://") :] + if url.startswith("http://"): + return "ws://" + url[len("http://") :] + return url + + +def create_sandbox() -> tuple[Daytona, Sandbox]: + """Create a Daytona sandbox and return (daytona, sandbox). + + This is intentionally synchronous so that Daytona's internal use of + ``asyncio.run()`` (for streaming snapshot-build logs) does not conflict + with an already-running event loop. + """ + + daytona_api_key = _get_env_or_fail("DAYTONA_API_KEY") + anthropic_api_key = _get_env_or_fail("ANTHROPIC_API_KEY") + + image = Image.base("node:lts").run_commands( + "apt-get update && apt-get install -y unzip", + "npm install -g @anthropic-ai/claude-code", + ) + + daytona = Daytona(config=DaytonaConfig(api_key=daytona_api_key)) + + print("Creating Daytona sandbox (this may take a few minutes the first time)...") + sandbox = daytona.create( + CreateSandboxFromImageParams( + image=image, + env_vars={ + "ANTHROPIC_API_KEY": anthropic_api_key, + "IS_SANDBOX": "1", + "CLAUDE_SKIP_ROOT_CHECK": "1", + }, + public=True, + ), + timeout=300, + on_snapshot_create_logs=lambda chunk: print(chunk, end=""), + ) + print(f"Sandbox created: {sandbox.id}") + + return daytona, sandbox + + +async def _start_server_and_wait(sandbox: Sandbox) -> str: + """Start the runtimeuse server, stream logs, and wait for the ready signal. + + Returns the WebSocket URL once the server is listening. + """ + + sandbox.process.create_session(_SESSION_ID) + exec_resp = sandbox.process.execute_session_command( + _SESSION_ID, + SessionExecuteRequest( + command=f"npx -y runtimeuse --agent claude", + run_async=True, + ), + ) + + ready = asyncio.Event() + + def _on_stdout(log: str) -> None: + print(f"[runtimeuse] {log}") + if _SERVER_READY_SIGNAL in log: + ready.set() + + def _on_stderr(log: str) -> None: + print(f"[runtimeuse:err] {log}") + + log_task = asyncio.create_task( + sandbox.process.get_session_command_logs_async( + _SESSION_ID, + exec_resp.cmd_id, + _on_stdout, + _on_stderr, + ) + ) + + print("Waiting for runtimeuse server to start...") + try: + await asyncio.wait_for(ready.wait(), timeout=_SERVER_STARTUP_TIMEOUT_S) + except asyncio.TimeoutError: + log_task.cancel() + raise RuntimeError( + f"runtimeuse server did not start within {_SERVER_STARTUP_TIMEOUT_S}s" + ) + + preview = sandbox.create_signed_preview_url(8080, expires_in_seconds=3600) + ws_url = _http_to_ws(preview.url) + print(f"Sandbox ready at {ws_url}") + return ws_url + + +async def _run_query(ws_url: str) -> None: + client = RuntimeUseClient(ws_url=ws_url) + print(f"Connected to {ws_url}") + + async def on_message(msg: AssistantMessageInterface) -> None: + for block in msg.text_blocks: + print(f"[assistant] {block}") + + prompt = "Summarize the contents of the codex repository and list your favorite file in the repository." + print(f"Sending query: {prompt}") + + result = await client.query( + prompt=prompt, + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="claude-sonnet-4-20250514", + on_assistant_message=on_message, + pre_agent_downloadables=[ + RuntimeEnvironmentDownloadableInterface( + download_url="https://github.com/openai/codex/archive/refs/heads/main.zip", + working_dir=".", + ) + ], + ), + ) + + print("\n--- Final Result ---") + assert isinstance(result.data, TextResult) + print(result.data.text) + + +def main() -> None: + daytona, sandbox = create_sandbox() + try: + ws_url = asyncio.run(_start_server_and_wait(sandbox)) + asyncio.run(_run_query(ws_url)) + finally: + daytona.delete(sandbox) + print("Sandbox deleted.") + + +if __name__ == "__main__": + main() diff --git a/examples/e2b-quickstart.py b/examples/e2b-quickstart.py index 937abc0..d25416b 100644 --- a/examples/e2b-quickstart.py +++ b/examples/e2b-quickstart.py @@ -21,6 +21,7 @@ from e2b_code_interpreter import Sandbox from runtimeuse_client import ( + RuntimeEnvironmentDownloadableInterface, RuntimeUseClient, QueryOptions, AssistantMessageInterface, @@ -35,24 +36,21 @@ def _get_env_or_fail(name: str) -> str: return value -def create_sandbox() -> tuple[Sandbox, str]: - """Build an E2B template with runtimeuse + Claude Code and return (sandbox, ws_url).""" - e2b_api_key = _get_env_or_fail("E2B_API_KEY") +def _create_template_with_alias(alias: str): anthropic_api_key = _get_env_or_fail("ANTHROPIC_API_KEY") - - alias = "runtimeuse-quickstart-claude" start_cmd = "npx -y runtimeuse --agent claude" - print( - f"Building E2B template '{alias}' (this may take a few minutes the first time)..." - ) - template = ( Template() .from_node_image("lts") - .apt_install(["unzip"]) .npm_install(["@anthropic-ai/claude-code"], g=True) - .set_envs({"ANTHROPIC_API_KEY": anthropic_api_key}) + .set_envs( + { + "ANTHROPIC_API_KEY": anthropic_api_key, + "IS_SANDBOX": "1", + "CLAUDE_SKIP_ROOT_CHECK": "1", + } + ) .set_start_cmd(start_cmd, wait_for_port(8080)) ) @@ -64,6 +62,19 @@ def create_sandbox() -> tuple[Sandbox, str]: on_build_logs=default_build_logger(), ) + +def create_sandbox() -> tuple[Sandbox, str]: + """Build an E2B template with runtimeuse + Claude Code and return (sandbox, ws_url).""" + + alias = "runtimeuse-quickstart-claude" + e2b_api_key = _get_env_or_fail("E2B_API_KEY") + + print( + f"Building E2B template '{alias}' (this may take a few minutes the first time)..." + ) + + _create_template_with_alias(alias) + sandbox = Sandbox.create(template=alias, api_key=e2b_api_key) ws_url = f"wss://{sandbox.get_host(8080)}" print(f"Sandbox ready at {ws_url}") @@ -81,7 +92,7 @@ async def on_message(msg: AssistantMessageInterface) -> None: for block in msg.text_blocks: print(f"[assistant] {block}") - prompt = "What files are in the current directory? List them." + prompt = "Summarize the contents of the codex repository and list your favorite file in the repository." print(f"Sending query: {prompt}") result = await client.query( @@ -90,6 +101,12 @@ async def on_message(msg: AssistantMessageInterface) -> None: system_prompt="You are a helpful assistant.", model="claude-sonnet-4-20250514", on_assistant_message=on_message, + pre_agent_downloadables=[ + RuntimeEnvironmentDownloadableInterface( + download_url="https://github.com/openai/codex/archive/refs/heads/main.zip", + working_dir=".", + ) + ], ), ) diff --git a/examples/modal-quickstart.py b/examples/modal-quickstart.py new file mode 100644 index 0000000..da4c128 --- /dev/null +++ b/examples/modal-quickstart.py @@ -0,0 +1,199 @@ +""" +Modal Quickstart -- Run Claude Code in a Modal sandbox using runtimeuse. + +Setup: + pip install runtimeuse-client modal + + Authenticate with Modal by running `modal token set` or by setting + MODAL_TOKEN_ID and MODAL_TOKEN_SECRET environment variables. + +Environment variables: + ANTHROPIC_API_KEY - your Anthropic API key + +Usage: + python modal-quickstart.py +""" + +from __future__ import annotations + +import asyncio +import os +import queue +import threading +import time + +import modal +from modal.exception import ClientClosed + +from runtimeuse_client import ( + RuntimeEnvironmentDownloadableInterface, + RuntimeUseClient, + QueryOptions, + AssistantMessageInterface, + TextResult, +) + +_SERVER_READY_SIGNAL = "RuntimeUse server listening on port" +_SERVER_STARTUP_TIMEOUT_S = 120 + + +def _get_env_or_fail(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} environment variable is not set") + return value + + +def _http_to_ws(url: str) -> str: + if url.startswith("https://"): + return "wss://" + url[len("https://") :] + if url.startswith("http://"): + return "ws://" + url[len("http://") :] + return url + + +def _wait_for_server_ready(process) -> None: + log_queue: queue.Queue[tuple[str, str]] = queue.Queue() + ready = threading.Event() + + def _pump_stream(prefix: str, stream) -> None: + try: + for line in stream: + log_queue.put((prefix, line)) + if _SERVER_READY_SIGNAL in line: + ready.set() + except ClientClosed: + # The example tears down the sandbox after the query finishes, which can + # close the underlying Modal client while daemon threads are still + # draining logs. + return + + for prefix, stream in ( + ("[runtimeuse]", process.stdout), + ("[runtimeuse:err]", process.stderr), + ): + threading.Thread( + target=_pump_stream, + args=(prefix, stream), + daemon=True, + ).start() + + deadline = time.monotonic() + _SERVER_STARTUP_TIMEOUT_S + while time.monotonic() < deadline: + while True: + try: + prefix, line = log_queue.get_nowait() + except queue.Empty: + break + print(f"{prefix} {line}", end="") + + if ready.is_set(): + return + + exit_code = process.poll() + if exit_code is not None: + raise RuntimeError( + f"runtimeuse server exited before becoming ready (exit code {exit_code})" + ) + + time.sleep(0.2) + + raise RuntimeError( + f"runtimeuse server did not start within {_SERVER_STARTUP_TIMEOUT_S}s" + ) + + +def create_sandbox() -> tuple[modal.Sandbox, str]: + """Create a Modal Sandbox with runtimeuse + Claude Code and return (sandbox, ws_url).""" + + anthropic_api_key = _get_env_or_fail("ANTHROPIC_API_KEY") + + app = modal.App.lookup("runtimeuse-quickstart", create_if_missing=True) + + image = modal.Image.from_registry("node:lts").run_commands( + "apt-get update && apt-get install -y unzip", + "npm install -g @anthropic-ai/claude-code", + ) + + secret = modal.Secret.from_dict( + { + "ANTHROPIC_API_KEY": anthropic_api_key, + "IS_SANDBOX": "1", + "CLAUDE_SKIP_ROOT_CHECK": "1", + } + ) + + print("Creating Modal Sandbox (this may take a few minutes the first time)...") + with modal.enable_output(): + sandbox = modal.Sandbox.create( + app=app, + image=image, + secrets=[secret], + encrypted_ports=[8080], + timeout=600, + ) + print(f"Sandbox created: {sandbox.object_id}") + + print("Starting runtimeuse server...") + + process = sandbox.exec( + "npx", + "-y", + "runtimeuse", + "--agent", + "claude", + ) + + print("Waiting for runtimeuse server to start...") + _wait_for_server_ready(process) + + tunnel = sandbox.tunnels()[8080] + ws_url = _http_to_ws(tunnel.url) + print(f"Sandbox ready at {ws_url}") + + return sandbox, ws_url + + +async def _run_query(ws_url: str) -> None: + client = RuntimeUseClient(ws_url=ws_url) + print(f"Connected to {ws_url}") + + async def on_message(msg: AssistantMessageInterface) -> None: + for block in msg.text_blocks: + print(f"[assistant] {block}") + + prompt = "Summarize the contents of the codex repository and list your favorite file in the repository." + print(f"Sending query: {prompt}") + + result = await client.query( + prompt=prompt, + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="claude-sonnet-4-20250514", + on_assistant_message=on_message, + pre_agent_downloadables=[ + RuntimeEnvironmentDownloadableInterface( + download_url="https://github.com/openai/codex/archive/refs/heads/main.zip", + working_dir=".", + ) + ], + ), + ) + + print("\n--- Final Result ---") + assert isinstance(result.data, TextResult) + print(result.data.text) + + +def main() -> None: + sandbox, ws_url = create_sandbox() + try: + asyncio.run(_run_query(ws_url)) + finally: + sandbox.terminate() + sandbox.detach() + print("Sandbox terminated.") + + +if __name__ == "__main__": + main() diff --git a/examples/vercel-quickstart.py b/examples/vercel-quickstart.py new file mode 100644 index 0000000..940428e --- /dev/null +++ b/examples/vercel-quickstart.py @@ -0,0 +1,159 @@ +""" +Vercel Quickstart -- Run Claude Code in a Vercel Sandbox using runtimeuse. + +Setup: + pip install runtimeuse-client vercel python-dotenv + npm i -g vercel # needed for auth setup + vercel link # link to a Vercel project + vercel env pull # creates .env.local with OIDC token + + Alternatively, set VERCEL_TOKEN to a Vercel access token. + +Environment variables: + VERCEL_TOKEN - your Vercel access token + VERCEL_PROJECT_ID - your Vercel project ID + VERCEL_TEAM_ID - your Vercel team ID + ANTHROPIC_API_KEY - your Anthropic API key + +Usage: + python vercel-quickstart.py +""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + +from dotenv import load_dotenv +from pydantic import BaseModel + +load_dotenv() +load_dotenv(Path.cwd() / ".env.local") + +from vercel.sandbox import Sandbox + +from runtimeuse_client import ( + RuntimeEnvironmentDownloadableInterface, + RuntimeUseClient, + QueryOptions, + AssistantMessageInterface, + StructuredOutputResult, +) + +_SERVER_READY_SIGNAL = "RuntimeUse server listening on port" + + +class RepoStats(BaseModel): + file_count: int + char_count: int + + +def _get_env_or_fail(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} environment variable is not set") + return value + + +def _http_to_ws(url: str) -> str: + if url.startswith("https://"): + return "wss://" + url[len("https://") :] + if url.startswith("http://"): + return "ws://" + url[len("http://") :] + return url + + +def create_sandbox() -> tuple[Sandbox, str]: + """Create a Vercel Sandbox, install deps, start runtimeuse, and return (sandbox, ws_url).""" + + anthropic_api_key = _get_env_or_fail("ANTHROPIC_API_KEY") + + print("Creating Vercel Sandbox...") + sandbox = Sandbox.create( + runtime="node24", + ports=[8081], + env={ + "ANTHROPIC_API_KEY": anthropic_api_key, + "IS_SANDBOX": "1", + "CLAUDE_SKIP_ROOT_CHECK": "1", + }, + ) + print(f"Sandbox created: {sandbox.sandbox_id}") + + print("Installing dependencies...") + sandbox.run_command("sudo", ["dnf", "install", "-y", "unzip"]) + sandbox.run_command("npm", ["install", "-g", "@anthropic-ai/claude-code"]) + + print("Starting runtimeuse server...") + cmd = sandbox.run_command_detached( + "npx", + ["-y", "runtimeuse", "--agent", "claude", "--port", "8081"], + ) + + print("Waiting for runtimeuse server to start...") + for log in cmd.logs(): + if log.stream == "stdout": + print(f"[runtimeuse] {log.data}", end="") + else: + print(f"[runtimeuse:err] {log.data}", end="") + if _SERVER_READY_SIGNAL in log.data: + break + + domain = sandbox.domain(8081) + ws_url = _http_to_ws(domain) + print(f"Sandbox ready at {ws_url}") + + return sandbox, ws_url + + +async def _run_query(ws_url: str) -> None: + client = RuntimeUseClient(ws_url=ws_url) + print(f"Connected to {ws_url}") + + async def on_message(msg: AssistantMessageInterface) -> None: + for block in msg.text_blocks: + print(f"[assistant] {block}") + + prompt = "Inspect the codex repository and return the total file count and total character count across all files as JSON." + print(f"Sending query: {prompt}") + + result = await client.query( + prompt=prompt, + options=QueryOptions( + system_prompt="You are a helpful assistant.", + model="claude-sonnet-4-20250514", + on_assistant_message=on_message, + pre_agent_downloadables=[ + RuntimeEnvironmentDownloadableInterface( + download_url="https://github.com/openai/codex/archive/refs/heads/main.zip", + working_dir=".", + ) + ], + output_format_json_schema_str=json.dumps( + { + "type": "json_schema", + "schema": RepoStats.model_json_schema(), + } + ), + ), + ) + + print("\n--- Final Result ---") + assert isinstance(result.data, StructuredOutputResult) + stats = RepoStats.model_validate(result.data.structured_output) + print(stats.model_dump()) + + +def main() -> None: + sandbox, ws_url = create_sandbox() + try: + asyncio.run(_run_query(ws_url)) + finally: + sandbox.stop() + print("Sandbox stopped.") + + +if __name__ == "__main__": + main() diff --git a/packages/runtimeuse-client-python/README.md b/packages/runtimeuse-client-python/README.md index 3eabb6f..9ddf0e4 100644 --- a/packages/runtimeuse-client-python/README.md +++ b/packages/runtimeuse-client-python/README.md @@ -16,7 +16,16 @@ Start the runtime inside any sandbox, then connect from outside: ```python import asyncio -from runtimeuse_client import RuntimeUseClient, QueryOptions, TextResult, StructuredOutputResult +from runtimeuse_client import ( + AssistantMessageInterface, + QueryOptions, + RuntimeEnvironmentDownloadableInterface, + RuntimeUseClient, + StructuredOutputResult, + TextResult, +) + +WORKDIR = "/runtimeuse" async def main(): # Start the runtime in a sandbox (provider-specific) @@ -26,12 +35,23 @@ async def main(): client = RuntimeUseClient(ws_url=ws_url) + async def on_assistant(msg: AssistantMessageInterface) -> None: + for block in msg.text_blocks: + print(f"[assistant] {block}") + # Text response (no output schema) result = await client.query( - prompt="What is the capital of France?", + prompt="Summarize the contents of the codex repository and list your favorite file in the repository.", options=QueryOptions( system_prompt="You are a helpful assistant.", model="gpt-4.1", + on_assistant_message=on_assistant, + pre_agent_downloadables=[ + RuntimeEnvironmentDownloadableInterface( + download_url="https://github.com/openai/codex/archive/refs/heads/main.zip", + working_dir=WORKDIR, + ) + ], ), ) assert isinstance(result.data, TextResult) @@ -39,11 +59,30 @@ async def main(): # Structured response (with output schema) result = await client.query( - prompt="Return the capital of France.", + prompt="Inspect the codex repository and return the total file count and total character count across all files as JSON.", options=QueryOptions( system_prompt="You are a helpful assistant.", model="gpt-4.1", - output_format_json_schema_str='{"type":"json_schema","schema":{"type":"object"}}', + pre_agent_downloadables=[ + RuntimeEnvironmentDownloadableInterface( + download_url="https://github.com/openai/codex/archive/refs/heads/main.zip", + working_dir=WORKDIR, + ) + ], + output_format_json_schema_str=""" +{ + "type": "json_schema", + "schema": { + "type": "object", + "properties": { + "file_count": { "type": "integer" }, + "char_count": { "type": "integer" } + }, + "required": ["file_count", "char_count"], + "additionalProperties": false + } +} +""", ), ) assert isinstance(result.data, StructuredOutputResult) @@ -71,10 +110,11 @@ Manages the WebSocket connection to the agent runtime and runs the message loop: client = RuntimeUseClient(ws_url="ws://localhost:8080") result = await client.query( - prompt="Do the thing.", + prompt="Summarize the contents of the codex repository.", options=QueryOptions( system_prompt="You are a helpful assistant.", model="gpt-4.1", + pre_agent_downloadables=[downloadable], # optional output_format_json_schema_str='...', # optional -- omit for text response on_assistant_message=on_assistant, # optional on_artifact_upload_request=on_artifact, # optional -- return ArtifactUploadResult diff --git a/packages/runtimeuse-client-python/pyproject.toml b/packages/runtimeuse-client-python/pyproject.toml index 753a19c..3ca787a 100644 --- a/packages/runtimeuse-client-python/pyproject.toml +++ b/packages/runtimeuse-client-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "runtimeuse-client" -version = "0.5.0" +version = "0.6.0" description = "Client library for AI agent runtime communication over WebSocket" readme = "README.md" license = {"text" = "FSL"} @@ -26,6 +26,9 @@ dev = [ "daytona", "e2b", "e2b-code-interpreter", + "modal", + "python-dotenv", + "vercel", ] [tool.hatch.build.targets.wheel] diff --git a/packages/runtimeuse-client-python/src/runtimeuse_client/exceptions.py b/packages/runtimeuse-client-python/src/runtimeuse_client/exceptions.py index 971211d..7ea739e 100644 --- a/packages/runtimeuse-client-python/src/runtimeuse_client/exceptions.py +++ b/packages/runtimeuse-client-python/src/runtimeuse_client/exceptions.py @@ -1,3 +1,6 @@ +import json + + class CancelledException(Exception): """Raised when an agent runtime invocation is cancelled.""" @@ -11,3 +14,14 @@ def __init__(self, error: str, metadata: dict | None = None): super().__init__(error) self.error = error self.metadata = metadata + + def __str__(self) -> str: + if not self.metadata: + return self.error + + try: + metadata_str = json.dumps(self.metadata, sort_keys=True, default=str) + except TypeError: + metadata_str = str(self.metadata) + + return f"{self.error}\nmetadata: {metadata_str}" diff --git a/packages/runtimeuse/README.md b/packages/runtimeuse/README.md index cfc911a..da81446 100644 --- a/packages/runtimeuse/README.md +++ b/packages/runtimeuse/README.md @@ -15,6 +15,7 @@ npm install runtimeuse Run the runtime inside any sandbox: ```bash +export OPENAI_API_KEY=your_openai_api_key npx -y runtimeuse ``` @@ -39,6 +40,8 @@ const server = new RuntimeUseServer({ handler: openaiHandler, port: 8080 }); await server.startListening(); ``` +Pair this with the richer Python client examples in [`runtimeuse-client`](../runtimeuse-client-python/README.md), including streamed assistant messages and `pre_agent_downloadables` for bootstrapping files into the sandbox before invocation. + ### Custom Handler Implement `AgentHandler` to plug in your own agent: diff --git a/packages/runtimeuse/package.json b/packages/runtimeuse/package.json index 9a39cf6..3c90a85 100644 --- a/packages/runtimeuse/package.json +++ b/packages/runtimeuse/package.json @@ -1,6 +1,6 @@ { "name": "runtimeuse", - "version": "0.5.0", + "version": "0.6.0", "description": "AI agent runtime with WebSocket protocol, artifact handling, and secret management", "license": "FSL", "type": "module", diff --git a/packages/runtimeuse/src/claude-handler.ts b/packages/runtimeuse/src/claude-handler.ts index 65273c7..0a6ff91 100644 --- a/packages/runtimeuse/src/claude-handler.ts +++ b/packages/runtimeuse/src/claude-handler.ts @@ -28,6 +28,9 @@ export const claudeHandler: AgentHandler = { sender: MessageSender, ): Promise { const abortController = new AbortController(); + let resultText: string | undefined; + let structuredOutput: Record | undefined; + const metadata: Record = {}; const onAbort = () => abortController.abort(); invocation.signal.addEventListener("abort", onAbort, { once: true }); @@ -64,10 +67,6 @@ export const claudeHandler: AgentHandler = { options: queryOptions as Parameters[0]["options"], }); - let resultText: string | undefined; - let structuredOutput: Record | undefined; - const metadata: Record = {}; - for await (const message of conversation) { if (message.type === "assistant") { const text = extractTextFromContent( diff --git a/packages/runtimeuse/src/error-utils.ts b/packages/runtimeuse/src/error-utils.ts new file mode 100644 index 0000000..80dbbd2 --- /dev/null +++ b/packages/runtimeuse/src/error-utils.ts @@ -0,0 +1,135 @@ +const MAX_DEPTH = 4; +const MAX_ARRAY_ITEMS = 20; +const MAX_OBJECT_KEYS = 20; +const MAX_STRING_LENGTH = 4_000; + +type ErrorWithMetadata = Error & { + cause?: unknown; + metadata?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function truncateString(value: string): string { + if (value.length <= MAX_STRING_LENGTH) { + return value; + } + return `${value.slice(0, MAX_STRING_LENGTH)}... [truncated ${value.length - MAX_STRING_LENGTH} chars]`; +} + +function toSerializable(value: unknown, depth = 0): unknown { + if (value == null || typeof value === "number" || typeof value === "boolean") { + return value; + } + + if (typeof value === "string") { + return truncateString(value); + } + + if (depth >= MAX_DEPTH) { + return "[max depth reached]"; + } + + if (Array.isArray(value)) { + return value.slice(0, MAX_ARRAY_ITEMS).map((item) => toSerializable(item, depth + 1)); + } + + if (value instanceof Error) { + return serializeErrorMetadata(value); + } + + if (isRecord(value)) { + const entries = Object.entries(value).slice(0, MAX_OBJECT_KEYS); + return Object.fromEntries( + entries.map(([key, entryValue]) => [ + key, + toSerializable(entryValue, depth + 1), + ]), + ); + } + + return String(value); +} + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string") { + return error; + } + const serializable = toSerializable(error); + if (typeof serializable === "string") { + return serializable; + } + return JSON.stringify(serializable); +} + +export function serializeErrorMetadata(error: unknown): Record { + if (error instanceof Error) { + const typedError = error as ErrorWithMetadata; + const metadata: Record = { + error_name: error.name, + }; + + if (error.stack) { + metadata.stack = truncateString(error.stack); + } + if (typedError.cause !== undefined) { + metadata.cause = toSerializable(typedError.cause); + } + if (isRecord(typedError.metadata)) { + const serializedMetadata = toSerializable(typedError.metadata); + if (isRecord(serializedMetadata)) { + Object.assign(metadata, serializedMetadata); + } + } + + const extraEntries = Object.entries(typedError).filter( + ([key]) => !["name", "message", "stack", "cause", "metadata"].includes(key), + ); + if (extraEntries.length > 0) { + metadata.error_details = Object.fromEntries( + extraEntries.map(([key, value]) => [key, toSerializable(value)]), + ); + } + + return metadata; + } + + if (typeof error === "string") { + return { error_type: "string" }; + } + + if (error && typeof error === "object") { + return { + error_type: "object", + error_details: toSerializable(error), + }; + } + + return { error_type: typeof error }; +} + +export function withErrorMetadata( + error: unknown, + metadata: Record, +): Error { + if (error instanceof Error) { + const typedError = error as ErrorWithMetadata; + const existingMetadata = isRecord(typedError.metadata) + ? typedError.metadata + : {}; + typedError.metadata = { + ...existingMetadata, + ...metadata, + }; + return error; + } + + const wrapped = new Error(getErrorMessage(error)); + (wrapped as ErrorWithMetadata).metadata = metadata; + return wrapped; +} diff --git a/packages/runtimeuse/src/session.test.ts b/packages/runtimeuse/src/session.test.ts index 6e71421..d53ce56 100644 --- a/packages/runtimeuse/src/session.test.ts +++ b/packages/runtimeuse/src/session.test.ts @@ -276,6 +276,35 @@ describe("WebSocketSession", () => { expectSentError(ws, "agent crashed"); }); + + it("includes structured metadata when agent throws", async () => { + const error = Object.assign(new Error("agent crashed"), { + metadata: { + handler: "claude", + session_id: "abc123", + stderr_tail: "permission denied", + }, + code: "ERR_AGENT_CRASH", + }); + mockHandlerRun.mockRejectedValueOnce(error); + + const { session, ws } = createSession(); + const done = session.run(); + sendMessage(ws, INVOCATION_MSG); + await done; + + const sent = parseSentMessages(ws); + const runtimeError = sent.find((m) => m.message_type === "error_message"); + expect(runtimeError).toBeDefined(); + expect(runtimeError!.metadata).toMatchObject({ + error_name: "Error", + handler: "claude", + session_id: "abc123", + }); + expect(runtimeError!.metadata.error_details).toMatchObject({ + code: "ERR_AGENT_CRASH", + }); + }); }); describe("finalization", () => { @@ -389,9 +418,12 @@ describe("WebSocketSession", () => { }); it("redacts secrets from error messages", async () => { - mockHandlerRun.mockRejectedValueOnce( - new Error("crash with secret123 in trace"), - ); + const error = Object.assign(new Error("crash with secret123 in trace"), { + metadata: { + stderr_tail: "secret123 appeared in stderr", + }, + }); + mockHandlerRun.mockRejectedValueOnce(error); const { session, ws } = createSession(); const done = session.run(); @@ -399,10 +431,12 @@ describe("WebSocketSession", () => { await done; const sent = parseSentMessages(ws); - const error = sent.find((m) => m.message_type === "error_message"); - expect(error).toBeDefined(); - expect(error!.error).not.toContain("secret123"); - expect(error!.error).toContain("[REDACTED]"); + const runtimeError = sent.find((m) => m.message_type === "error_message"); + expect(runtimeError).toBeDefined(); + expect(runtimeError!.error).not.toContain("secret123"); + expect(runtimeError!.error).toContain("[REDACTED]"); + expect(JSON.stringify(runtimeError!.metadata)).not.toContain("secret123"); + expect(JSON.stringify(runtimeError!.metadata)).toContain("[REDACTED]"); }); }); diff --git a/packages/runtimeuse/src/session.ts b/packages/runtimeuse/src/session.ts index 043adbd..e62e6c2 100644 --- a/packages/runtimeuse/src/session.ts +++ b/packages/runtimeuse/src/session.ts @@ -4,6 +4,7 @@ import type { AgentHandler } from "./agent-handler.js"; import { ArtifactManager } from "./artifact-manager.js"; import type { UploadTracker } from "./upload-tracker.js"; import type { InvocationMessage, IncomingMessage, OutgoingMessage } from "./types.js"; +import { getErrorMessage, serializeErrorMetadata } from "./error-utils.js"; import { redactSecrets, sleep } from "./utils.js"; import { createLogger, createRedactingLogger, defaultLogger, type Logger } from "./logger.js"; import { InvocationRunner } from "./invocation-runner.js"; @@ -43,11 +44,9 @@ export class WebSocketSession { this.logger.error("Error processing message:", error); this.send({ message_type: "error_message", - error: String(error), - metadata: {}, + error: getErrorMessage(error), + metadata: serializeErrorMetadata(error), }); - - // todo: maybe close ws on error since nothing will happen after? } }); @@ -89,8 +88,8 @@ export class WebSocketSession { this.logger.error("Error uploading artifact:", error); this.send({ message_type: "error_message", - error: String(error), - metadata: {}, + error: getErrorMessage(error), + metadata: serializeErrorMetadata(error), }); } break; @@ -145,8 +144,8 @@ export class WebSocketSession { this.logger.error("Error in agent execution:", error); this.send({ message_type: "error_message", - error: error instanceof Error ? error.message : JSON.stringify(error), - metadata: {}, + error: getErrorMessage(error), + metadata: serializeErrorMetadata(error), }); } }