diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 1f872b9d1b..efc5fdf24f 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -31,6 +31,35 @@ agent catalog during startup, raise the limit in milliseconds: The limit applies independently to every provider refresh and covers availability plus the entire catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent. +## Provider options + +Set provider defaults in `agents.providers..options`. At agent creation, use +`providerOptions` on the wire or `config.options` in the SDK. Both are opaque +`Record` values: the provider owns validation and application. +Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error. +Providers without option handling ignore the record. + +The daemon deep-merges the agent's options over the provider defaults at each +session launch or resume. Plain objects merge recursively; arrays, scalars, and +`null` replace the base value. Only the agent's own options are persisted, so edits +to provider defaults apply on the next launch or resume. A profile inherits its +base provider's options unless it sets its own options record. + +```json +{ + "agents": { + "providers": { + "codex": { + "options": { "sandbox_mode": "read-only", "approval_policy": "never" } + } + } + } +} +``` + +See the [SDK provider options guide](../public-docs/sdk/provider-options.md) for +per-agent examples and provider-specific keys. + --- ## Table of Contents @@ -391,7 +420,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` "XDG_CONFIG_HOME": "~/.config/omp-work", "XDG_STATE_HOME": "~/.local/state/omp-work" }, - "params": { + "options": { "sessionDir": "~/.local/state/omp-work/omp/agent/sessions", "rpcTimeoutMs": 60000, "smolModel": "openai/gpt-5-mini", @@ -404,7 +433,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` } ``` -`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines. +`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines. For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory: @@ -416,7 +445,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere "extends": "pi", "label": "My Pi Fork", "command": ["my-pi-fork"], - "params": { + "options": { "sessionDir": "~/.my-pi-fork/sessions", "rpcTimeoutMs": 60000 } @@ -426,7 +455,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere } ``` -This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session `. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline. +This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session `. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline. --- @@ -484,7 +513,7 @@ Required fields for ACP providers: - `label` - `command` — the command to spawn the agent process (must support ACP over stdio) -Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`: +Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`: ```json { @@ -494,7 +523,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog "extends": "acp", "label": "My Agent", "command": ["my-agent", "acp"], - "params": { + "options": { "supportsMcpServers": false } } @@ -505,7 +534,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog ACP agents execute filesystem operations in their own environment by default, while terminal operations run through Paseo on the host. To customize which -operations Paseo handles, configure client capabilities in provider params: +operations Paseo handles, configure client capabilities in provider options: ```json { @@ -515,7 +544,7 @@ operations Paseo handles, configure client capabilities in provider params: "extends": "acp", "label": "Container Agent", "command": ["container-agent", "acp"], - "params": { + "options": { "clientCapabilities": { "fs": { "readTextFile": false, @@ -684,7 +713,7 @@ Every entry under `agents.providers` accepts these fields: | `description` | `string` | No | Short description shown in the UI | | `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | | `env` | `Record` | No | Environment variables to set for the agent process | -| `params` | `Record` | No | Provider-specific options such as `supportsMcpServers: false` | +| `options` | `Record` | No | Provider-specific options such as `supportsMcpServers: false` | | `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | | `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) | | `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) | diff --git a/docs/plugins.md b/docs/plugins.md index a354a20374..a739d417a0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -431,7 +431,11 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`. Route messages, structured commands, steering, and command side effects through `session.prompt`. Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in -the opaque `providerOptions` config object. +the opaque `ProviderSessionConfig.providerOptions` object on `session.open`. +It contains the provider defaults and per-agent overrides merged by the daemon. +Validate and apply it inside the provider; core does not know your option shape. +See [provider options](custom-providers.md#provider-options) for configuration and +merge semantics. Agent refresh closes the current provider session and opens it again with current configuration and persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the diff --git a/docs/providers.md b/docs/providers.md index 6373caa57c..0036c2e6f2 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -22,47 +22,15 @@ provider icon map. You do not need a core manifest entry or provider factory. ## Provider-native session options -`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The -names and nesting are the provider's native contract; options are not portable between providers. -Paseo validates the object with the selected provider's strict schema before constructing a session. -Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model, -prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as -top-level provider options. +The provider owns validation and application of the opaque record in +`AgentSessionConfig.providerOptions`. The registry supplies the effective options +at session startup. See [provider configuration](custom-providers.md#provider-options) +for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md) +for native keys and examples. -This Paseo version accepts these keys: - -- **Codex:** `approval_policy`, `sandbox_mode`, - `sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`, - `web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may - contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`, - `allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`, - `dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the - [Codex configuration reference](https://developers.openai.com/codex/config-reference). -- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and - `settings`. `providerOptions.extraArgs` passes the SDK's documented - [`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map - unchanged: keys omit the leading `--`, string values supply an argument value, and `null` - supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }` - passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`. - Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see - [plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal; - shell expressions such as `$(command)` are not evaluated. The accepted sandbox - fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands, - filesystem read/write rules, network domain/socket/local-binding rules, weaker nested - sandboxing, ignored violations, and the ripgrep command. `settings` accepts native - `permissions.{allow,ask,deny}` and sandbox settings. See the - [Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript) - and [Claude settings reference](https://code.claude.com/docs/en/settings). -- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule - object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, - `external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`, - `repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the - [OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are - application policy, not an OS sandbox. - -Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider -must fail closed for Hub unattended execution until it can approve one exact injected MCP server -and tool identity without approving native tools. +Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail +closed for Hub unattended execution until it can approve one exact injected MCP +server and tool identity without approving native tools. ## Core adapter patterns diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index e7f87ed103..75cf7ff0c9 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -1242,6 +1242,7 @@ test.describe("Agent message submission", () => { await gate.waitForHeldServerMessage("send_agent_message_response"); await expect(page.getByText("hello", { exact: true })).toHaveCount(1); await expect(page.getByText(/^Worked for/)).toHaveCount(0); + await expectAgentReadyToInterrupt(page); gate.releaseHeldServerMessage("send_agent_message_response"); await expect(page.getByText("hello", { exact: true })).toHaveCount(1); } finally { diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index 772d0d3eac..33b49ab447 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -114,7 +114,7 @@ export interface ProviderSessionConfig { mode?: string; thinkingOption?: string; settings: Readonly>; - providerOptions?: Readonly>; + providerOptions?: Readonly>; title?: string; persist: boolean; } @@ -742,7 +742,7 @@ const sessionConfigSchema = z mode: z.string().optional(), thinkingOption: z.string().optional(), settings: jsonObjectSchema, - providerOptions: jsonObjectSchema.optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), title: z.string().optional(), persist: z.boolean(), }) diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index f86dc63575..5c0fce9608 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -498,7 +498,7 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; -export type ProviderOptions = Record; +export type ProviderOptions = Record; export interface McpToolRef { kind: "mcp"; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6549ca3aa6..69595c9ad6 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -418,7 +418,7 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [ McpSseServerConfigSchema, ]); -const ProviderOptionsSchema = z.record(z.string(), z.json()); +const ProviderOptionsSchema = z.record(z.string(), z.unknown()); const McpToolRefSchema = z .object({ diff --git a/packages/protocol/src/provider-config.ts b/packages/protocol/src/provider-config.ts index 7687c813c6..c94f8ca965 100644 --- a/packages/protocol/src/provider-config.ts +++ b/packages/protocol/src/provider-config.ts @@ -54,6 +54,8 @@ export const ProviderOverrideSchema = z.object({ description: z.string().optional(), command: z.array(z.string().min(1)).min(1).optional(), env: z.record(z.string(), z.string()).optional(), + options: z.record(z.string(), z.unknown()).optional(), + // COMPAT(providerParams): added in v0.10.0, remove after 2027-03-30 once configs use options. params: z.record(z.string(), z.unknown()).optional(), models: z.array(ProviderProfileModelSchema).optional(), additionalModels: z.array(ProviderProfileModelSchema).optional(), diff --git a/packages/protocol/src/schedule/types.ts b/packages/protocol/src/schedule/types.ts index 62c02bd38d..718c7e05ca 100644 --- a/packages/protocol/src/schedule/types.ts +++ b/packages/protocol/src/schedule/types.ts @@ -33,7 +33,7 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [ archiveOnFinish: z.boolean().optional(), isolation: z.enum(["local", "worktree"]).optional(), title: z.string().trim().min(1).nullable().optional(), - providerOptions: z.record(z.string(), z.json()).optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), featureValues: z.record(z.string(), z.unknown()).optional(), systemPrompt: z.string().optional(), mcpServers: z.record(z.string(), z.unknown()).optional(), diff --git a/packages/server/src/server/agent/agent-configuration-validator.ts b/packages/server/src/server/agent/agent-configuration-validator.ts index 1135d25e0b..0893f3a692 100644 --- a/packages/server/src/server/agent/agent-configuration-validator.ts +++ b/packages/server/src/server/agent/agent-configuration-validator.ts @@ -3,7 +3,6 @@ import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messag import type { ProviderSnapshotEntry } from "./agent-sdk-types.js"; import { filterSelectableAgentModels } from "./agent-sdk-types.js"; -import { ProviderOptionsValidationError } from "./provider-options.js"; export interface AgentConfigurationValidationInput { provider: string; @@ -16,13 +15,11 @@ export interface AgentConfigurationValidationInput { interface AgentConfigurationValidationContext { input: AgentConfigurationValidationInput; provider: ProviderSnapshotEntry; - validateOptions(options: ProviderOptions | undefined): ProviderOptions | undefined; } export function validateAgentConfigurationAgainstProvider({ input, provider, - validateOptions, }: AgentConfigurationValidationContext): HubExecutionAgentValidationIssue[] { const issues: HubExecutionAgentValidationIssue[] = []; const models = filterSelectableAgentModels(provider.models); @@ -53,17 +50,5 @@ export function validateAgentConfigurationAgainstProvider({ }); } - try { - validateOptions(input.providerOptions); - } catch (error) { - if (!(error instanceof ProviderOptionsValidationError)) throw error; - issues.push( - ...error.issues.map((issue) => ({ - path: ["providerOptions", ...issue.path], - message: issue.message, - })), - ); - } - return issues; } diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 87d3b290aa..aebede5fa1 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -17,7 +17,7 @@ import { PARENT_AGENT_ID_LABEL, } from "@getpaseo/protocol/agent-labels"; import type { Logger } from "pino"; -import type { ProviderOptions, ToolPolicy } from "@getpaseo/protocol/agent-types"; +import type { ToolPolicy } from "@getpaseo/protocol/agent-types"; import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; import { z } from "zod"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; @@ -292,11 +292,6 @@ interface AgentManagerRescueTimeouts { interface ProviderEnabledFlag { enabled: boolean; derivedFromProviderId?: string | null; - validateOptions?: (options: ProviderOptions | undefined) => ProviderOptions | undefined; - applyOptions?: ( - config: AgentSessionConfig, - options: ProviderOptions | undefined, - ) => AgentSessionConfig; applyToolPolicy?: ( config: AgentSessionConfig, toolPolicy: ToolPolicy | undefined, @@ -5094,22 +5089,15 @@ export class AgentManager { private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig { const definition = this.providerDefinitions.get(config.provider); - if (config.providerOptions !== undefined && !definition?.validateOptions) { - throw new Error(`Provider '${config.provider}' does not accept providerOptions`); - } - const validatedOptions = definition?.validateOptions?.(config.providerOptions); - const withOptions = definition?.applyOptions - ? definition.applyOptions(config, validatedOptions) - : config; - this.validateToolPolicyServers(withOptions); - if (withOptions.toolPolicy && !definition?.applyToolPolicy) { + this.validateToolPolicyServers(config); + if (config.toolPolicy && !definition?.applyToolPolicy) { throw new Error( `Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`, ); } return definition?.applyToolPolicy - ? definition.applyToolPolicy(withOptions, withOptions.toolPolicy) - : withOptions; + ? definition.applyToolPolicy(config, config.toolPolicy) + : config; } private validateToolPolicyServers(config: AgentSessionConfig): void { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index f2b42525e9..a0ed0eedc3 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -535,6 +535,7 @@ export interface AgentSlashCommand { } export interface ListImportableSessionsOptions { + providerOptions?: ProviderOptions; limit?: number; /** Optional case-insensitive descriptor search text. */ query?: string; @@ -697,7 +698,7 @@ export interface AgentSession { } | null; } -export type FetchCatalogOptions = +export type FetchCatalogOptions = { providerOptions?: ProviderOptions } & ( | { scope: "global"; force: boolean; @@ -706,7 +707,8 @@ export type FetchCatalogOptions = scope: "workspace"; cwd: string; force: boolean; - }; + } +); export interface ProviderRefreshContext { readonly signal: AbortSignal; diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index b41bc75cfc..be94a31d49 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -16,7 +16,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z model: z.string().nullable().optional(), thinkingOptionId: z.string().nullable().optional(), featureValues: z.record(z.string(), z.unknown()).nullable().optional(), - providerOptions: z.record(z.string(), z.json()).nullable().optional(), + providerOptions: z.record(z.string(), z.unknown()).nullable().optional(), toolPolicy: z .object({ preapproved: z.array( diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index e1976e22a0..da416ee7e1 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -10,6 +10,10 @@ import { setImmediate as nextTurn } from "node:timers/promises"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js"; +import { toStoredAgentRecord } from "./agent-projections.js"; +import { AgentManager } from "./agent-manager.js"; +import { buildProviderRegistry } from "./provider-registry.js"; +import { ProviderOverrideSchema } from "@getpaseo/protocol/provider-config"; import { PluginAgentClientRegistry } from "./plugin-provider.js"; import { isStaleProviderSessionError, @@ -283,6 +287,109 @@ function expectNestedChildren(events: AgentStreamEvent[]) { } describe("PluginAgentClientRegistry", () => { + test("stores only agent options while the plugin receives merged defaults", async () => { + const logger = createTestLogger(); + const harness = createProviderHarness(); + const plugins = new PluginAgentClientRegistry(logger); + plugins.replace([harness.registration]); + const registry = buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides: { + "plugin-direct": { options: { nested: { base: true, replace: "config" } } }, + }, + }); + const manager = new AgentManager({ + logger, + clients: { "plugin-direct": registry["plugin-direct"].createClient(logger) }, + providerDefinitions: { "plugin-direct": registry["plugin-direct"] }, + }); + const own = { nested: { replace: "agent" } }; + const agent = await manager.createAgent( + { provider: "plugin-direct", cwd: "/tmp", providerOptions: own }, + undefined, + { workspaceId: undefined }, + ); + expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual(own); + expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({ + config: { providerOptions: { nested: { base: true, replace: "agent" } } }, + }); + await agent.session?.close(); + }); + + test.each([ + { options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, + { params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, + { + params: { ignored: true }, + options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" }, + }, + ])( + "merges configured options into plugin session.open, preserving caller input: %j", + async (override) => { + const logger = createTestLogger(); + const harness = createProviderHarness(); + const plugins = new PluginAgentClientRegistry(logger); + plugins.replace([harness.registration]); + const registry = buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides: { "plugin-direct": ProviderOverrideSchema.parse(override) }, + }); + const client = registry["plugin-direct"].createClient(logger); + const defaultsSession = await client.createSession({ + provider: "plugin-direct", + cwd: "/tmp", + }); + const defaultsOpen = harness.inputs.findLast((input) => input.type === "session.open")!; + expect(defaultsOpen.config.providerOptions).toEqual({ + nested: { base: true, replace: "config" }, + list: [1, 2], + scalar: "config", + }); + await defaultsSession.close(); + const config = { + provider: "plugin-direct", + cwd: "/tmp", + providerOptions: { nested: { replace: "agent" }, list: [3], scalar: null }, + }; + const session = await client.createSession(config); + const open = harness.inputs.findLast((input) => input.type === "session.open")!; + expect(open.config.providerOptions).toEqual({ + nested: { base: true, replace: "agent" }, + list: [3], + scalar: null, + }); + expect(config.providerOptions).toEqual({ + nested: { replace: "agent" }, + list: [3], + scalar: null, + }); + const handle = session.describePersistence()!; + await session.close(); + const updatedRegistry = buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides: { + "plugin-direct": { + options: { + nested: { base: false, replace: "new-config" }, + list: [4], + scalar: "new-config", + }, + }, + }, + }); + const resumed = await updatedRegistry["plugin-direct"] + .createClient(logger) + .resumeSession(handle, config); + const resumedOpen = harness.inputs.findLast((input) => input.type === "session.open")!; + expect(resumedOpen.config.providerOptions).toEqual({ + nested: { base: false, replace: "agent" }, + list: [3], + scalar: null, + }); + await resumed.close(); + }, + ); + test.each([false, true])( "contains a failed session open while send is pending: %s", async (pendingSend) => { diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index af936a0f7f..a17a8f7601 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; -import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { z } from "zod"; import { PROVIDER_CAPABILITIES, @@ -876,8 +876,6 @@ export class PluginAgentClientRegistry { } } -const PluginProviderOptionsSchema: z.ZodType = z.record(z.string(), z.json()); - function createPluginProviderDefinition( registration: ProviderRegistration, createClient: RegisteredProviderDefinition["createClient"], @@ -889,7 +887,6 @@ function createPluginProviderDefinition( iconSvg: registration.icon, defaultModeId: null, modes: [], - optionsSchema: PluginProviderOptionsSchema, supportsExactMcpPreapproval: true, createClient, }; @@ -1625,9 +1622,7 @@ function mapSessionConfig( mode: config.modeId, thinkingOption: config.thinkingOptionId, settings: toJsonObject(config.featureValues ?? {}, "provider settings"), - providerOptions: config.providerOptions - ? toJsonObject(config.providerOptions, "provider options") - : undefined, + providerOptions: config.providerOptions, title: config.title ?? undefined, persist, }; diff --git a/packages/server/src/server/agent/provider-options.test.ts b/packages/server/src/server/agent/provider-options.test.ts index 9b642d8b5e..f5b97d064a 100644 --- a/packages/server/src/server/agent/provider-options.test.ts +++ b/packages/server/src/server/agent/provider-options.test.ts @@ -1,7 +1,11 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { z } from "zod"; import { createTestLogger } from "../../test-utils/test-logger.js"; +import { AgentManager } from "./agent-manager.js"; import { validateProviderOptions } from "./provider-options.js"; import { applyClaudeToolPolicy, ClaudeProviderOptionsSchema } from "./providers/claude/options.js"; import { applyCodexToolPolicy, CodexProviderOptionsSchema } from "./providers/codex/options.js"; @@ -199,3 +203,66 @@ describe("exact MCP preapproval mappings", () => { ]); }); }); + +describe("configured provider options", () => { + test.each(["options", "params"] as const)( + "Pi imports read sessionDir from config %s", + async (key) => { + const dir = mkdtempSync(join(tmpdir(), "provider-options-import-")); + try { + const file = join(dir, "native.jsonl"); + writeFileSync( + file, + JSON.stringify({ + type: "session", + version: 3, + id: "native", + cwd: "/tmp/options-project", + }) + "\n", + ); + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + providerOverrides: { pi: { [key]: { sessionDir: dir } } }, + }); + const sessions = await registry.pi.createClient(logger).listImportableSessions!({ + cwd: "/tmp/options-project", + }); + expect(sessions.map((session) => session.providerHandleId)).toEqual([file]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + test("a provider without option handling ignores the record", async () => { + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + isDev: true, + providerOverrides: { mock: { options: { arbitrary: { base: true } } } }, + }); + const session = await registry.mock + .createClient(logger) + .createSession({ provider: "mock", cwd: "/tmp", providerOptions: { more: [1] } }); + expect(session.provider).toBe("mock"); + await session.close(); + }); + + test("config options reach Claude and provider validation reaches the create caller", async () => { + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { options: { sandbox: { network: { allowLocalBinding: "yes" } } } }, + }, + }); + const manager = new AgentManager({ + logger, + clients: { claude: registry.claude.createClient(logger) }, + providerDefinitions: { claude: registry.claude }, + }); + await expect( + manager.createAgent({ provider: "claude", cwd: "/tmp", model: "sonnet" }, undefined, { + workspaceId: undefined, + }), + ).rejects.toThrow("providerOptions.sandbox.network.allowLocalBinding"); + }); +}); diff --git a/packages/server/src/server/agent/provider-options.ts b/packages/server/src/server/agent/provider-options.ts index bf02410e22..8a7d0b16af 100644 --- a/packages/server/src/server/agent/provider-options.ts +++ b/packages/server/src/server/agent/provider-options.ts @@ -36,11 +36,11 @@ export class ToolPolicyUnsupportedError extends Error { } } -export function validateProviderOptions( +export function validateProviderOptions( provider: string, - schema: z.ZodType, + schema: z.ZodType, options: ProviderOptions | undefined, -): ProviderOptions | undefined { +): T | undefined { if (options === undefined) return undefined; const parsed = schema.safeParse(options); if (parsed.success) return parsed.data; @@ -75,3 +75,31 @@ function formatProviderOptionPath(path: Array): string { : `${formatted}[${JSON.stringify(segment)}]`; }, "providerOptions"); } + +/** Plain objects merge recursively; all other values replace the base value. */ +export function mergeProviderOptions( + base: ProviderOptions | undefined, + override: ProviderOptions | undefined, +): ProviderOptions | undefined { + if (base === undefined && override === undefined) return undefined; + const merged: ProviderOptions = { ...base }; + for (const [key, value] of Object.entries(override ?? {})) { + const previous = merged[key]; + Object.defineProperty(merged, key, { + value: + isPlainObject(previous) && isPlainObject(value) + ? mergeProviderOptions(previous, value) + : value, + enumerable: true, + configurable: true, + writable: true, + }); + } + return merged; +} + +function isPlainObject(value: unknown): value is ProviderOptions { + if (value === null || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/server/src/server/agent/provider-registry-wrap.test.ts b/packages/server/src/server/agent/provider-registry-wrap.test.ts index ae1254efa7..04d123ead1 100644 --- a/packages/server/src/server/agent/provider-registry-wrap.test.ts +++ b/packages/server/src/server/agent/provider-registry-wrap.test.ts @@ -6,6 +6,9 @@ import type { AgentSession, AgentStreamEvent, AgentRuntimeInfo, + SteerActiveTurnOptions, + SteerResult, + ImportedTimelineEntry, } from "./agent-sdk-types.js"; import { wrapSessionProvider } from "./provider-registry.js"; @@ -18,6 +21,7 @@ type OptionalAgentSessionMethodName = { }[keyof AgentSession]; const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [ + "steerActiveTurn", "listCommands", "setModel", "setThinkingOption", @@ -56,8 +60,12 @@ const RUNTIME_INFO: AgentRuntimeInfo = { class FakeSession implements AgentSession { readonly provider = "claude"; - readonly id = "session-1"; - readonly capabilities = CAPABILITIES; + id = "session-1"; + capabilities = CAPABILITIES; + initialTimeline: ImportedTimelineEntry[] = [ + { item: { type: "assistant_message", id: "initial", text: "Provider setup" } }, + ]; + readonly steers: Array<{ prompt: AgentPromptInput; options: SteerActiveTurnOptions }> = []; readonly features = []; readonly recordedCalls: string[] = []; @@ -71,6 +79,15 @@ class FakeSession implements AgentSession { return { turnId: "turn-1" }; } + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + this.recordedCalls.push("steerActiveTurn"); + this.steers.push({ prompt, options }); + return { status: "accepted" }; + } + subscribe(_callback: (event: AgentStreamEvent) => void) { this.recordedCalls.push("subscribe"); return () => {}; @@ -172,6 +189,7 @@ describe("wrapSessionProvider", () => { const session = new FakeSession(); const wrapped = wrapSessionProvider("custom-claude", session); + await wrapped.steerActiveTurn?.("follow-up", { expectedTurnId: "turn-1" }); await wrapped.listCommands?.(); await wrapped.setModel?.("sonnet"); await wrapped.setThinkingOption?.("high"); @@ -182,7 +200,11 @@ describe("wrapSessionProvider", () => { const handler = wrapped.tryHandleOutOfBand?.("/compact"); await handler?.run({ emit: () => {} }); + expect(session.steers).toEqual([ + { prompt: "follow-up", options: { expectedTurnId: "turn-1" } }, + ]); expect(session.recordedCalls).toEqual([ + "steerActiveTurn", "listCommands", "setModel", "setThinkingOption", @@ -194,4 +216,34 @@ describe("wrapSessionProvider", () => { "tryHandleOutOfBand.run", ]); }); + test("keeps provider-owned session values live", () => { + const session = new FakeSession(); + const wrapped = wrapSessionProvider("custom-claude", session); + session.id = "session-2"; + session.capabilities = { ...CAPABILITIES, supportsMcpServers: false }; + expect(wrapped.id).toBe("session-2"); + expect(wrapped.capabilities).toEqual(session.capabilities); + expect(wrapped.initialTimeline).toEqual(session.initialTimeline); + }); + + test("propagates steering failure without interrupting or replacing the turn", async () => { + const error = new Error("Provider steer transport failed"); + class RejectingSession extends FakeSession { + override async steerActiveTurn(): Promise { + throw error; + } + } + const session = new RejectingSession(); + const wrapped = wrapSessionProvider("custom-claude", session); + await expect(wrapped.steerActiveTurn!("follow-up", { expectedTurnId: "turn-1" })).rejects.toBe( + error, + ); + expect(session.recordedCalls).toEqual([]); + }); + + test("leaves steering unavailable when the provider has no implementation", () => { + const session: AgentSession = new FakeSession(); + session.steerActiveTurn = undefined; + expect(wrapSessionProvider("custom-claude", session).steerActiveTurn).toBeUndefined(); + }); }); diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 9c4a22a4f8..9a2cb768f0 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -24,7 +24,7 @@ const CLAUDE_CUSTOM_THINKING_FIELDS = { const mockState = vi.hoisted(() => { interface ConstructorEntry { runtimeSettings?: unknown; - providerParams?: unknown; + commandsRpcType?: unknown; } @@ -36,17 +36,14 @@ const mockState = vi.hoisted(() => { cursor: [] as Array<{ command: string[]; env?: Record; - providerParams?: unknown; }>, trae: [] as Array<{ command: string[]; env?: Record; - providerParams?: unknown; }>, kimi: [] as Array<{ command: string[]; env?: Record; - providerParams?: unknown; }>, pi: [] as ConstructorEntry[], genericAcp: [] as Array<{ @@ -54,7 +51,6 @@ const mockState = vi.hoisted(() => { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; }>, }, isCommandAvailable: vi.fn(async (_command: string) => false), @@ -261,13 +257,12 @@ vi.mock("./providers/pi/agent.js", () => ({ constructor(options: { runtimeSettings?: unknown; - providerParams?: unknown; + commandsRpcType?: unknown; }) { this.runtimeSettings = options.runtimeSettings; const entry: ConstructorEntry = { runtimeSettings: options.runtimeSettings, - providerParams: options.providerParams, }; if (options.commandsRpcType !== undefined) { entry.commandsRpcType = options.commandsRpcType; @@ -314,21 +309,7 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ env?: Record; providerId?: string; label?: string; - providerParams?: unknown; }) { - const providerParams = - options.providerParams && - typeof options.providerParams === "object" && - !Array.isArray(options.providerParams) - ? (options.providerParams as Record) - : {}; - this.capabilities = { - ...this.capabilities, - supportsMcpServers: - typeof providerParams.supportsMcpServers === "boolean" - ? providerParams.supportsMcpServers - : this.capabilities.supportsMcpServers, - }; this.runtimeSettings = { command: { mode: "replace", @@ -341,7 +322,6 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, }); } @@ -379,11 +359,7 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { - command: string[]; - env?: Record; - providerParams?: unknown; - }) { + constructor(options: { command: string[]; env?: Record }) { this.runtimeSettings = { command: { mode: "replace", @@ -394,7 +370,6 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({ mockState.constructorArgs.cursor.push({ command: options.command, env: options.env, - providerParams: options.providerParams, }); } @@ -445,11 +420,7 @@ vi.mock("./providers/trae-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { - command: string[]; - env?: Record; - providerParams?: unknown; - }) { + constructor(options: { command: string[]; env?: Record }) { this.runtimeSettings = { command: { mode: "replace", @@ -460,7 +431,6 @@ vi.mock("./providers/trae-acp-agent.js", () => ({ mockState.constructorArgs.trae.push({ command: options.command, env: options.env, - providerParams: options.providerParams, }); } @@ -498,11 +468,7 @@ vi.mock("./providers/kimi-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { - command: string[]; - env?: Record; - providerParams?: unknown; - }) { + constructor(options: { command: string[]; env?: Record }) { this.runtimeSettings = { command: { mode: "replace", @@ -513,7 +479,6 @@ vi.mock("./providers/kimi-acp-agent.js", () => ({ mockState.constructorArgs.kimi.push({ command: options.command, env: options.env, - providerParams: options.providerParams, }); } @@ -728,7 +693,6 @@ test("new provider extending acp uses GenericACPAgentClient", () => { }, providerId: "my-agent", label: "My Agent", - providerParams: undefined, }, { command: ["my-agent", "--acp"], @@ -737,7 +701,6 @@ test("new provider extending acp uses GenericACPAgentClient", () => { }, providerId: "my-agent", label: "My Agent", - providerParams: undefined, }, ]); }); @@ -812,43 +775,20 @@ test("ordinary custom ACP providers remain fail-closed for exact MCP grants", () ).toThrow(/cannot preapprove exact MCP tools for unattended execution/u); }); -test("ACP provider params can disable MCP support", () => { +test("ACP provider configuration retains opaque options", () => { const registry = buildProviderRegistry(logger, { providerOverrides: { "no-mcp-acp": { extends: "acp", label: "No MCP ACP", command: ["no-mcp-acp", "serve"], - params: { - supportsMcpServers: false, - }, + options: { supportsMcpServers: false }, }, }, }); - - const client = registry["no-mcp-acp"].createClient(logger); - - expect(client.capabilities.supportsMcpServers).toBe(false); - expect(mockState.constructorArgs.genericAcp).toEqual([ - { - command: ["no-mcp-acp", "serve"], - env: undefined, - providerId: "no-mcp-acp", - label: "No MCP ACP", - providerParams: { - supportsMcpServers: false, - }, - }, - { - command: ["no-mcp-acp", "serve"], - env: undefined, - providerId: "no-mcp-acp", - label: "No MCP ACP", - providerParams: { - supportsMcpServers: false, - }, - }, - ]); + expect(registry["no-mcp-acp"].configuration?.providerOptions).toEqual({ + supportsMcpServers: false, + }); }); test("cursor provider extending acp uses CursorACPAgentClient", () => { @@ -872,14 +812,12 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => { env: { CURSOR_AGENT_LOG: "debug", }, - providerParams: undefined, }, { command: ["cursor-agent", "acp"], env: { CURSOR_AGENT_LOG: "debug", }, - providerParams: undefined, }, ]); expect(mockState.constructorArgs.genericAcp).toEqual([]); @@ -936,12 +874,10 @@ test("traecli provider extending acp uses TraeACPAgentClient", () => { { command: ["traecli", "acp", "serve"], env: undefined, - providerParams: undefined, }, { command: ["traecli", "acp", "serve"], env: undefined, - providerParams: undefined, }, ]); expect(mockState.constructorArgs.genericAcp).toEqual([]); @@ -963,12 +899,10 @@ test("kimi provider extending acp uses KimiACPAgentClient", () => { { command: ["kimi", "acp"], env: undefined, - providerParams: undefined, }, { command: ["kimi", "acp"], env: undefined, - providerParams: undefined, }, ]); expect(mockState.constructorArgs.genericAcp).toEqual([]); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 004f16c6c4..3a66ffcfcf 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -1,6 +1,5 @@ import type { Logger } from "pino"; import type { ProviderOptions, ToolPolicy } from "@getpaseo/protocol/agent-types"; -import { z } from "zod"; import type { AgentClient, @@ -49,10 +48,7 @@ import { PiRpcAgentClient } from "./providers/pi/agent.js"; import { TraeACPAgentClient } from "./providers/trae-acp-agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { MockSlowProviderClient } from "./providers/mock-slow-provider.js"; -import { ClaudeProviderOptionsSchema } from "./providers/claude/options.js"; -import { CodexProviderOptionsSchema } from "./providers/codex/options.js"; -import { OpenCodeProviderOptionsSchema } from "./providers/opencode/options.js"; -import { ToolPolicyUnsupportedError, validateProviderOptions } from "./provider-options.js"; +import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js"; import { AGENT_PROVIDER_DEFINITIONS, BUILTIN_PROVIDER_IDS, @@ -80,13 +76,7 @@ export interface ProviderDefinition extends AgentProviderDefinition { * generic ACP providers (which only extend the literal "acp" sentinel). */ derivedFromProviderId: string | null; - optionsSchema: z.ZodType; supportsExactMcpPreapproval: boolean; - validateOptions: (options: ProviderOptions | undefined) => ProviderOptions | undefined; - applyOptions: ( - config: AgentSessionConfig, - options: ProviderOptions | undefined, - ) => AgentSessionConfig; applyToolPolicy: ( config: AgentSessionConfig, toolPolicy: ToolPolicy | undefined, @@ -108,7 +98,6 @@ export interface ProviderDefinition extends AgentProviderDefinition { /** Provider metadata and an unconfigured factory; the registry owns override application. */ export interface RegisteredProviderDefinition extends AgentProviderDefinition { iconSvg?: string; - optionsSchema: z.ZodType; supportsExactMcpPreapproval: boolean; createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient; } @@ -129,7 +118,6 @@ interface ProviderClientFactoryOptions extends Pick< "workspaceGitService" | "managedProcesses" | "ompRuntime" > { openCodeBridge?: OpenCodeBridge; - providerParams?: unknown; customProvider?: { id: string; label: string; @@ -151,27 +139,23 @@ interface ResolvedProvider { profileModelsAreAdditive: boolean; enabled: boolean; derivedFromProviderId: string | null; - providerParams?: unknown; + providerOptions?: ProviderOptions; createBaseClient: (logger: Logger) => AgentClient; contract: ProviderContract; } interface ProviderContract { - optionsSchema: z.ZodType; supportsExactMcpPreapproval: boolean; applyToolPolicy?: (provider: string, toolPolicy: ToolPolicy) => ToolPolicy; } -const EmptyProviderOptionsSchema: z.ZodType = z.object({}).strict(); - const PROVIDER_CONTRACTS: Record = { - claude: { optionsSchema: ClaudeProviderOptionsSchema, supportsExactMcpPreapproval: true }, - codex: { optionsSchema: CodexProviderOptionsSchema, supportsExactMcpPreapproval: true }, - opencode: { optionsSchema: OpenCodeProviderOptionsSchema, supportsExactMcpPreapproval: true }, + claude: { supportsExactMcpPreapproval: true }, + codex: { supportsExactMcpPreapproval: true }, + opencode: { supportsExactMcpPreapproval: true }, }; const UNSUPPORTED_PROVIDER_CONTRACT: ProviderContract = { - optionsSchema: EmptyProviderOptionsSchema, supportsExactMcpPreapproval: false, }; @@ -181,7 +165,6 @@ const HUB_E2E_TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u; // The cross-repository Hub harness owns this synthetic provider ID. It exercises the production // registry path without extending exact-preapproval support to user-defined ACP providers. const HUB_E2E_PROVIDER_CONTRACT: ProviderContract = { - optionsSchema: EmptyProviderOptionsSchema, supportsExactMcpPreapproval: true, applyToolPolicy: (provider, toolPolicy) => { for (const grant of toolPolicy.preapproved) { @@ -229,17 +212,15 @@ const PROVIDER_CLIENT_FACTORIES: Record = { managedProcesses: options?.managedProcesses, bridge: options?.openCodeBridge, }), - pi: (logger, runtimeSettings, options) => + pi: (logger, runtimeSettings) => new PiRpcAgentClient({ logger, runtimeSettings, - providerParams: options?.providerParams, }), omp: (logger, runtimeSettings, options) => new OmpAgentClient({ logger, runtimeSettings, - providerParams: options?.providerParams, runtime: options?.ompRuntime, }), mock: (logger) => new MockLoadTestAgentClient(logger), @@ -450,16 +431,27 @@ function mergeModelAdditions( ); } +// Every session member must cross this boundary, including optional capabilities. +type ForwardedAgentSession = { [K in keyof Required]: AgentSession[K] }; + export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession { return { provider, - id: inner.id, - capabilities: inner.capabilities, + get id() { + return inner.id; + }, + get capabilities() { + return inner.capabilities; + }, + get initialTimeline() { + return inner.initialTimeline; + }, get features() { return inner.features; }, run: (prompt, options) => inner.run(prompt, options), startTurn: (prompt, options) => inner.startTurn(prompt, options), + steerActiveTurn: inner.steerActiveTurn?.bind(inner), subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))), async *streamHistory() { for await (const event of inner.streamHistory()) { @@ -483,7 +475,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession revertFiles: inner.revertFiles?.bind(inner), revertBoth: inner.revertBoth?.bind(inner), tryHandleOutOfBand: inner.tryHandleOutOfBand?.bind(inner), - }; + } satisfies ForwardedAgentSession; } function wrapClientProvider( @@ -492,6 +484,7 @@ function wrapClientProvider( profileModels: ProviderProfileModel[], additionalModels: ProviderProfileModel[], profileModelsAreAdditive: boolean, + providerOptions: ProviderOptions | undefined, ): AgentClient { const listImportableSessions = inner.listImportableSessions?.bind(inner); const importSession = inner.importSession?.bind(inner); @@ -512,6 +505,7 @@ function wrapClientProvider( { ...config, provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), }, launchContext, options, @@ -525,18 +519,17 @@ function wrapClientProvider( ...handle, provider: inner.provider, }, - overrides - ? { - ...overrides, - provider: inner.provider, - } - : undefined, + { + ...overrides, + provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions), + }, launchContext, options, ), ), fetchCatalog: async (options, context) => { - const catalog = await inner.fetchCatalog(options, context); + const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context); return { ...catalog, models: mergeModels(provider, profileModels, additionalModels, catalog.models, { @@ -548,7 +541,11 @@ function wrapClientProvider( resolveDefaultModeId: inner.resolveDefaultModeId ? async ({ config, env, signal }: ResolveAgentDefaultModeInput) => await inner.resolveDefaultModeId?.({ - config: { ...config, provider: inner.provider }, + config: { + ...config, + provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), + }, env, signal, }) @@ -557,10 +554,15 @@ function wrapClientProvider( resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), listFeatures: listFeatures - ? async (config) => await listFeatures({ ...config, provider: inner.provider }) + ? async (config) => + await listFeatures({ + ...config, + provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), + }) : undefined, listImportableSessions: listImportableSessions - ? async (options) => await listImportableSessions(options) + ? async (options) => await listImportableSessions({ ...options, providerOptions }) : undefined, importSession: importSession ? async (input, context) => { @@ -569,6 +571,10 @@ function wrapClientProvider( config: { ...context.config, provider: inner.provider, + providerOptions: mergeProviderOptions( + providerOptions, + context.config.providerOptions, + ), }, storedConfig: { ...context.storedConfig, @@ -585,6 +591,7 @@ function wrapClientProvider( config: { ...imported.config, provider, + providerOptions: context.storedConfig.providerOptions, }, persistence, }; @@ -607,7 +614,7 @@ function createRegistryEntry( provider: AgentProvider, resolved: ResolvedProvider, ): ProviderDefinition { - const modelClient = resolved.createBaseClient(logger); + const modelClient = createResolvedProviderClient(logger, provider, resolved); const profileModels = resolveConfiguredModels(provider, modelClient, resolved.profileModels); const additionalModels = resolveConfiguredModels( provider, @@ -638,11 +645,7 @@ function createRegistryEntry( configuration, enabled: resolved.enabled, derivedFromProviderId: resolved.derivedFromProviderId, - optionsSchema: resolved.contract.optionsSchema, supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval, - validateOptions: (options) => - validateProviderOptions(provider, resolved.contract.optionsSchema, options), - applyOptions: (config, options) => ({ ...config, providerOptions: options }), applyToolPolicy: (config, toolPolicy) => { if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) { throw new ToolPolicyUnsupportedError(provider); @@ -713,16 +716,13 @@ function createResolvedProviderClient( const inner = resolved.createBaseClient(logger); const profileModels = resolveConfiguredModels(provider, inner, resolved.profileModels); const additionalModels = resolveConfiguredModels(provider, inner, resolved.additionalModels); - const hasModelOverrides = profileModels.length > 0 || additionalModels.length > 0; - if (inner.provider === provider && !hasModelOverrides) { - return inner; - } return wrapClientProvider( provider, inner, profileModels, additionalModels, resolved.profileModelsAreAdditive, + resolved.providerOptions, ); } @@ -745,7 +745,7 @@ function resolveRegisteredProvider(input: RegisteredProvider): ResolvedProvider profileModelsAreAdditive: false, enabled: override?.enabled ?? definition.enabledByDefault ?? true, derivedFromProviderId: null, - providerParams: override?.params, + providerOptions: configuredProviderOptions(override), createBaseClient: (logger) => input.createClient(logger, runtimeSettings), contract, }; @@ -781,7 +781,6 @@ function buildResolvedBuiltinProviders( managedProcesses: options.managedProcesses, ompRuntime: options.ompRuntime, openCodeBridge: options.openCodeBridge, - providerParams: override?.params, }), contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT, }), @@ -831,7 +830,7 @@ function addDerivedProviders( profileModelsAreAdditive: false, enabled: override.enabled !== false, derivedFromProviderId: null, - providerParams: override.params, + providerOptions: configuredProviderOptions(override), createBaseClient: (logger) => { const acpOptions = { logger, @@ -839,7 +838,6 @@ function addDerivedProviders( env: override.env, providerId, label: override.label ?? providerId, - providerParams: override.params, }; if (providerId === "cursor") { return new CursorACPAgentClient(acpOptions); @@ -877,7 +875,7 @@ function addDerivedProviders( ); const baseDefinition = baseProvider.definition; const baseFactory = getProviderClientFactory(baseProviderId); - const providerParams = override.params ?? baseProvider.providerParams; + const providerOptions = configuredProviderOptions(override) ?? baseProvider.providerOptions; resolvedProviders.set(providerId, { definition: createDerivedDefinition(providerId, baseDefinition, override), @@ -887,12 +885,11 @@ function addDerivedProviders( profileModelsAreAdditive: false, enabled: override.enabled !== false, derivedFromProviderId: baseProviderId, - providerParams, + providerOptions, createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings, { managedProcesses: options.managedProcesses, openCodeBridge: options.openCodeBridge, - providerParams, customProvider: { id: providerId, label: override.label ?? providerId, @@ -938,7 +935,6 @@ export function buildProviderRegistry( runtimeSettings: runtimeSettings?.[provider], createClient: definition.createClient, contract: { - optionsSchema: definition.optionsSchema, supportsExactMcpPreapproval: definition.supportsExactMcpPreapproval, }, }), @@ -1009,3 +1005,11 @@ export async function shutdownAgentClients( }), ); } + +function configuredProviderOptions( + override: ProviderOverride | undefined, +): ProviderOptions | undefined { + // COMPAT(providerParams): added in v0.10.0, remove after 2027-03-30 once configs use options. + // options wins as a whole record when both spellings are supplied. + return override?.options ?? override?.params; +} diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index b8c59d739d..66d86f8931 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -238,7 +238,7 @@ describe("ProviderSnapshotManager public surface", () => { } }); - test("validates complete Hub agent configurations through the current provider contract", async () => { + test("validates catalog choices while leaving provider options opaque", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), extraClients: { @@ -293,11 +293,19 @@ describe("ProviderSnapshotManager public surface", () => { path: ["thinkingOptionId"], message: "Thinking option 'missing' is not available for provider 'codex'", }, - { - path: ["providerOptions", "sandbox_workspace_write", "network_access"], - message: "Invalid input: expected boolean, received string", - }, ]); + await expect( + manager.validateAgentConfiguration({ + provider: "codex", + model: "gpt-latest", + modeId: "auto-review", + thinkingOptionId: "xhigh", + providerOptions: { + sandbox_workspace_write: { network_access: "sometimes" }, + arbitrary: true, + }, + }), + ).resolves.toEqual([]); } finally { manager.destroy(); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 22a706a279..82acd13e1f 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -185,10 +185,7 @@ export interface AgentManagerProviderState { providerDefinitions: Partial< Record< AgentProvider, - Pick< - ProviderDefinition, - "enabled" | "derivedFromProviderId" | "validateOptions" | "applyOptions" | "applyToolPolicy" - > + Pick > >; clients: Partial>; @@ -369,8 +366,6 @@ export class ProviderSnapshotManager { providerDefinitions[provider] = { enabled: definition.enabled, derivedFromProviderId: definition.derivedFromProviderId, - validateOptions: definition.validateOptions, - applyOptions: definition.applyOptions, applyToolPolicy: definition.applyToolPolicy, }; if (definition.enabled) { @@ -516,11 +511,9 @@ export class ProviderSnapshotManager { ]; } - const definition = this.requireProvider(input.provider); return validateAgentConfigurationAgainstProvider({ input, provider, - validateOptions: definition.validateOptions, }); } diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index b097f8b1e5..524ede92dd 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1,3 +1,4 @@ +import { ACPProviderOptionsSchema } from "./acp-options.js"; import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; @@ -961,6 +962,7 @@ export class ACPAgentClient implements AgentClient { launchContext?: AgentLaunchContext, ): Promise { this.assertProvider(config); + const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {}); const session = new ACPAgentSession( { ...config, provider: this.provider }, { @@ -973,14 +975,18 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, - clientCapabilities: this.clientCapabilities, + clientCapabilities: providerOptions.clientCapabilities ?? this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, beforeModeWriter: this.beforeModeWriter, thinkingOptionWriter: this.thinkingOptionWriter, - capabilities: this.capabilities, + capabilities: { + ...this.capabilities, + supportsMcpServers: + providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, + }, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1013,6 +1019,7 @@ export class ACPAgentClient implements AgentClient { provider: this.provider, cwd, }; + const providerOptions = ACPProviderOptionsSchema.parse(mergedConfig.providerOptions ?? {}); const session = new ACPAgentSession(mergedConfig, { provider: this.provider, logger: this.logger, @@ -1023,14 +1030,18 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, - clientCapabilities: this.clientCapabilities, + clientCapabilities: providerOptions.clientCapabilities ?? this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, beforeModeWriter: this.beforeModeWriter, thinkingOptionWriter: this.thinkingOptionWriter, - capabilities: this.capabilities, + capabilities: { + ...this.capabilities, + supportsMcpServers: + providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, + }, handle, agentId: launchContext?.agentId, launchEnv: launchContext?.env, @@ -1082,6 +1093,7 @@ export class ACPAgentClient implements AgentClient { raceProviderRefreshAbort( context?.signal, this.spawnProcess(PROBE_ENV, { + providerOptions: options.providerOptions, onSpawned: (spawned) => { probe = spawned; if (context?.signal.aborted) void closeProbe().catch(() => undefined); @@ -1327,6 +1339,7 @@ export class ACPAgentClient implements AgentClient { launchEnv?: Record, options?: { initializeTimeoutMs?: number; + providerOptions?: Record; onSpawned?: (probe: UninitializedACPProcess) => void; client?: ACPClient; }, @@ -1339,7 +1352,11 @@ export class ACPAgentClient implements AgentClient { }; options?.onSpawned?.(probe); try { - const initialize = await this.initializeTransport(transport, options?.initializeTimeoutMs); + const initialize = await this.initializeTransport( + transport, + options?.initializeTimeoutMs, + options?.providerOptions, + ); const initializedProbe: SpawnedACPProcess = { ...probe, initialize, @@ -1398,7 +1415,9 @@ export class ACPAgentClient implements AgentClient { protected async initializeTransport( transport: ACPProcessTransport, initializeTimeoutMs?: number, + providerOptions?: Record, ): Promise { + const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {}); let timeout: ReturnType | null = null; const initializeTimeoutPromise = initializeTimeoutMs ? new Promise((_, reject) => { @@ -1415,7 +1434,7 @@ export class ACPAgentClient implements AgentClient { protocolVersion: PROTOCOL_VERSION, clientCapabilities: buildACPClientCapabilities( this.clientCapabilityMeta, - this.clientCapabilities, + configuredOptions.clientCapabilities ?? this.clientCapabilities, ), clientInfo: { name: "Paseo", version: "dev" }, }), @@ -2419,12 +2438,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!this.sessionId) { return null; } + const { providerOptions: _providerOptions, ...persistedConfig } = this.config; return { provider: this.provider, sessionId: this.sessionId, nativeHandle: this.sessionId, metadata: { - ...this.config, + ...persistedConfig, title: this.currentTitle, }, }; diff --git a/packages/server/src/server/agent/providers/acp-options.ts b/packages/server/src/server/agent/providers/acp-options.ts new file mode 100644 index 0000000000..4f75456e38 --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-options.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const ACPProviderOptionsSchema = z + .object({ + supportsMcpServers: z.boolean().optional(), + clientCapabilities: z + .object({ + fs: z + .object({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + }) + .optional(), + terminal: z.boolean().optional(), + }) + .optional(), + }) + .passthrough(); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index dd79b57a25..b6a366370b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,3 +1,4 @@ +import { validateProviderOptions } from "../../provider-options.js"; import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; @@ -1692,7 +1693,8 @@ export class ClaudeAgentClient implements AgentClient { throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); } const model = config.model?.trim(); - const providerOptions = ClaudeProviderOptionsSchema.parse(config.providerOptions ?? {}); + const providerOptions = + validateProviderOptions("claude", ClaudeProviderOptionsSchema, config.providerOptions) ?? {}; return { ...config, provider: "claude", @@ -2659,11 +2661,12 @@ class ClaudeAgentSession implements AgentSession { if (!this.claudeSessionId) { return null; } + const { providerOptions: _providerOptions, ...persistedConfig } = this.config; this.persistence = { provider: "claude", sessionId: this.claudeSessionId, nativeHandle: this.claudeSessionId, - metadata: { ...this.config }, + metadata: { ...persistedConfig }, }; return this.persistence; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index ea52d982e8..bf3ab15c62 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1164,6 +1164,23 @@ describe("Codex app-server provider", () => { } }); + test("provider persistence leaves options owned by the stored agent config", async () => { + const appServer = createFakeCodexAppServer(); + const provider = createProviderWithFakeAppServer(appServer); + const session = await provider.createSession( + createConfig({ providerOptions: { sandbox_mode: "read-only", approval_policy: "never" } }), + ); + try { + await session.startTurn("persist the configured session"); + await appServer.waitForTurnStart(); + const handle = session.describePersistence()!; + expect(handle.metadata).not.toHaveProperty("providerOptions"); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("preapproves only granted tools on the injected Codex MCP server", async () => { const session = createSession({ modeId: undefined, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index b1dad5332c..81ea190454 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1,3 +1,4 @@ +import { validateProviderOptions } from "../provider-options.js"; import { getAgentStreamEventTurnId, type AgentPermissionAction, @@ -3532,7 +3533,8 @@ export class CodexAppServerAgentSession implements AgentSession { } this.hasWorkflowModeOverride = config.modeId !== undefined; this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID; - this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {}); + this.providerOptions = + validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions) ?? {}; this.config = config; this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env); @@ -4902,7 +4904,6 @@ export class CodexAppServerAgentSession implements AgentSession { modeId: this.config.modeId, model: this.config.model ?? null, thinkingOptionId, - providerOptions: this.config.providerOptions, toolPolicy: this.config.toolPolicy, systemPrompt: this.config.systemPrompt, mcpServers: this.config.mcpServers, diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.ts index 0a7ce428b3..0b5b9ac4ec 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.ts @@ -16,7 +16,6 @@ interface CursorACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; @@ -93,7 +92,6 @@ export class CursorACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, // cursor-agent publishes slash commands asynchronously via available_commands_update. waitForInitialCommands: true, initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts index b37e1a9a54..d70117d57d 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts @@ -1,3 +1,4 @@ +import { buildProviderRegistry } from "../provider-registry.js"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -254,22 +255,28 @@ describe("GenericACPAgentClient diagnostics", () => { test("sends configured client capabilities in catalog and live session initialization", async () => { await withFakeACPAgent("success", async (scriptPath, mode, testDir) => { const initializeTracePath = path.join(testDir, "initialize.jsonl"); - const client = new GenericACPAgentClient({ - logger: createTestLogger(), - command: [process.execPath, scriptPath, mode, "", initializeTracePath], - providerParams: { - clientCapabilities: { - fs: { - readTextFile: true, - writeTextFile: true, + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + providerOverrides: { + "configured-acp": { + extends: "acp", + label: "Configured ACP", + command: [process.execPath, scriptPath, mode, "", initializeTracePath], + options: { + supportsMcpServers: false, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, }, - terminal: true, }, }, }); + const client = registry["configured-acp"].createClient(logger); await client.fetchCatalog({ scope: "workspace", cwd: testDir, force: true }); - const session = await client.createSession({ provider: "acp", cwd: testDir }); + const session = await client.createSession({ provider: "configured-acp", cwd: testDir }); + expect(session.capabilities.supportsMcpServers).toBe(false); await session.close(); const initializeRequests = parseInitializeTrace(await readFile(initializeTracePath, "utf8")); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts index c266cc148b..a1f5c497ec 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts @@ -66,21 +66,4 @@ describe("GenericACPAgentClient", () => { }, ]); }); - - test("uses provider params to report MCP support", () => { - const _client = new GenericACPAgentClient({ - logger: createTestLogger(), - command: ["no-mcp-acp", "serve"], - providerParams: { - supportsMcpServers: false, - }, - }); - void _client; - - expect(mockState.superConstructorOptions.at(-1)).toMatchObject({ - capabilities: { - supportsMcpServers: false, - }, - }); - }); }); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index c8f8960ea2..5a14d99137 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -1,7 +1,5 @@ import type { Logger } from "pino"; -import { z } from "zod"; -import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { ACPAgentClient, @@ -18,32 +16,12 @@ import { toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; -export const GenericACPProviderParamsSchema = z - .object({ - supportsMcpServers: z.boolean().optional(), - clientCapabilities: z - .object({ - fs: z - .object({ - readTextFile: z.boolean().optional(), - writeTextFile: z.boolean().optional(), - }) - .optional(), - terminal: z.boolean().optional(), - }) - .optional(), - }) - .passthrough(); - -type GenericACPProviderParams = z.infer; - interface GenericACPAgentClientOptions { logger: Logger; command: [string, ...string[]]; env?: Record; providerId?: string; label?: string; - providerParams?: unknown; waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; diagnosticPhaseTimeoutMs?: number; @@ -61,7 +39,6 @@ export class GenericACPAgentClient extends ACPAgentClient { private readonly diagnosticPhaseTimeoutMs?: number; constructor(options: GenericACPAgentClientOptions) { - const providerParams = parseGenericACPProviderParams(options.providerParams); super({ provider: "acp", logger: options.logger, @@ -69,12 +46,11 @@ export class GenericACPAgentClient extends ACPAgentClient { env: options.env, }, defaultCommand: options.command, - capabilities: buildGenericACPCapabilities(providerParams), + capabilities: DEFAULT_ACP_CAPABILITIES, // ACP agents advertise slash commands with available_commands_update after // session/new, so the first listCommands() waits for that batch. waitForInitialCommands: options.waitForInitialCommands ?? true, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, - clientCapabilities: providerParams.clientCapabilities, clientCapabilityMeta: options.clientCapabilityMeta, configFeatureOptions: options.configFeatureOptions, extensionCommandsParser: options.extensionCommandsParser, @@ -165,17 +141,6 @@ export class GenericACPAgentClient extends ACPAgentClient { } } -function buildGenericACPCapabilities(params: GenericACPProviderParams): AgentCapabilityFlags { - return { - ...DEFAULT_ACP_CAPABILITIES, - supportsMcpServers: params.supportsMcpServers ?? DEFAULT_ACP_CAPABILITIES.supportsMcpServers, - }; -} - -function parseGenericACPProviderParams(params: unknown): GenericACPProviderParams { - return GenericACPProviderParamsSchema.parse(params ?? {}); -} - export interface CommandInvocation { command: string; args: string[]; diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.ts index 50effdf33a..7ae144f653 100644 --- a/packages/server/src/server/agent/providers/kimi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.ts @@ -15,7 +15,6 @@ interface KimiACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } // Kimi exposes thinking options only for the selected model. Keep its model-switching @@ -88,7 +87,6 @@ export class KimiACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, catalogModelResolver: resolveKimiCatalogModels, }); } diff --git a/packages/server/src/server/agent/providers/kiro-acp-agent.ts b/packages/server/src/server/agent/providers/kiro-acp-agent.ts index 53fd7f2408..41bbd97647 100644 --- a/packages/server/src/server/agent/providers/kiro-acp-agent.ts +++ b/packages/server/src/server/agent/providers/kiro-acp-agent.ts @@ -10,7 +10,6 @@ interface KiroACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } // Kiro CLI publishes its slash commands and skills asynchronously through the @@ -92,7 +91,6 @@ export class KiroACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, waitForInitialCommands: true, initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, extensionCommandsParser: parseKiroExtensionCommands, diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 90b82cff4b..3841f8bd85 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "../../tools/types.js"; import type { OmpAgentMessage } from "./rpc-types.js"; import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js"; import type { OmpUsagePollScheduler } from "./usage-poller.js"; -import { resolveOmpProviderParams } from "./provider-config.js"; +import { resolveOmpProviderOptions } from "./provider-config.js"; import { OmpRuntimeEventSchema } from "./rpc-types.js"; import { OmpHarness } from "./test-utils/omp-harness.js"; import { OmpAgentClient } from "./agent.js"; @@ -40,11 +40,11 @@ const ABORTED_TERMINAL_RESPONSE: OmpAgentMessage = { }; test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => { - expect(resolveOmpProviderParams({}).runtimeProviderParams).toMatchObject({ + expect(resolveOmpProviderOptions({}).runtimeOptions).toMatchObject({ readyTimeoutMs: 20_000, rpcTimeoutMs: 60_000, }); - expect(resolveOmpProviderParams({ rpcTimeoutMs: 90_000 }).runtimeProviderParams).toMatchObject({ + expect(resolveOmpProviderOptions({ rpcTimeoutMs: 90_000 }).runtimeOptions).toMatchObject({ readyTimeoutMs: 90_000, rpcTimeoutMs: 90_000, }); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 4e700df275..089376c8ca 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -63,10 +63,9 @@ import { mergeOmpRuntimeSettings, resolveOmpDiagnosticPaths, resolveOmpLaunchMode, - resolveOmpProviderParams, + resolveOmpProviderOptions, OMP_MODES, - type OmpModelRoleParams, - type OmpRuntimeProviderParams, + type OmpRuntimeOptions, } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; @@ -133,7 +132,6 @@ const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = { export interface OmpAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; - providerParams?: unknown; runtime?: OmpRuntime; subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; @@ -591,15 +589,15 @@ function isOmpAgentSessionEvent(event: OmpRuntimeEvent): event is OmpAgentSessio function createRuntime( logger: Logger, runtimeSettings: ProviderRuntimeSettings | undefined, - providerParams: OmpRuntimeProviderParams, + providerOptions: OmpRuntimeOptions, ): OmpRuntime { return new OmpCliRuntime({ logger, runtimeSettings, command: ["omp"], commandsRpcName: "get_available_commands", - readyTimeoutMs: providerParams.readyTimeoutMs, - requestTimeoutMs: providerParams.rpcTimeoutMs, + readyTimeoutMs: providerOptions.readyTimeoutMs, + requestTimeoutMs: providerOptions.rpcTimeoutMs, }); } @@ -2201,19 +2199,14 @@ export class OmpAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly providerParams: OmpRuntimeProviderParams; - private readonly modelRoleParams: OmpModelRoleParams; private readonly subagentCardScheduler?: OmpSubagentCardScheduler; private readonly providerIdleScheduler?: OmpProviderIdleScheduler; private readonly noTurnScheduler?: OmpNoTurnScheduler; private readonly usagePollScheduler?: OmpUsagePollScheduler; private readonly providerIdleDeadlineMs?: number; - private readonly runtime: OmpRuntime; + private readonly runtime?: OmpRuntime; constructor(options: OmpAgentClientOptions) { - const { runtimeProviderParams, modelRoleParams } = resolveOmpProviderParams( - options.providerParams, - ); const runtimeSettings = mergeOmpRuntimeSettings( { command: { @@ -2225,15 +2218,12 @@ export class OmpAgentClient implements AgentClient { ); this.logger = options.logger; this.runtimeSettings = runtimeSettings; - this.providerParams = runtimeProviderParams; - this.modelRoleParams = modelRoleParams; this.subagentCardScheduler = options.subagentCardScheduler; this.providerIdleScheduler = options.providerIdleScheduler; this.noTurnScheduler = options.noTurnScheduler; this.usagePollScheduler = options.usagePollScheduler; this.providerIdleDeadlineMs = options.providerIdleDeadlineMs; - this.runtime = - options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams); + this.runtime = options.runtime; } private async configureNativePaseoTools( @@ -2276,7 +2266,7 @@ export class OmpAgentClient implements AgentClient { config: AgentSessionConfig, launchContext?: AgentLaunchContext, ): Promise { - const launchMode = this.resolveLaunchMode(config.modeId); + const launchMode = this.resolveLaunchMode(config.modeId, config.providerOptions); const startInput: OmpStartSessionInput = { cwd: config.cwd, protocolMode: "rpc-ui", @@ -2288,7 +2278,9 @@ export class OmpAgentClient implements AgentClient { systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), env: launchContext?.env, }; - const runtimeSession = await this.runtime.startSession(startInput); + const runtimeSession = await this.resolveRuntime(config.providerOptions).startSession( + startInput, + ); let hostTools: OmpHostToolRouter | undefined; try { hostTools = await this.configureNativePaseoTools( @@ -2347,14 +2339,19 @@ export class OmpAgentClient implements AgentClient { ); } - const launchMode = this.resolveLaunchMode(resumeConfig.modeId); + const launchMode = this.resolveLaunchMode( + resumeConfig.modeId, + resumeConfig.config.providerOptions, + ); const startInput = buildResumeStartInput({ resumeConfig, sessionFile, launchContext, launchMode, }); - const runtimeSession = await this.runtime.startSession(startInput); + const runtimeSession = await this.resolveRuntime( + resumeConfig.config.providerOptions, + ).startSession(startInput); let hostTools: OmpHostToolRouter | undefined; try { hostTools = await this.configureNativePaseoTools( @@ -2396,8 +2393,8 @@ export class OmpAgentClient implements AgentClient { launchContext?: AgentLaunchContext, ): OmpAgentSessionOptions["restartRuntime"] { return async (sessionFile, modeId) => { - const launchMode = this.resolveLaunchMode(modeId); - const next = await this.runtime.startSession({ + const launchMode = this.resolveLaunchMode(modeId, config.providerOptions); + const next = await this.resolveRuntime(config.providerOptions).startSession({ ...startInput, model: config.model, thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined, @@ -2428,7 +2425,7 @@ export class OmpAgentClient implements AgentClient { options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { - const launchMode = this.resolveLaunchMode(undefined); + const launchMode = this.resolveLaunchMode(undefined, options.providerOptions); let runtimeSession: OmpRuntimeSession | undefined; let closePromise: Promise | undefined; const closeSession = () => { @@ -2440,7 +2437,7 @@ export class OmpAgentClient implements AgentClient { context?.signal.addEventListener("abort", handleAbort, { once: true }); try { await runProviderRefreshActivity(context, "runtime.start", async () => { - runtimeSession = await this.runtime.startSession({ + runtimeSession = await this.resolveRuntime(options.providerOptions).startSession({ cwd: options.scope === "global" ? homedir() : options.cwd, protocolMode: "rpc-ui", modeId: launchMode.modeId, @@ -2474,14 +2471,15 @@ export class OmpAgentClient implements AgentClient { ): Promise { return await listOmpImportableSessions({ ...options, - sessionDir: this.providerParams.sessionDir, + sessionDir: resolveOmpProviderOptions(options?.providerOptions).runtimeOptions.sessionDir, runtimeSettings: this.runtimeSettings, }); } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { const descriptorOptions = { - sessionDir: this.providerParams.sessionDir, + sessionDir: resolveOmpProviderOptions(context.config.providerOptions).runtimeOptions + .sessionDir, runtimeSettings: this.runtimeSettings, }; const importConfig = await readOmpImportSessionConfig( @@ -2556,11 +2554,19 @@ export class OmpAgentClient implements AgentClient { } } - private resolveLaunchMode(modeId: string | undefined): { + private resolveLaunchMode( + modeId: string | undefined, + providerOptions?: Record, + ): { modeId: string; extraArgs: string[]; } { - return resolveOmpLaunchMode(modeId, this.modelRoleParams); + return resolveOmpLaunchMode(modeId, resolveOmpProviderOptions(providerOptions).modelRoles); + } + + private resolveRuntime(providerOptions: Record | undefined): OmpRuntime { + const { runtimeOptions } = resolveOmpProviderOptions(providerOptions); + return this.runtime ?? createRuntime(this.logger, this.runtimeSettings, runtimeOptions); } private async resolveOmpLaunch(): Promise { diff --git a/packages/server/src/server/agent/providers/omp/provider-config.ts b/packages/server/src/server/agent/providers/omp/provider-config.ts index 2fe5067845..0bcd369a1c 100644 --- a/packages/server/src/server/agent/providers/omp/provider-config.ts +++ b/packages/server/src/server/agent/providers/omp/provider-config.ts @@ -13,7 +13,7 @@ const DEFAULT_OMP_RPC_TIMEOUT_MS = 60_000; export const MIN_SUPPORTED_OMP_VERSION = "16.3.9"; export { OMP_MODES }; -export const OmpProviderParamsSchema = z +export const OmpProviderOptionsSchema = z .object({ sessionDir: z.string().min(1).optional(), rpcTimeoutMs: z.number().int().positive().optional(), @@ -23,13 +23,13 @@ export const OmpProviderParamsSchema = z }) .strict(); -export interface OmpRuntimeProviderParams { +export interface OmpRuntimeOptions { sessionDir?: string; readyTimeoutMs: number; rpcTimeoutMs: number; } -export interface OmpModelRoleParams { +export interface OmpModelRoles { smolModel?: string; slowModel?: string; planModel?: string; @@ -37,9 +37,9 @@ export interface OmpModelRoleParams { export function resolveOmpLaunchMode( modeId: string | undefined, - modelRoleParams: OmpModelRoleParams = {}, + modelRoles: OmpModelRoles = {}, ): { modeId: string; extraArgs: string[] } { - const modelRoleArgs = resolveOmpModelRoleArgs(modelRoleParams); + const modelRoleArgs = resolveOmpModelRoleArgs(modelRoles); switch (modeId ?? DEFAULT_OMP_MODE_ID) { case "full": return { modeId: "full", extraArgs: ["--approval-mode", "yolo", ...modelRoleArgs] }; @@ -58,11 +58,11 @@ export function resolveOmpLaunchMode( } } -function resolveOmpModelRoleArgs(modelRoleParams: OmpModelRoleParams): string[] { +function resolveOmpModelRoleArgs(modelRoles: OmpModelRoles): string[] { const args: string[] = []; - if (modelRoleParams.smolModel) args.push("--smol", modelRoleParams.smolModel); - if (modelRoleParams.slowModel) args.push("--slow", modelRoleParams.slowModel); - if (modelRoleParams.planModel) args.push("--plan", modelRoleParams.planModel); + if (modelRoles.smolModel) args.push("--smol", modelRoles.smolModel); + if (modelRoles.slowModel) args.push("--slow", modelRoles.slowModel); + if (modelRoles.planModel) args.push("--plan", modelRoles.planModel); return args; } @@ -128,22 +128,22 @@ export function formatOmpVersionSupport(versionOutput: string): string { return `${match[1]}.${match[2]}.${match[3]} (${supported ? "supported" : "unsupported"}; minimum ${MIN_SUPPORTED_OMP_VERSION})`; } -export function resolveOmpProviderParams(providerParams: unknown): { - runtimeProviderParams: OmpRuntimeProviderParams; - modelRoleParams: OmpModelRoleParams; +export function resolveOmpProviderOptions(providerOptions: unknown): { + runtimeOptions: OmpRuntimeOptions; + modelRoles: OmpModelRoles; } { - const params = OmpProviderParamsSchema.parse(providerParams ?? {}); - const configuredRpcTimeoutMs = params.rpcTimeoutMs; + const options = OmpProviderOptionsSchema.parse(providerOptions ?? {}); + const configuredRpcTimeoutMs = options.rpcTimeoutMs; return { - runtimeProviderParams: { - sessionDir: params.sessionDir, + runtimeOptions: { + sessionDir: options.sessionDir, readyTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_READY_TIMEOUT_MS, rpcTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_RPC_TIMEOUT_MS, }, - modelRoleParams: { - ...(params.smolModel ? { smolModel: params.smolModel } : {}), - ...(params.slowModel ? { slowModel: params.slowModel } : {}), - ...(params.planModel ? { planModel: params.planModel } : {}), + modelRoles: { + ...(options.smolModel ? { smolModel: options.smolModel } : {}), + ...(options.slowModel ? { slowModel: options.slowModel } : {}), + ...(options.planModel ? { planModel: options.planModel } : {}), }, }; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 70f7f367e1..ccd605ef06 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,3 +1,4 @@ +import { validateProviderOptions } from "../provider-options.js"; import { createOpencodeClient, type AssistantMessage as OpenCodeAssistantMessage, @@ -1930,7 +1931,9 @@ export class OpenCodeAgentClient implements AgentClient { if (config.provider !== "opencode") { throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); } - const providerOptions = OpenCodeProviderOptionsSchema.parse(config.providerOptions ?? {}); + const providerOptions = + validateProviderOptions("opencode", OpenCodeProviderOptionsSchema, config.providerOptions) ?? + {}; return normalizeOpenCodeConfig({ ...config, provider: "opencode", providerOptions }); } diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 29c515ad76..bf04d54071 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -18,7 +18,7 @@ import { describe, expect, onTestFinished, test } from "vitest"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; import { - PiProviderParamsSchema, + PiProviderOptionsSchema, PiRpcAgentClient, PiRpcAgentSession, transformPiModels, @@ -56,8 +56,8 @@ interface PiThinkingCatalogCase { } test("Pi RPC timeout defaults to 60 seconds and accepts an override", () => { - expect(PiProviderParamsSchema.parse({}).rpcTimeoutMs).toBe(60_000); - expect(PiProviderParamsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000); + expect(PiProviderOptionsSchema.parse({}).rpcTimeoutMs).toBe(60_000); + expect(PiProviderOptionsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000); }); function createClient( @@ -2465,10 +2465,11 @@ describe("PiRpcAgentClient", () => { const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: new FakePi(), - providerParams: { sessionDir: sessionsDir }, }); - await expect(client.listImportableSessions({ cwd })).resolves.toEqual([ + await expect( + client.listImportableSessions({ cwd, providerOptions: { sessionDir: sessionsDir } }), + ).resolves.toEqual([ { providerHandleId: sessionFile, cwd, @@ -2516,7 +2517,9 @@ describe("PiRpcAgentClient", () => { }, }); - await expect(client.listImportableSessions({ cwd })).resolves.toMatchObject([ + await expect( + client.listImportableSessions({ cwd, providerOptions: { sessionDir: sessionsDir } }), + ).resolves.toMatchObject([ { providerHandleId: sessionFile, cwd, @@ -2572,7 +2575,6 @@ describe("PiRpcAgentClient", () => { const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi, - providerParams: { sessionDir: sessionsDir }, }); const imported = await client.importSession( diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 1d8b969030..6b5de0314e 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -105,7 +105,7 @@ const DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS = 30_000; const DEFAULT_PI_RPC_TIMEOUT_MS = 60_000; const QUESTION_RESPONSE_HEADER = "Response"; -export const PiProviderParamsSchema = z +export const PiProviderOptionsSchema = z .object({ sessionDir: z.string().min(1).optional(), rpcTimeoutMs: z.number().int().positive().default(DEFAULT_PI_RPC_TIMEOUT_MS), @@ -113,8 +113,6 @@ export const PiProviderParamsSchema = z }) .strict(); -type PiProviderParams = z.infer; - const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ { name: "compact", @@ -186,7 +184,6 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{ export interface PiRpcAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; - providerParams?: unknown; runtime?: PiRuntime; usagePollScheduler?: PiUsagePollScheduler; } @@ -2465,8 +2462,7 @@ export class PiRpcAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly providerParams: PiProviderParams; - private readonly runtime: PiRuntime; + private readonly runtime?: PiRuntime; private readonly usagePollScheduler?: PiUsagePollScheduler; constructor(options: PiRpcAgentClientOptions) { @@ -2474,10 +2470,7 @@ export class PiRpcAgentClient implements AgentClient { this.capabilities = capabilitiesForClient(); this.logger = options.logger; this.runtimeSettings = options.runtimeSettings; - this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {}); - this.runtime = - options.runtime ?? - createRuntime(options.logger, options.runtimeSettings, this.providerParams.rpcTimeoutMs); + this.runtime = options.runtime; this.usagePollScheduler = options.usagePollScheduler; } @@ -2485,11 +2478,13 @@ export class PiRpcAgentClient implements AgentClient { config: AgentSessionConfig, launchContext?: AgentLaunchContext, ): Promise { + const providerOptions = PiProviderOptionsSchema.parse(config.providerOptions ?? {}); + const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs); const mcpEnv = { ...this.runtimeSettings?.env, ...launchContext?.env, }; - const mcp = await this.prepareMcpInjection(config.cwd, config.mcpServers, mcpEnv); + const mcp = await this.prepareMcpInjection(config.cwd, config.mcpServers, mcpEnv, runtime); const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null; const paseoExtension = createPiPaseoExtensionFile({ systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), @@ -2497,7 +2492,7 @@ export class PiRpcAgentClient implements AgentClient { }); let runtimeSession: PiRuntimeSession; try { - runtimeSession = await this.runtime.startSession({ + runtimeSession = await runtime.startSession({ cwd: config.cwd, model: config.model, thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? undefined, @@ -2518,7 +2513,7 @@ export class PiRpcAgentClient implements AgentClient { initialState: await runtimeSession.getState(), capabilities: capabilitiesForSession(mcp !== null), cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), - extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + extensionTimeoutMs: providerOptions.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, }); @@ -2543,6 +2538,10 @@ export class PiRpcAgentClient implements AgentClient { const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider); + const providerOptions = PiProviderOptionsSchema.parse( + resumeConfig.config.providerOptions ?? {}, + ); + const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs); const mcpEnv = { ...this.runtimeSettings?.env, ...launchContext?.env, @@ -2551,6 +2550,7 @@ export class PiRpcAgentClient implements AgentClient { resumeConfig.cwd, resumeConfig.config.mcpServers, mcpEnv, + runtime, ); const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null; const paseoExtension = createPiPaseoExtensionFile({ @@ -2562,7 +2562,7 @@ export class PiRpcAgentClient implements AgentClient { }); let runtimeSession: PiRuntimeSession; try { - runtimeSession = await this.runtime.startSession( + runtimeSession = await runtime.startSession( buildResumeStartInput({ resumeConfig, sessionFile, @@ -2583,7 +2583,7 @@ export class PiRpcAgentClient implements AgentClient { initialState: await runtimeSession.getState(), capabilities: capabilitiesForSession(mcp !== null), cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), - extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + extensionTimeoutMs: providerOptions.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, }); @@ -2599,6 +2599,8 @@ export class PiRpcAgentClient implements AgentClient { options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { + const providerOptions = PiProviderOptionsSchema.parse(options.providerOptions ?? {}); + const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs); let runtimeSession: PiRuntimeSession | undefined; let closePromise: Promise | undefined; const closeSession = () => { @@ -2610,7 +2612,7 @@ export class PiRpcAgentClient implements AgentClient { context?.signal.addEventListener("abort", handleAbort, { once: true }); try { await runProviderRefreshActivity(context, "runtime.start", async () => { - runtimeSession = await this.runtime.startSession({ + runtimeSession = await runtime.startSession({ cwd: options.scope === "global" ? homedir() : options.cwd, signal: context?.signal, }); @@ -2652,7 +2654,7 @@ export class PiRpcAgentClient implements AgentClient { ): Promise { return await listPiImportableSessions({ ...options, - sessionDir: this.providerParams.sessionDir, + sessionDir: PiProviderOptionsSchema.parse(options?.providerOptions ?? {}).sessionDir, runtimeSettings: this.runtimeSettings, }); } @@ -2704,15 +2706,20 @@ export class PiRpcAgentClient implements AgentClient { } } + private resolveRuntime(rpcTimeoutMs: number): PiRuntime { + return this.runtime ?? createRuntime(this.logger, this.runtimeSettings, rpcTimeoutMs); + } + private async prepareMcpInjection( cwd: string, servers: Record | undefined, env: Record | undefined, + runtime: PiRuntime, ): Promise { if (!servers || Object.keys(servers).length === 0) { return null; } - const mcpSupport = await this.detectMcpSupport(cwd, env); + const mcpSupport = await this.detectMcpSupport(cwd, env, runtime); if (mcpSupport === "builtin") { return { kind: "builtin", servers: toPiBuiltinMcpServers(servers) }; } @@ -2727,9 +2734,10 @@ export class PiRpcAgentClient implements AgentClient { private async detectMcpSupport( cwd: string, - env?: Record, + env: Record | undefined, + runtime: PiRuntime, ): Promise { - const runtimeSession = await this.runtime.startSession({ cwd, env }).catch((error) => { + const runtimeSession = await runtime.startSession({ cwd, env }).catch((error) => { this.logger.debug({ err: error, cwd }, "Pi MCP probe failed to start"); return null; }); diff --git a/packages/server/src/server/agent/providers/trae-acp-agent.ts b/packages/server/src/server/agent/providers/trae-acp-agent.ts index b1e0893e4b..c14525211e 100644 --- a/packages/server/src/server/agent/providers/trae-acp-agent.ts +++ b/packages/server/src/server/agent/providers/trae-acp-agent.ts @@ -8,7 +8,6 @@ interface TraeACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } const TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; @@ -21,7 +20,6 @@ export class TraeACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, // traecli publishes slash commands and skills asynchronously via available_commands_update. waitForInitialCommands: true, initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts index a96dc8cfdd..d78b29d732 100644 --- a/packages/server/src/server/hub/daemon-executions.test.ts +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -1,3 +1,7 @@ +import type { AgentClient } from "../agent/agent-sdk-types.js"; +import { validateProviderOptions } from "../agent/provider-options.js"; +import { CodexProviderOptionsSchema } from "../agent/providers/codex/options.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { afterEach, expect, test } from "vitest"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; @@ -8,8 +12,12 @@ afterEach(async () => { relationship = null; }); -async function launchRelationship(): Promise { - const launched = await HubRelationshipHarness.start(); +async function launchRelationship( + clients?: Record, +): Promise { + const launched = clients + ? await HubRelationshipHarness.startWithRealAgentClients(clients) + : await HubRelationshipHarness.start(); await launched.beginConnect().result; launched.connectLatestSocket(); relationship = launched; @@ -127,8 +135,22 @@ test("Hub can preapprove only tools on MCP servers injected in the same request" expect(hub.providerCreations()).toBe(0); }); -test("Hub returns path-specific structured provider option feedback", async () => { - const hub = await launchRelationship(); +test("Hub returns path-specific feedback thrown by the provider", async () => { + const clients = createTestAgentClients(); + const inner = clients.codex; + const provider: AgentClient = { + provider: inner.provider, + capabilities: inner.capabilities, + isAvailable: (signal, options) => inner.isAvailable(signal, options), + fetchCatalog: (options, context) => inner.fetchCatalog(options, context), + async createSession(config, launch, options) { + validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions); + return inner.createSession(config, launch, options); + }, + resumeSession: (handle, overrides, launch, options) => + inner.resumeSession(handle, overrides, launch, options), + }; + const hub = await launchRelationship({ ...clients, codex: provider }); hub.beginOwnedCreate("invalid-options", "invalid-options-execution", { providerOptions: { sandbox_workspace_write: { writable_roots: ["/tmp", 42] }, diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 612bb7e9a9..14e0221913 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -21,8 +21,6 @@ import type { AgentStreamEvent, } from "../agent/agent-sdk-types.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; -import { validateProviderOptions } from "../agent/provider-options.js"; -import { ClaudeProviderOptionsSchema } from "../agent/providers/claude/options.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; @@ -66,12 +64,6 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick - validateProviderOptions("claude", ClaudeProviderOptionsSchema, options), - applyOptions: (config: AgentSessionConfig, options: AgentSessionConfig["providerOptions"]) => ({ - ...config, - ...(options ? { providerOptions: options } : {}), - }), }; let workspaceArchiveInProgress = false; diff --git a/public-docs/custom-providers.md b/public-docs/custom-providers.md index b325492768..8cc80add18 100644 --- a/public-docs/custom-providers.md +++ b/public-docs/custom-providers.md @@ -24,6 +24,25 @@ To override a bundled provider plugin, use its provider ID and omit `extends`. F The examples below are a quick tour. The full, up-to-date reference is on GitHub: [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md). +## Provider options + +Set defaults in `agents.providers..options`: + +```json +{ + "agents": { + "providers": { + "pi": { "options": { "rpcTimeoutMs": 90000 } } + } + } +} +``` + +Override defaults for one agent with SDK `config.options` (wire +`providerOptions`). The provider validates and applies the resulting record. +See [Provider options](/docs/sdk/provider-options) for the two layers, deep-merge +rule, persistence behavior, and provider-specific examples. + ## Extending a first-class provider ```json @@ -194,4 +213,4 @@ Any agent that speaks [ACP](https://agentclientprotocol.com) over stdio can be a ## Full reference -For the complete field reference (`extends`, `label`, `command`, `env`, `models`, `additionalModels`, `disallowedTools`, `paseoTools`, `enabled`, `order`), model and thinking-option schemas, and deeper examples for each plan, see [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md) on GitHub. See [Limit Paseo tools by provider](/docs/mcp#limit-paseo-tools-by-provider) for `paseoTools` configuration. +For the complete field reference (`extends`, `label`, `command`, `env`, `options`, `models`, `additionalModels`, `disallowedTools`, `paseoTools`, `enabled`, `order`), model and thinking-option schemas, and deeper examples for each plan, see [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md) on GitHub. See [Limit Paseo tools by provider](/docs/mcp#limit-paseo-tools-by-provider) for `paseoTools` configuration. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 4d84edb578..d96b18f9d6 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -288,7 +288,10 @@ effects. Repeat `clientMessageId` on the live user timeline item and publish exa `session.prompt_result`. Publish provider-created children as sessions with `parentSessionId`. Provider settings are toggle/select descriptors that Paseo renders in the composer. Keep -provider-private JSON under `providerOptions`. Host tools arrive as MCP servers in the complete +provider-private options under `ProviderSessionConfig.providerOptions` on `session.open`. +This record includes configured defaults and per-agent overrides; validate and apply it in your +provider. See [Provider options](/docs/sdk/provider-options) for configuration and merge semantics. +Host tools arrive as MCP servers in the complete session config. Paseo refreshes an agent by closing its current provider session and opening it with current @@ -603,7 +606,7 @@ type PluginTurnOutcome = | `provider`, `model` | Separate fields; changing provider may require changing model/mode/options | | `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections | | `title`, `systemPrompt` | Agent configuration | -| `providerOptions` | Provider-specific validated options | +| `providerOptions` | Opaque provider-specific options | | `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | | `cwd` | Cannot change | | `internal` | Daemon-owned; cannot change through this hook | diff --git a/public-docs/sdk/provider-options.md b/public-docs/sdk/provider-options.md index a8bd5bad0b..5450890224 100644 --- a/public-docs/sdk/provider-options.md +++ b/public-docs/sdk/provider-options.md @@ -8,9 +8,46 @@ category: TypeScript SDK # Provider options -`config.options` passes settings straight through to the provider CLI. Paseo validates the object against that provider's strict schema before starting the agent, so an unknown or misspelled key fails agent creation instead of silently doing nothing. +Provider options are an opaque `Record` passed to the provider. +The provider decides how to validate and apply it. Claude, Codex, and OpenCode +use strict schemas: an unknown or misspelled key fails agent creation with a +message naming the bad key. Providers without option handling ignore the record. +Pi, OMP, ACP, and plugin providers interpret their own keys. -Options are provider-native. A Codex sandbox key is not a Claude sandbox key. Codex, Claude, and OpenCode accept options; every other provider rejects a non-empty `options`. +You set options in two places: + +- **Provider defaults:** `config.json` → `agents.providers..options`. +- **One agent:** `config.options` in `client.agents.create()`. Its wire name is + `providerOptions`. + +The daemon deep-merges the agent's options over the provider defaults at session +launch or resume. Plain objects merge recursively; arrays, scalars, and `null` +replace the base value. Only the agent's own options are stored. Changing +`config.json` defaults takes effect on the next launch or resume. + +For example, set Codex defaults once: + +```json +{ + "agents": { + "providers": { + "codex": { + "options": { + "sandbox_mode": "read-only", + "approval_policy": "never", + "sandbox_workspace_write": { "network_access": false } + } + } + } + } +} +``` + +Then create one agent with `config.options: { sandbox_mode: "workspace-write" }`. +It receives `workspace-write`, retains `approval_policy: "never"`, and retains +`sandbox_workspace_write.network_access: false`. With no per-agent options it +receives the defaults above. Keys are provider-native; use the sections below +for the options each provider accepts. Provider options are not a host boundary. They constrain the agent CLI, which runs as your user on your machine. For untrusted work, run the daemon in a container or on a separate machine. See [Security](/docs/security).