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:

  1. 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.
  2. 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.
  3. 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.
  4. 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

OptionProsCons
ACP (Agent Control Protocol) over JSON-RPC 2.0Adopted 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 methodsReuses existing pkg/mcpserver/ infrastructure verbatimMCP has no session concept; grafting one on requires deviating from the MCP spec and confuses editors that treat MCP strictly
Custom JSON-RPC over stdioFull schema controlNo editor support out-of-box; every editor integration requires a bespoke adapter; no ecosystem tooling or shared test harnesses
gRPC bidirectional streamingNative bidirectionality; strong typing via protobufNo editor CLI support; requires protobuf toolchain for editor plugin authors; conflicts with go-plugin usage in ADR-015

Bidirectionality Mechanism

OptionDescriptionProsCons
Server.CallClient with sync.Map response channelsServer 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 IDMinimal surface (one primitive); Go-idiomatic goroutine+channel; unambiguous frame routing; tested by -raceCaller 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 requestsTrue duplex isolationRequires editor support for two-channel mode; doubles subprocess stdio plumbing; no ACP spec precedent
Polling via notification acknowledgementsAWF sends a session/request_permission notification; editor sends a session/permission_response request at its convenienceZero in-process waitingRace between cancel and delayed response; does not satisfy the synchronous approval-gate semantics required by FR-009

Process Topology

OptionDescriptionProsCons
A: Per-session subprocess awf acp-serveAWF exposes a hidden awf acp-serve Cobra subcommand; editor spawns one process per editing session; protocol served over stdin/stdoutCrash-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 binaryOne extra AWF process per editor session (~10 MB RSS)
B: Shared server multiplexed with HTTP serveACP added as a second protocol surface inside awf serve alongside HTTP+SSEFewer processesCouples HTTP serve evolution to ACP evolution; multiplexer complexity; entangles SSE subscriber model with session model
C: External sidecar binary awf-acpSeparate binary proxies to awf run subprocessesZero changes to main AWF binaryDuplicates 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/acpserver depends on Go stdlib only — no internal/ imports, no framework deps. Verified at every CI run by architecture_test.go.
  • ACPClient port (domain) has exactly one method for v1: RequestPermission(ctx, toolCall, options) (bool, error). fs/ and terminal/ methods are deferred; pre-declaring stubs would leak out-of-scope features into the domain.
  • ACPRenderer is 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-serve is Hidden: true — not user-facing; no independent stability guarantees; registered in root.go adjacent to newMCPServeCommand.
  • ACP protocol version is a single integer constant in pkg/acpserver/protocol.go. Mismatches surface as USER.ACP.PROTOCOL_VERSION_UNSUPPORTED with a textual message rather than a silent failure.
  • FanoutPublisher wraps the existing ports.EventPublisherExecutionService is 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 run and parsing stdout.
  • Multi-turn conversational sessions are first-class: ConversationManager parking across session/prompt cycles is supported by the ACPInputReader channel bridge.
  • Approval gates are synchronous from the workflow’s perspective: ACPClient.RequestPermission blocks 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 ACPClient port and implementing a new infrastructure adapter, with no changes to ACPSessionService or the engine.
  • External consumers can embed pkg/acpserver to 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 to pkg/acpserver and requires a coordinated release with editor plugin updates.
  • Each awf acp-serve process 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 in Server.CallClient require callers to always pass a context.Context with a deadline; an unbounded context would park the goroutine indefinitely if the editor never responds to a session/request_permission.
  • Two near-identical Cobra serve scaffolds (mcp_serve.go + acp_serve.go) coexist; the ServeInitializer DRY 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

PrincipleStatusJustification
Hexagonal ArchitectureCompliantpkg/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 IdiomsCompliantcontext.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 AbstractionCompliantSingle 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 TaxonomyCompliantFive 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 FirstCompliantSecretMasker.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 DevelopmentCompliantpkg/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-locationCompliantpkg/acpserver/doc.go and internal/infrastructure/acp/doc.go each ≥100 lines per project rule; YAML schema documented in struct comments