Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 9 additions & 1 deletion docs/api_to_audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,20 @@ so each agent's vendor side channels (grok's `_meta["x.ai/tool"]`, Cursor's
read by a small profile-keyed module that a plugin can supply for its own
agent and name in its registration's bridge options (`acpDialect`).
`experimental_handleAcpBridgeLine` is the raw line handler for harnesses.
`experimental_probeAcpAgent` asks one installed agent what it supports
(`initialize` → `agentCapabilities`) so a plugin can replace a declared guess
with the agent's own answer, and `experimental_acpAgentProbeSchema` validates
that answer across a host RPC boundary.
`experimental_parseAcpAgentModelLines` / `experimental_buildAcpAgentModelCatalog`
/ `experimental_splitAcpPrimaryModels` build a model picker from an agent's
`--list-models` output. `experimental_acpProfileFromLaunchSpec` and
`experimental_ACP_*` expose the launch profile and the protocol vocabularies.

**Audit before stabilizing.** Decide whether `AcpDialect` is the right shape
**Audit before stabilizing.** Decide what `probeAcpAgent` owes a caller:
today it spawns the agent with a 10s timeout, advertises the bridge's own
client capabilities, and answers `-32601` to anything the agent asks — settle
whether the timeout, the client capabilities and the refusal are the caller's
to choose. Decide whether `AcpDialect` is the right shape
for a third-party agent — today it has four optional hooks (`toolIdentity`,
`classifyToolCall`, `handleClientRequest`, `maintenance`) and no versioning,
so adding a fifth is a silent capability change for every dialect. Decide
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-sdk/src/provider-bridge-acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export type {
AcpToolIdentity,
} from "@bb/provider-bridge-acp";

export {
acpAgentProbeSchema as experimental_acpAgentProbeSchema,
probeAcpAgent as experimental_probeAcpAgent,
} from "@bb/provider-bridge-acp";
export type { AcpAgentProbe, AcpAgentProbeRequest } from "@bb/provider-bridge-acp";

export { acpProfileFromLaunchSpec as experimental_acpProfileFromLaunchSpec } from "@bb/provider-bridge-acp";
export type { AcpAgentProfile } from "@bb/provider-bridge-acp";

Expand Down
3 changes: 3 additions & 0 deletions packages/provider-bridge-acp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export type {
AcpToolIdentity,
} from "./dialect.js";

export { acpAgentProbeSchema, probeAcpAgent } from "./probe.js";
export type { AcpAgentProbe, AcpAgentProbeRequest } from "./probe.js";

export type { AcpAgentProfile } from "./profiles.js";
export { acpProfileFromLaunchSpec } from "./profiles.js";

Expand Down
71 changes: 71 additions & 0 deletions packages/provider-bridge-acp/src/probe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { acpAgentProbeSchema, probeAcpAgent } from "./probe.js";

describe("probeAcpAgent", () => {
// An agent that is not installed is the common case on any host, and it
// must never throw: a probe failure is an answer, not an error.
it("reports a missing agent instead of throwing", async () => {
const probe = await probeAcpAgent({
command: "bb-acp-agent-that-does-not-exist",
args: [],
cwd: process.cwd(),
timeoutMs: 5_000,
});

expect(probe.reachable).toBe(false);
expect(probe.reachable === false && probe.reason).toContain("ENOENT");
expect(acpAgentProbeSchema.safeParse(probe).success).toBe(true);
});

it("gives up on an agent that never answers initialize", async () => {
const probe = await probeAcpAgent({
// An agent that starts, holds its stdin open, and answers nothing —
// the failure the timeout exists for. (`cat` would not do: it echoes
// the request back, which is a different, answered conversation.)
command: process.execPath,
args: ["-e", "process.stdin.resume();"],
cwd: process.cwd(),
timeoutMs: 300,
});

expect(probe).toEqual({
reachable: false,
reason: "the agent did not answer initialize within 300ms",
});
});

it("reads the capabilities an agent reports", async () => {
const probe = await probeAcpAgent({
command: process.execPath,
args: [
"-e",
`process.stdin.on("data", () => {
process.stdout.write(JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
sessionCapabilities: { fork: {} },
promptCapabilities: { image: true },
},
authMethods: [{ id: "token" }],
},
}) + "\\n");
});`,
],
cwd: process.cwd(),
timeoutMs: 10_000,
});

expect(probe).toEqual({
reachable: true,
protocolVersion: 1,
fork: true,
loadSession: true,
promptImage: true,
authMethods: ["token"],
});
});
});
149 changes: 149 additions & 0 deletions packages/provider-bridge-acp/src/probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* What one installed ACP agent can actually do (Q21).
*
* A provider declaration states its capabilities before any agent has spoken,
* so bb declared one answer for every ACP agent and got them wrong: the ACP
* tier offered `session/fork` for five agents, of which the two bb has since
* read the wire for support none of it. A declaration above what the agent
* answers is not a missing feature — the bridge refuses the fork only after
* bb created the fork thread, so the thread dies on start (get-bb/bb#1833).
*
* The agent already reports the truth: `initialize` returns
* `agentCapabilities`. This probe asks it. It runs on the host, because the
* agent is a host-local executable, and it is deliberately cheap and
* disposable: spawn, initialize, read the reply, kill. It never starts a
* session and never prompts.
*/

import { z } from "zod";
import {
AcpAgentExitedError,
createAcpAgentConnection,
} from "./bridge/agent-connection.js";
import { ACP_PROTOCOL_VERSION, acpInitializeResultSchema } from "./wire.js";

/** How long the whole probe may take before bb gives up on the agent. */
const PROBE_TIMEOUT_MS = 10_000;

export interface AcpAgentProbeRequest {
command: string;
args: readonly string[];
/** Extra environment the agent's launch spec asks for. */
env?: Record<string, string>;
/** Where to run the probe; the agent may refuse to start without one. */
cwd: string;
timeoutMs?: number;
}

/** What the agent said about itself, or why bb could not ask. */
export type AcpAgentProbe =
| {
reachable: true;
/** The protocol version the agent negotiated. */
protocolVersion: number;
/** The agent implements the unstable `session/fork`. */
fork: boolean;
/** The agent can reload a past session (`session/load`). */
loadSession: boolean;
/** The agent accepts image content in a prompt. */
promptImage: boolean;
/** The agent requires one of these authentication methods. */
authMethods: string[];
}
| { reachable: false; reason: string };

const probeInitializeSchema = acpInitializeResultSchema;

function describe(error: unknown): string {
if (error instanceof AcpAgentExitedError) {
return `the agent exited before it answered initialize: ${error.message}`;
}
return error instanceof Error ? error.message : String(error);
}

/**
* Ask one agent what it supports. Never throws: an agent that is missing,
* broken, or too slow is a `reachable: false` answer with the reason, which
* the caller reports as "bb could not verify this agent" rather than as a
* capability.
*/
export async function probeAcpAgent(
request: AcpAgentProbeRequest,
): Promise<AcpAgentProbe> {
const timeoutMs = request.timeoutMs ?? PROBE_TIMEOUT_MS;
let connection: ReturnType<typeof createAcpAgentConnection> | undefined;
try {
connection = createAcpAgentConnection({
command: request.command,
args: [...request.args],
cwd: request.cwd,
env: { ...process.env, ...(request.env ?? {}) },
recordThreadId: null,
onNotification: () => {},
// A probe is not a session: an agent that asks the client anything
// before initialize returns gets a plain "not supported".
onRequest: (_method, _params, responder) => {
responder.error(-32601, "bb is probing this agent's capabilities");
},
onExit: () => {},
});
} catch (error) {
return { reachable: false, reason: describe(error) };
}

const connected = connection;
const timeout = new Promise<never>((_resolve, reject) => {
setTimeout(
() =>
reject(
new Error(`the agent did not answer initialize within ${timeoutMs}ms`),
),
timeoutMs,
).unref?.();
});

try {
const result = await Promise.race([
connected.request({
method: "initialize",
params: {
protocolVersion: ACP_PROTOCOL_VERSION,
clientInfo: { name: "bb", version: "1.0.0" },
// The probe advertises what bb's bridge advertises, so an agent
// that varies its capabilities by client sees the same client.
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
terminal: false,
},
},
resultSchema: probeInitializeSchema,
}),
timeout,
]);
const capabilities = result.agentCapabilities;
return {
reachable: true,
protocolVersion: result.protocolVersion,
fork: capabilities?.sessionCapabilities?.fork != null,
loadSession: capabilities?.loadSession ?? false,
promptImage: capabilities?.promptCapabilities?.image ?? false,
authMethods: (result.authMethods ?? []).map((method) => method.id),
};
} catch (error) {
return { reachable: false, reason: describe(error) };
} finally {
connected.kill();
}
}

export const acpAgentProbeSchema: z.ZodType<AcpAgentProbe> = z.union([
z.object({
reachable: z.literal(true),
protocolVersion: z.number(),
fork: z.boolean(),
loadSession: z.boolean(),
promptImage: z.boolean(),
authMethods: z.array(z.string()),
}),
z.object({ reachable: z.literal(false), reason: z.string() }),
]);
1 change: 1 addition & 0 deletions plugins/provider-acp/public-sdk-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const PLUGIN_IMPORT_ALLOWLIST = [
/^@get-bb\/plugin-sdk$/u,
/^@get-bb\/plugin-sdk\/(?:provider-bridge|host|app)$/u,
/^@get-bb\/plugin-sdk\/provider-bridge\/acp$/u,
/^@get-bb\/plugin-sdk\/host$/u,
/^zod$/u,
/^node:/u,
/^\.\.?\//u,
Expand Down
Loading
Loading