fix(providers): apply configured options to every agent provider (#5780)

* fix(providers): apply configured options at session startup

Treat provider options as one opaque record, with provider-owned validation and per-agent overrides over configured defaults. Keep agent overrides separate from defaults so configuration changes apply on the next launch or resume.

* test(providers): verify provider-owned validation through Hub

* fix(providers): preserve the complete wrapped session interface
This commit is contained in:
Mohamed Boudra
2026-09-30 19:37:14 +01:00
committed by GitHub
parent 318a84a570
commit 29c198f955
45 changed files with 690 additions and 425 deletions
+38 -9
View File
@@ -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 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. catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
## Provider options
Set provider defaults in `agents.providers.<id>.options`. At agent creation, use
`providerOptions` on the wire or `config.options` in the SDK. Both are opaque
`Record<string, unknown>` 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 ## 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_CONFIG_HOME": "~/.config/omp-work",
"XDG_STATE_HOME": "~/.local/state/omp-work" "XDG_STATE_HOME": "~/.local/state/omp-work"
}, },
"params": { "options": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions", "sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"rpcTimeoutMs": 60000, "rpcTimeoutMs": 60000,
"smolModel": "openai/gpt-5-mini", "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: 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", "extends": "pi",
"label": "My Pi Fork", "label": "My Pi Fork",
"command": ["my-pi-fork"], "command": ["my-pi-fork"],
"params": { "options": {
"sessionDir": "~/.my-pi-fork/sessions", "sessionDir": "~/.my-pi-fork/sessions",
"rpcTimeoutMs": 60000 "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 <session-file>`. `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 <session-file>`. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
--- ---
@@ -484,7 +513,7 @@ Required fields for ACP providers:
- `label` - `label`
- `command` — the command to spawn the agent process (must support ACP over stdio) - `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 ```json
{ {
@@ -494,7 +523,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
"extends": "acp", "extends": "acp",
"label": "My Agent", "label": "My Agent",
"command": ["my-agent", "acp"], "command": ["my-agent", "acp"],
"params": { "options": {
"supportsMcpServers": false "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, ACP agents execute filesystem operations in their own environment by default,
while terminal operations run through Paseo on the host. To customize which 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 ```json
{ {
@@ -515,7 +544,7 @@ operations Paseo handles, configure client capabilities in provider params:
"extends": "acp", "extends": "acp",
"label": "Container Agent", "label": "Container Agent",
"command": ["container-agent", "acp"], "command": ["container-agent", "acp"],
"params": { "options": {
"clientCapabilities": { "clientCapabilities": {
"fs": { "fs": {
"readTextFile": false, "readTextFile": false,
@@ -684,7 +713,7 @@ Every entry under `agents.providers` accepts these fields:
| `description` | `string` | No | Short description shown in the UI | | `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | | `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process | | `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` | | `options` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | | `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) | | `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"]`) | | `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
+5 -1
View File
@@ -431,7 +431,11 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`. configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`. 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 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 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 persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
+8 -40
View File
@@ -22,47 +22,15 @@ provider icon map. You do not need a core manifest entry or provider factory.
## Provider-native session options ## Provider-native session options
`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The The provider owns validation and application of the opaque record in
names and nesting are the provider's native contract; options are not portable between providers. `AgentSessionConfig.providerOptions`. The registry supplies the effective options
Paseo validates the object with the selected provider's strict schema before constructing a session. at session startup. See [provider configuration](custom-providers.md#provider-options)
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model, for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as for native keys and examples.
top-level provider options.
This Paseo version accepts these keys: 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
- **Codex:** `approval_policy`, `sandbox_mode`, server and tool identity without approving native tools.
`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.
## Core adapter patterns ## Core adapter patterns
@@ -1242,6 +1242,7 @@ test.describe("Agent message submission", () => {
await gate.waitForHeldServerMessage("send_agent_message_response"); await gate.waitForHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1); await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByText(/^Worked for/)).toHaveCount(0); await expect(page.getByText(/^Worked for/)).toHaveCount(0);
await expectAgentReadyToInterrupt(page);
gate.releaseHeldServerMessage("send_agent_message_response"); gate.releaseHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1); await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
} finally { } finally {
+2 -2
View File
@@ -114,7 +114,7 @@ export interface ProviderSessionConfig {
mode?: string; mode?: string;
thinkingOption?: string; thinkingOption?: string;
settings: Readonly<Record<string, JsonValue>>; settings: Readonly<Record<string, JsonValue>>;
providerOptions?: Readonly<Record<string, JsonValue>>; providerOptions?: Readonly<Record<string, unknown>>;
title?: string; title?: string;
persist: boolean; persist: boolean;
} }
@@ -742,7 +742,7 @@ const sessionConfigSchema = z
mode: z.string().optional(), mode: z.string().optional(),
thinkingOption: z.string().optional(), thinkingOption: z.string().optional(),
settings: jsonObjectSchema, settings: jsonObjectSchema,
providerOptions: jsonObjectSchema.optional(), providerOptions: z.record(z.string(), z.unknown()).optional(),
title: z.string().optional(), title: z.string().optional(),
persist: z.boolean(), persist: z.boolean(),
}) })
+1 -1
View File
@@ -498,7 +498,7 @@ export type JsonValue =
| JsonValue[] | JsonValue[]
| { [key: string]: JsonValue }; | { [key: string]: JsonValue };
export type ProviderOptions = Record<string, JsonValue>; export type ProviderOptions = Record<string, unknown>;
export interface McpToolRef { export interface McpToolRef {
kind: "mcp"; kind: "mcp";
+1 -1
View File
@@ -418,7 +418,7 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [
McpSseServerConfigSchema, McpSseServerConfigSchema,
]); ]);
const ProviderOptionsSchema = z.record(z.string(), z.json()); const ProviderOptionsSchema = z.record(z.string(), z.unknown());
const McpToolRefSchema = z const McpToolRefSchema = z
.object({ .object({
+2
View File
@@ -54,6 +54,8 @@ export const ProviderOverrideSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(), command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string(), z.string()).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(), params: z.record(z.string(), z.unknown()).optional(),
models: z.array(ProviderProfileModelSchema).optional(), models: z.array(ProviderProfileModelSchema).optional(),
additionalModels: z.array(ProviderProfileModelSchema).optional(), additionalModels: z.array(ProviderProfileModelSchema).optional(),
+1 -1
View File
@@ -33,7 +33,7 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [
archiveOnFinish: z.boolean().optional(), archiveOnFinish: z.boolean().optional(),
isolation: z.enum(["local", "worktree"]).optional(), isolation: z.enum(["local", "worktree"]).optional(),
title: z.string().trim().min(1).nullable().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(), featureValues: z.record(z.string(), z.unknown()).optional(),
systemPrompt: z.string().optional(), systemPrompt: z.string().optional(),
mcpServers: z.record(z.string(), z.unknown()).optional(), mcpServers: z.record(z.string(), z.unknown()).optional(),
@@ -3,7 +3,6 @@ import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messag
import type { ProviderSnapshotEntry } from "./agent-sdk-types.js"; import type { ProviderSnapshotEntry } from "./agent-sdk-types.js";
import { filterSelectableAgentModels } from "./agent-sdk-types.js"; import { filterSelectableAgentModels } from "./agent-sdk-types.js";
import { ProviderOptionsValidationError } from "./provider-options.js";
export interface AgentConfigurationValidationInput { export interface AgentConfigurationValidationInput {
provider: string; provider: string;
@@ -16,13 +15,11 @@ export interface AgentConfigurationValidationInput {
interface AgentConfigurationValidationContext { interface AgentConfigurationValidationContext {
input: AgentConfigurationValidationInput; input: AgentConfigurationValidationInput;
provider: ProviderSnapshotEntry; provider: ProviderSnapshotEntry;
validateOptions(options: ProviderOptions | undefined): ProviderOptions | undefined;
} }
export function validateAgentConfigurationAgainstProvider({ export function validateAgentConfigurationAgainstProvider({
input, input,
provider, provider,
validateOptions,
}: AgentConfigurationValidationContext): HubExecutionAgentValidationIssue[] { }: AgentConfigurationValidationContext): HubExecutionAgentValidationIssue[] {
const issues: HubExecutionAgentValidationIssue[] = []; const issues: HubExecutionAgentValidationIssue[] = [];
const models = filterSelectableAgentModels(provider.models); 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; return issues;
} }
@@ -17,7 +17,7 @@ import {
PARENT_AGENT_ID_LABEL, PARENT_AGENT_ID_LABEL,
} from "@getpaseo/protocol/agent-labels"; } from "@getpaseo/protocol/agent-labels";
import type { Logger } from "pino"; 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 type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
import { z } from "zod"; import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
@@ -292,11 +292,6 @@ interface AgentManagerRescueTimeouts {
interface ProviderEnabledFlag { interface ProviderEnabledFlag {
enabled: boolean; enabled: boolean;
derivedFromProviderId?: string | null; derivedFromProviderId?: string | null;
validateOptions?: (options: ProviderOptions | undefined) => ProviderOptions | undefined;
applyOptions?: (
config: AgentSessionConfig,
options: ProviderOptions | undefined,
) => AgentSessionConfig;
applyToolPolicy?: ( applyToolPolicy?: (
config: AgentSessionConfig, config: AgentSessionConfig,
toolPolicy: ToolPolicy | undefined, toolPolicy: ToolPolicy | undefined,
@@ -5094,22 +5089,15 @@ export class AgentManager {
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig { private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
const definition = this.providerDefinitions.get(config.provider); const definition = this.providerDefinitions.get(config.provider);
if (config.providerOptions !== undefined && !definition?.validateOptions) { this.validateToolPolicyServers(config);
throw new Error(`Provider '${config.provider}' does not accept providerOptions`); if (config.toolPolicy && !definition?.applyToolPolicy) {
}
const validatedOptions = definition?.validateOptions?.(config.providerOptions);
const withOptions = definition?.applyOptions
? definition.applyOptions(config, validatedOptions)
: config;
this.validateToolPolicyServers(withOptions);
if (withOptions.toolPolicy && !definition?.applyToolPolicy) {
throw new Error( throw new Error(
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`, `Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
); );
} }
return definition?.applyToolPolicy return definition?.applyToolPolicy
? definition.applyToolPolicy(withOptions, withOptions.toolPolicy) ? definition.applyToolPolicy(config, config.toolPolicy)
: withOptions; : config;
} }
private validateToolPolicyServers(config: AgentSessionConfig): void { private validateToolPolicyServers(config: AgentSessionConfig): void {
@@ -535,6 +535,7 @@ export interface AgentSlashCommand {
} }
export interface ListImportableSessionsOptions { export interface ListImportableSessionsOptions {
providerOptions?: ProviderOptions;
limit?: number; limit?: number;
/** Optional case-insensitive descriptor search text. */ /** Optional case-insensitive descriptor search text. */
query?: string; query?: string;
@@ -697,7 +698,7 @@ export interface AgentSession {
} | null; } | null;
} }
export type FetchCatalogOptions = export type FetchCatalogOptions = { providerOptions?: ProviderOptions } & (
| { | {
scope: "global"; scope: "global";
force: boolean; force: boolean;
@@ -706,7 +707,8 @@ export type FetchCatalogOptions =
scope: "workspace"; scope: "workspace";
cwd: string; cwd: string;
force: boolean; force: boolean;
}; }
);
export interface ProviderRefreshContext { export interface ProviderRefreshContext {
readonly signal: AbortSignal; readonly signal: AbortSignal;
@@ -16,7 +16,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
model: z.string().nullable().optional(), model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(), thinkingOptionId: z.string().nullable().optional(),
featureValues: z.record(z.string(), z.unknown()).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 toolPolicy: z
.object({ .object({
preapproved: z.array( preapproved: z.array(
@@ -10,6 +10,10 @@ import { setImmediate as nextTurn } from "node:timers/promises";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.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 { PluginAgentClientRegistry } from "./plugin-provider.js";
import { import {
isStaleProviderSessionError, isStaleProviderSessionError,
@@ -283,6 +287,109 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
} }
describe("PluginAgentClientRegistry", () => { 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])( test.each([false, true])(
"contains a failed session open while send is pending: %s", "contains a failed session open while send is pending: %s",
async (pendingSend) => { async (pendingSend) => {
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { resolve as resolvePath } from "node:path"; import { resolve as resolvePath } from "node:path";
import type { Logger } from "pino"; 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 { z } from "zod";
import { import {
PROVIDER_CAPABILITIES, PROVIDER_CAPABILITIES,
@@ -876,8 +876,6 @@ export class PluginAgentClientRegistry {
} }
} }
const PluginProviderOptionsSchema: z.ZodType<ProviderOptions> = z.record(z.string(), z.json());
function createPluginProviderDefinition( function createPluginProviderDefinition(
registration: ProviderRegistration, registration: ProviderRegistration,
createClient: RegisteredProviderDefinition["createClient"], createClient: RegisteredProviderDefinition["createClient"],
@@ -889,7 +887,6 @@ function createPluginProviderDefinition(
iconSvg: registration.icon, iconSvg: registration.icon,
defaultModeId: null, defaultModeId: null,
modes: [], modes: [],
optionsSchema: PluginProviderOptionsSchema,
supportsExactMcpPreapproval: true, supportsExactMcpPreapproval: true,
createClient, createClient,
}; };
@@ -1625,9 +1622,7 @@ function mapSessionConfig(
mode: config.modeId, mode: config.modeId,
thinkingOption: config.thinkingOptionId, thinkingOption: config.thinkingOptionId,
settings: toJsonObject(config.featureValues ?? {}, "provider settings"), settings: toJsonObject(config.featureValues ?? {}, "provider settings"),
providerOptions: config.providerOptions providerOptions: config.providerOptions,
? toJsonObject(config.providerOptions, "provider options")
: undefined,
title: config.title ?? undefined, title: config.title ?? undefined,
persist, persist,
}; };
@@ -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 { describe, expect, test } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { validateProviderOptions } from "./provider-options.js"; import { validateProviderOptions } from "./provider-options.js";
import { applyClaudeToolPolicy, ClaudeProviderOptionsSchema } from "./providers/claude/options.js"; import { applyClaudeToolPolicy, ClaudeProviderOptionsSchema } from "./providers/claude/options.js";
import { applyCodexToolPolicy, CodexProviderOptionsSchema } from "./providers/codex/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");
});
});
@@ -36,11 +36,11 @@ export class ToolPolicyUnsupportedError extends Error {
} }
} }
export function validateProviderOptions( export function validateProviderOptions<T extends ProviderOptions>(
provider: string, provider: string,
schema: z.ZodType<ProviderOptions>, schema: z.ZodType<T>,
options: ProviderOptions | undefined, options: ProviderOptions | undefined,
): ProviderOptions | undefined { ): T | undefined {
if (options === undefined) return undefined; if (options === undefined) return undefined;
const parsed = schema.safeParse(options); const parsed = schema.safeParse(options);
if (parsed.success) return parsed.data; if (parsed.success) return parsed.data;
@@ -75,3 +75,31 @@ function formatProviderOptionPath(path: Array<string | number>): string {
: `${formatted}[${JSON.stringify(segment)}]`; : `${formatted}[${JSON.stringify(segment)}]`;
}, "providerOptions"); }, "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;
}
@@ -6,6 +6,9 @@ import type {
AgentSession, AgentSession,
AgentStreamEvent, AgentStreamEvent,
AgentRuntimeInfo, AgentRuntimeInfo,
SteerActiveTurnOptions,
SteerResult,
ImportedTimelineEntry,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import { wrapSessionProvider } from "./provider-registry.js"; import { wrapSessionProvider } from "./provider-registry.js";
@@ -18,6 +21,7 @@ type OptionalAgentSessionMethodName = {
}[keyof AgentSession]; }[keyof AgentSession];
const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [ const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
"steerActiveTurn",
"listCommands", "listCommands",
"setModel", "setModel",
"setThinkingOption", "setThinkingOption",
@@ -56,8 +60,12 @@ const RUNTIME_INFO: AgentRuntimeInfo = {
class FakeSession implements AgentSession { class FakeSession implements AgentSession {
readonly provider = "claude"; readonly provider = "claude";
readonly id = "session-1"; id = "session-1";
readonly capabilities = CAPABILITIES; capabilities = CAPABILITIES;
initialTimeline: ImportedTimelineEntry[] = [
{ item: { type: "assistant_message", id: "initial", text: "Provider setup" } },
];
readonly steers: Array<{ prompt: AgentPromptInput; options: SteerActiveTurnOptions }> = [];
readonly features = []; readonly features = [];
readonly recordedCalls: string[] = []; readonly recordedCalls: string[] = [];
@@ -71,6 +79,15 @@ class FakeSession implements AgentSession {
return { turnId: "turn-1" }; return { turnId: "turn-1" };
} }
async steerActiveTurn(
prompt: AgentPromptInput,
options: SteerActiveTurnOptions,
): Promise<SteerResult> {
this.recordedCalls.push("steerActiveTurn");
this.steers.push({ prompt, options });
return { status: "accepted" };
}
subscribe(_callback: (event: AgentStreamEvent) => void) { subscribe(_callback: (event: AgentStreamEvent) => void) {
this.recordedCalls.push("subscribe"); this.recordedCalls.push("subscribe");
return () => {}; return () => {};
@@ -172,6 +189,7 @@ describe("wrapSessionProvider", () => {
const session = new FakeSession(); const session = new FakeSession();
const wrapped = wrapSessionProvider("custom-claude", session); const wrapped = wrapSessionProvider("custom-claude", session);
await wrapped.steerActiveTurn?.("follow-up", { expectedTurnId: "turn-1" });
await wrapped.listCommands?.(); await wrapped.listCommands?.();
await wrapped.setModel?.("sonnet"); await wrapped.setModel?.("sonnet");
await wrapped.setThinkingOption?.("high"); await wrapped.setThinkingOption?.("high");
@@ -182,7 +200,11 @@ describe("wrapSessionProvider", () => {
const handler = wrapped.tryHandleOutOfBand?.("/compact"); const handler = wrapped.tryHandleOutOfBand?.("/compact");
await handler?.run({ emit: () => {} }); await handler?.run({ emit: () => {} });
expect(session.steers).toEqual([
{ prompt: "follow-up", options: { expectedTurnId: "turn-1" } },
]);
expect(session.recordedCalls).toEqual([ expect(session.recordedCalls).toEqual([
"steerActiveTurn",
"listCommands", "listCommands",
"setModel", "setModel",
"setThinkingOption", "setThinkingOption",
@@ -194,4 +216,34 @@ describe("wrapSessionProvider", () => {
"tryHandleOutOfBand.run", "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<SteerResult> {
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();
});
}); });
@@ -24,7 +24,7 @@ const CLAUDE_CUSTOM_THINKING_FIELDS = {
const mockState = vi.hoisted(() => { const mockState = vi.hoisted(() => {
interface ConstructorEntry { interface ConstructorEntry {
runtimeSettings?: unknown; runtimeSettings?: unknown;
providerParams?: unknown;
commandsRpcType?: unknown; commandsRpcType?: unknown;
} }
@@ -36,17 +36,14 @@ const mockState = vi.hoisted(() => {
cursor: [] as Array<{ cursor: [] as Array<{
command: string[]; command: string[];
env?: Record<string, string>; env?: Record<string, string>;
providerParams?: unknown;
}>, }>,
trae: [] as Array<{ trae: [] as Array<{
command: string[]; command: string[];
env?: Record<string, string>; env?: Record<string, string>;
providerParams?: unknown;
}>, }>,
kimi: [] as Array<{ kimi: [] as Array<{
command: string[]; command: string[];
env?: Record<string, string>; env?: Record<string, string>;
providerParams?: unknown;
}>, }>,
pi: [] as ConstructorEntry[], pi: [] as ConstructorEntry[],
genericAcp: [] as Array<{ genericAcp: [] as Array<{
@@ -54,7 +51,6 @@ const mockState = vi.hoisted(() => {
env?: Record<string, string>; env?: Record<string, string>;
providerId?: string; providerId?: string;
label?: string; label?: string;
providerParams?: unknown;
}>, }>,
}, },
isCommandAvailable: vi.fn(async (_command: string) => false), isCommandAvailable: vi.fn(async (_command: string) => false),
@@ -261,13 +257,12 @@ vi.mock("./providers/pi/agent.js", () => ({
constructor(options: { constructor(options: {
runtimeSettings?: unknown; runtimeSettings?: unknown;
providerParams?: unknown;
commandsRpcType?: unknown; commandsRpcType?: unknown;
}) { }) {
this.runtimeSettings = options.runtimeSettings; this.runtimeSettings = options.runtimeSettings;
const entry: ConstructorEntry = { const entry: ConstructorEntry = {
runtimeSettings: options.runtimeSettings, runtimeSettings: options.runtimeSettings,
providerParams: options.providerParams,
}; };
if (options.commandsRpcType !== undefined) { if (options.commandsRpcType !== undefined) {
entry.commandsRpcType = options.commandsRpcType; entry.commandsRpcType = options.commandsRpcType;
@@ -314,21 +309,7 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
env?: Record<string, string>; env?: Record<string, string>;
providerId?: string; providerId?: string;
label?: string; label?: string;
providerParams?: unknown;
}) { }) {
const providerParams =
options.providerParams &&
typeof options.providerParams === "object" &&
!Array.isArray(options.providerParams)
? (options.providerParams as Record<string, unknown>)
: {};
this.capabilities = {
...this.capabilities,
supportsMcpServers:
typeof providerParams.supportsMcpServers === "boolean"
? providerParams.supportsMcpServers
: this.capabilities.supportsMcpServers,
};
this.runtimeSettings = { this.runtimeSettings = {
command: { command: {
mode: "replace", mode: "replace",
@@ -341,7 +322,6 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
env: options.env, env: options.env,
providerId: options.providerId, providerId: options.providerId,
label: options.label, label: options.label,
providerParams: options.providerParams,
}); });
} }
@@ -379,11 +359,7 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
readonly provider = "acp"; readonly provider = "acp";
readonly runtimeSettings?: unknown; readonly runtimeSettings?: unknown;
constructor(options: { constructor(options: { command: string[]; env?: Record<string, string> }) {
command: string[];
env?: Record<string, string>;
providerParams?: unknown;
}) {
this.runtimeSettings = { this.runtimeSettings = {
command: { command: {
mode: "replace", mode: "replace",
@@ -394,7 +370,6 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
mockState.constructorArgs.cursor.push({ mockState.constructorArgs.cursor.push({
command: options.command, command: options.command,
env: options.env, env: options.env,
providerParams: options.providerParams,
}); });
} }
@@ -445,11 +420,7 @@ vi.mock("./providers/trae-acp-agent.js", () => ({
readonly provider = "acp"; readonly provider = "acp";
readonly runtimeSettings?: unknown; readonly runtimeSettings?: unknown;
constructor(options: { constructor(options: { command: string[]; env?: Record<string, string> }) {
command: string[];
env?: Record<string, string>;
providerParams?: unknown;
}) {
this.runtimeSettings = { this.runtimeSettings = {
command: { command: {
mode: "replace", mode: "replace",
@@ -460,7 +431,6 @@ vi.mock("./providers/trae-acp-agent.js", () => ({
mockState.constructorArgs.trae.push({ mockState.constructorArgs.trae.push({
command: options.command, command: options.command,
env: options.env, env: options.env,
providerParams: options.providerParams,
}); });
} }
@@ -498,11 +468,7 @@ vi.mock("./providers/kimi-acp-agent.js", () => ({
readonly provider = "acp"; readonly provider = "acp";
readonly runtimeSettings?: unknown; readonly runtimeSettings?: unknown;
constructor(options: { constructor(options: { command: string[]; env?: Record<string, string> }) {
command: string[];
env?: Record<string, string>;
providerParams?: unknown;
}) {
this.runtimeSettings = { this.runtimeSettings = {
command: { command: {
mode: "replace", mode: "replace",
@@ -513,7 +479,6 @@ vi.mock("./providers/kimi-acp-agent.js", () => ({
mockState.constructorArgs.kimi.push({ mockState.constructorArgs.kimi.push({
command: options.command, command: options.command,
env: options.env, env: options.env,
providerParams: options.providerParams,
}); });
} }
@@ -728,7 +693,6 @@ test("new provider extending acp uses GenericACPAgentClient", () => {
}, },
providerId: "my-agent", providerId: "my-agent",
label: "My Agent", label: "My Agent",
providerParams: undefined,
}, },
{ {
command: ["my-agent", "--acp"], command: ["my-agent", "--acp"],
@@ -737,7 +701,6 @@ test("new provider extending acp uses GenericACPAgentClient", () => {
}, },
providerId: "my-agent", providerId: "my-agent",
label: "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); ).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, { const registry = buildProviderRegistry(logger, {
providerOverrides: { providerOverrides: {
"no-mcp-acp": { "no-mcp-acp": {
extends: "acp", extends: "acp",
label: "No MCP ACP", label: "No MCP ACP",
command: ["no-mcp-acp", "serve"], command: ["no-mcp-acp", "serve"],
params: { options: { supportsMcpServers: false },
supportsMcpServers: false,
},
}, },
}, },
}); });
expect(registry["no-mcp-acp"].configuration?.providerOptions).toEqual({
const client = registry["no-mcp-acp"].createClient(logger); supportsMcpServers: false,
});
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,
},
},
]);
}); });
test("cursor provider extending acp uses CursorACPAgentClient", () => { test("cursor provider extending acp uses CursorACPAgentClient", () => {
@@ -872,14 +812,12 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
env: { env: {
CURSOR_AGENT_LOG: "debug", CURSOR_AGENT_LOG: "debug",
}, },
providerParams: undefined,
}, },
{ {
command: ["cursor-agent", "acp"], command: ["cursor-agent", "acp"],
env: { env: {
CURSOR_AGENT_LOG: "debug", CURSOR_AGENT_LOG: "debug",
}, },
providerParams: undefined,
}, },
]); ]);
expect(mockState.constructorArgs.genericAcp).toEqual([]); expect(mockState.constructorArgs.genericAcp).toEqual([]);
@@ -936,12 +874,10 @@ test("traecli provider extending acp uses TraeACPAgentClient", () => {
{ {
command: ["traecli", "acp", "serve"], command: ["traecli", "acp", "serve"],
env: undefined, env: undefined,
providerParams: undefined,
}, },
{ {
command: ["traecli", "acp", "serve"], command: ["traecli", "acp", "serve"],
env: undefined, env: undefined,
providerParams: undefined,
}, },
]); ]);
expect(mockState.constructorArgs.genericAcp).toEqual([]); expect(mockState.constructorArgs.genericAcp).toEqual([]);
@@ -963,12 +899,10 @@ test("kimi provider extending acp uses KimiACPAgentClient", () => {
{ {
command: ["kimi", "acp"], command: ["kimi", "acp"],
env: undefined, env: undefined,
providerParams: undefined,
}, },
{ {
command: ["kimi", "acp"], command: ["kimi", "acp"],
env: undefined, env: undefined,
providerParams: undefined,
}, },
]); ]);
expect(mockState.constructorArgs.genericAcp).toEqual([]); expect(mockState.constructorArgs.genericAcp).toEqual([]);
@@ -1,6 +1,5 @@
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { ProviderOptions, ToolPolicy } from "@getpaseo/protocol/agent-types"; import type { ProviderOptions, ToolPolicy } from "@getpaseo/protocol/agent-types";
import { z } from "zod";
import type { import type {
AgentClient, AgentClient,
@@ -49,10 +48,7 @@ import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js"; import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js"; import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
import { ClaudeProviderOptionsSchema } from "./providers/claude/options.js"; import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js";
import { CodexProviderOptionsSchema } from "./providers/codex/options.js";
import { OpenCodeProviderOptionsSchema } from "./providers/opencode/options.js";
import { ToolPolicyUnsupportedError, validateProviderOptions } from "./provider-options.js";
import { import {
AGENT_PROVIDER_DEFINITIONS, AGENT_PROVIDER_DEFINITIONS,
BUILTIN_PROVIDER_IDS, BUILTIN_PROVIDER_IDS,
@@ -80,13 +76,7 @@ export interface ProviderDefinition extends AgentProviderDefinition {
* generic ACP providers (which only extend the literal "acp" sentinel). * generic ACP providers (which only extend the literal "acp" sentinel).
*/ */
derivedFromProviderId: string | null; derivedFromProviderId: string | null;
optionsSchema: z.ZodType<ProviderOptions>;
supportsExactMcpPreapproval: boolean; supportsExactMcpPreapproval: boolean;
validateOptions: (options: ProviderOptions | undefined) => ProviderOptions | undefined;
applyOptions: (
config: AgentSessionConfig,
options: ProviderOptions | undefined,
) => AgentSessionConfig;
applyToolPolicy: ( applyToolPolicy: (
config: AgentSessionConfig, config: AgentSessionConfig,
toolPolicy: ToolPolicy | undefined, toolPolicy: ToolPolicy | undefined,
@@ -108,7 +98,6 @@ export interface ProviderDefinition extends AgentProviderDefinition {
/** Provider metadata and an unconfigured factory; the registry owns override application. */ /** Provider metadata and an unconfigured factory; the registry owns override application. */
export interface RegisteredProviderDefinition extends AgentProviderDefinition { export interface RegisteredProviderDefinition extends AgentProviderDefinition {
iconSvg?: string; iconSvg?: string;
optionsSchema: z.ZodType<ProviderOptions>;
supportsExactMcpPreapproval: boolean; supportsExactMcpPreapproval: boolean;
createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient; createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient;
} }
@@ -129,7 +118,6 @@ interface ProviderClientFactoryOptions extends Pick<
"workspaceGitService" | "managedProcesses" | "ompRuntime" "workspaceGitService" | "managedProcesses" | "ompRuntime"
> { > {
openCodeBridge?: OpenCodeBridge; openCodeBridge?: OpenCodeBridge;
providerParams?: unknown;
customProvider?: { customProvider?: {
id: string; id: string;
label: string; label: string;
@@ -151,27 +139,23 @@ interface ResolvedProvider {
profileModelsAreAdditive: boolean; profileModelsAreAdditive: boolean;
enabled: boolean; enabled: boolean;
derivedFromProviderId: string | null; derivedFromProviderId: string | null;
providerParams?: unknown; providerOptions?: ProviderOptions;
createBaseClient: (logger: Logger) => AgentClient; createBaseClient: (logger: Logger) => AgentClient;
contract: ProviderContract; contract: ProviderContract;
} }
interface ProviderContract { interface ProviderContract {
optionsSchema: z.ZodType<ProviderOptions>;
supportsExactMcpPreapproval: boolean; supportsExactMcpPreapproval: boolean;
applyToolPolicy?: (provider: string, toolPolicy: ToolPolicy) => ToolPolicy; applyToolPolicy?: (provider: string, toolPolicy: ToolPolicy) => ToolPolicy;
} }
const EmptyProviderOptionsSchema: z.ZodType<ProviderOptions> = z.object({}).strict();
const PROVIDER_CONTRACTS: Record<string, ProviderContract> = { const PROVIDER_CONTRACTS: Record<string, ProviderContract> = {
claude: { optionsSchema: ClaudeProviderOptionsSchema, supportsExactMcpPreapproval: true }, claude: { supportsExactMcpPreapproval: true },
codex: { optionsSchema: CodexProviderOptionsSchema, supportsExactMcpPreapproval: true }, codex: { supportsExactMcpPreapproval: true },
opencode: { optionsSchema: OpenCodeProviderOptionsSchema, supportsExactMcpPreapproval: true }, opencode: { supportsExactMcpPreapproval: true },
}; };
const UNSUPPORTED_PROVIDER_CONTRACT: ProviderContract = { const UNSUPPORTED_PROVIDER_CONTRACT: ProviderContract = {
optionsSchema: EmptyProviderOptionsSchema,
supportsExactMcpPreapproval: false, 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 // 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. // registry path without extending exact-preapproval support to user-defined ACP providers.
const HUB_E2E_PROVIDER_CONTRACT: ProviderContract = { const HUB_E2E_PROVIDER_CONTRACT: ProviderContract = {
optionsSchema: EmptyProviderOptionsSchema,
supportsExactMcpPreapproval: true, supportsExactMcpPreapproval: true,
applyToolPolicy: (provider, toolPolicy) => { applyToolPolicy: (provider, toolPolicy) => {
for (const grant of toolPolicy.preapproved) { for (const grant of toolPolicy.preapproved) {
@@ -229,17 +212,15 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
managedProcesses: options?.managedProcesses, managedProcesses: options?.managedProcesses,
bridge: options?.openCodeBridge, bridge: options?.openCodeBridge,
}), }),
pi: (logger, runtimeSettings, options) => pi: (logger, runtimeSettings) =>
new PiRpcAgentClient({ new PiRpcAgentClient({
logger, logger,
runtimeSettings, runtimeSettings,
providerParams: options?.providerParams,
}), }),
omp: (logger, runtimeSettings, options) => omp: (logger, runtimeSettings, options) =>
new OmpAgentClient({ new OmpAgentClient({
logger, logger,
runtimeSettings, runtimeSettings,
providerParams: options?.providerParams,
runtime: options?.ompRuntime, runtime: options?.ompRuntime,
}), }),
mock: (logger) => new MockLoadTestAgentClient(logger), 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>]: AgentSession[K] };
export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession { export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession {
return { return {
provider, provider,
id: inner.id, get id() {
capabilities: inner.capabilities, return inner.id;
},
get capabilities() {
return inner.capabilities;
},
get initialTimeline() {
return inner.initialTimeline;
},
get features() { get features() {
return inner.features; return inner.features;
}, },
run: (prompt, options) => inner.run(prompt, options), run: (prompt, options) => inner.run(prompt, options),
startTurn: (prompt, options) => inner.startTurn(prompt, options), startTurn: (prompt, options) => inner.startTurn(prompt, options),
steerActiveTurn: inner.steerActiveTurn?.bind(inner),
subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))), subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))),
async *streamHistory() { async *streamHistory() {
for await (const event of inner.streamHistory()) { for await (const event of inner.streamHistory()) {
@@ -483,7 +475,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
revertFiles: inner.revertFiles?.bind(inner), revertFiles: inner.revertFiles?.bind(inner),
revertBoth: inner.revertBoth?.bind(inner), revertBoth: inner.revertBoth?.bind(inner),
tryHandleOutOfBand: inner.tryHandleOutOfBand?.bind(inner), tryHandleOutOfBand: inner.tryHandleOutOfBand?.bind(inner),
}; } satisfies ForwardedAgentSession;
} }
function wrapClientProvider( function wrapClientProvider(
@@ -492,6 +484,7 @@ function wrapClientProvider(
profileModels: ProviderProfileModel[], profileModels: ProviderProfileModel[],
additionalModels: ProviderProfileModel[], additionalModels: ProviderProfileModel[],
profileModelsAreAdditive: boolean, profileModelsAreAdditive: boolean,
providerOptions: ProviderOptions | undefined,
): AgentClient { ): AgentClient {
const listImportableSessions = inner.listImportableSessions?.bind(inner); const listImportableSessions = inner.listImportableSessions?.bind(inner);
const importSession = inner.importSession?.bind(inner); const importSession = inner.importSession?.bind(inner);
@@ -512,6 +505,7 @@ function wrapClientProvider(
{ {
...config, ...config,
provider: inner.provider, provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
}, },
launchContext, launchContext,
options, options,
@@ -525,18 +519,17 @@ function wrapClientProvider(
...handle, ...handle,
provider: inner.provider, provider: inner.provider,
}, },
overrides {
? { ...overrides,
...overrides, provider: inner.provider,
provider: inner.provider, providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions),
} },
: undefined,
launchContext, launchContext,
options, options,
), ),
), ),
fetchCatalog: async (options, context) => { fetchCatalog: async (options, context) => {
const catalog = await inner.fetchCatalog(options, context); const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context);
return { return {
...catalog, ...catalog,
models: mergeModels(provider, profileModels, additionalModels, catalog.models, { models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
@@ -548,7 +541,11 @@ function wrapClientProvider(
resolveDefaultModeId: inner.resolveDefaultModeId resolveDefaultModeId: inner.resolveDefaultModeId
? async ({ config, env, signal }: ResolveAgentDefaultModeInput) => ? async ({ config, env, signal }: ResolveAgentDefaultModeInput) =>
await inner.resolveDefaultModeId?.({ await inner.resolveDefaultModeId?.({
config: { ...config, provider: inner.provider }, config: {
...config,
provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
},
env, env,
signal, signal,
}) })
@@ -557,10 +554,15 @@ function wrapClientProvider(
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner), resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
listFeatures: listFeatures listFeatures: listFeatures
? async (config) => await listFeatures({ ...config, provider: inner.provider }) ? async (config) =>
await listFeatures({
...config,
provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
})
: undefined, : undefined,
listImportableSessions: listImportableSessions listImportableSessions: listImportableSessions
? async (options) => await listImportableSessions(options) ? async (options) => await listImportableSessions({ ...options, providerOptions })
: undefined, : undefined,
importSession: importSession importSession: importSession
? async (input, context) => { ? async (input, context) => {
@@ -569,6 +571,10 @@ function wrapClientProvider(
config: { config: {
...context.config, ...context.config,
provider: inner.provider, provider: inner.provider,
providerOptions: mergeProviderOptions(
providerOptions,
context.config.providerOptions,
),
}, },
storedConfig: { storedConfig: {
...context.storedConfig, ...context.storedConfig,
@@ -585,6 +591,7 @@ function wrapClientProvider(
config: { config: {
...imported.config, ...imported.config,
provider, provider,
providerOptions: context.storedConfig.providerOptions,
}, },
persistence, persistence,
}; };
@@ -607,7 +614,7 @@ function createRegistryEntry(
provider: AgentProvider, provider: AgentProvider,
resolved: ResolvedProvider, resolved: ResolvedProvider,
): ProviderDefinition { ): ProviderDefinition {
const modelClient = resolved.createBaseClient(logger); const modelClient = createResolvedProviderClient(logger, provider, resolved);
const profileModels = resolveConfiguredModels(provider, modelClient, resolved.profileModels); const profileModels = resolveConfiguredModels(provider, modelClient, resolved.profileModels);
const additionalModels = resolveConfiguredModels( const additionalModels = resolveConfiguredModels(
provider, provider,
@@ -638,11 +645,7 @@ function createRegistryEntry(
configuration, configuration,
enabled: resolved.enabled, enabled: resolved.enabled,
derivedFromProviderId: resolved.derivedFromProviderId, derivedFromProviderId: resolved.derivedFromProviderId,
optionsSchema: resolved.contract.optionsSchema,
supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval, supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval,
validateOptions: (options) =>
validateProviderOptions(provider, resolved.contract.optionsSchema, options),
applyOptions: (config, options) => ({ ...config, providerOptions: options }),
applyToolPolicy: (config, toolPolicy) => { applyToolPolicy: (config, toolPolicy) => {
if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) { if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) {
throw new ToolPolicyUnsupportedError(provider); throw new ToolPolicyUnsupportedError(provider);
@@ -713,16 +716,13 @@ function createResolvedProviderClient(
const inner = resolved.createBaseClient(logger); const inner = resolved.createBaseClient(logger);
const profileModels = resolveConfiguredModels(provider, inner, resolved.profileModels); const profileModels = resolveConfiguredModels(provider, inner, resolved.profileModels);
const additionalModels = resolveConfiguredModels(provider, inner, resolved.additionalModels); const additionalModels = resolveConfiguredModels(provider, inner, resolved.additionalModels);
const hasModelOverrides = profileModels.length > 0 || additionalModels.length > 0;
if (inner.provider === provider && !hasModelOverrides) {
return inner;
}
return wrapClientProvider( return wrapClientProvider(
provider, provider,
inner, inner,
profileModels, profileModels,
additionalModels, additionalModels,
resolved.profileModelsAreAdditive, resolved.profileModelsAreAdditive,
resolved.providerOptions,
); );
} }
@@ -745,7 +745,7 @@ function resolveRegisteredProvider(input: RegisteredProvider): ResolvedProvider
profileModelsAreAdditive: false, profileModelsAreAdditive: false,
enabled: override?.enabled ?? definition.enabledByDefault ?? true, enabled: override?.enabled ?? definition.enabledByDefault ?? true,
derivedFromProviderId: null, derivedFromProviderId: null,
providerParams: override?.params, providerOptions: configuredProviderOptions(override),
createBaseClient: (logger) => input.createClient(logger, runtimeSettings), createBaseClient: (logger) => input.createClient(logger, runtimeSettings),
contract, contract,
}; };
@@ -781,7 +781,6 @@ function buildResolvedBuiltinProviders(
managedProcesses: options.managedProcesses, managedProcesses: options.managedProcesses,
ompRuntime: options.ompRuntime, ompRuntime: options.ompRuntime,
openCodeBridge: options.openCodeBridge, openCodeBridge: options.openCodeBridge,
providerParams: override?.params,
}), }),
contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT, contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT,
}), }),
@@ -831,7 +830,7 @@ function addDerivedProviders(
profileModelsAreAdditive: false, profileModelsAreAdditive: false,
enabled: override.enabled !== false, enabled: override.enabled !== false,
derivedFromProviderId: null, derivedFromProviderId: null,
providerParams: override.params, providerOptions: configuredProviderOptions(override),
createBaseClient: (logger) => { createBaseClient: (logger) => {
const acpOptions = { const acpOptions = {
logger, logger,
@@ -839,7 +838,6 @@ function addDerivedProviders(
env: override.env, env: override.env,
providerId, providerId,
label: override.label ?? providerId, label: override.label ?? providerId,
providerParams: override.params,
}; };
if (providerId === "cursor") { if (providerId === "cursor") {
return new CursorACPAgentClient(acpOptions); return new CursorACPAgentClient(acpOptions);
@@ -877,7 +875,7 @@ function addDerivedProviders(
); );
const baseDefinition = baseProvider.definition; const baseDefinition = baseProvider.definition;
const baseFactory = getProviderClientFactory(baseProviderId); const baseFactory = getProviderClientFactory(baseProviderId);
const providerParams = override.params ?? baseProvider.providerParams; const providerOptions = configuredProviderOptions(override) ?? baseProvider.providerOptions;
resolvedProviders.set(providerId, { resolvedProviders.set(providerId, {
definition: createDerivedDefinition(providerId, baseDefinition, override), definition: createDerivedDefinition(providerId, baseDefinition, override),
@@ -887,12 +885,11 @@ function addDerivedProviders(
profileModelsAreAdditive: false, profileModelsAreAdditive: false,
enabled: override.enabled !== false, enabled: override.enabled !== false,
derivedFromProviderId: baseProviderId, derivedFromProviderId: baseProviderId,
providerParams, providerOptions,
createBaseClient: (logger) => createBaseClient: (logger) =>
baseFactory(logger, mergedRuntimeSettings, { baseFactory(logger, mergedRuntimeSettings, {
managedProcesses: options.managedProcesses, managedProcesses: options.managedProcesses,
openCodeBridge: options.openCodeBridge, openCodeBridge: options.openCodeBridge,
providerParams,
customProvider: { customProvider: {
id: providerId, id: providerId,
label: override.label ?? providerId, label: override.label ?? providerId,
@@ -938,7 +935,6 @@ export function buildProviderRegistry(
runtimeSettings: runtimeSettings?.[provider], runtimeSettings: runtimeSettings?.[provider],
createClient: definition.createClient, createClient: definition.createClient,
contract: { contract: {
optionsSchema: definition.optionsSchema,
supportsExactMcpPreapproval: definition.supportsExactMcpPreapproval, 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;
}
@@ -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({ const manager = new ProviderSnapshotManager({
logger: createTestLogger(), logger: createTestLogger(),
extraClients: { extraClients: {
@@ -293,11 +293,19 @@ describe("ProviderSnapshotManager public surface", () => {
path: ["thinkingOptionId"], path: ["thinkingOptionId"],
message: "Thinking option 'missing' is not available for provider 'codex'", 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 { } finally {
manager.destroy(); manager.destroy();
} }
@@ -185,10 +185,7 @@ export interface AgentManagerProviderState {
providerDefinitions: Partial< providerDefinitions: Partial<
Record< Record<
AgentProvider, AgentProvider,
Pick< Pick<ProviderDefinition, "enabled" | "derivedFromProviderId" | "applyToolPolicy">
ProviderDefinition,
"enabled" | "derivedFromProviderId" | "validateOptions" | "applyOptions" | "applyToolPolicy"
>
> >
>; >;
clients: Partial<Record<AgentProvider, AgentClient>>; clients: Partial<Record<AgentProvider, AgentClient>>;
@@ -369,8 +366,6 @@ export class ProviderSnapshotManager {
providerDefinitions[provider] = { providerDefinitions[provider] = {
enabled: definition.enabled, enabled: definition.enabled,
derivedFromProviderId: definition.derivedFromProviderId, derivedFromProviderId: definition.derivedFromProviderId,
validateOptions: definition.validateOptions,
applyOptions: definition.applyOptions,
applyToolPolicy: definition.applyToolPolicy, applyToolPolicy: definition.applyToolPolicy,
}; };
if (definition.enabled) { if (definition.enabled) {
@@ -516,11 +511,9 @@ export class ProviderSnapshotManager {
]; ];
} }
const definition = this.requireProvider(input.provider);
return validateAgentConfigurationAgainstProvider({ return validateAgentConfigurationAgainstProvider({
input, input,
provider, provider,
validateOptions: definition.validateOptions,
}); });
} }
@@ -1,3 +1,4 @@
import { ACPProviderOptionsSchema } from "./acp-options.js";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
@@ -961,6 +962,7 @@ export class ACPAgentClient implements AgentClient {
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
): Promise<AgentSession> { ): Promise<AgentSession> {
this.assertProvider(config); this.assertProvider(config);
const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {});
const session = new ACPAgentSession( const session = new ACPAgentSession(
{ ...config, provider: this.provider }, { ...config, provider: this.provider },
{ {
@@ -973,14 +975,18 @@ export class ACPAgentClient implements AgentClient {
sessionResponseTransformer: this.sessionResponseTransformer, sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer, configOptionsTransformer: this.configOptionsTransformer,
configFeatureOptions: this.configFeatureOptions, configFeatureOptions: this.configFeatureOptions,
clientCapabilities: this.clientCapabilities, clientCapabilities: providerOptions.clientCapabilities ?? this.clientCapabilities,
clientCapabilityMeta: this.clientCapabilityMeta, clientCapabilityMeta: this.clientCapabilityMeta,
modeIdTransformer: this.modeIdTransformer, modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter, providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter, beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter, thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities, capabilities: {
...this.capabilities,
supportsMcpServers:
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
},
agentId: launchContext?.agentId, agentId: launchContext?.agentId,
launchEnv: launchContext?.env, launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser, extensionCommandsParser: this.extensionCommandsParser,
@@ -1013,6 +1019,7 @@ export class ACPAgentClient implements AgentClient {
provider: this.provider, provider: this.provider,
cwd, cwd,
}; };
const providerOptions = ACPProviderOptionsSchema.parse(mergedConfig.providerOptions ?? {});
const session = new ACPAgentSession(mergedConfig, { const session = new ACPAgentSession(mergedConfig, {
provider: this.provider, provider: this.provider,
logger: this.logger, logger: this.logger,
@@ -1023,14 +1030,18 @@ export class ACPAgentClient implements AgentClient {
sessionResponseTransformer: this.sessionResponseTransformer, sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer, configOptionsTransformer: this.configOptionsTransformer,
configFeatureOptions: this.configFeatureOptions, configFeatureOptions: this.configFeatureOptions,
clientCapabilities: this.clientCapabilities, clientCapabilities: providerOptions.clientCapabilities ?? this.clientCapabilities,
clientCapabilityMeta: this.clientCapabilityMeta, clientCapabilityMeta: this.clientCapabilityMeta,
modeIdTransformer: this.modeIdTransformer, modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter, providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter, beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter, thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities, capabilities: {
...this.capabilities,
supportsMcpServers:
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
},
handle, handle,
agentId: launchContext?.agentId, agentId: launchContext?.agentId,
launchEnv: launchContext?.env, launchEnv: launchContext?.env,
@@ -1082,6 +1093,7 @@ export class ACPAgentClient implements AgentClient {
raceProviderRefreshAbort( raceProviderRefreshAbort(
context?.signal, context?.signal,
this.spawnProcess(PROBE_ENV, { this.spawnProcess(PROBE_ENV, {
providerOptions: options.providerOptions,
onSpawned: (spawned) => { onSpawned: (spawned) => {
probe = spawned; probe = spawned;
if (context?.signal.aborted) void closeProbe().catch(() => undefined); if (context?.signal.aborted) void closeProbe().catch(() => undefined);
@@ -1327,6 +1339,7 @@ export class ACPAgentClient implements AgentClient {
launchEnv?: Record<string, string>, launchEnv?: Record<string, string>,
options?: { options?: {
initializeTimeoutMs?: number; initializeTimeoutMs?: number;
providerOptions?: Record<string, unknown>;
onSpawned?: (probe: UninitializedACPProcess) => void; onSpawned?: (probe: UninitializedACPProcess) => void;
client?: ACPClient; client?: ACPClient;
}, },
@@ -1339,7 +1352,11 @@ export class ACPAgentClient implements AgentClient {
}; };
options?.onSpawned?.(probe); options?.onSpawned?.(probe);
try { try {
const initialize = await this.initializeTransport(transport, options?.initializeTimeoutMs); const initialize = await this.initializeTransport(
transport,
options?.initializeTimeoutMs,
options?.providerOptions,
);
const initializedProbe: SpawnedACPProcess = { const initializedProbe: SpawnedACPProcess = {
...probe, ...probe,
initialize, initialize,
@@ -1398,7 +1415,9 @@ export class ACPAgentClient implements AgentClient {
protected async initializeTransport( protected async initializeTransport(
transport: ACPProcessTransport, transport: ACPProcessTransport,
initializeTimeoutMs?: number, initializeTimeoutMs?: number,
providerOptions?: Record<string, unknown>,
): Promise<InitializeResponse> { ): Promise<InitializeResponse> {
const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {});
let timeout: ReturnType<typeof setTimeout> | null = null; let timeout: ReturnType<typeof setTimeout> | null = null;
const initializeTimeoutPromise = initializeTimeoutMs const initializeTimeoutPromise = initializeTimeoutMs
? new Promise<never>((_, reject) => { ? new Promise<never>((_, reject) => {
@@ -1415,7 +1434,7 @@ export class ACPAgentClient implements AgentClient {
protocolVersion: PROTOCOL_VERSION, protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildACPClientCapabilities( clientCapabilities: buildACPClientCapabilities(
this.clientCapabilityMeta, this.clientCapabilityMeta,
this.clientCapabilities, configuredOptions.clientCapabilities ?? this.clientCapabilities,
), ),
clientInfo: { name: "Paseo", version: "dev" }, clientInfo: { name: "Paseo", version: "dev" },
}), }),
@@ -2419,12 +2438,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!this.sessionId) { if (!this.sessionId) {
return null; return null;
} }
const { providerOptions: _providerOptions, ...persistedConfig } = this.config;
return { return {
provider: this.provider, provider: this.provider,
sessionId: this.sessionId, sessionId: this.sessionId,
nativeHandle: this.sessionId, nativeHandle: this.sessionId,
metadata: { metadata: {
...this.config, ...persistedConfig,
title: this.currentTitle, title: this.currentTitle,
}, },
}; };
@@ -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();
@@ -1,3 +1,4 @@
import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
@@ -1692,7 +1693,8 @@ export class ClaudeAgentClient implements AgentClient {
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
} }
const model = config.model?.trim(); const model = config.model?.trim();
const providerOptions = ClaudeProviderOptionsSchema.parse(config.providerOptions ?? {}); const providerOptions =
validateProviderOptions("claude", ClaudeProviderOptionsSchema, config.providerOptions) ?? {};
return { return {
...config, ...config,
provider: "claude", provider: "claude",
@@ -2659,11 +2661,12 @@ class ClaudeAgentSession implements AgentSession {
if (!this.claudeSessionId) { if (!this.claudeSessionId) {
return null; return null;
} }
const { providerOptions: _providerOptions, ...persistedConfig } = this.config;
this.persistence = { this.persistence = {
provider: "claude", provider: "claude",
sessionId: this.claudeSessionId, sessionId: this.claudeSessionId,
nativeHandle: this.claudeSessionId, nativeHandle: this.claudeSessionId,
metadata: { ...this.config }, metadata: { ...persistedConfig },
}; };
return this.persistence; return this.persistence;
} }
@@ -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 () => { test("preapproves only granted tools on the injected Codex MCP server", async () => {
const session = createSession({ const session = createSession({
modeId: undefined, modeId: undefined,
@@ -1,3 +1,4 @@
import { validateProviderOptions } from "../provider-options.js";
import { import {
getAgentStreamEventTurnId, getAgentStreamEventTurnId,
type AgentPermissionAction, type AgentPermissionAction,
@@ -3532,7 +3533,8 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
this.hasWorkflowModeOverride = config.modeId !== undefined; this.hasWorkflowModeOverride = config.modeId !== undefined;
this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID; 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.config = config;
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env); this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
@@ -4902,7 +4904,6 @@ export class CodexAppServerAgentSession implements AgentSession {
modeId: this.config.modeId, modeId: this.config.modeId,
model: this.config.model ?? null, model: this.config.model ?? null,
thinkingOptionId, thinkingOptionId,
providerOptions: this.config.providerOptions,
toolPolicy: this.config.toolPolicy, toolPolicy: this.config.toolPolicy,
systemPrompt: this.config.systemPrompt, systemPrompt: this.config.systemPrompt,
mcpServers: this.config.mcpServers, mcpServers: this.config.mcpServers,
@@ -16,7 +16,6 @@ interface CursorACPAgentClientOptions {
env?: Record<string, string>; env?: Record<string, string>;
providerId?: string; providerId?: string;
label?: string; label?: string;
providerParams?: unknown;
} }
const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
@@ -93,7 +92,6 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
env: options.env, env: options.env,
providerId: options.providerId, providerId: options.providerId,
label: options.label, label: options.label,
providerParams: options.providerParams,
// cursor-agent publishes slash commands asynchronously via available_commands_update. // cursor-agent publishes slash commands asynchronously via available_commands_update.
waitForInitialCommands: true, waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
@@ -1,3 +1,4 @@
import { buildProviderRegistry } from "../provider-registry.js";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -254,22 +255,28 @@ describe("GenericACPAgentClient diagnostics", () => {
test("sends configured client capabilities in catalog and live session initialization", async () => { test("sends configured client capabilities in catalog and live session initialization", async () => {
await withFakeACPAgent("success", async (scriptPath, mode, testDir) => { await withFakeACPAgent("success", async (scriptPath, mode, testDir) => {
const initializeTracePath = path.join(testDir, "initialize.jsonl"); const initializeTracePath = path.join(testDir, "initialize.jsonl");
const client = new GenericACPAgentClient({ const logger = createTestLogger();
logger: createTestLogger(), const registry = buildProviderRegistry(logger, {
command: [process.execPath, scriptPath, mode, "", initializeTracePath], providerOverrides: {
providerParams: { "configured-acp": {
clientCapabilities: { extends: "acp",
fs: { label: "Configured ACP",
readTextFile: true, command: [process.execPath, scriptPath, mode, "", initializeTracePath],
writeTextFile: true, 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 }); 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(); await session.close();
const initializeRequests = parseInitializeTrace(await readFile(initializeTracePath, "utf8")); const initializeRequests = parseInitializeTrace(await readFile(initializeTracePath, "utf8"));
@@ -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,
},
});
});
}); });
@@ -1,7 +1,5 @@
import type { Logger } from "pino"; 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 { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
import { import {
ACPAgentClient, ACPAgentClient,
@@ -18,32 +16,12 @@ import {
toDiagnosticErrorMessage, toDiagnosticErrorMessage,
} from "./diagnostic-utils.js"; } 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<typeof GenericACPProviderParamsSchema>;
interface GenericACPAgentClientOptions { interface GenericACPAgentClientOptions {
logger: Logger; logger: Logger;
command: [string, ...string[]]; command: [string, ...string[]];
env?: Record<string, string>; env?: Record<string, string>;
providerId?: string; providerId?: string;
label?: string; label?: string;
providerParams?: unknown;
waitForInitialCommands?: boolean; waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number; initialCommandsWaitTimeoutMs?: number;
diagnosticPhaseTimeoutMs?: number; diagnosticPhaseTimeoutMs?: number;
@@ -61,7 +39,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
private readonly diagnosticPhaseTimeoutMs?: number; private readonly diagnosticPhaseTimeoutMs?: number;
constructor(options: GenericACPAgentClientOptions) { constructor(options: GenericACPAgentClientOptions) {
const providerParams = parseGenericACPProviderParams(options.providerParams);
super({ super({
provider: "acp", provider: "acp",
logger: options.logger, logger: options.logger,
@@ -69,12 +46,11 @@ export class GenericACPAgentClient extends ACPAgentClient {
env: options.env, env: options.env,
}, },
defaultCommand: options.command, defaultCommand: options.command,
capabilities: buildGenericACPCapabilities(providerParams), capabilities: DEFAULT_ACP_CAPABILITIES,
// ACP agents advertise slash commands with available_commands_update after // ACP agents advertise slash commands with available_commands_update after
// session/new, so the first listCommands() waits for that batch. // session/new, so the first listCommands() waits for that batch.
waitForInitialCommands: options.waitForInitialCommands ?? true, waitForInitialCommands: options.waitForInitialCommands ?? true,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
clientCapabilities: providerParams.clientCapabilities,
clientCapabilityMeta: options.clientCapabilityMeta, clientCapabilityMeta: options.clientCapabilityMeta,
configFeatureOptions: options.configFeatureOptions, configFeatureOptions: options.configFeatureOptions,
extensionCommandsParser: options.extensionCommandsParser, 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 { export interface CommandInvocation {
command: string; command: string;
args: string[]; args: string[];
@@ -15,7 +15,6 @@ interface KimiACPAgentClientOptions {
env?: Record<string, string>; env?: Record<string, string>;
providerId?: string; providerId?: string;
label?: string; label?: string;
providerParams?: unknown;
} }
// Kimi exposes thinking options only for the selected model. Keep its model-switching // 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, env: options.env,
providerId: options.providerId, providerId: options.providerId,
label: options.label, label: options.label,
providerParams: options.providerParams,
catalogModelResolver: resolveKimiCatalogModels, catalogModelResolver: resolveKimiCatalogModels,
}); });
} }
@@ -10,7 +10,6 @@ interface KiroACPAgentClientOptions {
env?: Record<string, string>; env?: Record<string, string>;
providerId?: string; providerId?: string;
label?: string; label?: string;
providerParams?: unknown;
} }
// Kiro CLI publishes its slash commands and skills asynchronously through the // Kiro CLI publishes its slash commands and skills asynchronously through the
@@ -92,7 +91,6 @@ export class KiroACPAgentClient extends GenericACPAgentClient {
env: options.env, env: options.env,
providerId: options.providerId, providerId: options.providerId,
label: options.label, label: options.label,
providerParams: options.providerParams,
waitForInitialCommands: true, waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
extensionCommandsParser: parseKiroExtensionCommands, extensionCommandsParser: parseKiroExtensionCommands,
@@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "../../tools/types.js";
import type { OmpAgentMessage } from "./rpc-types.js"; import type { OmpAgentMessage } from "./rpc-types.js";
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js"; import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
import type { OmpUsagePollScheduler } from "./usage-poller.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 { OmpRuntimeEventSchema } from "./rpc-types.js";
import { OmpHarness } from "./test-utils/omp-harness.js"; import { OmpHarness } from "./test-utils/omp-harness.js";
import { OmpAgentClient } from "./agent.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", () => { test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => {
expect(resolveOmpProviderParams({}).runtimeProviderParams).toMatchObject({ expect(resolveOmpProviderOptions({}).runtimeOptions).toMatchObject({
readyTimeoutMs: 20_000, readyTimeoutMs: 20_000,
rpcTimeoutMs: 60_000, rpcTimeoutMs: 60_000,
}); });
expect(resolveOmpProviderParams({ rpcTimeoutMs: 90_000 }).runtimeProviderParams).toMatchObject({ expect(resolveOmpProviderOptions({ rpcTimeoutMs: 90_000 }).runtimeOptions).toMatchObject({
readyTimeoutMs: 90_000, readyTimeoutMs: 90_000,
rpcTimeoutMs: 90_000, rpcTimeoutMs: 90_000,
}); });
@@ -63,10 +63,9 @@ import {
mergeOmpRuntimeSettings, mergeOmpRuntimeSettings,
resolveOmpDiagnosticPaths, resolveOmpDiagnosticPaths,
resolveOmpLaunchMode, resolveOmpLaunchMode,
resolveOmpProviderParams, resolveOmpProviderOptions,
OMP_MODES, OMP_MODES,
type OmpModelRoleParams, type OmpRuntimeOptions,
type OmpRuntimeProviderParams,
} from "./provider-config.js"; } from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
@@ -133,7 +132,6 @@ const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = {
export interface OmpAgentClientOptions { export interface OmpAgentClientOptions {
logger: Logger; logger: Logger;
runtimeSettings?: ProviderRuntimeSettings; runtimeSettings?: ProviderRuntimeSettings;
providerParams?: unknown;
runtime?: OmpRuntime; runtime?: OmpRuntime;
subagentCardScheduler?: OmpSubagentCardScheduler; subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler; providerIdleScheduler?: OmpProviderIdleScheduler;
@@ -591,15 +589,15 @@ function isOmpAgentSessionEvent(event: OmpRuntimeEvent): event is OmpAgentSessio
function createRuntime( function createRuntime(
logger: Logger, logger: Logger,
runtimeSettings: ProviderRuntimeSettings | undefined, runtimeSettings: ProviderRuntimeSettings | undefined,
providerParams: OmpRuntimeProviderParams, providerOptions: OmpRuntimeOptions,
): OmpRuntime { ): OmpRuntime {
return new OmpCliRuntime({ return new OmpCliRuntime({
logger, logger,
runtimeSettings, runtimeSettings,
command: ["omp"], command: ["omp"],
commandsRpcName: "get_available_commands", commandsRpcName: "get_available_commands",
readyTimeoutMs: providerParams.readyTimeoutMs, readyTimeoutMs: providerOptions.readyTimeoutMs,
requestTimeoutMs: providerParams.rpcTimeoutMs, requestTimeoutMs: providerOptions.rpcTimeoutMs,
}); });
} }
@@ -2201,19 +2199,14 @@ export class OmpAgentClient implements AgentClient {
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly providerParams: OmpRuntimeProviderParams;
private readonly modelRoleParams: OmpModelRoleParams;
private readonly subagentCardScheduler?: OmpSubagentCardScheduler; private readonly subagentCardScheduler?: OmpSubagentCardScheduler;
private readonly providerIdleScheduler?: OmpProviderIdleScheduler; private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
private readonly noTurnScheduler?: OmpNoTurnScheduler; private readonly noTurnScheduler?: OmpNoTurnScheduler;
private readonly usagePollScheduler?: OmpUsagePollScheduler; private readonly usagePollScheduler?: OmpUsagePollScheduler;
private readonly providerIdleDeadlineMs?: number; private readonly providerIdleDeadlineMs?: number;
private readonly runtime: OmpRuntime; private readonly runtime?: OmpRuntime;
constructor(options: OmpAgentClientOptions) { constructor(options: OmpAgentClientOptions) {
const { runtimeProviderParams, modelRoleParams } = resolveOmpProviderParams(
options.providerParams,
);
const runtimeSettings = mergeOmpRuntimeSettings( const runtimeSettings = mergeOmpRuntimeSettings(
{ {
command: { command: {
@@ -2225,15 +2218,12 @@ export class OmpAgentClient implements AgentClient {
); );
this.logger = options.logger; this.logger = options.logger;
this.runtimeSettings = runtimeSettings; this.runtimeSettings = runtimeSettings;
this.providerParams = runtimeProviderParams;
this.modelRoleParams = modelRoleParams;
this.subagentCardScheduler = options.subagentCardScheduler; this.subagentCardScheduler = options.subagentCardScheduler;
this.providerIdleScheduler = options.providerIdleScheduler; this.providerIdleScheduler = options.providerIdleScheduler;
this.noTurnScheduler = options.noTurnScheduler; this.noTurnScheduler = options.noTurnScheduler;
this.usagePollScheduler = options.usagePollScheduler; this.usagePollScheduler = options.usagePollScheduler;
this.providerIdleDeadlineMs = options.providerIdleDeadlineMs; this.providerIdleDeadlineMs = options.providerIdleDeadlineMs;
this.runtime = this.runtime = options.runtime;
options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams);
} }
private async configureNativePaseoTools( private async configureNativePaseoTools(
@@ -2276,7 +2266,7 @@ export class OmpAgentClient implements AgentClient {
config: AgentSessionConfig, config: AgentSessionConfig,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
): Promise<AgentSession> { ): Promise<AgentSession> {
const launchMode = this.resolveLaunchMode(config.modeId); const launchMode = this.resolveLaunchMode(config.modeId, config.providerOptions);
const startInput: OmpStartSessionInput = { const startInput: OmpStartSessionInput = {
cwd: config.cwd, cwd: config.cwd,
protocolMode: "rpc-ui", protocolMode: "rpc-ui",
@@ -2288,7 +2278,9 @@ export class OmpAgentClient implements AgentClient {
systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
env: launchContext?.env, env: launchContext?.env,
}; };
const runtimeSession = await this.runtime.startSession(startInput); const runtimeSession = await this.resolveRuntime(config.providerOptions).startSession(
startInput,
);
let hostTools: OmpHostToolRouter | undefined; let hostTools: OmpHostToolRouter | undefined;
try { try {
hostTools = await this.configureNativePaseoTools( 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({ const startInput = buildResumeStartInput({
resumeConfig, resumeConfig,
sessionFile, sessionFile,
launchContext, launchContext,
launchMode, launchMode,
}); });
const runtimeSession = await this.runtime.startSession(startInput); const runtimeSession = await this.resolveRuntime(
resumeConfig.config.providerOptions,
).startSession(startInput);
let hostTools: OmpHostToolRouter | undefined; let hostTools: OmpHostToolRouter | undefined;
try { try {
hostTools = await this.configureNativePaseoTools( hostTools = await this.configureNativePaseoTools(
@@ -2396,8 +2393,8 @@ export class OmpAgentClient implements AgentClient {
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
): OmpAgentSessionOptions["restartRuntime"] { ): OmpAgentSessionOptions["restartRuntime"] {
return async (sessionFile, modeId) => { return async (sessionFile, modeId) => {
const launchMode = this.resolveLaunchMode(modeId); const launchMode = this.resolveLaunchMode(modeId, config.providerOptions);
const next = await this.runtime.startSession({ const next = await this.resolveRuntime(config.providerOptions).startSession({
...startInput, ...startInput,
model: config.model, model: config.model,
thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined, thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined,
@@ -2428,7 +2425,7 @@ export class OmpAgentClient implements AgentClient {
options: FetchCatalogOptions, options: FetchCatalogOptions,
context?: ProviderRefreshContext, context?: ProviderRefreshContext,
): Promise<ProviderCatalog> { ): Promise<ProviderCatalog> {
const launchMode = this.resolveLaunchMode(undefined); const launchMode = this.resolveLaunchMode(undefined, options.providerOptions);
let runtimeSession: OmpRuntimeSession | undefined; let runtimeSession: OmpRuntimeSession | undefined;
let closePromise: Promise<void> | undefined; let closePromise: Promise<void> | undefined;
const closeSession = () => { const closeSession = () => {
@@ -2440,7 +2437,7 @@ export class OmpAgentClient implements AgentClient {
context?.signal.addEventListener("abort", handleAbort, { once: true }); context?.signal.addEventListener("abort", handleAbort, { once: true });
try { try {
await runProviderRefreshActivity(context, "runtime.start", async () => { 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, cwd: options.scope === "global" ? homedir() : options.cwd,
protocolMode: "rpc-ui", protocolMode: "rpc-ui",
modeId: launchMode.modeId, modeId: launchMode.modeId,
@@ -2474,14 +2471,15 @@ export class OmpAgentClient implements AgentClient {
): Promise<ImportableProviderSession[]> { ): Promise<ImportableProviderSession[]> {
return await listOmpImportableSessions({ return await listOmpImportableSessions({
...options, ...options,
sessionDir: this.providerParams.sessionDir, sessionDir: resolveOmpProviderOptions(options?.providerOptions).runtimeOptions.sessionDir,
runtimeSettings: this.runtimeSettings, runtimeSettings: this.runtimeSettings,
}); });
} }
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
const descriptorOptions = { const descriptorOptions = {
sessionDir: this.providerParams.sessionDir, sessionDir: resolveOmpProviderOptions(context.config.providerOptions).runtimeOptions
.sessionDir,
runtimeSettings: this.runtimeSettings, runtimeSettings: this.runtimeSettings,
}; };
const importConfig = await readOmpImportSessionConfig( 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<string, unknown>,
): {
modeId: string; modeId: string;
extraArgs: string[]; extraArgs: string[];
} { } {
return resolveOmpLaunchMode(modeId, this.modelRoleParams); return resolveOmpLaunchMode(modeId, resolveOmpProviderOptions(providerOptions).modelRoles);
}
private resolveRuntime(providerOptions: Record<string, unknown> | undefined): OmpRuntime {
const { runtimeOptions } = resolveOmpProviderOptions(providerOptions);
return this.runtime ?? createRuntime(this.logger, this.runtimeSettings, runtimeOptions);
} }
private async resolveOmpLaunch(): Promise<ResolvedProviderLaunch> { private async resolveOmpLaunch(): Promise<ResolvedProviderLaunch> {
@@ -13,7 +13,7 @@ const DEFAULT_OMP_RPC_TIMEOUT_MS = 60_000;
export const MIN_SUPPORTED_OMP_VERSION = "16.3.9"; export const MIN_SUPPORTED_OMP_VERSION = "16.3.9";
export { OMP_MODES }; export { OMP_MODES };
export const OmpProviderParamsSchema = z export const OmpProviderOptionsSchema = z
.object({ .object({
sessionDir: z.string().min(1).optional(), sessionDir: z.string().min(1).optional(),
rpcTimeoutMs: z.number().int().positive().optional(), rpcTimeoutMs: z.number().int().positive().optional(),
@@ -23,13 +23,13 @@ export const OmpProviderParamsSchema = z
}) })
.strict(); .strict();
export interface OmpRuntimeProviderParams { export interface OmpRuntimeOptions {
sessionDir?: string; sessionDir?: string;
readyTimeoutMs: number; readyTimeoutMs: number;
rpcTimeoutMs: number; rpcTimeoutMs: number;
} }
export interface OmpModelRoleParams { export interface OmpModelRoles {
smolModel?: string; smolModel?: string;
slowModel?: string; slowModel?: string;
planModel?: string; planModel?: string;
@@ -37,9 +37,9 @@ export interface OmpModelRoleParams {
export function resolveOmpLaunchMode( export function resolveOmpLaunchMode(
modeId: string | undefined, modeId: string | undefined,
modelRoleParams: OmpModelRoleParams = {}, modelRoles: OmpModelRoles = {},
): { modeId: string; extraArgs: string[] } { ): { modeId: string; extraArgs: string[] } {
const modelRoleArgs = resolveOmpModelRoleArgs(modelRoleParams); const modelRoleArgs = resolveOmpModelRoleArgs(modelRoles);
switch (modeId ?? DEFAULT_OMP_MODE_ID) { switch (modeId ?? DEFAULT_OMP_MODE_ID) {
case "full": case "full":
return { modeId: "full", extraArgs: ["--approval-mode", "yolo", ...modelRoleArgs] }; 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[] = []; const args: string[] = [];
if (modelRoleParams.smolModel) args.push("--smol", modelRoleParams.smolModel); if (modelRoles.smolModel) args.push("--smol", modelRoles.smolModel);
if (modelRoleParams.slowModel) args.push("--slow", modelRoleParams.slowModel); if (modelRoles.slowModel) args.push("--slow", modelRoles.slowModel);
if (modelRoleParams.planModel) args.push("--plan", modelRoleParams.planModel); if (modelRoles.planModel) args.push("--plan", modelRoles.planModel);
return args; 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})`; return `${match[1]}.${match[2]}.${match[3]} (${supported ? "supported" : "unsupported"}; minimum ${MIN_SUPPORTED_OMP_VERSION})`;
} }
export function resolveOmpProviderParams(providerParams: unknown): { export function resolveOmpProviderOptions(providerOptions: unknown): {
runtimeProviderParams: OmpRuntimeProviderParams; runtimeOptions: OmpRuntimeOptions;
modelRoleParams: OmpModelRoleParams; modelRoles: OmpModelRoles;
} { } {
const params = OmpProviderParamsSchema.parse(providerParams ?? {}); const options = OmpProviderOptionsSchema.parse(providerOptions ?? {});
const configuredRpcTimeoutMs = params.rpcTimeoutMs; const configuredRpcTimeoutMs = options.rpcTimeoutMs;
return { return {
runtimeProviderParams: { runtimeOptions: {
sessionDir: params.sessionDir, sessionDir: options.sessionDir,
readyTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_READY_TIMEOUT_MS, readyTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_READY_TIMEOUT_MS,
rpcTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_RPC_TIMEOUT_MS, rpcTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_RPC_TIMEOUT_MS,
}, },
modelRoleParams: { modelRoles: {
...(params.smolModel ? { smolModel: params.smolModel } : {}), ...(options.smolModel ? { smolModel: options.smolModel } : {}),
...(params.slowModel ? { slowModel: params.slowModel } : {}), ...(options.slowModel ? { slowModel: options.slowModel } : {}),
...(params.planModel ? { planModel: params.planModel } : {}), ...(options.planModel ? { planModel: options.planModel } : {}),
}, },
}; };
} }
@@ -1,3 +1,4 @@
import { validateProviderOptions } from "../provider-options.js";
import { import {
createOpencodeClient, createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage, type AssistantMessage as OpenCodeAssistantMessage,
@@ -1930,7 +1931,9 @@ export class OpenCodeAgentClient implements AgentClient {
if (config.provider !== "opencode") { if (config.provider !== "opencode") {
throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); 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 }); return normalizeOpenCodeConfig({ ...config, provider: "opencode", providerOptions });
} }
@@ -18,7 +18,7 @@ import { describe, expect, onTestFinished, test } from "vitest";
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
import { import {
PiProviderParamsSchema, PiProviderOptionsSchema,
PiRpcAgentClient, PiRpcAgentClient,
PiRpcAgentSession, PiRpcAgentSession,
transformPiModels, transformPiModels,
@@ -56,8 +56,8 @@ interface PiThinkingCatalogCase {
} }
test("Pi RPC timeout defaults to 60 seconds and accepts an override", () => { test("Pi RPC timeout defaults to 60 seconds and accepts an override", () => {
expect(PiProviderParamsSchema.parse({}).rpcTimeoutMs).toBe(60_000); expect(PiProviderOptionsSchema.parse({}).rpcTimeoutMs).toBe(60_000);
expect(PiProviderParamsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000); expect(PiProviderOptionsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000);
}); });
function createClient( function createClient(
@@ -2465,10 +2465,11 @@ describe("PiRpcAgentClient", () => {
const client = new PiRpcAgentClient({ const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
runtime: new FakePi(), 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, providerHandleId: sessionFile,
cwd, 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, providerHandleId: sessionFile,
cwd, cwd,
@@ -2572,7 +2575,6 @@ describe("PiRpcAgentClient", () => {
const client = new PiRpcAgentClient({ const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
runtime: pi, runtime: pi,
providerParams: { sessionDir: sessionsDir },
}); });
const imported = await client.importSession( const imported = await client.importSession(
@@ -105,7 +105,7 @@ const DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS = 30_000;
const DEFAULT_PI_RPC_TIMEOUT_MS = 60_000; const DEFAULT_PI_RPC_TIMEOUT_MS = 60_000;
const QUESTION_RESPONSE_HEADER = "Response"; const QUESTION_RESPONSE_HEADER = "Response";
export const PiProviderParamsSchema = z export const PiProviderOptionsSchema = z
.object({ .object({
sessionDir: z.string().min(1).optional(), sessionDir: z.string().min(1).optional(),
rpcTimeoutMs: z.number().int().positive().default(DEFAULT_PI_RPC_TIMEOUT_MS), rpcTimeoutMs: z.number().int().positive().default(DEFAULT_PI_RPC_TIMEOUT_MS),
@@ -113,8 +113,6 @@ export const PiProviderParamsSchema = z
}) })
.strict(); .strict();
type PiProviderParams = z.infer<typeof PiProviderParamsSchema>;
const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
{ {
name: "compact", name: "compact",
@@ -186,7 +184,6 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{
export interface PiRpcAgentClientOptions { export interface PiRpcAgentClientOptions {
logger: Logger; logger: Logger;
runtimeSettings?: ProviderRuntimeSettings; runtimeSettings?: ProviderRuntimeSettings;
providerParams?: unknown;
runtime?: PiRuntime; runtime?: PiRuntime;
usagePollScheduler?: PiUsagePollScheduler; usagePollScheduler?: PiUsagePollScheduler;
} }
@@ -2465,8 +2462,7 @@ export class PiRpcAgentClient implements AgentClient {
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly providerParams: PiProviderParams; private readonly runtime?: PiRuntime;
private readonly runtime: PiRuntime;
private readonly usagePollScheduler?: PiUsagePollScheduler; private readonly usagePollScheduler?: PiUsagePollScheduler;
constructor(options: PiRpcAgentClientOptions) { constructor(options: PiRpcAgentClientOptions) {
@@ -2474,10 +2470,7 @@ export class PiRpcAgentClient implements AgentClient {
this.capabilities = capabilitiesForClient(); this.capabilities = capabilitiesForClient();
this.logger = options.logger; this.logger = options.logger;
this.runtimeSettings = options.runtimeSettings; this.runtimeSettings = options.runtimeSettings;
this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {}); this.runtime = options.runtime;
this.runtime =
options.runtime ??
createRuntime(options.logger, options.runtimeSettings, this.providerParams.rpcTimeoutMs);
this.usagePollScheduler = options.usagePollScheduler; this.usagePollScheduler = options.usagePollScheduler;
} }
@@ -2485,11 +2478,13 @@ export class PiRpcAgentClient implements AgentClient {
config: AgentSessionConfig, config: AgentSessionConfig,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
): Promise<AgentSession> { ): Promise<AgentSession> {
const providerOptions = PiProviderOptionsSchema.parse(config.providerOptions ?? {});
const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs);
const mcpEnv = { const mcpEnv = {
...this.runtimeSettings?.env, ...this.runtimeSettings?.env,
...launchContext?.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 mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null;
const paseoExtension = createPiPaseoExtensionFile({ const paseoExtension = createPiPaseoExtensionFile({
systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
@@ -2497,7 +2492,7 @@ export class PiRpcAgentClient implements AgentClient {
}); });
let runtimeSession: PiRuntimeSession; let runtimeSession: PiRuntimeSession;
try { try {
runtimeSession = await this.runtime.startSession({ runtimeSession = await runtime.startSession({
cwd: config.cwd, cwd: config.cwd,
model: config.model, model: config.model,
thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? undefined, thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? undefined,
@@ -2518,7 +2513,7 @@ export class PiRpcAgentClient implements AgentClient {
initialState: await runtimeSession.getState(), initialState: await runtimeSession.getState(),
capabilities: capabilitiesForSession(mcp !== null), capabilities: capabilitiesForSession(mcp !== null),
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
extensionTimeoutMs: this.providerParams.extensionTimeoutMs, extensionTimeoutMs: providerOptions.extensionTimeoutMs,
logger: this.logger, logger: this.logger,
usagePollScheduler: this.usagePollScheduler, usagePollScheduler: this.usagePollScheduler,
}); });
@@ -2543,6 +2538,10 @@ export class PiRpcAgentClient implements AgentClient {
const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider);
const providerOptions = PiProviderOptionsSchema.parse(
resumeConfig.config.providerOptions ?? {},
);
const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs);
const mcpEnv = { const mcpEnv = {
...this.runtimeSettings?.env, ...this.runtimeSettings?.env,
...launchContext?.env, ...launchContext?.env,
@@ -2551,6 +2550,7 @@ export class PiRpcAgentClient implements AgentClient {
resumeConfig.cwd, resumeConfig.cwd,
resumeConfig.config.mcpServers, resumeConfig.config.mcpServers,
mcpEnv, mcpEnv,
runtime,
); );
const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null; const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null;
const paseoExtension = createPiPaseoExtensionFile({ const paseoExtension = createPiPaseoExtensionFile({
@@ -2562,7 +2562,7 @@ export class PiRpcAgentClient implements AgentClient {
}); });
let runtimeSession: PiRuntimeSession; let runtimeSession: PiRuntimeSession;
try { try {
runtimeSession = await this.runtime.startSession( runtimeSession = await runtime.startSession(
buildResumeStartInput({ buildResumeStartInput({
resumeConfig, resumeConfig,
sessionFile, sessionFile,
@@ -2583,7 +2583,7 @@ export class PiRpcAgentClient implements AgentClient {
initialState: await runtimeSession.getState(), initialState: await runtimeSession.getState(),
capabilities: capabilitiesForSession(mcp !== null), capabilities: capabilitiesForSession(mcp !== null),
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
extensionTimeoutMs: this.providerParams.extensionTimeoutMs, extensionTimeoutMs: providerOptions.extensionTimeoutMs,
logger: this.logger, logger: this.logger,
usagePollScheduler: this.usagePollScheduler, usagePollScheduler: this.usagePollScheduler,
}); });
@@ -2599,6 +2599,8 @@ export class PiRpcAgentClient implements AgentClient {
options: FetchCatalogOptions, options: FetchCatalogOptions,
context?: ProviderRefreshContext, context?: ProviderRefreshContext,
): Promise<ProviderCatalog> { ): Promise<ProviderCatalog> {
const providerOptions = PiProviderOptionsSchema.parse(options.providerOptions ?? {});
const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs);
let runtimeSession: PiRuntimeSession | undefined; let runtimeSession: PiRuntimeSession | undefined;
let closePromise: Promise<void> | undefined; let closePromise: Promise<void> | undefined;
const closeSession = () => { const closeSession = () => {
@@ -2610,7 +2612,7 @@ export class PiRpcAgentClient implements AgentClient {
context?.signal.addEventListener("abort", handleAbort, { once: true }); context?.signal.addEventListener("abort", handleAbort, { once: true });
try { try {
await runProviderRefreshActivity(context, "runtime.start", async () => { await runProviderRefreshActivity(context, "runtime.start", async () => {
runtimeSession = await this.runtime.startSession({ runtimeSession = await runtime.startSession({
cwd: options.scope === "global" ? homedir() : options.cwd, cwd: options.scope === "global" ? homedir() : options.cwd,
signal: context?.signal, signal: context?.signal,
}); });
@@ -2652,7 +2654,7 @@ export class PiRpcAgentClient implements AgentClient {
): Promise<ImportableProviderSession[]> { ): Promise<ImportableProviderSession[]> {
return await listPiImportableSessions({ return await listPiImportableSessions({
...options, ...options,
sessionDir: this.providerParams.sessionDir, sessionDir: PiProviderOptionsSchema.parse(options?.providerOptions ?? {}).sessionDir,
runtimeSettings: this.runtimeSettings, 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( private async prepareMcpInjection(
cwd: string, cwd: string,
servers: Record<string, McpServerConfig> | undefined, servers: Record<string, McpServerConfig> | undefined,
env: Record<string, string> | undefined, env: Record<string, string> | undefined,
runtime: PiRuntime,
): Promise<PiMcpInjection | null> { ): Promise<PiMcpInjection | null> {
if (!servers || Object.keys(servers).length === 0) { if (!servers || Object.keys(servers).length === 0) {
return null; return null;
} }
const mcpSupport = await this.detectMcpSupport(cwd, env); const mcpSupport = await this.detectMcpSupport(cwd, env, runtime);
if (mcpSupport === "builtin") { if (mcpSupport === "builtin") {
return { kind: "builtin", servers: toPiBuiltinMcpServers(servers) }; return { kind: "builtin", servers: toPiBuiltinMcpServers(servers) };
} }
@@ -2727,9 +2734,10 @@ export class PiRpcAgentClient implements AgentClient {
private async detectMcpSupport( private async detectMcpSupport(
cwd: string, cwd: string,
env?: Record<string, string>, env: Record<string, string> | undefined,
runtime: PiRuntime,
): Promise<PiMcpInjection["kind"] | null> { ): Promise<PiMcpInjection["kind"] | null> {
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"); this.logger.debug({ err: error, cwd }, "Pi MCP probe failed to start");
return null; return null;
}); });
@@ -8,7 +8,6 @@ interface TraeACPAgentClientOptions {
env?: Record<string, string>; env?: Record<string, string>;
providerId?: string; providerId?: string;
label?: string; label?: string;
providerParams?: unknown;
} }
const TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; const TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
@@ -21,7 +20,6 @@ export class TraeACPAgentClient extends GenericACPAgentClient {
env: options.env, env: options.env,
providerId: options.providerId, providerId: options.providerId,
label: options.label, label: options.label,
providerParams: options.providerParams,
// traecli publishes slash commands and skills asynchronously via available_commands_update. // traecli publishes slash commands and skills asynchronously via available_commands_update.
waitForInitialCommands: true, waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
@@ -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 { afterEach, expect, test } from "vitest";
import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js";
@@ -8,8 +12,12 @@ afterEach(async () => {
relationship = null; relationship = null;
}); });
async function launchRelationship(): Promise<HubRelationshipHarness> { async function launchRelationship(
const launched = await HubRelationshipHarness.start(); clients?: Record<string, AgentClient>,
): Promise<HubRelationshipHarness> {
const launched = clients
? await HubRelationshipHarness.startWithRealAgentClients(clients)
: await HubRelationshipHarness.start();
await launched.beginConnect().result; await launched.beginConnect().result;
launched.connectLatestSocket(); launched.connectLatestSocket();
relationship = launched; 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); expect(hub.providerCreations()).toBe(0);
}); });
test("Hub returns path-specific structured provider option feedback", async () => { test("Hub returns path-specific feedback thrown by the provider", async () => {
const hub = await launchRelationship(); 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", { hub.beginOwnedCreate("invalid-options", "invalid-options-execution", {
providerOptions: { providerOptions: {
sandbox_workspace_write: { writable_roots: ["/tmp", 42] }, sandbox_workspace_write: { writable_roots: ["/tmp", 42] },
@@ -21,8 +21,6 @@ import type {
AgentStreamEvent, AgentStreamEvent,
} from "../agent/agent-sdk-types.js"; } from "../agent/agent-sdk-types.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.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 { createTestLogger } from "../../test-utils/test-logger.js";
import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
@@ -66,12 +64,6 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreat
const TEST_CLAUDE_PROVIDER_DEFINITION = { const TEST_CLAUDE_PROVIDER_DEFINITION = {
enabled: true, enabled: true,
validateOptions: (options: AgentSessionConfig["providerOptions"]) =>
validateProviderOptions("claude", ClaudeProviderOptionsSchema, options),
applyOptions: (config: AgentSessionConfig, options: AgentSessionConfig["providerOptions"]) => ({
...config,
...(options ? { providerOptions: options } : {}),
}),
}; };
let workspaceArchiveInProgress = false; let workspaceArchiveInProgress = false;
+20 -1
View File
@@ -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). 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.<id>.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 ## Extending a first-class provider
```json ```json
@@ -194,4 +213,4 @@ Any agent that speaks [ACP](https://agentclientprotocol.com) over stdio can be a
## Full reference ## 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.
+5 -2
View File
@@ -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`. `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 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. session config.
Paseo refreshes an agent by closing its current provider session and opening it with current 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 | | `provider`, `model` | Separate fields; changing provider may require changing model/mode/options |
| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections | | `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections |
| `title`, `systemPrompt` | Agent configuration | | `title`, `systemPrompt` | Agent configuration |
| `providerOptions` | Provider-specific validated options | | `providerOptions` | Opaque provider-specific options |
| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | | `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals |
| `cwd` | Cannot change | | `cwd` | Cannot change |
| `internal` | Daemon-owned; cannot change through this hook | | `internal` | Daemon-owned; cannot change through this hook |
+39 -2
View File
@@ -8,9 +8,46 @@ category: TypeScript SDK
# Provider options # 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<string, unknown>` 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.<id>.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). 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).