Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1b54771
chore(acp): establish spec conformance tracking foundation
yordis Jul 7, 2026
ee6e7ff
feat(acp-nats): stop dropping additionalDirectories and unstable capa…
yordis Jul 7, 2026
fcd99d6
feat(acp-nats)!: migrate to agent-client-protocol 1.2 with bridge-own…
yordis Jul 7, 2026
0db8187
feat(acp-nats-agent)!: dispatch runner traffic through the bridge-own…
yordis Jul 7, 2026
ead731c
feat(acp)!: adapt byte-stream boundaries to SDK 1.2 builder connections
yordis Jul 8, 2026
0548ca5
feat(acp): route session/delete and honor request cancellation end to…
yordis Jul 8, 2026
fd9d631
feat(acp): adopt elicitation, providers routing, and MCP-over-ACP pay…
yordis Jul 8, 2026
8edf750
chore(acp): drop the working plan file
yordis Jul 8, 2026
445b725
docs(acp): point plan references at durable artifacts
yordis Jul 8, 2026
01f70c0
refactor(acp-nats): name the boundary handlers
yordis Jul 8, 2026
aa3f3d0
refactor(ci): extract the ACP freshness check into a mise task
yordis Jul 8, 2026
72c2e9d
Merge branch 'main' into yordis/check-acp-protocol-version
yordis Jul 8, 2026
ab4c4fe
refactor(ci): move drift issue filing into the acp-freshness mise task
yordis Jul 8, 2026
6d92479
feat(dylint): forbid function-local macro_rules and fix the boundary …
yordis Jul 8, 2026
f0cfb55
test(acp): satisfy the coverage gate and typed-error lint
yordis Jul 8, 2026
7cfd09d
test(acp-nats): finish covering the new boundary and routing surface
yordis Jul 8, 2026
831020b
style(acp-nats): hoist test imports to module level
yordis Jul 8, 2026
0f8c435
refactor(acp-nats): address review findings on elicitation errors and…
yordis Jul 8, 2026
091a78a
fix(acp-nats): ignore zero-length reads in the EOF signal reader
yordis Jul 8, 2026
22c5976
fix(acp): abort connection tasks when the transport closes
yordis Jul 8, 2026
3616174
fix(ci): box the a2a dispatch error frame and reach the mcp fallthrou…
yordis Jul 8, 2026
672530d
test(acp-nats): make boundary fallthrough coverage deterministic
yordis Jul 8, 2026
270f941
test(acp-nats): hit the request catch-all with a decodable enum variant
yordis Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .config/mise/tasks/acp-freshness
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#MISE description="Compare the pinned agent-client-protocol SDK and bundled schema against crates.io latest; set ACP_FRESHNESS_FILE_ISSUE=true to file a drift issue"
set -euo pipefail

root="$(cd "$(dirname "$0")/../../.." && pwd)"

crates_io() {
curl -sf --retry 3 -A "trogonai-acp-freshness" "https://crates.io/api/v1/crates/$1" |
jq -r '.crate.max_stable_version // .crate.max_version'
}

pinned="$(grep -oE 'agent-client-protocol = \{ version = "=?[0-9]+\.[0-9]+\.[0-9]+"' "$root/rsworkspace/Cargo.toml" |
grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
if [[ -z "$pinned" ]]; then
echo "Could not find pinned agent-client-protocol version in rsworkspace/Cargo.toml" >&2
exit 1
fi

bundled_schema="$(grep -A2 'name = "agent-client-protocol-schema"' "$root/rsworkspace/Cargo.lock" |
grep -oE 'version = "[0-9]+\.[0-9]+\.[0-9]+"' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"

latest_sdk="$(crates_io agent-client-protocol)"
latest_schema="$(crates_io agent-client-protocol-schema)"

drift=false
if [[ "$pinned" != "$latest_sdk" || "${bundled_schema:-unknown}" != "$latest_schema" ]]; then
drift=true
fi

echo "ACP freshness:"
echo " SDK pinned=$pinned latest=$latest_sdk"
echo " schema bundled=${bundled_schema:-unknown} latest=$latest_schema"
echo " drift=$drift"

if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
{
echo "pinned=$pinned"
echo "latest_sdk=$latest_sdk"
echo "bundled_schema=${bundled_schema:-unknown}"
echo "latest_schema=$latest_schema"
echo "drift=$drift"
} >>"$GITHUB_OUTPUT"
fi

if [[ "$drift" != "true" || "${ACP_FRESHNESS_FILE_ISSUE:-}" != "true" ]]; then
exit 0
fi

title="ACP drift: agent-client-protocol $pinned is behind $latest_sdk"
body_file="$(mktemp)"
trap 'rm -f "$body_file"' EXIT
cat >"$body_file" <<EOF
The pinned \`agent-client-protocol\` version has fallen behind crates.io.

| | pinned | latest |
| --- | --- | --- |
| SDK (\`agent-client-protocol\`) | $pinned | $latest_sdk |
| Schema (\`agent-client-protocol-schema\`) | ${bundled_schema:-unknown} | $latest_schema |

A version bump is never just a version change. Per the upgrade ritual in \`docs/architecture/acp-conformance.md\`, the bump PR must:

- [ ] Diff the schema changelog between the pinned and latest versions: https://github.com/agentclientprotocol/agent-client-protocol/blob/main/CHANGELOG.md
- [ ] For each added or stabilized method: add subject mapping in \`acp-nats/src/nats/parsing.rs\`, a handler, and tests, or a conformance matrix row with opt-out rationale
- [ ] For each added field or \`session/update\` variant: add a round-trip test through the bridge (typed re-encode silently drops unmapped fields)
- [ ] For each new unstable flag: enable it per the opt-in policy and wire it
- [ ] Update \`docs/architecture/acp-conformance.md\` (matrix and spec position) in the same PR

This issue is filed automatically by \`.github/workflows/acp-freshness.yml\` and updated on re-runs.
EOF
Comment thread
coderabbitai[bot] marked this conversation as resolved.

existing="$(gh issue list --state open --search 'in:title "ACP drift:"' --json number --jq '.[0].number // empty')"
if [[ -n "$existing" ]]; then
gh issue edit "$existing" --title "$title" --body-file "$body_file"
echo "Updated issue #$existing"
else
gh issue create --title "$title" --body-file "$body_file" --label dependencies
echo "Created new drift issue"
fi
23 changes: 23 additions & 0 deletions .github/workflows/acp-freshness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: ACP Freshness

on:
schedule:
# Weekly, Monday 09:00 UTC
- cron: '0 9 * * 1'
workflow_dispatch:

permissions:
contents: read
issues: write

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Check ACP freshness and file a drift issue if behind
env:
GH_TOKEN: ${{ github.token }}
ACP_FRESHNESS_FILE_ISSUE: 'true'
run: ./.config/mise/tasks/acp-freshness
5 changes: 4 additions & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export default async () => {
},
{
text: "Architecture",
items: [{ text: "Event Metadata", link: "/architecture/event-metadata" }],
items: [
{ text: "ACP Conformance", link: "/architecture/acp-conformance" },
{ text: "Event Metadata", link: "/architecture/event-metadata" },
],
},
{
text: "Architecture Decision Records",
Expand Down
76 changes: 76 additions & 0 deletions docs/adr/0020-acp-sdk-1x-boundary-and-bridge-traits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
number: "0020"
slug: acp-sdk-1x-boundary-and-bridge-traits
status: accepted
date: 2026-07-07
---

# ADR 0020: ACP SDK 1.x Boundary and Bridge-Owned Callback Traits

## Context

The `agent-client-protocol` Rust SDK was redesigned in its 0.11.0 release and
stabilized as 1.x. Two changes force a decision here:

1. The SDK's `Agent` and `Client` traits no longer exist. They are role marker
structs now; message handling is registered through builder callbacks
(`Agent.builder().on_receive_request(...)`) and outbound calls go through
`ConnectionTo<Role>::send_request(...)`. Our crates used those traits as the
internal contract everywhere: runners implement `Agent`, the server-side
`Bridge` implements `Agent`, and `NatsClientProxy` implements `Client`
([ADR 0004](./0004-protocol-and-transport-layering.md) describes this
layering).
2. The SDK now ships official transports (`ByteStreams`, and HTTP/WebSocket in
`agent-client-protocol-http`). Our ACP-over-NATS transport is hand-rolled,
which raises the question of whether the official transport abstraction
should replace parts of it.

## Decision

### 1. Keep the hand-rolled NATS transport

The SDK transports model point-to-point byte streams between exactly two
peers. The NATS leg is not that: it is subject-routed (per-session subjects,
global subjects, wildcard subscriptions), durable where required (JetStream
COMMANDS stream with keepalive acks), and multi-peer. Flattening it into a
`ByteStreams` pair would discard the routing model that ADR 0003 and ADR 0004
establish. The SDK builder connections are used only at true byte-stream
boundaries: the WebSocket duplex in `acp-nats-server` and stdio in
`acp-nats-stdio`.

### 2. Bridge-owned callback traits replace the removed SDK traits

`acp-nats` defines its own `AgentHandler` and `ClientHandler` traits,
mirroring the method surface the bridge routes (the SDK's `schema::v1::*`
request/response types remain the argument and return types, so wire
compatibility is unchanged). The names avoid colliding with the 1.x SDK's own
`AcpAgent` subprocess helper. Runners implement `AgentHandler`; the
server-side `Bridge` implements `AgentHandler` by forwarding over NATS;
`NatsClientProxy` implements `ClientHandler`. This was
already the intended shape in ADR 0004 (an "ACP agent SDK" exposing agent
callback traits); the SDK redesign makes it mandatory rather than optional.

### 3. SDK builder callbacks adapt boundaries to the bridge traits

At each byte-stream boundary, a thin adapter registers one `on_receive_*`
callback per routed method and delegates to the `AgentHandler`/`ClientHandler`
implementation. Outbound calls from the bridge to the peer go through the
connection handle (`ConnectionTo<...>`). Adapters contain no logic beyond
delegation, per the zero-cost passthrough rule in `rsworkspace/crates/AGENTS.md`.

The adapters are shared by `acp-nats-server` (WebSocket and HTTP duplex) and
`acp-nats-stdio`, so they live in one place: the `boundary` module of
`acp-nats`. That module is the single SDK-connection-aware part of the crate;
the NATS routing core remains free of connection machinery.

## Consequences

- The bridge's method surface is defined in one place (the bridge traits), and
the conformance matrix (`docs/architecture/acp-conformance.md`) tracks it.
- New spec methods require touching trait, adapter, and subject mapping. The
upgrade ritual in the conformance doc makes that explicit instead of
accidental.
- The SDK's own request cancellation, session helpers, and future transport
work apply at the boundaries without constraining the NATS leg.
- We keep full control of JetStream durability semantics, backpressure, and
keepalives, which the SDK transport abstraction does not model.
58 changes: 58 additions & 0 deletions docs/adr/0021-typed-decode-over-passthrough-forwarding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
number: "0021"
slug: typed-decode-over-passthrough-forwarding
status: accepted
date: 2026-07-07
---

# ADR 0021: Typed Decode over Passthrough Forwarding

## Context

The ACP-over-NATS bridge decodes every message into typed SDK structs and
re-serializes them (`acp-nats/src/wire.rs`). During the schema 0.11.4 to 1.4.0
catch-up this bit us: fields the pinned SDK did not model were silently
stripped in transit, and unknown `session/update` variants failed decode and
were dropped. The catch-up effort (issue #474) asked whether the bridge should instead forward
payloads losslessly (raw `serde_json::Value` passthrough, typed validation
only where the bridge reads fields), so future spec additions degrade to
"forwarded" instead of "dropped".

## Decision

Keep typed decode. The evaluation concluded that passthrough trades a managed
maintenance cost for the loss of properties the bridge depends on:

1. **Validation at the boundary.** The bridge rejects malformed payloads with
`InvalidParams` before they reach runners or JetStream durable streams.
With passthrough, malformed frames propagate and fail deep inside
consumers, where the blast radius includes persisted garbage in the
COMMANDS stream.
2. **The bridge reads most of what it routes.** Session ids, cwd, mcp server
counts, prompt payloads for telemetry spans, response session ids for
session-ready scheduling: the majority of routed messages are already
inspected, so "validate only where we read" converges back to typed decode
for most of the surface anyway.
3. **The failure mode is now managed, not silent.** The original harm was
silent drift. That is addressed by the tracking foundation instead of by
loosening the wire layer: decode failures emit a `session_update` /
`decode_failure` error metric, the weekly freshness workflow files an issue
the moment upstream moves, and the upgrade ritual in
`docs/architecture/acp-conformance.md` makes round-trip tests for new
fields a mandatory part of every bump.
4. **Schema-level leniency reduces the sharp edges.** Since schema 1.x,
unknown optional fields tolerate errors (`DefaultOnError` annotations), and
enum extension guidance is landing upstream for v2. The cost of typed
decode shrinks with each upstream release rather than growing.

## Consequences

- A peer sending a field newer than our pin still loses that field until the
next bump. The freshness workflow bounds that window to roughly a week of
detection latency plus the bump turnaround, and the conformance matrix makes
the gap visible instead of silent.
- Re-evaluate if either condition changes: upstream begins shipping breaking
schema changes faster than the bump cadence can absorb, or the bridge stops
reading payloads it routes (for example a pure relay deployment mode). In
that case a passthrough mode scoped to specific methods is the fallback
design.
2 changes: 2 additions & 0 deletions docs/adr/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ future implementation work.
- [ADR 0017: AAuth Agent Authentication over a Trogon NATS PoP Binding](./0017-aauth-agent-authentication.md)
- [ADR 0018: ConnectRPC Gateway for Browser Product Surfaces](./0018-connectrpc-gateway-for-browser-product-surfaces.md)
- [ADR 0019: Console Webapp Stack](./0019-console-webapp-stack.md)
- [ADR 0020: ACP SDK 1.x Boundary and Bridge-Owned Callback Traits](./0020-acp-sdk-1x-boundary-and-bridge-traits.md)
- [ADR 0021: Typed Decode over Passthrough Forwarding](./0021-typed-decode-over-passthrough-forwarding.md)
99 changes: 99 additions & 0 deletions docs/architecture/acp-conformance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# ACP Conformance

This document is the single source of truth for where this repository stands relative to the Agent Client Protocol (ACP) specification. Update it in the same PR as any `agent-client-protocol` version bump or any change to the bridged method surface.

## Spec position

| Fact | Value |
| --- | --- |
| Wire protocol | v1 |
| Pinned Rust SDK | `agent-client-protocol` 1.2.0 (`rsworkspace/Cargo.toml`) |
| Bundled schema (effective spec level) | 1.4.0 (plus direct `agent-client-protocol-schema` dependency for schema-only unstable flags) |
| Latest upstream SDK at last review | 1.2.0 (2026-07-07) |
| Latest upstream schema at last review | 1.4.0 (2026-07-06) |
| Last reviewed | 2026-07-08 |

Upstream repositories: [spec/schema](https://github.com/agentclientprotocol/agent-client-protocol), [Rust SDK](https://github.com/agentclientprotocol/rust-sdk).

## Policy

Opt in to unstable spec features ahead of stabilization. The default for every unstable feature is to enable the flag, wire the routing, and test it. Opting out is the exception and requires a rationale in the matrix below.

## Why this matters here

The bridge decodes every message into typed SDK structs and re-serializes them (`acp-nats/src/wire.rs`). Fields the pinned SDK does not model are silently stripped in transit, and unknown `session/update` variants fail decode. Spec lag means silent data loss, not graceful passthrough, so this matrix must stay accurate.

## Conformance matrix

Status values: `implemented` (routed, typed, tested), `capabilities implemented` (capability payloads round-trip, but the methods behind them are not routed), `unwired` (SDK flag enabled but no routing), `dropped` (peers may send it, the bridge strips or rejects it), `unrepresentable` (pinned SDK cannot express it), `not supported` (deliberate opt-out with rationale), `watch-only` (tracked for adoption, deliberately not implemented while upstream still churns).

### Agent-side methods (client to agent)

| Spec surface | Spec stage (schema 1.4.0) | Our status | Notes |
| --- | --- | --- | --- |
| `initialize` | stable | implemented | |
| `authenticate` | stable | implemented | `unstable_auth_methods` shapes enabled |
| `logout` | stable (0.13.3) | implemented | |
| `session/new` | stable | implemented | includes `additionalDirectories` |
| `session/load` | stable | implemented | includes `additionalDirectories` |
| `session/list` | stable | implemented | |
| `providers/list` | unstable (0.11.7) | unrepresentable | not a routing gap: bridge-owned `AgentHandler::list_providers` and NATS subject routing (`providers.list`) are implemented and tested, but `agent-client-protocol` 1.2.0 cannot express the request at the byte-stream boundary (no `unstable_llm_providers` feature, provider types omitted from its `JsonRpcRequest` registrations); blocked on upstream SDK support |
| `providers/set` | unstable (0.11.7) | unrepresentable | see `providers/list`; `AgentHandler::set_provider` and NATS subject routing (`providers.set`) implemented and tested |
| `providers/disable` | unstable (0.11.7) | unrepresentable | see `providers/list`; `AgentHandler::disable_provider` and NATS subject routing (`providers.disable`) implemented and tested |
| `session/prompt` | stable | implemented | |
| `session/cancel` (notification) | stable | implemented | |
| `session/set_mode` | stable | implemented | |
| `session/set_config_option` | stable | implemented | 1.4.0 shape, boolean and `model_config` round-trip tested |
| `session/set_model` | **removed upstream** (0.13.5) | removed | deleted with the SDK migration; model switching goes through `model_config` config options |
| `session/fork` | unstable | implemented | |
| `session/resume` | stable (0.12.2) | implemented | |
| `session/close` | stable (0.12.2) | implemented | |
| `session/delete` | stable (0.13.6) | implemented | routed end to end with tests, span `acp.session.delete` |
| JSON-RPC request cancellation | stable (1.2.0) | implemented | boundary honors `$/cancel_request`: bridge-side work is dropped and the request answers with `request_cancelled` (tested); prompt-turn cancellation on the runner side remains `session/cancel` per spec |
| `ext/*` (extension methods) | stable | implemented | passthrough |

### Client-side methods (agent to client)

| Spec surface | Spec stage | Our status | Notes |
| --- | --- | --- | --- |
| `fs/read_text_file` | stable | implemented | |
| `fs/write_text_file` | stable | implemented | |
| `session/request_permission` | stable | implemented | |
| `session/update` | stable | implemented | unknown variants fail decode and are dropped with a `session_update`/`decode_failure` error metric |
| `terminal/create` | stable | implemented | |
| `terminal/output` | stable | implemented | |
| `terminal/release` | stable | implemented | |
| `terminal/wait_for_exit` | stable | implemented | |
| `terminal/kill` | stable | implemented | |
| `elicitation/create` | unstable | implemented | `unstable_elicitation` SDK flag; routed both through the bridge-owned `ClientHandler::elicitation_create` and the SDK byte-stream boundary (`AgentRequest::CreateElicitationRequest`); `ElicitationScope::Request` (pre-session, no session id) is not routable since all NATS client subjects and `NatsClientProxy` construction are session-scoped |
| `elicitation/complete` (notification) | unstable | implemented | `unstable_elicitation` SDK flag; routed as a `ClientHandler::elicitation_complete` notification |
| `ext/*` | stable | implemented | passthrough, plus bullard-specific `ext/session/prompt_response` |

### Payload-level capabilities

| Spec surface | Spec stage | Our status | Notes |
| --- | --- | --- | --- |
| `additionalDirectories` (session/new, session/load) | stable (0.13.5) | implemented | round-trip tested through the bridge |
| Message IDs on chunks | stable (0.13.6) | implemented | 1.4.0 shape, round-trip tested |
| Session usage updates | stable (0.13.6) | implemented | 1.4.0 shape, round-trip tested |
| Session config options | stable | implemented | 1.4.0 shape, `ConfigOptionUpdate` round-trip tested |
| Boolean config options | stable (1.3.0) | implemented | stabilized shape, round-trip tested |
| `model_config` option category | stable (1.1.0) | implemented | round-trip tested |
| NES (next edit suggestions) | unstable | capabilities implemented | capability payloads round-trip via schema-level flag; NES document sync methods are not routed (no runner demand yet, revisit with Phase 4 adoption cadence) |
| Plan operations | unstable (0.13.4) | implemented | `PlanUpdate`/`PlanRemoved` round-trip tested via schema-level flag |
| Providers | unstable (0.11.7) | unrepresentable | see `providers/list`/`providers/set`/`providers/disable` rows above |
| MCP-over-ACP message types | unstable (0.13.0) | implemented | `McpServer::Acp` and `McpCapabilities.acp` payload round-trip tested via schema-level and `unstable_mcp_over_acp` SDK flag; the `mcp/connect`, `mcp/message`, `mcp/disconnect` RPC methods are not routed (no runner demand yet, revisit with Phase 4 adoption cadence) |
| Elicitation enum option descriptions | unstable (1.4.0) | implemented | `EnumOption` descriptions on `StringPropertySchema.one_of` round-trip tested |
| Protocol v2 | unstable, heavy churn | watch-only | adopt once upstream marks it preview; the freshness workflow surfaces every release it churns in |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Upgrade ritual

A version bump of `agent-client-protocol` (or the schema it bundles) is never just a version change. Every bump PR must:

1. Diff the schema changelog between the old and new pinned versions ([changelog](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/CHANGELOG.md)).
2. For each added or stabilized method: add subject mapping in `acp-nats/src/nats/parsing.rs`, a handler, and tests, or add a matrix row with an opt-out rationale.
3. For each added field or `session/update` variant: add a round-trip test through the bridge. Typed re-encode means unmapped fields are silently dropped, so a green compile proves nothing about coverage.
4. For each new unstable flag: enable it per the opt-in policy and wire it.
5. Update this document (matrix and spec position table) in the same PR.

The scheduled freshness workflow (`.github/workflows/acp-freshness.yml`) embeds this checklist in the issue it files when drift is detected.
Loading
Loading