Skip to content

feat(acp): serve Kannaka's memory as an ACP agent (kannaka-acp) - #660

Merged
NickFlach merged 4 commits into
masterfrom
feat/kannaka-acp
Jul 29, 2026
Merged

feat(acp): serve Kannaka's memory as an ACP agent (kannaka-acp)#660
NickFlach merged 4 commits into
masterfrom
feat/kannaka-acp

Conversation

@NickFlach

Copy link
Copy Markdown
Owner

Adds kannaka-acp, a binary that serves Kannaka's HRM as an ACP agent over stdio — so Kannaka can join a Buzz workspace as a first-class agent member.

Why this shape

Buzz upstream shipped a "bring your own harness" seam (block/buzz#2773): a generic ACP runtime registered from a JSON definition. Speaking ACP therefore means Kannaka plugs into the Buzz desktop client with no fork and no upstream PR — the strategic reason to prefer it over carrying fork weight. This is Tier 1 of ADR-0052.

What's here

src/acp/ — protocol (NDJSON framing, not Content-Length), server, dispatch, prompt parsing, rendering, and a buzz CLI reply sink. Plus src/bin/kannaka_acp.rs and the harness definition.

Details worth review

  • Read-only is enforced twice. The HRM is single-writer, so HrmMemory::open sets KANNAKA_READONLY=1 and HrmStore::set_readonly(true) — neither alone closes the write path.
  • The HRM opens lazily. Loading a 47MB substrate takes ~16s; ACP clients read silence as death and time out at 10s. The handshake must never touch the substrate (handshake 15.9s → 2.5s).
  • Never resonate the raw prompt. The harness hands over a sectioned prompt ([Context], [Thread Context], [Agent Memory], [Event]). Querying the whole blob lets scaffolding dominate the resonance vector and echoes harness internals back to users, so we extract the Content: of the last event block.
  • The reply destination is parsed only from [Context]. The prompt carries untrusted participant text; scanning all of it for --reply-to would let anyone redirect the agent's replies.
  • buzz-acp never posts agent text — it only logs agent_message_chunk. The agent must send its own reply, and the path is the buzz CLI, not MCP. The sink is opt-in by probe, which is correct for the desktop gallery (it renders chunks itself, so posting would double the answer).

Verification

61 ACP tests; full lib suite 890 pass / 0 fail. Handshake verified against the real buzz-acp client — auth-methods{"methods": []}, models → agent info — which is the only thing that caught the two bugs above.

🤖 Generated with Claude Code

flaukowski and others added 4 commits July 27, 2026 01:35
Adds `kannaka-acp`, an Agent Client Protocol agent that answers prompts by
resonating them through the HRM. This lets Kannaka register as a Buzz harness
via the upstream "bring your own harness" seam (block/buzz#2773) — a JSON
definition in `<app_data>/custom_harnesses/`, so no fork of Buzz is required.

  Buzz Relay --WS--> buzz-acp --ACP/stdio--> kannaka-acp --> HRM (read-only)

Layout:
  src/acp/protocol.rs  JSON-RPC 2.0 / NDJSON framing
  src/acp/server.rs    method dispatch behind a `MemorySource` trait
  src/acp/mod.rs       transport loop + read-only HRM source
  src/bin/kannaka_acp.rs
  config/kannaka-acp-harness.json   harness definition for the BYOH gallery

Notes on the design:

- Read-only is enforced twice — `KANNAKA_READONLY=1` and
  `HrmStore::set_readonly(true)`. The HRM is single-writer and neither alone
  closes the write path. Mirrors `attention serve` and `swarm`.
- Transport is a blocking line loop, not async: this crate deliberately
  dropped tokio with the old MCP server, and ACP over stdio does not need it.
- Dispatch is pure (`Inbound` in, frames out) and the substrate sits behind a
  trait, so the whole protocol surface is testable with no HRM on disk.
- A failed recall is reported in-band with `stopReason: end_turn` rather than
  as a JSON-RPC error, because buzz-acp treats an error on `session/prompt` as
  an agent fault and recycles the process.
- stdout carries frames only; every diagnostic goes to stderr.

Known gap: buzz-acp only logs `agent_message_chunk` and never publishes it,
so replies do not yet reach a Buzz channel. That needs an MCP client calling
`send_message` on the server the harness injects into `session/new`. The
desktop BYOH gallery renders replies itself and works today.

33 new tests; full lib suite 883 passed / 0 failed. Verified end-to-end
against the live HRM, which was confirmed unmodified afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…limit

`src/acp/server.rs` landed at 599 lines, over this crate's file-size rule.
Two extractions, both of which also improve separation:

- `src/acp/render.rs` — answer formatting, split from protocol dispatch so
  wording and wire format can change independently. Adds boundary tests for
  the 1h/24h age transitions and content trimming.
- `tests/acp_dispatch.rs` — the dispatch tests, which drive only the public
  surface (decoded messages in, frames out), so they belong in `tests/` per
  the project layout rules.

Adds `Agent::memory()` so those tests can assert which queries actually
reached the substrate now that they sit outside the module.

No behavior change. All files now under 500 lines (server.rs 599 -> 268).
36 acp tests preserved (20 lib + 16 integration) and re-verified end-to-end
against the live HRM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the round trip. `buzz-acp` logs `agent_message_chunk` but never
publishes it — its only two `publish_event` call sites are presence and
observer telemetry — so an agent's answer never reached the channel. The
harness base prompt names the `buzz` CLI as the agent's interface, so that is
the path taken here (not MCP; `--mcp-command` is optional extra tooling and
defaults to empty):

    buzz messages send --channel <uuid> [--reply-to <event>] --content -

Credentials arrive by inheritance: buzz-acp spawns agents without
`env_clear()`, so BUZZ_RELAY_URL / BUZZ_PRIVATE_KEY are already present.

  src/acp/buzz_cli.rs  reply-target parsing + the CLI sink
  src/acp/prompt.rs    recovering the actual question from the prompt

Two bugs found while building this, both worth calling out:

- The harness renders the flag inside backticks — ``use `--reply-to <id>` `` —
  so splitting on whitespace dragged a trailing backtick into the event id and
  every reply silently lost its threading. Now takes the leading hex run.

- The recall query was the *entire* assembled prompt, `[Context]` scaffolding
  included. That both let boilerplate dominate the resonance vector (recall
  matching "Context/Channel/Event" instead of the question) and echoed harness
  internals back to real users. `session_prompt` now keeps two views: `full`
  for locating the reply destination, `query` for resonating. Only end-to-end
  testing surfaced this — a mock sink accepts any string.

Security: the reply destination is parsed ONLY from the harness-authored
`[Context]` block, never the wider prompt, which carries untrusted participant
text. Scanning all of it would let anyone redirect this agent's replies by
typing `--reply-to` into a message. Channel UUID and event id are both
format-validated, with tests asserting an injected anchor in `[Event]` is
ignored.

A missing `buzz` CLI is probed once at startup and degrades to streaming only
— correct for the desktop harness gallery, which renders replies itself and
would otherwise show the answer twice.

61 acp tests (40 lib + 21 integration). Verified end-to-end against the live
HRM with a stub CLI: correct argv, body over stdin, and a clean query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… `acp` arg

Handshaking `kannaka-acp` with the actual `buzz-acp` client (rather than
frames written by hand) failed twice. Both are compatibility bugs no amount of
mock-based testing would have surfaced.

1. `error: agent timed out (10s)`

   Opening the medium reads and reconstructs the whole tensor — 15.9s for a
   47 MB kannaka.hrm — and it happened before `initialize` could be answered.
   ACP clients read silence as a dead agent, and buzz-acp's helper subcommands
   allow 10s. The agent looked broken before it could say hello.

   `HrmMemory` now holds `Option<KannakaMemorySystem>` and opens on first
   recall, so the handshake never touches the substrate: 15.9s -> 2.5s, and a
   client that only probes capabilities never pays the load at all. The first
   prompt pays it (~17s, now logged with timing), well inside the 60s per-turn
   idle timeout. `KANNAKA_READONLY` is still asserted up front, before any code
   path can construct a store.

2. `unknown argument: acp` (exit 2)

   `buzz-acp --agent-args` defaults to `"acp"` because goose is spawned as
   `goose acp`. Its `normalize_agent_args` strips that token only for runtimes
   it recognizes, so an unrecognized command receives it verbatim — meaning the
   *default* invocation, the one everybody hits first, died immediately. A bare
   `acp` token is now accepted and ignored; we are always in ACP mode.

Verified against the real client: `buzz-acp auth-methods` returns
`{"methods": []}` and `buzz-acp models` reads back agentInfo
`kannaka-acp 0.12.0`. Re-verified end-to-end with a stub CLI that the lazy open
still yields a clean query and a posted reply. 61 acp tests unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NickFlach
NickFlach merged commit 2a5e675 into master Jul 29, 2026
2 checks passed
@NickFlach
NickFlach deleted the feat/kannaka-acp branch July 29, 2026 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants