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