diff --git a/docs/providers.md b/docs/providers.md index 298bee651b..1a43558ae3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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. diff --git a/packages/server/src/server/agent/provider-runtime.ts b/packages/server/src/server/agent/provider-runtime.ts index 9094527739..398d35746d 100644 --- a/packages/server/src/server/agent/provider-runtime.ts +++ b/packages/server/src/server/agent/provider-runtime.ts @@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "./tools/types.js"; export interface AgentProviderRuntime { snapshotManager: ProviderSnapshotManager; - setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void; + setPaseoToolCatalog(catalog: Pick | null): void; shutdown(): Promise; } diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index d3d295cc8e..843ef21ebb 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -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> | 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) + .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"); diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 4b42410845..8fc6c23d93 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -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): { } 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> }; + } = await import( + pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href + ); + const tools = new Map(); + 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; } interface V2TestContext { diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts index 4e9902bd7a..7abe34475d 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -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 | 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 | null): void { this.manifestCatalog = catalog; } diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 34a8d5ebeb..30f28426b1 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -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 { + // 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(), diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 727c7b7899..73e668735f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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);