018: ACP Transparent Agent Server via JSON-RPC 2.0 stdio Subprocess
Status: Accepted Date: 2026-05-30 Issue: F102 Supersedes: N/A Superseded by: ADR 020 (implementation detail, not decision)
Context
AWF orchestrates AI agents through YAML workflows. Editors and IDE extensions (Zed, acp.nvim) that already drive those same agents want to use AWF as a transparent backend: the editor sends prompts, AWF dispatches them through configured workflows, and structured events flow back in real time — without the user switching to a terminal.
F102 must solve four problems that together constitute an external-facing API contract:
- Session lifecycle: Editors need to open, prompt, and cancel named sessions that persist across multiple turns of a conversation, with AWF routing each prompt through an appropriate workflow.
- Event streaming: Step lifecycle events (started, completed, failed), agent message chunks, tool calls, and thought chunks must reach the editor as structured notifications as they occur — not as a single bulk response at completion.
- Approval gates: When a workflow step requires user confirmation (e.g., shell command execution), AWF must call back into the editor to request permission before proceeding.
- MCP server overlay: Editors may provide their own MCP server configuration that must be merged with the workflow’s per-step MCP proxy configuration, with editor entries taking precedence on key collision.
Two protocol-level questions are load-bearing beyond this feature:
- Which protocol governs the editor–AWF session contract? The answer locks in an external-facing API that editor plugin authors will compile against.
- How is bidirectionality handled? AWF must originate requests to the editor (for approval gates), not only respond to editor-originated requests. This is a structural departure from MCP’s purely server-driven request/response model.
Candidates
Protocol
| Option | Pros | Cons |
|---|---|---|
| ACP (Agent Control Protocol) over JSON-RPC 2.0 | Adopted by Zed and acp.nvim; standardised session semantics (session/new, session/prompt, session/cancel, session/request_permission); JSON-RPC 2.0 base is well-understood; notifications are first-class (no response required) | Spec is younger than MCP; subset selection required |
| MCP with custom session methods | Reuses existing pkg/mcpserver/ infrastructure verbatim | MCP has no session concept; grafting one on requires deviating from the MCP spec and confuses editors that treat MCP strictly |
| Custom JSON-RPC over stdio | Full schema control | No editor support out-of-box; every editor integration requires a bespoke adapter; no ecosystem tooling or shared test harnesses |
| gRPC bidirectional streaming | Native bidirectionality; strong typing via protobuf | No editor CLI support; requires protobuf toolchain for editor plugin authors; conflicts with go-plugin usage in ADR-015 |
Bidirectionality Mechanism
| Option | Description | Pros | Cons |
|---|---|---|---|
Server.CallClient with sync.Map response channels | Server generates a unique request ID, marshals an outbound JSON-RPC request, parks the goroutine on a buffered channel stored in a sync.Map keyed by ID, disambiguates inbound frames by probing for "method":"" + matching ID | Minimal surface (one primitive); Go-idiomatic goroutine+channel; unambiguous frame routing; tested by -race | Caller must hold a context.Context with a deadline to prevent permanent parking |
| Separate outbound connection (second stdio pair) | Editor and AWF open two stdio channels: one for editor-originated requests, one for AWF-originated requests | True duplex isolation | Requires editor support for two-channel mode; doubles subprocess stdio plumbing; no ACP spec precedent |
| Polling via notification acknowledgements | AWF sends a session/request_permission notification; editor sends a session/permission_response request at its convenience | Zero in-process waiting | Race between cancel and delayed response; does not satisfy the synchronous approval-gate semantics required by FR-009 |
Process Topology
| Option | Description | Pros | Cons |
|---|---|---|---|
A: Per-session subprocess awf acp-serve | AWF exposes a hidden awf acp-serve Cobra subcommand; editor spawns one process per editing session; protocol served over stdin/stdout | Crash-isolated per session; proven pattern from ADR-017 (awf mcp-serve); signal-aware shutdown via signal.NotifyContext; hidden subcommand has no stability guarantees independent of binary | One extra AWF process per editor session (~10 MB RSS) |
| B: Shared server multiplexed with HTTP serve | ACP added as a second protocol surface inside awf serve alongside HTTP+SSE | Fewer processes | Couples HTTP serve evolution to ACP evolution; multiplexer complexity; entangles SSE subscriber model with session model |
C: External sidecar binary awf-acp | Separate binary proxies to awf run subprocesses | Zero changes to main AWF binary | Duplicates provider/execution machinery; bypasses existing OTel hooks; version drift risk |
Decision
Protocol: Adopt ACP over JSON-RPC 2.0 (stdio transport). Implement the required subset for v1: initialize, initialized, session/new, session/prompt, session/cancel, session/request_permission (server-originated), and shutdown. Prompts, resources, fs/ tools, and terminal/ methods are out of scope and deferred.
Process topology: Option A — per-session subprocess awf acp-serve. One awf acp-serve process is spawned by the editor per session. The subprocess serves ACP over stdin/stdout. Lifecycle is signal-aware: signal.NotifyContext with SIGTERM→SIGKILL grace matching newMCPServeCommand.
Bidirectionality: Server.CallClient(ctx, method, params) — a single outbound primitive on acpserver.Server. It serialises the request through a sync.Mutex-protected json.Encoder (same encoder used for responses, preventing interleave), parks the goroutine on a buffered channel stored in a sync.Map keyed by a server-generated integer ID, and returns when the matching inbound response is dispatched by the serve loop’s probe path. The probe unmarshals only the method and id fields; frames with method == "" and a matching pending ID are treated as responses; all others as requests.
Public package: ACP engine lives in pkg/acpserver/ (not internal/), with zero internal/ imports enforced by an AST-based architecture test (architecture_test.go). This mirrors the pkg/mcpserver/ invariant from ADR-017 and gives future external consumers a stable embeddable ACP engine.
MCP merge-precedence rule (FR-011): When an editor provides session/new.mcpServers, those entries are merged with the workflow’s per-step MCP proxy configuration inside ACPSessionService.handleSessionNew. On key collision, the editor-provided entry wins. The merge result is stored on the ACPSession and overlaid at step-start time. This rule is implemented in the application layer; ExecutionService is not modified.
Key rules established:
pkg/acpserverdepends on Go stdlib only — nointernal/imports, no framework deps. Verified at every CI run byarchitecture_test.go.ACPClientport (domain) has exactly one method for v1:RequestPermission(ctx, toolCall, options) (bool, error).fs/andterminal/methods are deferred; pre-declaring stubs would leak out-of-scope features into the domain.ACPRendereris instantiated per workflow step, not per session — tool-call ID deduplication (first occurrence →tool_call, subsequent →tool_call_update) must not bleed across steps.USER.ACP.*error codes extend the existing taxonomy at exit code 1:INVALID_PROMPT,UNSUPPORTED_BLOCK,PROMPT_IN_FLIGHT,UNKNOWN_SESSION,PROTOCOL_VERSION_UNSUPPORTED. No new exit-code category is introduced.awf acp-serveisHidden: true— not user-facing; no independent stability guarantees; registered inroot.goadjacent tonewMCPServeCommand.- ACP protocol version is a single integer constant in
pkg/acpserver/protocol.go. Mismatches surface asUSER.ACP.PROTOCOL_VERSION_UNSUPPORTEDwith a textual message rather than a silent failure. FanoutPublisherwraps the existingports.EventPublisher—ExecutionServiceis not modified to support N publishers. The fan-out wiring happens at the interfaces layer.
Consequences
What becomes easier:
- Editors (Zed, acp.nvim) can use AWF as a transparent workflow backend without shelling out to
awf runand parsing stdout. - Multi-turn conversational sessions are first-class:
ConversationManagerparking acrosssession/promptcycles is supported by theACPInputReaderchannel bridge. - Approval gates are synchronous from the workflow’s perspective:
ACPClient.RequestPermissionblocks the step until the editor responds or the session context is cancelled. - All step and workflow lifecycle events reach the editor as structured notifications via
WorkflowEventProjector— no polling, no log scraping. - Future fs/terminal methods can be added by extending the
ACPClientport and implementing a new infrastructure adapter, with no changes toACPSessionServiceor the engine. - External consumers can embed
pkg/acpserverto build custom ACP-enabled tooling; the stdlib-only invariant makes it a zero-overhead dependency.
What becomes harder:
- Adding new ACP methods (e.g.,
fs/read,terminal/exec) is a semver-visible change topkg/acpserverand requires a coordinated release with editor plugin updates. - Each
awf acp-serveprocess consumes ~10 MB RSS. Long-lived editor sessions that never close their ACP process will hold that memory until the editor exits or explicitly closes the session. - The
sync.Map-tracked response channels inServer.CallClientrequire callers to always pass acontext.Contextwith a deadline; an unbounded context would park the goroutine indefinitely if the editor never responds to asession/request_permission. - Two near-identical Cobra serve scaffolds (
mcp_serve.go+acp_serve.go) coexist; theServeInitializerDRY extraction is intentionally deferred until a third serve variant stabilises. - Windows support is deferred:
Setpgid+syscall.Kill(-pgid, ...)for process-group teardown is POSIX-only. ACP integration tests gate on//go:build integration && !windows.
Constitution Compliance
| Principle | Status | Justification |
|---|---|---|
| Hexagonal Architecture | Compliant | pkg/acpserver has zero internal/ imports (AST-enforced); domain gains only ports.ACPClient + error codes; application gets ACPSessionService; infrastructure adds internal/infrastructure/acp/; interface layer adds acp_serve.go; .go-arch-lint.yml extended with pkg-acpserver and infra-acp components |
| Go Idioms | Compliant | context.Context threads from RunE through Server.Serve and Server.CallClient; goroutine+buffered-channel+sync.Map for bidirectional dispatch; errors.Join for FanoutPublisher.Close; signal.NotifyContext for shutdown |
| Minimal Abstraction | Compliant | Single ACPClient port method for v1; FanoutPublisher is a 30-LOC wrapper (not a generic pub/sub framework); no ServeInitializer extracted yet (deferred per cleanup research) |
| Error Taxonomy | Compliant | Five new USER.ACP.* codes; no new exit-code category (all map to cli.ExitUser = 1 or cli.ExitExecution = 3 via existing switch in ErrorCode.ExitCode()) |
| Security First | Compliant | SecretMasker.MaskText applied to all agent_message_chunk, agent_thought_chunk, and tool_call args before emission; 10 MiB bufio.Scanner ceiling prevents OOM; signal.NotifyContext SIGTERM→SIGKILL prevents zombie processes |
| Test-Driven Development | Compliant | pkg/acpserver/architecture_test.go is the first test written (RED before production code); ≥85% coverage required on concurrency-heavy code; make test-race mandatory for pkg/acpserver/, internal/infrastructure/acp/, and integration package |
| Documentation Co-location | Compliant | pkg/acpserver/doc.go and internal/infrastructure/acp/doc.go each ≥100 lines per project rule; YAML schema documented in struct comments |