feat(plugin): provider plugins declare option controls and receive option changes

A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.

A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).

The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.
This commit is contained in:
Mohamed Boudra
2026-11-02 00:29:50 +01:00
parent c3a7547ce5
commit 389db81784
19 changed files with 519 additions and 183 deletions
+11 -3
View File
@@ -511,13 +511,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
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 `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
A provider's configuration is 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.
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
(`providerOptions`) in `session.config`. A running-agent change arrives as
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
its value into an option and is sent both shapes in `session.configure`; delete it with
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
@@ -0,0 +1,16 @@
import { Settings2 } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { getAgentFeatureIcon } from "./icons";
describe("getAgentFeatureIcon", () => {
it("draws the Lucide icon a plugin provider names", () => {
const icon = getAgentFeatureIcon("Mic");
expect(icon).not.toBe(Settings2);
expect(getAgentFeatureIcon("Mic")).toBe(icon);
});
it("falls back to the generic icon for a missing or unknown name", () => {
expect(getAgentFeatureIcon()).toBe(Settings2);
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
});
});
+5 -1
View File
@@ -13,6 +13,7 @@ import {
ShieldQuestionMark,
Zap,
} from "lucide-react-native";
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export interface AgentControlIconProps {
@@ -60,6 +61,9 @@ export function getAgentModeOptionIcon(
return icon ? MODE_ICONS[icon] : undefined;
}
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
if (!icon) return Settings2;
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
}
+5
View File
@@ -25,6 +25,11 @@ function findLucideIcon(name: string): LucideIcon | null {
return isComponent(candidate) ? (candidate as LucideIcon) : null;
}
/** Whether `name` is a Lucide icon, so a caller can fall back before drawing nothing. */
export function isLucideIconName(name: string): boolean {
return findLucideIcon(name) !== null;
}
function isComponent(value: unknown): boolean {
return (
typeof value === "function" ||
+74 -2
View File
@@ -116,7 +116,9 @@ export interface ProviderSessionConfig {
model?: string;
mode?: string;
thinkingOption?: string;
/** @deprecated The same values as `providerOptions`. */
settings: Readonly<Record<string, JsonValue>>;
/** The agent's options, the provider defaults from `config.json` included. */
providerOptions?: Readonly<Record<string, unknown>>;
title?: string;
persist: boolean;
@@ -126,6 +128,13 @@ export interface ProviderConfigChanges {
model?: string | null;
mode?: string | null;
thinkingOption?: string | null;
/**
* The option keys to change on the running session. A key it cannot apply live is not in
* `controls`, and Paseo does not send it. `null` returns the key to the provider's own default.
* Apply the change or fail the request; the committed values come back in `session.config`.
*/
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */
settings?: Readonly<Record<string, JsonValue>>;
}
@@ -311,6 +320,34 @@ export interface ProviderMode {
isUnattended?: boolean;
}
/**
* Draws one top-level option key in the composer. The control holds no value: Paseo reads it
* from the agent's options.
*/
export type ProviderOptionControl =
| {
type: "toggle";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** Shown while the key has no value. */
defaultValue?: boolean;
}
| {
type: "select";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** The choice shown while the key has no value. */
defaultValue?: string;
choices: ReadonlyArray<{ value: string; label: string; description?: string }>;
};
/** @deprecated Report `controls` and `providerOptions` instead. */
export type ProviderSetting =
| {
type: "toggle";
@@ -335,7 +372,12 @@ export interface ProviderConfigState {
models: readonly ProviderModel[];
modes: readonly ProviderMode[];
thinkingOptions: readonly ProviderThinkingOption[];
settings: readonly ProviderSetting[];
/** The option keys that get a composer control. May change at runtime, for example per model. */
controls?: readonly ProviderOptionControl[];
/** What the session runs with now, for the keys it reports. Publish the committed values. */
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Report `controls` and `providerOptions`. Each setting id is an option key. */
settings?: readonly ProviderSetting[];
}
export interface ProviderCatalog {
@@ -888,6 +930,7 @@ const configChangesSchema = z
model: z.string().nullable().optional(),
mode: z.string().nullable().optional(),
thinkingOption: z.string().nullable().optional(),
providerOptions: jsonObjectSchema.optional(),
settings: jsonObjectSchema.optional(),
})
.strict();
@@ -1024,6 +1067,33 @@ const settingSchema = z.discriminatedUnion("type", [
})
.strip(),
]);
const controlSchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("toggle"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.boolean().optional(),
})
.strip(),
z
.object({
type: z.literal("select"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.string().optional(),
choices: z.array(
z
.object({ value: z.string(), label: z.string(), description: z.string().optional() })
.strip(),
),
})
.strip(),
]);
const configStateSchema = z
.object({
model: z.string().optional(),
@@ -1032,7 +1102,9 @@ const configStateSchema = z
models: z.array(modelSchema),
modes: z.array(modeSchema),
thinkingOptions: z.array(selectOptionSchema),
settings: z.array(settingSchema),
controls: z.array(controlSchema).optional(),
providerOptions: jsonObjectSchema.optional(),
settings: z.array(settingSchema).optional(),
})
.strip();
const catalogSchema = z
@@ -0,0 +1,60 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { ProviderConfigState } from "@getpaseo/plugin/server/provider";
import type { AgentOptionControl } from "./agent-sdk-types.js";
/** How a provider session's composer options look to the daemon, whichever shape it reports. */
export interface PluginOptionState {
controls: AgentOptionControl[];
/** The values the session runs with, for the keys it reports. */
options: Record<string, JsonValue>;
/** The provider reports `settings`, so it also reads `changes.settings`. */
legacySettings: boolean;
}
/**
* A provider that reports `controls` and `providerOptions` is read as is.
* COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A provider still
* reporting `settings` has each setting id read as an option key and its value as the option.
*/
export function readPluginOptionState(config: ProviderConfigState): PluginOptionState {
if (config.controls || config.providerOptions) {
return {
controls: (config.controls ?? []).map(toControl),
options: { ...config.providerOptions },
legacySettings: false,
};
}
const settings = config.settings ?? [];
return {
controls: settings.map(
(setting): AgentOptionControl =>
setting.type === "toggle"
? {
type: "toggle",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
})),
},
),
options: Object.fromEntries(settings.map((setting) => [setting.id, setting.value])),
legacySettings: true,
};
}
function toControl(
control: NonNullable<ProviderConfigState["controls"]>[number],
): AgentOptionControl {
return control.type === "toggle"
? { ...control }
: { ...control, choices: control.choices.map((choice) => ({ ...choice })) };
}
@@ -58,6 +58,10 @@ interface ProviderHarnessOptions {
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
function controlKey(control: { key: string }): string {
return control.key;
}
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
return inputs.findLast((input) => input.type === "session.configure");
}
@@ -1004,7 +1008,9 @@ describe("PluginAgentClientRegistry", () => {
await agent.session?.close();
});
describe("settings", () => {
// The same contract holds for a plugin that reports `controls` and `providerOptions` and for one
// still reporting `settings`.
describe.each(["controls", "settings"] as const)("options of a plugin reporting %s", (shape) => {
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
async function openStubbornVoiceSession(
given: Record<string, boolean | string>,
@@ -1012,19 +1018,46 @@ describe("PluginAgentClientRegistry", () => {
) {
const state = { voice: "direct", loud: false };
let openedSessionId = "";
const settings = () => [
{
type: "select" as const,
id: "voice",
label: "Voice",
value: state.voice,
options: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
];
const reported = () =>
shape === "controls"
? {
controls: [
{
type: "select" as const,
key: "voice",
label: "Voice",
icon: "Mic",
defaultValue: "direct",
choices: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{
type: "toggle" as const,
key: "loud",
label: "Loud",
icon: "Volume2",
defaultValue: false,
},
],
providerOptions: { voice: state.voice, loud: state.loud },
}
: {
settings: [
{
type: "select" as const,
id: "voice",
label: "Voice",
value: state.voice,
options: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
],
};
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
emit({
type: "session.config",
@@ -1035,7 +1068,7 @@ describe("PluginAgentClientRegistry", () => {
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: settings(),
...reported(),
},
});
const harness = createProviderHarness({
@@ -1056,7 +1089,7 @@ describe("PluginAgentClientRegistry", () => {
return true;
}
if (input.type === "session.configure") {
const loud = input.changes.settings?.loud;
const loud = input.changes.providerOptions?.loud;
if (typeof loud === "boolean") state.loud = loud;
emitConfig(emit, input.sessionId);
emit(
@@ -1115,10 +1148,12 @@ describe("PluginAgentClientRegistry", () => {
await session.close();
});
test("settings the plugin opened with that nobody set are in the open patch", async () => {
test("options the plugin opened with that nobody set are in the open patch, unless they are the control's default", async () => {
const { session, events } = await openStubbornVoiceSession({});
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
expect(session.openOptionsPatch).toEqual(
shape === "controls" ? {} : { voice: "direct", loud: false },
);
expect(eventsOfType(events, "options_changed")).toEqual([]);
await session.close();
});
@@ -1129,10 +1164,42 @@ describe("PluginAgentClientRegistry", () => {
await session.applyOptions?.({ loud: true });
const configure = lastConfigure(harness.inputs);
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
expect(
Object.keys((configure as { changes: { settings: object } }).changes.settings),
).toEqual(["loud"]);
// Only a plugin that reports `settings` is also sent them.
expect((configure as { changes: object }).changes).toEqual(
shape === "settings"
? { providerOptions: { loud: true }, settings: { loud: true } }
: { providerOptions: { loud: true } },
);
await session.close();
});
test("a removal reaches the plugin as null", async () => {
const { harness, session } = await openStubbornVoiceSession({});
const ended = await session.applyOptions?.({ loud: null });
expect(lastConfigure(harness.inputs)).toMatchObject({
changes: { providerOptions: { loud: null } },
});
// It went back to its default; only a plugin without a declared default pins that value.
expect(ended).toEqual(shape === "controls" ? {} : { loud: false });
await session.close();
});
test("draws a control from what the plugin reports, with its icon and default", async () => {
const { session } = await openStubbornVoiceSession({});
expect((session.controls ?? []).map(controlKey)).toEqual(["voice", "loud"]);
if (shape === "controls") {
expect(session.controls?.[0]).toMatchObject({
icon: "Mic",
defaultValue: "direct",
choices: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
});
}
await session.close();
});
@@ -1148,8 +1215,10 @@ describe("PluginAgentClientRegistry", () => {
// The value it opened with is not a change, so a whole profile can be applied.
await session.applyOptions?.({ loud: true, launch_key: "original" });
const configure = lastConfigure(harness.inputs) as { changes: { settings: object } };
expect(Object.keys(configure.changes.settings)).toEqual(["loud"]);
const configure = lastConfigure(harness.inputs) as {
changes: { providerOptions: object };
};
expect(Object.keys(configure.changes.providerOptions)).toEqual(["loud"]);
await session.close();
});
@@ -65,6 +65,7 @@ import {
type ProviderRuntimeSettings,
} from "./provider-launch-config.js";
import { assertChangesAreLive } from "./provider-options.js";
import { readPluginOptionState } from "./plugin-provider-options.js";
import type { RegisteredProviderDefinition } from "./provider-registry.js";
import { runProviderTurn } from "./providers/provider-runner.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
@@ -606,7 +607,7 @@ class ProviderRuntimeSession {
private readonly activeTurnIds = new Set<string>();
private terminal = false;
private closePromise: Promise<void> | null = null;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
persistence: ProviderPersistence | null = null;
@@ -1119,6 +1120,7 @@ class PluginAgentClient implements AgentClient {
forget,
async (persistence, state) => {
forget();
const { options } = readPluginOptionState(state);
return await this.runtime.openSession({
sessionId: randomUUID(),
config: {
@@ -1126,9 +1128,8 @@ class PluginAgentClient implements AgentClient {
model: state.model,
mode: state.mode,
thinkingOption: state.thinkingOption,
settings: Object.fromEntries(
state.settings.map((setting) => [setting.id, setting.value]),
),
settings: { ...config.settings, ...options },
providerOptions: { ...config.providerOptions, ...options },
},
persistence,
history: "replay",
@@ -1204,9 +1205,9 @@ class PluginAgentSession implements AgentSession {
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
/** How the opened session differs from the settings it was given. */
/** How the opened session differs from the options it was given. */
readonly openOptionsPatch: Record<string, JsonValue>;
private knownSettings: Record<string, JsonValue> = {};
private knownOptions: Record<string, JsonValue> = {};
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
private applyingOptions = false;
private currentTurnId: string | null = null;
@@ -1229,12 +1230,12 @@ class PluginAgentSession implements AgentSession {
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
private readonly givenSettings: Readonly<Record<string, JsonValue>>,
private readonly givenOptions: Readonly<Record<string, JsonValue>>,
) {
this.subagentIdsBySession.set(bridge.id, null);
// Every setting the session opened with that was not given, including ones nobody set.
this.openOptionsPatch = this.settingDifferences(givenSettings);
this.knownSettings = this.currentSettings();
this.openOptionsPatch = this.optionDifferences(givenOptions);
this.knownOptions = this.currentOptions();
for (const event of bridge.history) this.accept(event, false);
// Opening notices arrive before the manager subscribes to the session.
// Commit them on registration and replay the same timestamps from history.
@@ -1258,28 +1259,8 @@ class PluginAgentSession implements AgentSession {
};
}
/** The plugin's `settings` are its option controls; each setting id is an option key. */
get controls(): AgentOptionControl[] {
return this.bridge.config.settings.map(
(setting): AgentOptionControl =>
setting.type === "toggle"
? {
type: "toggle",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
})),
},
);
return readPluginOptionState(this.bridge.config).controls;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
@@ -1437,35 +1418,41 @@ class PluginAgentSession implements AgentSession {
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
this.assertReplayComplete();
const before = this.currentSettings();
// A key the plugin does not report as a setting is one it only reads when the session opens;
const before = this.currentOptions();
const { controls, legacySettings } = readPluginOptionState(this.bridge.config);
const live = controls.map((control) => control.key);
// A key the plugin has no control for is one it only reads when the session opens;
// a value equal to the one it opened with is not a change.
assertChangesAreLive({
provider: this.provider,
patch: options,
current: this.givenSettings,
live: Object.keys(before),
current: this.givenOptions,
live,
});
const settings: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(options)) {
if (!(id in before)) continue;
settings[id] = toJsonValue(value, `setting '${id}'`);
if (!live.includes(id)) continue;
settings[id] = toJsonValue(value, `option '${id}'`);
}
if (Object.keys(settings).length === 0) return {};
this.applyingOptions = true;
try {
await this.bridge.configure({ settings });
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01.
await this.bridge.configure({
providerOptions: settings,
...(legacySettings ? { settings } : {}),
});
} catch (error) {
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
// call has ended.
this.applyingOptions = false;
for (const event of this.reportSettingChanges()) this.publish(event);
for (const event of this.reportOptionChanges()) this.publish(event);
throw error;
} finally {
this.applyingOptions = false;
}
const ended = this.settingDifferences({ ...before, ...settings });
this.knownSettings = this.currentSettings();
const ended = this.optionDifferences({ ...before, ...settings });
this.knownOptions = this.currentOptions();
return ended;
}
@@ -1648,28 +1635,32 @@ class PluginAgentSession implements AgentSession {
};
}
private currentSettings(): Record<string, JsonValue> {
return Object.fromEntries(
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
);
private currentOptions(): Record<string, JsonValue> {
return readPluginOptionState(this.bridge.config).options;
}
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
private settingDifferences(
known: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
/**
* Options whose value differs from `known`: the plugin's own change or its opening state. A key
* with no value in `known` that the plugin reports at its control's declared default stays
* unset, so the agent follows the default instead of pinning it.
*/
private optionDifferences(known: Readonly<Record<string, JsonValue>>): Record<string, JsonValue> {
const { controls, options } = readPluginOptionState(this.bridge.config);
const patch: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(this.currentSettings())) {
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
for (const [id, value] of Object.entries(options)) {
if (isDeepStrictEqual(known[id], value)) continue;
const control = controls.find((candidate) => candidate.key === id);
if (known[id] == null && control?.defaultValue === value) continue;
patch[id] = value;
}
return patch;
}
private reportSettingChanges(): AgentStreamEvent[] {
private reportOptionChanges(): AgentStreamEvent[] {
if (this.applyingOptions) return [];
const patch = this.settingDifferences(this.knownSettings);
const patch = this.optionDifferences(this.knownOptions);
if (Object.keys(patch).length === 0) return [];
this.knownSettings = this.currentSettings();
this.knownOptions = this.currentOptions();
return [{ type: "options_changed", provider: this.provider, patch }];
}
@@ -1677,7 +1668,7 @@ class PluginAgentSession implements AgentSession {
event: Extract<ProviderEvent, { type: "session.config" }>,
): AgentStreamEvent[] {
return [
...this.reportSettingChanges(),
...this.reportOptionChanges(),
{
type: "mode_changed",
provider: this.provider,
+2 -1
View File
@@ -1,7 +1,8 @@
# Direct provider example
This plugin implements `ProviderRegistration` directly. It demonstrates connection negotiation,
catalog and session listing, session creation, prompt and command disposition, settings,
catalog and session listing, session creation, prompt and command disposition, option controls
with icons (`controls` and `providerOptions`),
persistence and replay, archive actions, and an ordinary child session.
The provider emits versioned `provider-result` timeline items for its root and child sessions.
@@ -40,7 +40,7 @@ describe("direct provider example", () => {
await connection.close();
});
it("demonstrates settings, commands, persistence, and provider-rendered timeline items", async () => {
it("demonstrates option controls, commands, persistence, and provider-rendered timeline items", async () => {
const connection = await createDirectExampleProvider().connect({
versions: [1],
capabilities: [
@@ -65,8 +65,8 @@ describe("direct provider example", () => {
mcpServers: {},
model: "example-1",
mode: "build",
settings: { concise: true, voice: "calm" },
providerOptions: { privateValue: 1 },
settings: {},
providerOptions: { concise: true, voice: "calm", privateValue: 1 },
persist: true,
},
history: "skip",
@@ -75,10 +75,11 @@ describe("direct provider example", () => {
expect.objectContaining({
type: "session.config",
config: expect.objectContaining({
settings: expect.arrayContaining([
expect.objectContaining({ id: "concise", value: true }),
expect.objectContaining({ id: "voice", value: "calm" }),
]),
controls: [
expect.objectContaining({ key: "concise", icon: "Zap" }),
expect.objectContaining({ key: "voice", icon: "Mic", defaultValue: "calm" }),
],
providerOptions: { concise: true, voice: "calm" },
}),
}),
);
@@ -88,8 +89,20 @@ describe("direct provider example", () => {
type: "session.configure",
requestId: "configure-complete",
sessionId: "session-complete",
changes: { settings: { voice: "direct" } },
changes: { providerOptions: { voice: "direct", concise: null } },
});
expect(events.findLast((event) => event.type === "session.config")).toMatchObject({
config: { providerOptions: { voice: "direct" } },
});
await connection.send({
type: "session.configure",
requestId: "configure-refused",
sessionId: "session-complete",
changes: { providerOptions: { voice: "loud" } },
});
expect(events).toContainEqual(
expect.objectContaining({ type: "request.failed", requestId: "configure-refused" }),
);
await connection.send({
type: "session.prompt",
sessionId: "session-complete",
@@ -1,7 +1,9 @@
import {
negotiateProviderCapabilities,
requireProviderCapabilities,
type ProviderConfigChanges,
type ProviderConfigState,
type ProviderOptionControl,
type ProviderConnection,
type ProviderEvent,
type ProviderInput,
@@ -25,8 +27,29 @@ const CAPABILITIES = [
"timeline.plugin",
] as const;
const VOICES = [
{ value: "calm", label: "Calm" },
{ value: "direct", label: "Direct" },
];
// Which option keys get a composer control. The control holds no value: Paseo reads it from
// `providerOptions`, in a draft as well as on a running session.
const CONTROLS: ProviderOptionControl[] = [
{ type: "toggle", key: "concise", label: "Concise responses", icon: "Zap" },
{
type: "select",
key: "voice",
label: "Voice",
icon: "Mic",
defaultValue: "calm",
choices: VOICES,
},
];
interface ExampleSession {
config: ProviderSessionConfig;
/** What the session runs with. A key with no value runs its default. */
options: { concise?: boolean; voice?: string };
persistence: ProviderPersistence;
turn: number;
activeTurnId: string | null;
@@ -175,8 +198,13 @@ function openSession(
version: 1,
data: { nativeSessionId: `example:${input.sessionId}` },
};
const { concise, voice } = input.config.providerOptions ?? {};
const session: ExampleSession = {
config: input.config,
options: {
...(typeof concise === "boolean" ? { concise } : {}),
...(VOICES.some((candidate) => candidate.value === voice) ? { voice: voice as string } : {}),
},
persistence,
turn: 0,
activeTurnId: null,
@@ -352,14 +380,41 @@ function configureSession(
changes.thinkingOption === null
? undefined
: (changes.thinkingOption ?? session.config.thinkingOption),
settings: changes.settings
? { ...session.config.settings, ...changes.settings }
: session.config.settings,
};
try {
session.options = applyOptions(session.options, changes.providerOptions ?? {});
} catch (error) {
state.emit({
type: "request.failed",
requestId: input.requestId,
error: { message: error instanceof Error ? error.message : String(error) },
});
return;
}
state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) });
state.emit({ type: "request.completed", requestId: input.requestId });
}
/** Apply the whole patch or throw. `null` returns a key to its default. */
function applyOptions(
options: ExampleSession["options"],
patch: NonNullable<ProviderConfigChanges["providerOptions"]>,
): ExampleSession["options"] {
const next = { ...options };
for (const [key, value] of Object.entries(patch)) {
if (key === "concise" && (value === null || typeof value === "boolean")) {
if (value === null) delete next.concise;
else next.concise = value;
} else if (key === "voice" && (value === null || VOICES.some((v) => v.value === value))) {
if (value === null) delete next.voice;
else next.voice = value as string;
} else {
throw new Error(`Cannot set ${key} to ${JSON.stringify(value)}`);
}
}
return next;
}
function configState(session: ExampleSession): ProviderConfigState {
return {
model: session.config.model ?? "example-1",
@@ -368,25 +423,9 @@ function configState(session: ExampleSession): ProviderConfigState {
models: [{ id: "example-1", label: "Example 1" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: [
{
type: "toggle",
id: "concise",
label: "Concise responses",
value: session.config.settings.concise === true,
},
{
type: "select",
id: "voice",
label: "Voice",
value:
typeof session.config.settings.voice === "string" ? session.config.settings.voice : null,
options: [
{ label: "Calm", value: "calm" },
{ label: "Direct", value: "direct" },
],
},
],
controls: CONTROLS,
// What the session ended up with, so Paseo stores what runs.
providerOptions: { ...session.options },
};
}
+8 -8
View File
@@ -206,14 +206,14 @@ Receives `{ request: { config, env? } }`. Config changes are saved and remain af
**`agent.create.before.config`** uses `AgentSessionConfig`:
| Fields | Constraint |
| --------------------------------------------- | -------------------------------------------------------------------------- |
| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options |
| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections |
| `title`, `systemPrompt` | Agent configuration |
| `providerOptions` | Opaque provider-specific options |
| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals |
| `cwd` | Cannot change |
| Fields | Constraint |
| ---------------------------- | -------------------------------------------------------------------------- |
| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options |
| `modeId`, `thinkingOptionId` | Provider-specific selections |
| `title`, `systemPrompt` | Agent configuration |
| `providerOptions` | Provider options, including speed and auto-accept selections |
| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals |
| `cwd` | Cannot change |
### Change saved configuration
+8 -8
View File
@@ -203,14 +203,14 @@ server.addHook("worktree.create.after", async ({ worktree }) => {
**`agent.create.before.config`** 使用 `AgentSessionConfig`:
| 字段 | 约束 |
| --------------------------------------------- | -------------------------------------------------------- |
| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 |
| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 |
| `title`, `systemPrompt` | Agent 配置 |
| `providerOptions` | 不透明的、特定于 Provider 的选项 |
| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 |
| `cwd` | 不能修改 |
| 字段 | 约束 |
| ---------------------------- | -------------------------------------------------------- |
| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 |
| `modeId`, `thinkingOptionId` | 特定于 Provider 的选择 |
| `title`, `systemPrompt` | Agent 配置 |
| `providerOptions` | Provider 选项,包括速度和自动批准的选择 |
| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 |
| `cwd` | 不能修改 |
### 修改已保存的配置 {#change-saved-configuration}
+20
View File
@@ -184,6 +184,26 @@ For paced timeline text, replace `useRevealedText(text, phase)` and the surround
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.
Use `SettingsSection` for plugin settings headings; `SettingsGroup` is deprecated.
### Provider options in v0.15
A provider plugin has one configuration record, `providerOptions`, and describes which keys get a
composer control. A provider that still reports `settings` keeps working through v0.15: Paseo reads each
setting `id` as an option key and each `value` as that option, and sends changes in both
`changes.providerOptions` and `changes.settings`. Remove `settings` before the shim is deleted after
2027-05-01.
| Previous API | Replacement |
| ---------------------------------------------- | -------------------------------------------------------------------------- |
| `session.config` `settings: ProviderSetting[]` | `controls: ProviderOptionControl[]` plus `providerOptions` |
| Setting `id` | Control `key` |
| Setting `value` | `providerOptions[key]`; the control holds no value |
| Select setting `options: { label, value }[]` | Select control `choices: { value, label, description? }[]` |
| Setting with no icon or default | Control `icon` (a Lucide name) and `defaultValue` |
| `session.configure` `changes.settings` | `changes.providerOptions`; `null` returns a key to its default |
| `session.open` `config.settings` | `config.providerOptions` |
| `ProviderSetting` type | `ProviderOptionControl` |
| `agent.create.before` `config.featureValues` | `config.providerOptions`; a returned `featureValues` edit is still applied |
## 5. Recognize half-migration errors
| Compiler or load error | Meaning and fix |
+16
View File
@@ -152,6 +152,22 @@ pill.remove();
对于逐步显示的时间线文本,把 `useRevealedText(text, phase)` 及其外层的 `Text` 换成 `/client/ui` 中的 `<StreamedText text={text} phase={phase} style={style} />`。插件设置的标题使用 `SettingsSection`;`SettingsGroup` 已弃用。
### v0.15 中的 Provider 选项 {#provider-options-in-v015}
Provider 插件只有一个配置记录 `providerOptions`,并用控件描述哪些键显示在 Composer 中。仍然报告 `settings` 的 Provider 在 v0.15 中继续可用:Paseo 把每个 setting 的 `id` 当作选项键,把 `value` 当作该选项的值,并同时在 `changes.providerOptions` 和 `changes.settings` 中发送修改。请在 2027-05-01 之后兼容层被删除之前移除 `settings`。
| 之前的 API | 替代方式 |
| ------------------------------------------------- | --------------------------------------------------------------- |
| `session.config` 的 `settings: ProviderSetting[]` | `controls: ProviderOptionControl[]` 加 `providerOptions` |
| Setting 的 `id` | 控件的 `key` |
| Setting 的 `value` | `providerOptions[key]`;控件本身不保存值 |
| 选择类 setting 的 `options: { label, value }[]` | 选择类控件的 `choices: { value, label, description? }[]` |
| 没有图标或默认值的 setting | 控件的 `icon`(Lucide 名称)和 `defaultValue` |
| `session.configure` 的 `changes.settings` | `changes.providerOptions`;`null` 表示恢复该键的默认值 |
| `session.open` 的 `config.settings` | `config.providerOptions` |
| `ProviderSetting` 类型 | `ProviderOptionControl` |
| `agent.create.before` 的 `config.featureValues` | `config.providerOptions`;返回的 `featureValues` 修改仍会被应用 |
## 5. 识别只迁移了一半的错误 {#5-recognize-half-migration-errors}
| 编译器或加载错误 | 含义与修复方法 |
+41 -25
View File
@@ -22,7 +22,7 @@ Choose one implementation path:
The examples are:
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, settings, prompts, persistence, child sessions, and a provider-owned timeline renderer;
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, option controls, prompts, persistence, child sessions, and a provider-owned timeline renderer;
- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command template with a Zod-validated vendor edit transformer. Replace `example-acp --stdio` with an installed ACP agent before loading it;
- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking): a renderer-only plugin that does not implement a provider.
@@ -138,7 +138,7 @@ later through `session.config`.
## Open a session
`session.open` contains the complete launch configuration: working directory, environment, system
prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence
prompt, MCP servers, tool policy, model, mode, `providerOptions`, and persistence
preference.
For persisted host-scoped plugin settings, use the handle returned by `server.registerSettings()`.
@@ -177,7 +177,8 @@ emit({
models,
modes,
thinkingOptions,
settings: [],
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
@@ -229,10 +230,16 @@ terminal `completed`, `failed`, or `canceled` event.
At this point the provider is usable: users can select a model, open a session, send a message, and
see the response.
## Add session-specific composer controls
## Add composer controls for provider options
Add `session.configure` only when the native agent has settings users should change after opening a
session. Publish them in `session.config` as toggle or select controls:
`providerOptions` is the provider's own configuration: one record of JSON values that Paseo
stores per agent and hands to `session.open`. It includes the defaults a user set in `config.json`
for your provider. Validate every key you read.
Add `controls` only when users should see and change some of those options in the composer. A
control draws one top-level key of `providerOptions` as a toggle or a select. It holds no value:
Paseo reads the value from `providerOptions`. Publish the controls and the values you run with in
`session.config`:
```ts
emit({
@@ -243,35 +250,44 @@ emit({
models: [{ id: "agent-large", label: "Agent Large" }],
modes: [],
thinkingOptions: [],
settings: [
{
type: "toggle",
id: "fast",
label: "Fast mode",
value: nativeSession.fast,
},
controls: [
{ type: "toggle", key: "fast", label: "Fast mode", icon: "Zap", defaultValue: false },
{
type: "select",
id: "approval",
key: "approval",
label: "Approvals",
value: nativeSession.approval,
options: [
{ label: "Ask", value: "ask" },
{ label: "Auto accept", value: "auto" },
icon: "Shield",
defaultValue: "ask",
choices: [
{ value: "ask", label: "Ask" },
{ value: "auto", label: "Auto accept" },
],
},
],
providerOptions: { fast: nativeSession.fast, approval: nativeSession.approval },
},
});
```
Paseo renders these controls in the composer. A user change arrives as `session.configure`; apply it
and publish the committed `session.config` before `request.completed`. The provider may change the
available controls at runtime, including after a model change.
`icon` is a Lucide icon name. `defaultValue` is what an unset key shows. A key you report at its
`defaultValue` that nobody set stays unset, so the agent keeps following the default; report any
other value and Paseo stores it. The same controls appear in the New workspace screen, in draft
tabs, and on running agents. Offer a control only for a key you can change on a running session.
The provider may change its controls at runtime, including after a model change.
`settings` are user-facing controls. `providerOptions` is opaque JSON supplied at session creation
for provider-specific configuration that Paseo does not render. A provider-specific token budget
belongs in one of those two places; it is not a dedicated prompt field.
A user change arrives as `session.configure` with `changes.providerOptions` holding only the keys
that changed. Apply the change and publish the committed `session.config` before
`request.completed`, or fail the request with `request.failed` and leave every key as it was. A
value of `null` returns that key to the provider's default. Publish what the session ended up
with, not a copy of the request: Paseo stores your published values so the stored options match
what runs.
If the provider changes an option on its own, for example a model switch turns Fast off, publish
a new `session.config` with the new values. Paseo stores them and does not set the old ones
back.
Keys without a control are still delivered in `session.open` and stored. A provider-specific
token budget, for example, belongs in `providerOptions` and is not a dedicated prompt field.
## Add persistence and replay
@@ -467,7 +483,7 @@ export const vendorEdits: AcpTransformer = {
Test the provider against the real agent, not only mocked frames:
1. install the plugin as a directory source;
2. confirm its SVG, models, modes, and settings appear;
2. confirm its SVG, models, modes, and option controls appear;
3. create a session and complete a real prompt;
4. exercise permissions, steering, cancellation, persistence, and replay when supported;
5. render every custom timeline item on desktop and mobile-width layouts;
+24 -20
View File
@@ -19,7 +19,7 @@ Provider 插件把一个编程 Agent 接入 Paseo,而无需把它加入 Paseo
示例有:
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct):会话、设置、提示词、持久化、子会话,以及一个由 Provider 拥有的时间线渲染器;
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct):会话、选项控件、提示词、持久化、子会话,以及一个由 Provider 拥有的时间线渲染器;
- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer):一个 ACP 命令模板,带有一个用 Zod 校验的厂商编辑转换器。加载之前,请把 `example-acp --stdio` 替换为一个已安装的 ACP Agent;
- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking):一个只包含渲染器、不实现 Provider 的插件。
@@ -125,7 +125,7 @@ if (input.type === "catalog") {
## 打开会话 {#open-a-session}
`session.open` 包含完整的启动配置:工作目录、环境变量、系统提示词、MCP 服务器、工具策略、模型、模式、设置、不透明的 Provider 选项以及持久化偏好。
`session.open` 包含完整的启动配置:工作目录、环境变量、系统提示词、MCP 服务器、工具策略、模型、模式、`providerOptions` 以及持久化偏好。
对于持久化的、作用于主机的插件设置,请使用 `server.registerSettings()` 返回的句柄。在目录和会话回调中读取它;当 Provider 在内存中保留配置时,订阅它。`read()` 和 `subscribe()` 的契约见[设置](./reference.md#persisted-values)。
@@ -160,7 +160,8 @@ emit({
models,
modes,
thinkingOptions,
settings: [],
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
@@ -207,9 +208,11 @@ emit({ type: "session.turn", sessionId, turnId, state: "completed" });
到这一步,Provider 已经可用:用户可以选择模型、打开会话、发送消息并看到回复。
## 添加会话专属的 Composer 控件 {#add-session-specific-composer-controls}
## 为 Provider 选项添加 Composer 控件 {#add-composer-controls-for-provider-options}
只有当原生 Agent 有用户在打开会话后需要修改的设置时,才添加 `session.configure`。在 `session.config` 中以开关或选择控件的形式发布它们:
`providerOptions` 是 Provider 自己的配置:一个由 JSON 值组成的记录,Paseo 按 Agent 保存它,并在 `session.open` 中交给你。它包含用户在 `config.json` 中为你的 Provider 设置的默认值。请校验你读取的每个键。
只有当用户需要在 Composer 中查看并修改其中一部分选项时,才添加 `controls`。一个控件把 `providerOptions` 的一个顶层键绘制为开关或选择器。控件本身不保存值:Paseo 从 `providerOptions` 读取值。在 `session.config` 中发布控件,以及你正在使用的值:
```ts
emit({
@@ -220,31 +223,32 @@ emit({
models: [{ id: "agent-large", label: "Agent Large" }],
modes: [],
thinkingOptions: [],
settings: [
{
type: "toggle",
id: "fast",
label: "Fast mode",
value: nativeSession.fast,
},
controls: [
{ type: "toggle", key: "fast", label: "Fast mode", icon: "Zap", defaultValue: false },
{
type: "select",
id: "approval",
key: "approval",
label: "Approvals",
value: nativeSession.approval,
options: [
{ label: "Ask", value: "ask" },
{ label: "Auto accept", value: "auto" },
icon: "Shield",
defaultValue: "ask",
choices: [
{ value: "ask", label: "Ask" },
{ value: "auto", label: "Auto accept" },
],
},
],
providerOptions: { fast: nativeSession.fast, approval: nativeSession.approval },
},
});
```
Paseo 会在 Composer 中渲染这些控件。用户的修改会以 `session.configure` 的形式到达;应用它,并在 `request.completed` 之前发布已确定的 `session.config`。Provider 可以在运行时改变可用的控件,包括在模型变化之后。
`icon` 是 Lucide 图标名称。`defaultValue` 是键未设置时显示的值。如果你为一个无人设置过的键报告了它的 `defaultValue`,该键保持未设置,Agent 继续跟随默认值;报告其他任何值,Paseo 都会保存它。相同的控件会出现在“新建工作区”界面、草稿标签页和运行中的 Agent 上。只为你能在运行中的会话里修改的键提供控件。Provider 可以在运行时改变控件,包括在模型变化之后。
`settings` 是面向用户的控件。`providerOptions` 是在创建会话时提供的不透明 JSON,用于 Paseo 不会渲染的 Provider 专属配置。Provider 专属的 token 预算应放在这两处之一;它不是一个专门的提示词字段。
用户的修改会以 `session.configure` 的形式到达,`changes.providerOptions` 只包含发生变化的键。应用它,并在 `request.completed` 之前发布已确定的 `session.config`;或者用 `request.failed` 使请求失败,并让每个键保持原样。值为 `null` 表示把该键恢复为 Provider 的默认值。请发布会话最终实际使用的值,而不是请求的副本:Paseo 会保存你发布的值,使保存的选项与实际运行的一致。
如果 Provider 自行改变了某个选项(例如切换模型后关闭了 Fast),请发布带有新值的 `session.config`。Paseo 会保存它们,不会把旧值设置回去。
没有控件的键仍会在 `session.open` 中交付并被保存。例如 Provider 专属的 token 预算应放在 `providerOptions` 中,它不是一个专门的提示词字段。
## 添加持久化与重放 {#add-persistence-and-replay}
@@ -405,7 +409,7 @@ export const vendorEdits: AcpTransformer = {
用真实的 Agent 测试 Provider,而不只是模拟的数据帧:
1. 以目录来源安装插件;
2. 确认它的 SVG、模型、模式和设置都会显示;
2. 确认它的 SVG、模型、模式和选项控件都会显示;
3. 创建会话并完成一段真实的提示词;
4. 在支持的情况下,试用权限、引导、取消、持久化和重放;
5. 在桌面和移动端宽度的布局中渲染每一种自定义时间线条目;
+7 -5
View File
@@ -409,7 +409,7 @@ process, credential, and other machine-local work under `server/`. A plugin with
### Providers
Follow [Build a provider plugin](/docs/plugins/providers) for direct and ACP implementations,
session lifecycle, composer settings, timeline renderers, testing, and distribution.
session lifecycle, option controls, timeline renderers, testing, and distribution.
Call `server.registerProvider()` with a `ProviderRegistration` from
`@getpaseo/plugin/server/provider`. Its connection accepts inputs with `send()` and emits complete state
@@ -420,10 +420,12 @@ Use the single `session.prompt` input for messages, structured commands, steerin
effects. Repeat `clientMessageId` on the live user timeline item and publish exactly one matching
`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 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.
Provider options are one record, `ProviderSessionConfig.providerOptions` on `session.open`. It
includes configured defaults and per-agent overrides; validate and apply it in your provider. Report
`controls` in `session.config` for the top-level keys that get a toggle or select in the composer,
with the values the session runs under `providerOptions`. A running-session change arrives as
`session.configure` with `changes.providerOptions`. See [Provider options](/docs/sdk/provider-options)
for configuration and merge semantics.
Host tools arrive as MCP servers in the complete
session config.
+2 -2
View File
@@ -353,13 +353,13 @@ Paseo 为服务端代码提供 `@getpaseo/plugin`、`@getpaseo/plugin/server`、
### Provider {#providers}
直接实现和 ACP 实现、会话生命周期、Composer 设置、时间线渲染器、测试和分发,见[构建 Provider 插件](/docs/plugins/providers)。
直接实现和 ACP 实现、会话生命周期、选项控件、时间线渲染器、测试和分发,见[构建 Provider 插件](/docs/plugins/providers)。
使用来自 `@getpaseo/plugin/server/provider` 的 `ProviderRegistration` 调用 `server.registerProvider()`。它的连接通过 `send()` 接收输入,并通过 `onEvent()` 发出完整的状态快照。`send()` 只报告是否接受;提示词的处理结果、轮次、配置、持久化、权限和失败都是事件。
消息、结构化命令、引导和命令副作用都使用同一个 `session.prompt` 输入。在实时的用户时间线条目上重复 `clientMessageId`,并恰好发布一个与之匹配的 `session.prompt_result`。把 Provider 创建的子会话发布为带有 `parentSessionId` 的会话。
Provider 设置是 Paseo 在 Composer 中渲染的开关/选择描述符。把 Provider 私有的选项保留在 `session.open` 的 `ProviderSessionConfig.providerOptions` 中。这个记录包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。配置和合并语义见 [Provider 选项](/docs/sdk/provider-options)。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。
Provider 选项是一个记录,即 `session.open` 中的 `ProviderSessionConfig.providerOptions`。它包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。在 `session.config` 中为需要在 Composer 里显示开关或选择器的顶层键报告 `controls`,并在 `providerOptions` 下报告会话正在使用的值。运行中会话的修改会以 `session.configure` 的形式到达,内容在 `changes.providerOptions` 中。配置和合并语义见 [Provider 选项](/docs/sdk/provider-options)。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。
Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,再用当前的配置和持久化值打开它。Provider 在 `session.open` 期间重新读取外部状态。