mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
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:
+1
-1
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user