Metadata-Version: 2.5
Name: spens-acp
Version: 1.0.1
Summary: ACP shim for spens agents
Author: spens contributors
License: MIT
Keywords: acp,agent-client-protocol,agents,spens
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.12
Requires-Dist: agent-client-protocol>=0.12
Requires-Dist: pydantic>=2.11
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# spens-acp

A shim that exposes agents running in the [spens](https://spens.refwd.ai) sandbox
over the [Agent Client Protocol](https://agentclientprotocol.com) (ACP).

## Overview

[spens](https://spens.refwd.ai) is a sandbox for running AI coding agents
(claude, codex, opencode, pi, …) inside isolated Docker containers with full
network interception, egress policy, and audit logging — every LLM call, tool
invocation, and file change is captured and reviewable.

**spens-acp is the bridge between your editor and that sandbox.** It speaks
ACP — JSON-RPC 2.0 over stdio — on one side, and drives the spens CLI on the
other. Any ACP-compatible client (Zed, VS Code with an ACP extension, …) can
then use spens-sandboxed agents from its normal agent panel: the editor thinks
it is talking to an ordinary ACP agent, while every prompt actually runs as a
fully sandboxed, intercepted, auditable spens session.

What you get:

- **Editor-native agent UX** — chat, streamed output, tool activity, and
  cancellation from your editor's agent panel, for any agent spens supports.
- **Sandboxing by default** — each prompt runs in an ephemeral container with
  network policy, secret injection, and audit logs (see the
  [spens docs](https://spens.refwd.ai)).
- **Continuous conversations** — consecutive prompts in a thread behave like
  one agent session (history is replayed as context), and sessions survive
  editor restarts (`session/resume`).
- **Full observability** — everything spens captures (chat transcripts, HTTP
  traffic, file changes) is still available via `spens log-viewer`.

## Quick start

### Prerequisites

- Python 3.12+
- The [spens](https://spens.refwd.ai) CLI installed and on `$PATH`, with
  Docker working (`spens list` should print environments and agents)
- An ACP-capable editor: [Zed](https://zed.dev) (native support) or VS Code
  with an ACP client extension

### 1. Install spens-acp

From a checkout of this repository:

```bash
# with uv (recommended) — installs the `spens-acp` command onto your PATH
uv tool install .

# or with pip
pip install .
```

For development:

```bash
uv sync                 # create .venv from uv.lock
uv run spens-acp --help
```

Verify the install (and that spens is reachable):

```bash
spens-acp --help
```

### 2. Configure the project you want to work on

For **each project/workspace** you want to drive through spens-acp, add
`default_env` and `default_agent` to `.spens.config.json` at the project root
(create the file if it does not exist):

```json
{
  "default_env": "python-3.12",
  "default_agent": "pi"
}
```

- `default_env` — the spens *environment* (container image + toolchain) to
  run in. Built-ins: `node-20`, `node-22`, `node-24`, `python-3.11`,
  `python-3.12`, `python-3.13`. Run `spens list` to see everything available.
- `default_agent` — the agent to run. Built-ins: `claude`, `codex`,
  `opencode`, `pi`.

The workspace config is the recommended way to configure spens-acp: one file
per project, and every editor/client that opens the project picks it up
automatically. (You can instead pass `--env`/`--agent` flags or
`SPENS_ENV`/`SPENS_AGENT` env vars per launch — see
[Configuration](#configuration--options) — but then you must configure each
editor entry separately.)

> `.spens.config.json` is also spens' own config file. Keys like
> `domain_rules`, `inject_headers`, `env`, and `addition_capture_urls`
> configure the sandbox itself — see the
> [spens docs](https://spens.refwd.ai). spens-acp only reads `default_env` /
> `default_agent` from it.

### 3. Smoke test (no editor needed)

Run one real prompt through the whole pipeline to confirm the wrapper, spens,
and your project config all work:

```bash
cd /path/to/your/project
spens-acp --smoke-test "reply with hello"
```

It drives initialize / session/new / session/prompt against your real spens
binary in the current directory and prints every session update to stderr. If
this passes, the stack is healthy and any editor problem is between the editor
and the wrapper (see [Troubleshooting](#troubleshooting)).

### 4. Zed

Zed has native ACP support. Agents are configured under **External Agents**:

1. Open Zed's settings (`zed: open settings`, or `cmd-,`).
2. Go to **AI → General → External Agents** (or run `agent: open settings`
   from the command palette and pick the External Agents page).
3. Click **Add Agent → Add Custom Agent** — Zed opens your `settings.json`
   with an `agent_servers` entry to fill in:

```json
{
  "agent_servers": {
    "spens": {
      "type": "custom",
      "command": "spens-acp",
      "args": [],
      "env": {
        "FIREWORKS_API_KEY": "fw-…"
      }
    }
  }
}
```

Everything in the `env` block is passed to the `spens-acp` process (and
inherited by the spens sessions it launches). Use it for:

- **Provider API keys** — `FIREWORKS_API_KEY`, `ANTHROPIC_API_KEY`, etc.
  The real key stays on the host: pair it with an `inject_headers` rule in
  the project's `.spens.config.json`, and spens' interceptor substitutes it
  into requests to the authorized domains only — the key itself never enters
  the sandbox (see the [spens docs](https://spens.refwd.ai)).
- **Any `SPENS_*` option** from the [options table](#configuration--options)
  — e.g. `SPENS_ENV` / `SPENS_AGENT` to pin an env/agent for this editor
  entry instead of relying on the workspace config, `SPENS_BIN` if the spens
  binary is not on Zed's `PATH`, or `SPENS_DEBUG=1` while setting things up.

Zed picks up settings changes automatically — no restart needed. Then:

1. Open your project folder in Zed (the folder containing
   `.spens.config.json`).
2. Open the agent panel (`cmd-?` on macOS, `ctrl-?` elsewhere).
3. Click **+** to start a new thread and select **spens** as the agent.
4. Chat — each message runs one sandboxed spens session in the workspace.

If `spens-acp` is not on Zed's `PATH` (e.g. it lives in a venv), point
`command` at the interpreter instead:

```json
"command": "python3", "args": ["-m", "spens_acp"]
```

### 5. VS Code

VS Code needs an ACP client extension — it does not ship with one. Install
**ACP Client** (`formulahendry.acp-client`) from the VS Code Marketplace
(alternatives exist, e.g. `strato-space.acp-plugin`, which uses the same
Zed-style `agent_servers` format).

Add the agent to your VS Code `settings.json`:

```json
{
  "acp.agents": {
    "spens": {
      "command": "spens-acp",
      "args": [],
      "env": {
        "FIREWORKS_API_KEY": "fw-…"
      }
    }
  }
}
```

The `env` block works exactly like Zed's — pass provider API keys (paired
with an `inject_headers` rule in the project's `.spens.config.json`) and any
`SPENS_*` options there.

Then:

1. Open the ACP panel from the Activity Bar (ACP icon).
2. Connect to the **spens** agent.
3. Open your project folder and start chatting.

> The extension spawns the agent process from the VS Code process itself —
> make sure `spens-acp` (and `spens`) are on its `PATH`. Launch VS Code from
> a shell where they are installed, or use an absolute path in `command`.

### Running it manually

```bash
spens-acp --env python-3.12 --agent pi
# or as a module:
python3 -m spens_acp --env python-3.12 --agent pi
# or via environment variables:
SPENS_ENV=python-3.12 SPENS_AGENT=pi spens-acp
```

This speaks JSON-RPC 2.0 on stdio — it is the interface editors spawn, not
meant for interactive use. Use `--smoke-test` for a human-readable run.

## Configuration / options

### Environment / agent resolution

Precedence (first match wins):

1. `session/new` params (`env` / `agent`, top-level or in `_meta`)
2. Explicit argument — `--env` / `--agent` or `SPENS_ENV` / `SPENS_AGENT`
3. Workspace config — `<workspace>/.spens.config.json` keys `default_env` /
   `default_agent`
4. Error — `"No environment/agent configured: pass --env/--agent or set
   default_env/default_agent in .spens.config.json"`

### All options

| Env var / flag | Default | Meaning |
|---|---|---|
| `SPENS_BIN` | `spens` | Path to spens binary |
| `SPENS_ENV` / `--env` | — | Explicit environment |
| `SPENS_AGENT` / `--agent` | — | Explicit agent |
| `SPENS_CHANGES` | `accept` | `accept` \| `reject` → `--accept-changes` / `--reject-changes` |
| `SPENS_REBUILD` | `auto` | `auto` (never pass flag) \| `always` (pass `--rebuild`) |
| `SPENS_DIR` | `<workspace>/.spens` | Override `--spens-dir` |
| `SPENS_INCLUDE_AGENT_OUTPUT` | `false` | Stream `agent_output` events as thought chunks |
| `SPENS_EMIT_SUMMARY` | `true` | Emit final summary thought chunk (tokens / cost / files) |
| `SPENS_TOOL_RESULT_MAX` | `2000` | Truncate tool-result content in updates |
| `SPENS_HISTORY` | `true` | Replay earlier turns of the ACP session as transcript context in each spens prompt (continuous-session behavior) |
| `SPENS_HISTORY_MAX_TURNS` | `20` | Maximum earlier turns included in the replayed transcript |
| `SPENS_HISTORY_MAX_CHARS` | `24000` | Character budget for the replayed transcript; oldest turns are dropped (whole turns) until it fits |
| `SPENS_RESUME` | `true` | Persist session state to `<spens-dir>/acp-sessions/` and support `session/resume` |
| `SPENS_POLL_INTERVAL` | `0.25` | File tailer poll interval (seconds) |
| `SPENS_LAUNCH_TIMEOUT` | `60` | Seconds to wait for `spens --output background` to print the session id |
| `SPENS_SESSION_DIR_TIMEOUT` | `60` | Seconds to wait for the session directory to appear after a confirmed launch |
| `SPENS_CANCEL_ON_EXIT` | `true` | SIGTERM/SIGINT/EOF → `spens cancel` in-flight sessions |
| `SPENS_DEBUG` | — | Set to `1` for verbose stderr diagnostics (argv, events, state transitions) |

### Example workspace config

A real-world `.spens.config.json` (this repo's own), combining the
spens-acp keys with spens' sandbox policy keys:

```json
{
  "default_env": "python-3.12",
  "default_agent": "pi",
  "addition_capture_urls": ["*api.fireworks.ai*", "*api.anthropic.com*"],
  "env": ["FIREWORKS_BASE_URL", "FIREWORKS_ACCOUNT_ID"],
  "domain_rules": [
    { "pattern": "*pypi.org", "allow": ["GET", "HEAD", "POST"] },
    { "pattern": "*api.anthropic.com", "allow": ["GET", "POST", "PUT", "DELETE", "OPTIONS"] }
  ],
  "inject_headers": [
    {
      "placeholder": "FIREWORKS_API_KEY",
      "env_var": "FIREWORKS_API_KEY",
      "for_domains": ["*api.fireworks.ai*"]
    }
  ]
}
```

Only `default_env` / `default_agent` are read by spens-acp; the rest is
spens' own configuration (see the [spens docs](https://spens.refwd.ai)).

### Continuous sessions (history replay)

spens itself is stateless — each prompt turn launches a fresh spens session.
To make an ACP session behave like one continuous conversation, the wrapper
records every completed exchange (user prompt + final assistant text from
`captured.jsonl`) and replays them as a compact transcript prefix on the next
prompt:

```
You are continuing an existing conversation. ...

User: <earlier prompt>
Assistant: <earlier reply>

User: <new prompt>
```

Only successful (`end_turn`) turns are recorded; failed or cancelled launches
are not replayed. The transcript is bounded by `SPENS_HISTORY_MAX_TURNS` and
`SPENS_HISTORY_MAX_CHARS` — oldest turns are dropped whole, with an
`[... N earlier turn(s) omitted ...]` marker — and an oversized single
exchange is mid-truncated with `...`. Disable the whole feature with
`SPENS_HISTORY=0` (every prompt then launches spens with the bare user
message).

### Resuming sessions (`session/resume`)

The wrapper process is itself stateless — when the client (editor) restarts
or reconnects, it spawns a fresh `spens-acp` with no memory. To let those
sessions continue, every ACP session's state (workspace `cwd`, resolved
env/agent, the replay-turn history, and the spens session ids already used)
is mirrored to a small JSON file:

```
<spens-dir>/acp-sessions/<sessionId>.json
```

Writes are atomic and best-effort — a failed write never fails the prompt it
serves, it only costs resumability. `session/resume` (which `initialize`
advertises via `sessionCapabilities.resume`) reloads that file in a fresh
process, re-validates the binary and env/agent, and restores the history so
the next prompt continues the conversation. The spens-id sequence continues
too, so a resumed turn never reuses a session directory already on disk.

Notes:

- A session can only be resumed in the workspace it was created in (the
  `cwd` of the resume request must match); with `SPENS_DIR` pointing at a
  shared directory the state is found there instead.
- The spens id is persisted *before* each launch, so even a crash mid-turn
  cannot make a later resume reuse the id.
- Disable with `SPENS_RESUME=0`: no state is written, the capability is not
  advertised, and cross-process resume answers `invalid params` (same-process
  resume of a live session still works).
- `session/resume` is still flagged *unstable* in this SDK build, so the
  agent enables `use_unstable_protocol` on its router; unhandled unstable
  methods still answer `method not found` individually.

## Architecture

```
ACP client (Zed / VS Code / …)
   │  JSON-RPC 2.0 over stdio (official agent-client-protocol SDK)
   ▼
spens-acp  (Python; acp.Agent implementation)
   │  subprocess (argv list, no shell)         polling tailers
   ├──────────────────────────►  spens CLI ──► <spens-dir>/sessions/<id>/
   │                                │            events.jsonl  (lifecycle)
   │                                │            state.json    (state machine)
   │                                │            traces/captured.jsonl (LLM traffic)
   ▼
 session/update notifications  ◄──  decode + map
```

### How it works

The wire protocol is implemented with the official
[`agent-client-protocol`](https://pypi.org/project/agent-client-protocol/)
SDK (`acp.run_agent` + `AgentSideConnection`), which provides the
cross-platform stdio transport (thread-based feeder on Windows,
`connect_read_pipe` on POSIX), JSON-RPC framing, request dispatch, and schema
validation. `spens_acp.acp_server.SpensAgent` implements the `acp.Agent`
protocol; internal update dataclasses from the decoders are converted to SDK
`SessionUpdate` types at the boundary.

**Life of a prompt:**

1. `session/new` — the agent generates an `sessionId`, records the workspace
   `cwd`, and resolves env/agent (session params → flags/env vars → workspace
   `.spens.config.json`), validating them against `spens list`.
2. `session/prompt` — the prompt is flattened to a string, earlier turns of
   the conversation are prepended as a transcript prefix (history replay),
   and one spens session is launched in yolo mode:
   `spens <env> <agent> <workspace> "<prompt>" --output background
   --accept-changes --session-id <id>` (spawned as an argv list, no shell).
3. While it runs, three concurrent watchers poll the session directory on
   disk: `events.jsonl` (lifecycle events → tool-call / plan / thought
   updates), `state.json` (state machine → terminal-state detection), and
   `traces/captured.jsonl` (raw LLM API traffic → streamed
   `agent_message_chunk` updates via provider-specific decoders).
4. Every decoded update is sent to the client as a `session/update`
   notification; a framing `spens <env>/<agent>` tool call shows activity
   from the moment of launch.
5. Terminal state (`finished` / `canceled` / `error`) → the tool call is
   closed, the exchange is recorded in the session history, and
   `session/prompt` returns a `PromptResponse` with `stopReason`
   (`end_turn` / `cancelled`; an `error` state fails the request with a
   JSON-RPC error instead).
6. `session/cancel` — calls `spens cancel <id>`; the in-flight prompt
   resolves with `stopReason: "cancelled"`.

The wrapper is intentionally **loose** — it couples only to spens' documented
CLI surface and on-disk session files. It never imports spens as a library,
and degrades gracefully when spens emits records it does not recognise
(skip + log, never crash).

### ACP surface

| Method | Behaviour |
|---|---|
| `initialize` | Echoes the client's `protocolVersion`, advertises `agentInfo`, validates spens binary and explicit env/agent config |
| `session/new` | **Agent generates `sessionId`** (official protocol). Records workspace `cwd`, resolves env/agent from params, workspace config, or error |
| `session/resume` | Restores a session in a fresh wrapper process from persisted state; validates cwd/env/agent, continues the conversation and spens-id sequence |
| `session/prompt` | Flattens prompt → string, prepends replayed session history, launches one spens yolo session, streams `session/update` notifications, returns `PromptResponse` |
| `session/cancel` | Notification. Calls `spens cancel <id>`; in-flight `session/prompt` resolves with `stopReason: "cancelled"` |

**Not implemented:** `session/set_mode`, `session/load`, file-system
methods, permission requests → JSON-RPC `method not found` (-32601).

### Provider format support

spens' interceptor captures LLM API traffic raw; the wrapper decodes it by
request URL so the client sees real streamed assistant output:

| URL pattern | Decoder | Used by |
|---|---|---|
| `*/chat/completions` | OpenAI Chat Completions | opencode, pi, Fireworks, OpenRouter, … |
| `*/v1/messages` (api.anthropic.com) | Anthropic Messages | claude |
| `*/v1/responses` | OpenAI Responses | codex |

Unknown URLs degrade to the `agent_output` fallback rather than crashing.

### Protocol notes

The implementation follows the official ACP schema (via the SDK); a few
points worth knowing where it differs from a naive reading of the spec:

- **`protocolVersion` is an integer (u16)**; the agent echoes the client's
  integer back (legacy date strings are coerced to `1` on input).
- **`sessionId` in `session/new` is generated by the agent**, not supplied by
  the client.
- **`PromptResponse` has no `message` field** — final assistant text is
  streamed incrementally via `agent_message_chunk` updates; a fallback chunk
  ("Spens session finished…") is sent only when no text was decoded.
- **`stopReason` has no `"error"` value** — a spens session ending in the
  `error` state fails `session/prompt` with a JSON-RPC error (`-32603`).
- **Error codes `-32000` / `-32002` are reserved by ACP** (authentication /
  resource-not-found) and clients discard the agent's text for them, so
  spens-acp uses `-32603` / `-32602` for its own errors.
- **No `"cancelled"` tool-call status exists** — the framing spens tool call
  is closed with `failed` when a prompt is cancelled; overall cancellation is
  signalled via `stopReason: "cancelled"`.
- **spens `env` / `agent` selection** travels in `session/new` params —
  either as top-level `env` / `agent` keys (spens-native clients) or in the
  official `_meta` extensibility field (official-SDK clients); both converge
  on the same handler.

### stdout discipline

Only JSON-RPC ever goes to stdout (the SDK owns the stdio transport). All
wrapper logging, spens subprocess stdout/stderr, and decoder warnings go to
stderr. Key milestones (launch confirmed, session ended) are always logged to
stderr; set `SPENS_DEBUG=1` for argv / event / state-transition detail.

### Project layout

```
spens_acp/
  __init__.py
  __main__.py          # python -m spens_acp entrypoint
  main.py              # env/flags → acp.run_agent() + signal handling + --smoke-test
  acp_server.py        # SpensAgent: acp.Agent implementation + session lifecycle
  event_mapper.py      # events.jsonl → internal updates (spec §7.3)
  history.py           # Per-session turn history → replayed transcript prefix
  persist.py           # Session state → <spens-dir>/acp-sessions/ (for session/resume)
  launcher.py          # argv builder + subprocess spawn + cancel
  watcher.py           # Polling JSONL tailer (partial-line safe, resume by offset)
  decoder/
    __init__.py          # URL-based dispatch
    chatcompletions.py   # OpenAI Chat Completions (golden-verified)
    anthropic.py         # Anthropic Messages (spec-built + synthetic tests)
    responses.py         # OpenAI Responses (spec-built + synthetic tests)
    common.py            # Tool kind mapping, result dedup, truncation
  session_map.py       # ACP sessionId ↔ spens session id
  config.py            # Env/flag/workspace-config resolution
  types.py             # Internal update dataclasses + SDK conversion + prompt flattening

tests/
  test_decoder_chatcompletions.py  # Unit tests + golden fixture from example-data
  test_decoder_anthropic.py        # Synthetic SSE events
  test_decoder_responses.py        # Synthetic events
  test_event_mapping_golden.py     # Real example-data events.jsonl → ACP updates
  test_history.py                 # History replay + truncation budgets
  test_persist.py                 # Resume state files: paths, sanitize, round-trip
  test_fake_spens_e2e.py           # Full prompt + cancel + error lifecycle (no Docker)
  test_protocol_e2e.py             # Wire test: SDK client ↔ agent subprocess over stdio
  test_acp_server.py               # SpensAgent handlers + event mapping
  test_config.py                   # Resolution + argv builder
  test_session_map.py              # ID sanitize, collision, truncation
  test_prompt_flattening.py        # SDK blocks + legacy dicts, image rejection
  test_tool_kind.py                # §7.5 mapping table
  test_truncation.py               # SPENS_TOOL_RESULT_MAX
  test_watcher.py                  # Partial lines, growing files, late creation
```

## Troubleshooting

First, split the problem in half — run one real prompt through the whole
pipeline **without any editor**:

```bash
spens-acp --smoke-test "reply with hello" --env python-3.12 --agent pi
```

If the smoke test passes but your editor hangs, the problem is between the
editor and the wrapper (see the log decision tree below); if the smoke test
hangs or fails, its last line shows exactly where the wrapper stopped, with
the session dir it was waiting for.

**Log decision tree** — every line below goes to stderr (`SPENS_DEBUG=1`
adds argv / event / state detail). In Zed, agent stderr lands in the Zed log
(`zed: open log`):

1. `spens-acp 0.1.x starting; spens binary: …` — no line at all means an old
   build is installed or the agent never started
2. `session '…' created (cwd=…, env=…, agent=…)` — session/new OK
3. `prompt received for session '…'` — the prompt request arrived
4. `launching spens session '…' in <workspace>` — right before spawn
5. `spens session '…' confirmed via …` — launch confirmed (session dir on
   disk, or the id on stdout); stuck before this line means the session dir
   never appeared where expected (the `SPENS_DEBUG` line
   `expecting session dir: …` shows the exact path — compare it with where
   spens actually wrote the session)
6. `spens session '…' ended: state='…' -> …` — terminal state reached

**Prompt hangs forever** — launch no longer waits for spens' pipes to close
(detached session children can hold them open after the CLI exits) and no
longer depends on spens' stdout at all (a piped Python CLI block-buffers
stdout, so the session-id line can be trapped until exit — or lost entirely
if spens daemonizes with `os._exit()`). Launch is confirmed by the **session
directory appearing on disk**, both pipes are drained for the launcher's
lifetime so a foreground spens can never freeze on a full pipe, and if
nothing confirms within `SPENS_LAUNCH_TIMEOUT` the launcher is killed with a
clear error. If the session directory never appears where the wrapper expects
it (`<workspace>/.spens/sessions/<id>/`, or `SPENS_DIR`), the prompt fails
after `SPENS_SESSION_DIR_TIMEOUT` with the exact path it was waiting for.

## Running tests

```bash
# All 99 tests. The ACP SDK must be importable; if it is not installed
# into the environment (e.g. wheels were extracted manually), point
# PYTHONPATH at it:
PYTHONPATH=.local-packages/site-packages python -m unittest discover -s tests -v
```

`test_protocol_e2e.py` spawns the real agent entrypoint as a subprocess with
a fake `spens` executable and drives it through the SDK's
`ClientSideConnection` — the same path a real editor client takes (POSIX-only;
skipped on Windows).

Golden tests use the real `example-data/` session capture shipped with this
repo (OpenAI Chat Completions). Synthetic fixtures cover Anthropic Messages
and OpenAI Responses until real captures are available.

## License

Same as [spens](https://spens.refwd.ai).
