From 7dda57c9f435f0583c8c3dd1cd9bccea16cb3913 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 17:34:56 +0000 Subject: [PATCH] Ask each ACP agent what it supports instead of declaring one answer for five (Q21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider declaration states capabilities before any agent has spoken, so bb guessed: the ACP tier offered session/fork for every acp-* provider. Both agents bb has since read the wire for — cursor-agent and grok — support none of it, and a fork bb offers but the agent refuses is not a missing feature: the bridge refuses it only after bb created the fork thread, so the thread dies on start (#1833). The agent already reports the truth at initialize. The kit gains probeAcpAgent — spawn, initialize, read agentCapabilities, kill — published as experimental_probeAcpAgent, and the plugin's bb.host artifact gains an RPC that runs it where the agent is installed. The plugin registers what it declares, then re-registers any agent whose answer differs. The rule is one-directional: bb narrows a capability the agent denies and never widens one it claims, because a probe verifies the agent's own answer, not that the whole fork path works. An unreachable host or an agent that is not installed there leaves the declaration untouched. Co-Authored-By: Claude --- docs/api_to_audit.md | 10 +- .../plugin-sdk/src/provider-bridge-acp.ts | 6 + packages/provider-bridge-acp/src/index.ts | 3 + .../provider-bridge-acp/src/probe.test.ts | 71 +++++++++ packages/provider-bridge-acp/src/probe.ts | 149 ++++++++++++++++++ plugins/provider-acp/public-sdk-only.test.ts | 1 + plugins/provider-acp/server.ts | 117 ++++++++++++-- plugins/provider-acp/src/contract.ts | 39 +++++ plugins/provider-acp/src/host.ts | 30 +++- .../src/probe-capabilities.test.ts | 53 +++++++ .../provider-acp/src/probe-capabilities.ts | 40 +++++ 11 files changed, 501 insertions(+), 18 deletions(-) create mode 100644 packages/provider-bridge-acp/src/probe.test.ts create mode 100644 packages/provider-bridge-acp/src/probe.ts create mode 100644 plugins/provider-acp/src/contract.ts create mode 100644 plugins/provider-acp/src/probe-capabilities.test.ts create mode 100644 plugins/provider-acp/src/probe-capabilities.ts diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 5cc98f19e1..60038dbba1 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -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 diff --git a/packages/plugin-sdk/src/provider-bridge-acp.ts b/packages/plugin-sdk/src/provider-bridge-acp.ts index b5ad515fce..9bca49901c 100644 --- a/packages/plugin-sdk/src/provider-bridge-acp.ts +++ b/packages/plugin-sdk/src/provider-bridge-acp.ts @@ -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"; diff --git a/packages/provider-bridge-acp/src/index.ts b/packages/provider-bridge-acp/src/index.ts index 02251658c4..c1c4bdeb3c 100644 --- a/packages/provider-bridge-acp/src/index.ts +++ b/packages/provider-bridge-acp/src/index.ts @@ -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"; diff --git a/packages/provider-bridge-acp/src/probe.test.ts b/packages/provider-bridge-acp/src/probe.test.ts new file mode 100644 index 0000000000..53446c806c --- /dev/null +++ b/packages/provider-bridge-acp/src/probe.test.ts @@ -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"], + }); + }); +}); diff --git a/packages/provider-bridge-acp/src/probe.ts b/packages/provider-bridge-acp/src/probe.ts new file mode 100644 index 0000000000..dec2daa643 --- /dev/null +++ b/packages/provider-bridge-acp/src/probe.ts @@ -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; + /** 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 { + const timeoutMs = request.timeoutMs ?? PROBE_TIMEOUT_MS; + let connection: ReturnType | 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((_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 = 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() }), +]); diff --git a/plugins/provider-acp/public-sdk-only.test.ts b/plugins/provider-acp/public-sdk-only.test.ts index 7e54b6299c..5aae6f025b 100644 --- a/plugins/provider-acp/public-sdk-only.test.ts +++ b/plugins/provider-acp/public-sdk-only.test.ts @@ -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, diff --git a/plugins/provider-acp/server.ts b/plugins/provider-acp/server.ts index 835e28f5a6..a2a0b59535 100644 --- a/plugins/provider-acp/server.ts +++ b/plugins/provider-acp/server.ts @@ -17,7 +17,9 @@ import { parseCustomAcpAgents, type AcpAgentDefinition, } from "./src/agents.js"; +import { acpHostContract, type AcpProbeResult } from "./src/contract.js"; import { acpProviderDeclaration } from "./src/declaration.js"; +import { applyAcpAgentProbe } from "./src/probe-capabilities.js"; import { KNOWN_ACP_AGENTS, KNOWN_ACP_PROVIDER_IDS, @@ -97,8 +99,31 @@ async function resolveCustomAgents( export default async function acpProvidersPlugin( bb: BbPluginApi, ): Promise { + const host = bb.hosts.experimental_client({ contract: acpHostContract }); + + /** + * Every agent this plugin has registered, and its disposer. A declaration + * states capabilities before any agent has spoken, so each one is + * registered from what it declares and re-registered from what the agent + * answers once a host can be asked (Q21). + */ + const registrations = new Map void }>(); + const definitions = new Map(); + + function register(agent: AcpAgentDefinition): void { + registrations.get(agent.id)?.dispose(); + definitions.set(agent.id, agent); + registrations.set(agent.id, bb.providers.register(acpProviderDeclaration(agent))); + } + + function unregister(providerId: string): void { + registrations.get(providerId)?.dispose(); + registrations.delete(providerId); + definitions.delete(providerId); + } + for (const agent of KNOWN_ACP_AGENTS) { - bb.providers.register(acpProviderDeclaration(agent)); + register(agent); } const settings = bb.settings.define({ @@ -113,30 +138,96 @@ export default async function acpProvidersPlugin( // Configured agents are re-registered whenever the setting changes: the // registry hands back a disposer per registration, and re-registering an // id this plugin already owns is only allowed after that disposer runs. - let disposeCustomAgents: (() => void)[] = []; + let customProviderIds: string[] = []; async function registerCustomAgents(settingValue: string): Promise { - for (const dispose of disposeCustomAgents.splice(0)) { - dispose(); - } const agents = await resolveCustomAgents(bb, settingValue); - disposeCustomAgents = agents.map( - (agent) => bb.providers.register(acpProviderDeclaration(agent)).dispose, - ); + const next = new Set(agents.map((agent) => agent.id)); + for (const providerId of customProviderIds) { + if (!next.has(providerId)) { + unregister(providerId); + } + } + for (const agent of agents) { + register(agent); + } + customProviderIds = [...next]; if (agents.length > 0) { bb.log.info(`Registered ${agents.length} configured ACP agent(s).`); } } + /** + * Ask each agent what it supports and re-register the ones whose answer + * differs from what bb declared. A host that cannot be reached, or an agent + * that is not installed there, leaves the declaration alone: bb narrows a + * capability it can verify and never widens one it cannot. + */ + async function probeAgents(hostId: string): Promise { + for (const [providerId, agent] of [...definitions]) { + let probe: AcpProbeResult; + try { + probe = await host.call( + "probeAgent", + { + command: agent.launch.command, + args: agent.launch.args, + env: agent.launch.env, + }, + { hostId }, + ); + } catch (error) { + bb.log.debug( + `Could not probe ${providerId} on host ${hostId}: ${String(error)}`, + ); + continue; + } + const applied = applyAcpAgentProbe(agent, probe); + if (applied === null) { + continue; + } + bb.log.info( + `${providerId} on host ${hostId}: ${applied.reason}; re-registering.`, + ); + register(applied.agent); + } + } + + async function probeAllHosts(): Promise { + const hosts = await bb.sdk.hosts.list(); + for (const available of hosts) { + if (available.status !== "connected") { + continue; + } + await probeAgents(available.id); + } + } + const initial = await settings.get(); await registerCustomAgents(initial.customAgents); settings.onChange((next) => { - void registerCustomAgents(next.customAgents).catch((error: unknown) => { - bb.log.error(`Could not re-register the configured ACP agents: ${String(error)}`); - }); + void registerCustomAgents(next.customAgents) + .then(probeAllHosts) + .catch((error: unknown) => { + bb.log.error( + `Could not re-register the configured ACP agents: ${String(error)}`, + ); + }); + }); + + // Probing spawns agents, so it never blocks plugin load: the declarations + // are live from the first moment and get more exact a moment later. + void probeAllHosts().catch((error: unknown) => { + bb.log.debug(`ACP capability probing failed: ${String(error)}`); + }); + host.experimental_onWorkerExit(({ hostId }) => { + void probeAgents(hostId).catch(() => {}); }); + bb.onDispose(() => { - for (const dispose of disposeCustomAgents.splice(0)) { - dispose(); + for (const [, registration] of registrations) { + registration.dispose(); } + registrations.clear(); + definitions.clear(); }); } diff --git a/plugins/provider-acp/src/contract.ts b/plugins/provider-acp/src/contract.ts new file mode 100644 index 0000000000..2586bd45fb --- /dev/null +++ b/plugins/provider-acp/src/contract.ts @@ -0,0 +1,39 @@ +/** + * The plugin's host RPC contract. + * + * A provider declaration states its capabilities before any agent has spoken. + * The agent itself reports the truth at `initialize`, but only on the machine + * where it is installed — so the plugin asks its own host worker (Q21). + */ + +import { defineRpcContract } from "@get-bb/plugin-sdk"; +import { z } from "zod"; + +export const acpProbeResultSchema = 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()), + }) + .strict(), + z.object({ reachable: z.literal(false), reason: z.string() }).strict(), +]); +export type AcpProbeResult = z.infer; + +export const acpHostContract = defineRpcContract({ + /** Ask one installed agent what it supports. Never throws. */ + probeAgent: { + input: z + .object({ + command: z.string().min(1), + args: z.array(z.string()).default([]), + env: z.record(z.string(), z.string()).default({}), + }) + .strict(), + output: acpProbeResultSchema, + }, +}); diff --git a/plugins/provider-acp/src/host.ts b/plugins/provider-acp/src/host.ts index 5b9573cc38..3c8cdd1613 100644 --- a/plugins/provider-acp/src/host.ts +++ b/plugins/provider-acp/src/host.ts @@ -1,9 +1,31 @@ /** * The plugin's `bb.host` artifact. * - * Every ACP agent bb ships runs on the published ACP kit — the same module a - * third-party plugin uses — so this plugin's host side is one re-export. The - * daemon's bridge bootstrap imports the artifact and looks for the named - * `experimental_providerBridge` export. + * Two surfaces, one artifact: the daemon's bridge bootstrap looks for the + * named `experimental_providerBridge` export, and the host worker looks for + * the default host entry. Every ACP agent bb ships runs on the published kit, + * so the bridge is one re-export; the host entry adds the one thing that can + * only happen on the machine the agent is installed on — asking the agent + * what it supports (Q21). */ + +import { experimental_defineHostEntry } from "@get-bb/plugin-sdk/host"; +import { experimental_probeAcpAgent } from "@get-bb/plugin-sdk/provider-bridge/acp"; +import { acpHostContract } from "./contract.js"; + export { experimental_acpProviderBridge as experimental_providerBridge } from "@get-bb/plugin-sdk/provider-bridge/acp"; + +export default experimental_defineHostEntry({ + contract: acpHostContract, + handlers: { + probeAgent: async (input, context) => + // The worker's temp directory is a real, writable path the agent can + // start in without touching a workspace. + experimental_probeAcpAgent({ + command: input.command, + args: input.args, + env: input.env, + cwd: context.experimental_paths.tempDir, + }), + }, +}); diff --git a/plugins/provider-acp/src/probe-capabilities.test.ts b/plugins/provider-acp/src/probe-capabilities.test.ts new file mode 100644 index 0000000000..b1d53924d9 --- /dev/null +++ b/plugins/provider-acp/src/probe-capabilities.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type { AcpAgentDefinition } from "./agents.js"; +import type { AcpProbeResult } from "./contract.js"; +import { applyAcpAgentProbe } from "./probe-capabilities.js"; + +const agent = (fork?: "none" | "tip"): AcpAgentDefinition => ({ + id: "acp-example", + displayName: "Example", + launch: { displayName: "Example", command: "example", args: [], env: {} }, + ...(fork === undefined ? {} : { fork }), +}); + +const reachable = (fork: boolean): AcpProbeResult => ({ + reachable: true, + protocolVersion: 1, + fork, + loadSession: true, + promptImage: false, + authMethods: [], +}); + +describe("applyAcpAgentProbe", () => { + // #1833: a declaration above what the agent answers makes POST + // /threads/fork create a thread that dies on start. + it("narrows a fork the agent does not advertise", () => { + const applied = applyAcpAgentProbe(agent("tip"), reachable(false)); + expect(applied?.agent.fork).toBe("none"); + expect(applied?.reason).toContain("does not advertise session/fork"); + }); + + it("changes nothing when the agent answers what bb declared", () => { + expect(applyAcpAgentProbe(agent("tip"), reachable(true))).toBeNull(); + expect(applyAcpAgentProbe(agent("none"), reachable(false))).toBeNull(); + }); + + // Never widen: bb has verified the agent's own answer, not that its fork + // works end to end through the bridge, the runtime and the timeline. + it("does not offer a fork bb never declared", () => { + expect(applyAcpAgentProbe(agent("none"), reachable(true))).toBeNull(); + expect(applyAcpAgentProbe(agent(), reachable(true))).toBeNull(); + }); + + // An agent that is not installed on this host, or a host that cannot be + // reached, must leave the declaration exactly as it is. + it("leaves an unreachable agent alone", () => { + expect( + applyAcpAgentProbe(agent("tip"), { + reachable: false, + reason: "spawn example ENOENT", + }), + ).toBeNull(); + }); +}); diff --git a/plugins/provider-acp/src/probe-capabilities.ts b/plugins/provider-acp/src/probe-capabilities.ts new file mode 100644 index 0000000000..769db89afc --- /dev/null +++ b/plugins/provider-acp/src/probe-capabilities.ts @@ -0,0 +1,40 @@ +/** + * What a probe result changes about an agent's declaration (Q21). + * + * The rule is one-directional: bb narrows a capability the agent denies and + * never widens one the agent claims. A declaration above what the agent + * answers is a user-visible failure — the bridge refuses a fork only after + * bb created the fork thread (#1833) — while a declaration below it is a + * missing affordance the agent's own `initialize` reply can restore only + * once bb has verified the rest of the path. + */ + +import type { AcpAgentDefinition } from "./agents.js"; +import type { AcpProbeResult } from "./contract.js"; + +export interface AcpProbeApplication { + agent: AcpAgentDefinition; + /** Why the declaration changed, for the log. */ + reason: string; +} + +/** + * The agent as its probe says it is, or null when nothing changed — an + * unreachable agent, or one that answers exactly what bb declared. + */ +export function applyAcpAgentProbe( + agent: AcpAgentDefinition, + probe: AcpProbeResult, +): AcpProbeApplication | null { + if (!probe.reachable) { + return null; + } + const declaredFork = agent.fork ?? "none"; + if (declaredFork === "none" || probe.fork) { + return null; + } + return { + agent: { ...agent, fork: "none" }, + reason: `the agent does not advertise session/fork, but bb declared fork "${declaredFork}"`, + }; +}