fix(opencode): advertise agent-scoped bridge tool schemas (#7383)

Co-authored-by: gszep <contact@gszep.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-19 16:58:21 +02:00
committed by GitHub
co-authored by gszep
parent 05920a236c
commit 91154ba4e8
7 changed files with 229 additions and 11 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
@@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "./tools/types.js";
export interface AgentProviderRuntime {
snapshotManager: ProviderSnapshotManager;
setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void;
setPaseoToolCatalog(catalog: Pick<PaseoToolCatalog, "tools"> | null): void;
shutdown(): Promise<void>;
}
@@ -13,6 +13,11 @@ import {
getRealProviderRuntimeSettings,
} from "../../daemon-e2e/real-provider-test-config.js";
import type { PaseoToolCatalog } from "../tools/types.js";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../tools/paseo-tools.js";
import { serializePaseoToolInputParameters } from "../tools/paseo-tool-serialization.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js";
import { OpenCodeBridge } from "./opencode/bridge.js";
@@ -23,6 +28,61 @@ import {
requireSessionId,
} from "./opencode/test-utils/v2-local-e2e-helpers.js";
test("real OpenCode server advertises agent-scoped notification descriptions", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-manifest-real-"));
const logger = createTestLogger();
const providerSnapshotManager = new ProviderSnapshotManager({ logger });
const dependencies = {
agentManager: new AgentManager({ logger }),
agentStorage: new AgentStorage(path.join(root, "agents"), logger),
providerSnapshotManager,
logger,
};
const bridge = new OpenCodeBridge({ paseoHome: root, logger });
bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies));
const manager = new OpenCodeServerManager({
logger,
resolveHomeDir: () => root,
runtimeSettings: {
env: {
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_CACHE_HOME: path.join(root, "cache"),
OPENCODE_DISABLE_AUTO_UPDATE: "1",
},
},
decorateServerEnv: (env) => bridge.decorateServerEnv(env),
});
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
try {
await bridge.start();
inspection = await manager.acquireCurrent();
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: root });
const response = await sdk.tool.list({ directory: root, provider: "openai", model: "gpt-5.4" });
expect(response.error).toBeUndefined();
const agentCatalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: "caller" });
for (const name of ["create_agent", "send_agent_prompt"]) {
const actual = response.data!.find((tool) => tool.id === `paseo_${name}`)!;
expect(actual).toBeDefined();
const expected = serializePaseoToolInputParameters(agentCatalog.getTool(name)!);
expect(actual.parameters).toMatchObject({
properties: {
notifyOnFinish: {
description: (expected.properties as Record<string, { description: string }>)
.notifyOnFinish.description,
},
},
});
}
} finally {
await inspection?.release();
await manager.shutdown();
await bridge.close();
await providerSnapshotManager.shutdown();
await rm(root, { recursive: true, force: true });
}
}, 120_000);
test("real OpenCode server persists provider permissions across creation and resume", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
const cwd = path.join(root, "repo");
@@ -5,15 +5,18 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { z } from "zod";
import Ajv from "ajv";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
import { PaseoToolInputError } from "../../tools/types.js";
import { createPaseoToolCatalog } from "../../tools/paseo-tools.js";
import { AgentManager } from "../../agent-manager.js";
import { AgentStorage } from "../../agent-storage.js";
import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js";
import { createTestAgentClients } from "../../../test-utils/fake-agent-client.js";
import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tools/paseo-tools.js";
import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js";
import {
OpenCodeBridge,
loadOpenCodeBridgePluginArtifact,
@@ -72,6 +75,144 @@ function readPluginOptions(env: Record<string, string>): {
}
describe("OpenCodeBridge", () => {
test("advertises agent-scoped schemas that execute through both bridge plugins", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-manifest-"));
temporaryDirectories.push(paseoHome);
const logger = createTestLogger();
const clients = createTestAgentClients();
const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
const agentManager = new AgentManager({ clients, registry: agentStorage, logger });
const providerSnapshotManager = new ProviderSnapshotManager({ logger, extraClients: clients });
const dependencies = { agentManager, agentStorage, providerSnapshotManager, logger };
const bridge = new OpenCodeBridge({ paseoHome, logger });
await bridge.start();
bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies));
try {
const parent = await agentManager.createAgent(
{ provider: "codex", cwd: paseoHome },
undefined,
{ workspaceId: "manifest-workspace" },
);
const catalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: parent.id });
bridge.bindSession({ sessionId: "bound", env: {}, tools: catalog });
const plugin = readPluginOptions(bridge.decorateServerEnv({}));
const response = await fetch(`${plugin.baseUrl}/_internal/opencode/tools`, {
headers: { Authorization: `Bearer ${plugin.token}` },
});
expect(response.status).toBe(200);
const manifest = z
.object({
tools: z.array(
z.object({
name: z.string(),
description: z.string(),
inputSchema: z.record(z.string(), z.unknown()),
}),
),
})
.parse(await response.json());
const definitions = new Map(manifest.tools.map((tool) => [tool.name, tool]));
const createDefinition = definitions.get("create_agent")!;
const sendDefinition = definitions.get("send_agent_prompt")!;
const topLevelCatalog = createPaseoToolCatalog(dependencies);
for (const name of ["create_agent", "send_agent_prompt"]) {
expect(serializePaseoToolInputParameters(topLevelCatalog.getTool(name)!)).toMatchObject({
properties: { notifyOnFinish: { default: false } },
});
}
const ajv = new Ajv({ useDefaults: true, strict: false });
const createInput = {
title: "Bridge child",
provider: "codex/gpt-5.4",
initialPrompt: "Hello",
notifyOnFinish: false,
};
expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true);
const v2Config = z
.object({ plugins: z.array(z.object({ package: z.string() })) })
.parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT));
const v2Module: {
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
} = await import(
pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href
);
const tools = new Map<string, V2TestTool>();
const dispose = await v2Module.default.setup({
options: plugin,
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "bound" }),
hook: async () => ({ dispose: async () => undefined }),
},
});
try {
const created = await tools
.get("paseo_create_agent")!
.execute(createInput, { sessionID: "bound" });
const children = agentManager.listAgents().filter((agent) => agent.id !== parent.id);
expect(children).toHaveLength(1);
const child = children[0]!;
expect(created).toMatchObject({
content: [{ type: "text", text: expect.stringContaining(child.id) }],
});
expect(child.workspaceId).toBe("manifest-workspace");
expect([...definitions.keys()]).toEqual([...catalog.tools.keys()]);
for (const definition of manifest.tools) {
const accepted = catalog.getTool(definition.name)!;
expect(definition.inputSchema).toEqual(serializePaseoToolInputParameters(accepted));
expect(definition.description).toBe(accepted.description);
expect(tools.get(`paseo_${definition.name}`)!.input).toEqual(definition.inputSchema);
}
expect(createDefinition.inputSchema).toMatchObject({
properties: { notifyOnFinish: { default: true } },
});
expect(createDefinition.inputSchema.properties).not.toHaveProperty("background");
expect(sendDefinition.inputSchema).toMatchObject({
properties: { notifyOnFinish: { default: true } },
});
await expect(
catalog.executeTool("create_agent", { ...createInput, background: true }),
).rejects.toThrow("background");
const v1Module = await import(plugin.pluginUrl);
const hooks = await v1Module.default({}, plugin);
expect(Object.keys(hooks.tool.paseo_create_agent.args)).toEqual(
Object.keys(
z.record(z.string(), z.unknown()).parse(createDefinition.inputSchema.properties),
),
);
const sendInput = { agentId: child.id, prompt: "Hello again", notifyOnFinish: false };
expect(ajv.compile(sendDefinition.inputSchema)(sendInput)).toBe(true);
await expect(
hooks.tool.paseo_send_agent_prompt.execute(sendInput, {
sessionID: "bound",
ask: async () => undefined,
}),
).resolves.toMatchObject({ output: expect.stringContaining('"success": true') });
} finally {
await dispose();
}
} finally {
await bridge.close();
await Promise.all(
agentManager.listAgents().map((agent) => agentManager.closeAgent(agent.id)),
);
await agentManager.flushForShutdown();
await providerSnapshotManager.shutdown();
}
});
test("loads packaged bundle bytes without invoking source compilation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-artifact-"));
temporaryDirectories.push(root);
@@ -696,6 +837,7 @@ describe("OpenCodeBridge", () => {
interface V2TestTool {
name: string;
input?: unknown;
execute(input: unknown, call: { sessionID: string }): Promise<unknown>;
}
interface V2TestContext {
@@ -60,7 +60,7 @@ export class OpenCodeBridge {
private baseUrl: string | null = null;
private pluginUrl: string | null = null;
private v2PluginUrl: string | null = null;
private manifestCatalog: PaseoToolCatalog | null = null;
private manifestCatalog: Pick<PaseoToolCatalog, "tools"> | null = null;
constructor(options: OpenCodeBridgeOptions) {
this.paseoHome = options.paseoHome;
@@ -89,7 +89,7 @@ export class OpenCodeBridge {
this.baseUrl = `http://127.0.0.1:${address.port}`;
}
setManifestCatalog(catalog: PaseoToolCatalog | null): void {
setManifestCatalog(catalog: Pick<PaseoToolCatalog, "tools"> | null): void {
this.manifestCatalog = catalog;
}
@@ -637,6 +637,20 @@ function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInp
}
export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog {
return createToolCatalog(options, options.callerAgentId ? "agent" : "top-level");
}
export function createPaseoAgentToolManifest(
options: PaseoToolHostDependencies,
): Pick<PaseoToolCatalog, "tools"> {
// Plugins register before a caller exists, but every execution uses an agent-bound catalog.
return { tools: createToolCatalog(options, "agent").tools };
}
function createToolCatalog(
options: PaseoToolHostDependencies,
scope: "agent" | "top-level",
): PaseoToolCatalog {
const {
agentManager,
agentStorage,
@@ -1209,7 +1223,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
.describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."),
};
const createAgentInputSchema = z
.object(callerAgentId ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
.object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
.passthrough();
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
const legacyAgentToAgentCreateAgentArgsSchema = z.object(legacyAgentToAgentInputSchema).strict();
@@ -1245,9 +1259,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
"Agent-scoped only: get notified when the prompted agent finishes, errors, or needs permission.",
),
};
const sendAgentPromptInputSchema = callerAgentId
? agentToAgentSendAgentPromptInputSchema
: topLevelSendAgentPromptInputSchema;
const sendAgentPromptInputSchema =
scope === "agent" ? agentToAgentSendAgentPromptInputSchema : topLevelSendAgentPromptInputSchema;
const inspectProviderInputSchema = {
provider: ProviderOrProviderModelInputSchema.describe(
"Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).",
@@ -2653,7 +2666,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
.optional()
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
name: z.string().optional(),
provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe(
provider: (scope === "agent" ? AgentProviderEnum.optional() : AgentProviderEnum).describe(
"Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.",
),
cwd: z.string().optional(),
+4 -1
View File
@@ -137,6 +137,7 @@ import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import {
createPaseoToolCatalog,
createPaseoAgentToolManifest,
type PaseoToolHostDependencies,
} from "./agent/tools/paseo-tools.js";
import type { PaseoToolRuntimeContext } from "./agent/tools/types.js";
@@ -1457,7 +1458,9 @@ export async function createPaseoDaemon(
const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) =>
createPaseoToolCatalog(createAgentToolHostDependencies(runtime));
const setAgentProviderToolsEnabled = (enabled: boolean) => {
agentProviderRuntime.setPaseoToolCatalog(enabled ? createAgentToolCatalog({}) : null);
agentProviderRuntime.setPaseoToolCatalog(
enabled ? createPaseoAgentToolManifest(createAgentToolHostDependencies({})) : null,
);
};
agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog);
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);