Skip to content

Latest commit

 

History

History
598 lines (457 loc) · 27.2 KB

File metadata and controls

598 lines (457 loc) · 27.2 KB

ACP — Agent Communication Protocol

Aar implements ACP v0.9.0 in two modes:

Mode Command Transport Use case
stdio aar acp stdin / stdout Zed and any ACP-compatible editor
HTTP/SSE aar acp --http HTTP REST + SSE Remote or programmatic ACP clients

Advertised capabilities

On connection, Aar reports the following capabilities to the editor:

Capability Value Meaning
load_session true Editor can resume previously saved sessions
fork_session supported Editor can branch a session at a specific message
session.list supported Editor can show Aar session history in its sidebar
session.close supported Editor notifies Aar when a session tab is closed
session.set_mode supported Editor can switch between auto / review / read-only modes
session.set_config_option supported Editor can change the model, mode, and Qwen3.8 reasoning effort at runtime
prompt.embedded_context true @-mentions embed file contents that Aar reads
mcp_capabilities.http true Editor forwards HTTP MCP servers to Aar
mcp_capabilities.sse false SSE transport not supported (servers skipped with warning)

Client capabilities consumed

Aar inspects the editor's advertised ClientCapabilities during initialize and gates optional features accordingly:

Client capability Effect when present
terminal Aar registers the acp_terminal tool so the agent can run shell commands through the editor's terminal pane instead of a local subprocess
fs.read_text_file / fs.write_text_file Aar routes file reads/writes through the editor (transparent to the agent)

1. Zed Editor — stdio setup

Zed communicates with the agent over stdin/stdout using the agent-client-protocol SDK. No HTTP server or port is needed.

Local development

Add to ~/.config/zed/settings.json:

{
  "agent_servers": {
    "Aar": {
      "type": "custom",
      "command": "aar",
      "args": ["acp"],
      "env": {}
    }
  }
}

Make sure aar is on your $PATH (pip install -e ".[all,acp]" from the repo root).

Published extension (release)

extension.toml at the repo root registers Aar as a Zed extension. Platform archives (.tar.gz / .zip) are attached to GitHub Releases and contain a launcher script that installs aar-agent from PyPI on first run.

Build the archives after bumping the version:

bash scripts/zed/build_archives.sh
# Attach the files in dist/zed/ to the GitHub Release for vX.Y.Z.
# Update the sha256 values in extension.toml.

Environment variables

Variable Default Description
AAR_LAUNCHED_BY — Set to "zed" by the extension; useful for log filtering
ANTHROPIC_API_KEY — Required for the Anthropic provider
OPENAI_API_KEY — Required for the OpenAI provider

Set these in Zed's Settings → Agent Servers → Environment or in your shell profile.

Logging

# Write logs to a file — useful for diagnosing Zed connection issues.
aar acp --log-level debug --log-file /tmp/aar-acp.log

Logs always go to stderr (or the log file) and never to stdout, so they cannot corrupt the JSON-RPC stream that Zed reads.


2. Other ACP-compatible editors

Any editor that supports ACP stdio agents works the same way — point it at aar acp. Check your editor's documentation for the equivalent of Zed's "type": "custom" agent server config.


3. HTTP/SSE mode

aar acp --http                       # 127.0.0.1:8000
aar acp --http --host 0.0.0.0 --port 9000

Endpoints

Method Path Description
GET /ping Health check — returns {"status": "ok"}
GET /agents List agents (manifest array)
GET /agents/{name} Single agent manifest
POST /runs Create a run
GET /runs/{run_id} Run status and output
POST /runs/{run_id}/cancel Cancel an in-progress run
GET /runs/{run_id}/events Full ACP event log for a run
GET /sessions/{session_id} Session metadata
GET /sessions/{session_id}/panels Extension UI panels registered for the session, with their actions
GET /sessions/{session_id}/panels/{name} Panel snapshot — {panel, root, status}; root is a UINode tree
POST /sessions/{session_id}/panels/{name}/actions/{action} Run an action; body {"node_id": "...", "args": {...}} → {message, root, status}

Panel semantics match the stdio _aar/panel_* methods (see §4): the client confirms destructive actions itself; mutating actions return 409 while a run is in progress for the session; unknown session/panel/action/node → 404, wrong node kind → 422. In stream mode a panel_changed SSE event is emitted after the run for every panel the extension flagged as stale. The transport keeps one Agent per session_id, so extension state persists across runs.

Limitations vs stdio

The HTTP transport implements only the run-execution subset of the ACP protocol. It is intended for simple programmatic / curl-based use. For full editor integration (Zed, VS Code via ACP Client, etc.) use the default stdio transport.

Missing relative to stdio (as of the Wave 4 audit, 2026-06):

Capability stdio --http
Run a prompt, stream assistant text ✅ ✅
MCP servers from the client (initialize.mcp_servers) ✅ ❌
Slash commands (/model, /help, /clear, …) ✅ ❌
Extension auto-discovery and register(api) hooks ✅ ✅ (per session; extension slash commands not parsed)
Extension UI panels ✅ (_aar/panel_*) ✅ (/sessions/{id}/panels…)
ACP session/request_permission round-trip ✅ ❌ (auto-approve fallback)
session/update notifications for live IDEs ✅ ❌ (events buffered on the run only)
set_session_model (provider/model switch mid-session) ✅ ❌
Session fork / load / list ✅ ❌ (GET /sessions/{id} metadata only)

If any of those features are required, run the stdio transport behind your own process supervisor instead.

Run modes

Set the mode field in POST /runs:

Mode Behaviour
sync Blocks until the run completes; returns the finished Run object
async Returns 202 immediately; poll GET /runs/{id} for status
stream Server-Sent Events; each line is data: <json>\n\n

Run lifecycle

created → in-progress → completed
                      → failed
                      → cancelled

SSE event types (stream mode)

Event type Description
run_in_progress Run has started
message_created A new assistant message chunk is available
run_completed Run finished successfully
run_failed Run terminated with an error
run_cancelled Run was cancelled
panel_changed An extension UI panel is stale (session_id, panel) — fetch a new snapshot

Quick example

# Create a sync run
curl -s -X POST http://127.0.0.1:8000/runs \
  -H "Content-Type: application/json" \
  -d '{
    "agent_name": "aar",
    "mode": "sync",
    "input": [{"role": "user", "parts": [{"content_type": "text/plain", "content": "Hello!"}]}]
  }' | python -m json.tool

# Stream a run
curl -s -N -X POST http://127.0.0.1:8000/runs \
  -H "Content-Type: application/json" \
  -d '{"agent_name": "aar", "mode": "stream", "input": [{"role": "user", "parts": [{"content": "Hello!"}]}]}'

4. Session lifecycle

What Aar handles on each ACP event

ACP event What Aar does
initialize Returns capabilities, protocol version, and agent info; stashes client capabilities for later gating
authenticate No-op success response — Aar uses provider API keys from env/config, not per-session auth
session/new Creates a new Aar session; stores cwd in session metadata; starts any MCP servers passed by the editor; returns the session's current modes and config_options
session/load Resumes a saved session from ~/.aar/sessions/; returns null if not found (editor creates new); also returns the resumed session's modes and config_options
session/list Returns all saved sessions with title (first assistant message) and cwd
session/close Cleans up in-memory session state and shuts down any per-session MCP bridges
session/prompt Runs the Aar agent loop; streams thinking, tool calls, and token updates back to the editor
session/cancel Sets the agent's cooperative cancel signal for the current prompt
session/fork Branches a session at a given message index; returns a fresh session_id with the trimmed history
session/resume Re-attaches to an existing session by id (equivalent to load for saved sessions, but does not replay history)
session/set_mode Switches the session between auto / review / read-only; emits a current_mode_update notification
session/set_config_option Changes the session model, mode, or Qwen3.8 reasoning_effort; returns the complete updated option list

Streaming updates pushed during a prompt

Update type Trigger
agent_message_chunk Each streaming token (when provider streaming is enabled) or complete assistant message
agent_thought_chunk Extended thinking / reasoning content
tool_call (start) When the agent calls a tool
tool_call_update (progress) When the tool returns its result (status: completed or failed)
plan Updated after each tool call and result — shows the current step list with statuses
usage_update After each provider call — reports token count and estimated cost
session_info_update After the first assistant response — sets the session title in the editor sidebar
available_commands_update Once per session on first prompt — advertises /model and /clear slash commands

Extension UI panels (_aar/panel_*)

Extensions can register a UI panel — a JSON-safe tree of nodes plus a list of actions (see agent.extensions.api.UIPanel). The fixed TUI draws it behind ctrl+b; over stdio ACP the same data is exposed through custom methods so an editor extension can render it. The SDK prefixes custom methods with _; Aar strips the prefix and dispatches on the aar/… name.

Direction Method Params Result
client → agent _aar/panel_list sessionId {panels: [{name, title, status, actions: [{id, label, key, kinds, destructive, confirm, inputs, mutates}]}]}
client → agent _aar/panel_snapshot sessionId, panel {panel, root, status} — root is a UINode (id, label, kind, style, expanded, data, children)
client → agent _aar/panel_action sessionId, panel, action, nodeId, args? {panel, action, message, root, status} — message is the same line the slash command would print; root is a fresh snapshot
agent → client _aar/panel_changed sessionId, panel notification, sent after a prompt or slash command when the extension flagged its state as stale; fetch a new snapshot to clear it

Rules:

  • args carries what the UI collected for the action's inputs — e.g. {"force": true} for a shadow-branching undo, {"message": "…"} for done. Destructive actions are not confirmed server-side — the client is expected to confirm using the action's confirm template ({label} is the node label).
  • Actions with mutates: true are rejected with invalid params while a prompt is in flight for the session; cancel first.
  • Unknown session / panel / action / node → JSON-RPC invalid params; unknown _aar/… method → method not found.
  • sessionId / nodeId are also accepted as session_id / node_id.

The shadow-branching extension (aar-ext-shadow-branching ≥ 0.3.0) registers the panel shadow_branching with actions undo, branch, switch, diff, delete, done, refresh.

Over HTTP/SSE the same data is served by GET /sessions/{id}/panels… (see §3).

Permission requests

By default, Aar forwards tool-approval prompts to the editor via the ACP request_permission mechanism. The editor shows an Allow / Deny dialog before each tool executes. If no editor connection is available, tools are auto-approved.

Approval timeout — make_acp_approval_callback(..., timeout=<seconds>) caps how long Aar waits for the editor to return a decision before auto-denying the request. timeout=0 (the default) means wait indefinitely. The factory validates the argument up-front and raises ValueError for negative, NaN, inf, or non-numeric values (including bool), so misconfigurations fail fast at wire-up time rather than silently denying every request later.

MCP servers from the editor

When an editor passes MCP server configurations in session/new or session/load, Aar starts those MCP bridges and registers their tools for the lifetime of that session. Both HTTP and stdio MCP transports are supported. SSE transport is not supported — SSE servers are skipped with a warning.

The bridges are shut down automatically when session/close is received.

Configuring MCP servers in Zed

In Zed, MCP servers are declared under "context_servers" in ~/.config/zed/settings.json. Zed forwards these to Aar automatically when it creates or loads a session.

Schema:

{
  "context_servers": {
    "<server-name>": {
      "command": "<executable>",
      "args": ["<arg1>", "..."],
      "env": {}
    }
  }
}

Bundled MCP tools — Zed config

The tools/ directory in the Aar repo contains ready-to-use MCP servers. Use absolute paths because Zed does not run from the repo root.

Replace /path/to/aar with your actual clone path.

{
  "context_servers": {
    "aar-web": {
      "command": "python",
      "args": [
        "-c",
        "import sys; sys.path.insert(0, '/path/to/aar/tools'); from web_mcp.server import mcp; mcp.run(transport='stdio')"
      ],
      "env": {}
    },
    "aar-gitlab": {
      "command": "python",
      "args": ["/path/to/aar/tools/gitlab_mcp/server.py"],
      "env": {}
    },
    "aar-signal": {
      "command": "python",
      "args": [
        "-c",
        "import sys; sys.path.insert(0, '/path/to/aar/tools'); from signal_mcp.server import mcp; mcp.run(transport='stdio')"
      ],
      "env": {}
    },
    "aar-chrome": {
      "command": "npx",
      "args": ["chrome-devtools-mcp@latest", "--no-usage-statistics"],
      "env": {}
    }
  }
}

Tip: You can also load any of the bundled servers directly from the Aar CLI using the --mcp flag instead of wiring them through Zed:

aar chat --mcp tools/mcp_web.json

Model selection

Aar advertises a /model slash command via AvailableCommandsUpdate so editors can offer model switching. When the editor calls set_session_model, Aar maps the model ID to a provider and applies it for the remainder of that session without affecting other sessions:

Model ID prefix Provider
claude-* Anthropic
gpt-*, o1-*, o3-*, o4-*, chatgpt-* OpenAI
anything else Ollama

In Zed, model switching is available once Zed exposes a UI for set_session_model. Until then you can watch the debug log (--log-level debug) to confirm the call arrives and is applied.

Plan tracking

Every tool call during a prompt builds a live plan that Aar streams back as plan updates:

  • When a tool starts → a new PlanEntry with status in_progress is appended.
  • When the tool returns → the entry flips to completed (or stays in_progress on error).

Editors that render the plan panel (e.g. Zed's tool-call sidebar) show each step as it executes. No configuration is needed — the plan is always active.

Session modes and config options

Each session advertises a set of modes and config options that the editor can show in a picker and toggle at runtime. Aar derives the defaults from the current SafetyConfig:

Mode Meaning
auto No approval prompts — writes and execute run automatically
review Approval required before writes and execute (the default for most safety configs)
read-only Writes and execute are denied entirely; only read-side tools run

The three config options, each a boolean toggle:

Option Effect
auto_approve_writes When true, disables approval prompts for write-side tools (write_file, edit_file, …)
auto_approve_execute When true, disables approval prompts for bash and other execute-side tools
read_only When true, writes and execute are blocked outright

How config.json seeds the picker

On session/new and session/load, Aar reads the loaded AgentConfig.safety and reports the current mode back to the editor. The mapping from SafetyConfig flags to the advertised current_mode_id and current_values:

safety.read_only require_approval_for_writes require_approval_for_execute Advertised current_mode_id auto_approve_writes auto_approve_execute read_only
true any any read-only false false true
false true true review (default) false false false
false false false auto true true false
false true false review (hybrid) false true false
false false true review (hybrid) true false false

So a config with read_only: false and both require_approval_for_*: true (the Aar default, and what aar init writes) opens in Review with all three options unchecked.

What the editor writes back

When the editor calls session/set_mode or session/set_config_option, Aar applies the change per-session and in-memory only — it never touches the loaded AgentConfig on disk or the global self._config:

Editor action Writes
set_mode("auto") require_approval_for_writes=false, require_approval_for_execute=false, read_only=false — atomic
set_mode("review") require_approval_for_writes=true, require_approval_for_execute=true, read_only=false — atomic
set_mode("read-only") require_approval_for_writes=true, require_approval_for_execute=true, read_only=true — atomic
set_config_option("auto_approve_writes", v) require_approval_for_writes = not v — single flag
set_config_option("auto_approve_execute", v) require_approval_for_execute = not v — single flag
set_config_option("read_only", v) read_only = v — single flag
set_config_option("reasoning_effort", v) Sets Ollama Qwen3.8 extra.reasoning_effort to xhigh, medium, or low for the session

set_mode is coarse (rewrites all three flags together); set_config_option is fine-grained (one flag at a time). After set_mode("auto") followed by set_config_option("read_only", true) you end up in a hybrid state — _build_mode_state re-reads the flags on the next load and would then report read-only because read_only wins the precedence check.

Both call paths:

  1. Read the active config from self._session_configs.get(session_id, self._config).
  2. Produce a new immutable SafetyConfig via model_copy(update=...).
  3. Write the result back into self._session_configs[session_id].
  4. Emit a current_mode_update (for set_mode) or config_option_update (for set_config_option) notification so any other panel in the editor stays in sync.

The new config takes effect on the next tool call — _make_aar_agent reads the per-session SafetyConfig every time a prompt starts, so the PermissionManager picks up the change immediately.

Scope and persistence

Aspect Behaviour
Config fields affected by editor toggles Only safety.read_only, safety.require_approval_for_writes, safety.require_approval_for_execute
Fields never touched provider, tools.*, denied_paths, allowed_paths, sandbox.*, guardrails, max_steps, token_budget, cost_limit, …
Storage AarAcpAgent._session_configs[session_id] (in-memory dict)
Lifetime Wiped on session/close or agent shutdown
Persistence Not written to ~/.aar/config.json; a fresh process re-reads the on-disk file
session/fork Copies the parent session's override into the new session id — child starts with the same mode
session/load on a previously toggled session Re-reads config.json defaults; editor-side changes from a previous process run are lost

In short: the mode picker and toggles are a runtime UI over the narrow subset of SafetyConfig that governs approvals, scoped to the current editor session. Everything else in config.json — your Ollama model, sandbox profile, deny-lists, budgets — stays authoritative and untouched.

ACP terminal tool

When the editor advertises ClientCapabilities.terminal = true during initialize, Aar registers an acp_terminal built-in tool. The LLM can call it exactly like bash, but instead of launching a local subprocess Aar drives the editor's terminal pane via the ACP terminal/* method family:

terminal/create   → terminal/wait_for_exit   → terminal/output   → terminal/release

Benefits:

  • Commands run in the editor's own PTY — users see output in the familiar terminal pane
  • cwd and env can be set per invocation; output is captured and returned to the agent
  • A timeout argument (default 60s) caps the wait; timeouts trigger terminal/kill + terminal/release so the editor never leaks PTYs

If the client does not advertise terminal support, acp_terminal is not registered — the agent falls back to the local bash tool (which still honors Aar's sandbox and deny-list). This avoids issuing terminal/* calls to a peer that would reject them.


5. Programmatic embedding

Embed the ACP HTTP app inside any ASGI framework (FastAPI, Starlette, etc.):

from agent.transports.acp import create_acp_asgi_app

app = create_acp_asgi_app(
    config=my_config,        # AgentConfig — optional, loads ~/.aar/config.json by default
    agent_name="aar",
    agent_description="My embedded Aar agent",
)

# Mount under uvicorn, FastAPI, or any ASGI server.

For stdio use:

import asyncio
from agent.transports.acp import run_acp_stdio

asyncio.run(run_acp_stdio(config=my_config, agent_name="aar"))

6. Dependencies

ACP support requires the agent-client-protocol package:

pip install "aar-agent[acp]"
# or
pip install agent-client-protocol

The package is listed as an optional dependency so the rest of Aar works without it. Only aar acp (stdio) and create_acp_asgi_app need it at runtime.

Supported SDK versions

Aar declares agent-client-protocol>=0.10.0 and is tested against both ends of that range (0.10.0 and 0.12.1). Every version so far speaks wire protocol version 1, so the SDK releases are additive within one protocol — an Aar agent built on an older SDK still interoperates with a newer client, it just doesn't advertise the newer capabilities.

One behavioural note for 0.12+: the unstable session/set_model method was removed (SetSessionModelResponse, SetSessionModelRequest, ModelInfo and SessionModelState are gone from acp.schema, and set_session_model is no longer on the acp.Agent protocol). AarAcpAgent.set_session_model keeps working on older SDKs and on direct programmatic calls, and returns None where the response type no longer exists. No 0.12+ client can route to it, so model switching over ACP is currently unavailable there. The replacement types (SetProviderRequest / SetProviderResponse, plus an AgentCapabilities.providers field) exist in 0.12.1 but are not yet wired to a session/* method by the SDK, so there is nothing stable to migrate to yet.

Capabilities added in 0.11 / 0.12 that Aar does not implement yet

These are opportunities, not gaps against the protocol version we target:

Capability What it enables What adopting it needs
Elicitation (ClientCapabilities.elicitation, Client.create_elicitation) The agent asks the editor for structured input — a form or a URL prompt — instead of guessing or failing Call create_elicitation(message, mode, ...) behind a capability check; decide where in the loop a structured ask beats a plain question
Plan updates (ClientCapabilities.plan, PlanUpdate / PlanItems / PlanMarkdown) A live plan/todo panel in the editor, updated as the agent works Aar has no plan/todo model in the loop today; that would come first
Runtime MCP attach (ConnectMcpRequest / DisconnectMcpRequest) Add or remove MCP servers mid-session instead of only at session/new Extend the existing per-session MCP bridge to add/remove servers while a session is live
Session delete (session/delete, SessionDeleteCapabilities) Client-driven deletion of a stored session Map to SessionStore deletion

Raising the floor to >=0.12.1 is only worthwhile once one of these is actually implemented — until then it would drop 0.10/0.11 users for no gain.


7. Module layout

agent/transports/acp/ is a package split by transport:

File Purpose
__init__.py Re-exports the full public API (AarAcpAgent, run_acp_stdio, AcpTransport, create_acp_asgi_app, data models) so existing imports keep working
common.py Helpers shared by both transports: config loading, tool-kind mapping, prompt-block extraction, stop-reason mapping, MCP config translation, provider inference, session mode/config builders
stdio.py AarAcpAgent + run_acp_stdio — SDK-backed stdio transport (Zed, editors)
http.py AcpTransport, create_acp_asgi_app, and the Pydantic run/event models — HTTP + SSE transport

The sibling agent/transports/acp_permissions.py provides make_acp_approval_callback, which both transports use when wiring Aar's PermissionManager to ACP's request_permission flow.

The acp_terminal built-in tool lives at agent/tools/builtin/acp_terminal.py and is registered by the stdio transport at session-setup time when the client advertises terminal support.

Concurrency model (stdio)

AarAcpAgent enforces at most one in-flight prompt per session:

  • A per-session asyncio.Lock guards lifecycle mutations (create/close/prompt-start/prompt-end)
  • A second prompt arriving while the first is still running is rejected with RuntimeError
  • close_session cancels the in-flight prompt (if any) and awaits it before tearing down
  • Fire-and-forget tasks (e.g. session_update delivery) are tracked in a strong-reference set so they cannot be silently garbage-collected mid-await; exceptions are logged
  • AarAcpAgent.shutdown() drains outstanding background tasks and cancels unfinished prompts