Skip to content

Read each ACP agent's vendor side channels in a profile-keyed dialect - #2216

Closed
SawyerHood wants to merge 1 commit into
ws2b-acp-2-richer-tool-reportingfrom
ws2b-acp-3-agent-dialects
Closed

Read each ACP agent's vendor side channels in a profile-keyed dialect#2216
SawyerHood wants to merge 1 commit into
ws2b-acp-2-richer-tool-reportingfrom
ws2b-acp-3-agent-dialects

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #2211.

What was wrong

Version 1 of the Agent Client Protocol has no sub-agent concept (session/fork is unstable and unrelated), and it standardizes nothing about rawInput. So the things that most distinguish one ACP agent from another live beside the protocol, and the bridge read none of them:

  • Cursor announces a sub-agent as a kind: "other" tool call titled "Task: Subagent task", then sends a vendor JSON-RPC request cursor/task carrying the description, the prompt, the child agentId and 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.)
  • grok runs sub-agents through a spawn_subagent tool call, which the protocol reports as an ordinary tool call.
  • grok stamps _meta["x.ai/tool"] (name, kind, namespace, label, read_only) on every tool event.

#2211 read grok's _meta from 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:

interface AcpDialect {
  readonly id: string;
  toolIdentity?(event): { name?, kind? } | undefined;        // grok's _meta
  classifyToolCall?(event): AcpClassifiedToolCall | undefined; // sub-agents
  handleClientRequest?(method, params): { result, delegation? } | undefined;
}
  • The shared wire schema learns no vendor key. _meta stays an opaque passthrough field on the tool-call schema; only a dialect reads it.
  • The classifier is pluggable, not replaced. A dialect gets the first word — only it can know a tool call is delegated work — and returning undefined (the normal answer) leaves every decision to the shared classifier. A bb-injected tool binding still wins over both.
  • Cursor: rawInput._toolName === "task" opens the call as a delegation; cursor/task is answered {} and its report gives the row the child agentId, 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.
  • grok: spawn_subagent opens as a delegation labelled by its description, with subagent_type as the row detail.
  • A profile names its dialect through the plugin's bridge options (experimental_bridgeOptions: { acpDialect: "cursor" }), which travel the opaque providerOptions path 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-configured grok instance 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), grok spawn_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".
  • Replaying the committed acp-cursor/subagent recording with the cursor dialect selected: the "Task: Subagent task" row is now a delegation (childRef, background: false, UserRound glyph) instead of a generic other tool.
  • pnpm --filter @bb/provider-parity rerecord --provider acp-cursor: no lane changed except one run-varying temp path in the fork cell'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 acpDialect existed, 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.

AGENT GENERATED: by Claude Opus 5

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>
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Coordinator review — WS2b layers 1–3 (#2198#2211#2216)

Reviewed against REVIEW-GATE.md. All items pass. Do not merge: the stack accumulates until the full migration is done.

Verified on the branch

  • M4 data-loss fix (Open the ACP tool-call enums at the wire boundary (M4) #2198): openAcpToolCallEnums normalizes at the wire edge. An unknown kind parses as other and keeps the raw value on rawKind. An unknown status parses as pending. A null reads as absent. A closed enum no longer drops a whole tool_call. This matches the ACP v2 draft Unknown(String) behavior.
  • Dialect isolation (Read each ACP agent's vendor side channels in a profile-keyed dialect #2216): wire.ts has no vendor parsing. _meta stays an opaque passthrough. dialect.ts and profiles.ts hold the per-agent behavior behind profile keys.
  • Parity allowlist entries name #2211.
  • Stack shape: three drafts, chained, off main.

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 webFetch. The measured alternative produced a duplicate row. A test pins this. The spec text in specs/WS2b-acp-kit.md will change to match.

G11 result accepted

The 83 production rows arrived after a provider/error settled the turn. provider/unhandled is the correct record for them. The in-turn replay shows the real win: 83 outputDelta events and 207,440 streamed characters where main produced zero. The PR body states this correctly.

Next: proceed to layer 4 (publish the kit at @get-bb/plugin-sdk/provider-bridge/acp with the public-SDK-only guard). Do not rebase on your own. I will sequence the re-stack onto the single chain when WS3 reports layer 5.

AGENT GENERATED: by Claude (coordinator)

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.

1 participant