Read each ACP agent's vendor side channels in a profile-keyed dialect - #2216
Read each ACP agent's vendor side channels in a profile-keyed dialect#2216SawyerHood wants to merge 1 commit into
Conversation
The protocol has no sub-agent concept in version 1, so every delegation an
ACP agent reports is vendor-specific: Cursor sends a `cursor/task` request
that bb answered -32601, and grok runs sub-agents through a spawn_subagent
tool that reads as an ordinary tool call. Both agents put their richest tool
data beside the protocol, in _meta and in vendor request params.
A dialect is a small profile-keyed module that reads those channels. It can
name a tool (grok's _meta["x.ai/tool"]), classify a call the protocol cannot
describe, and answer a vendor client request. The shared wire schema learns
no vendor key and the shared classifier keeps every other decision.
- src/dialect.ts: the AcpDialect contract, the generic dialect, and the
cursor and grok dialects.
- The registering plugin names the dialect through its bridge options
(`acpDialect`), so a third-party registration of a known agent gets the
same fidelity; an unnamed profile falls back to the launch executable.
- cursor/task is answered {} instead of -32601, and its report names the
child agent and the work while the row is still open.
Co-Authored-By: Claude <noreply@anthropic.com>
Coordinator review — WS2b layers 1–3 (#2198 → #2211 → #2216)Reviewed against Verified on the branch
Design call accepted (M5) The bridge keeps the opened shape and takes the permission headline as the title. It does not re-open the row as G11 result accepted The 83 production rows arrived after a Next: proceed to layer 4 (publish the kit at
|
Stacked on #2211.
What was wrong
Version 1 of the Agent Client Protocol has no sub-agent concept (
session/forkis unstable and unrelated), and it standardizes nothing aboutrawInput. So the things that most distinguish one ACP agent from another live beside the protocol, and the bridge read none of them:kind: "other"tool call titled "Task: Subagent task", then sends a vendor JSON-RPC requestcursor/taskcarrying the description, the prompt, the childagentIdand the duration. bb replied-32601"Unsupported ACP client method" — a protocol error for a request the agent is entitled to send — and threw the report away. (recordings/acp-cursor/subagent, seq 2946/2947.)spawn_subagenttool call, which the protocol reports as an ordinary tool call._meta["x.ai/tool"](name, kind, namespace, label, read_only) on every tool event.#2211 read grok's
_metafrom a single hardcoded helper. That does not scale to a second agent, and it does not survive the point of this workstream: a third-party plugin must be able to get the same fidelity for an agent bb has never heard of.What changed
plugins/provider-acp/src/dialect.ts— a dialect is a small, profile-keyed module with three optional hooks:_metastays an opaque passthrough field on the tool-call schema; only a dialect reads it.undefined(the normal answer) leaves every decision to the shared classifier. A bb-injected tool binding still wins over both.rawInput._toolName === "task"opens the call as adelegation;cursor/taskis answered{}and its report gives the row the childagentId, the description and the model while the row is still open. Cursor sends the report after the sub-agent finished, so a report for a settled row is ignored rather than re-opening it — the same rule Report what ACP agents actually send: real exit codes, streamed output, args and results #2211 established for the permission merge.spawn_subagentopens as adelegationlabelled by itsdescription, withsubagent_typeas the row detail.experimental_bridgeOptions: { acpDialect: "cursor" }), which travel the opaqueproviderOptionspath every other provider uses — no daemon schema change, no protocol bump. An unnamed profile falls back to the launch executable's base name, so a user-configuredgrokinstance gets grok's dialect without declaring anything. An id the kit does not ship resolves to the generic dialect.Keying on the profile rather than a bb provider id is the point: the ACP plugin owns several providers, the same agent can be registered under any id, and Amp's own plugin will name a dialect for an agent that has no bb-side id at all.
How I verified
pnpm exec turbo run typecheck test --filter=bb-plugin-provider-acp: 16 files, 232 tests. New coverage: dialect selection (profile id, executable fallback, unknown id → generic, generic answers nothing), grokspawn_subagent→ delegation and "every other grok tool falls through", Cursor_toolName: "task"→ delegation,cursor/task→{result: {}, delegation}and "still acknowledges a request it cannot read, claims nothing else", the translator's open → report → close path, "ignores a report for a call that already settled", and "a bb-injected tool binding stays ahead of the dialect".acp-cursor/subagentrecording with the cursor dialect selected: the "Task: Subagent task" row is now adelegation(childRef,background: false,UserRoundglyph) instead of a genericothertool.pnpm --filter @bb/provider-parity rerecord --provider acp-cursor: no lane changed except one run-varying temp path in theforkcell's error text, which I reverted.pnpm exec turbo run test --filter=@bb/provider-parity --force: 43/43, counts unchanged.What the recorded oracle cannot prove here, stated plainly: the committed acp-cursor recordings were made before
acpDialectexisted, so their runtime lane carries no dialect id, and the parity replay rewrites the launch command to the replay child (node …replay-provider-child.mjs) — so the executable fallback yields the generic dialect and the recorded cells replay exactly as before. That is why parity shows zero diffs for a change that visibly alters the subagent row. Fresh cells recorded against the installed agents (spike §8) are the next layer's task; until then the dialect's coverage is the unit tests and the live replay above.