diff --git a/docs/plugins.md b/docs/plugins.md index ffae136cc6..7cdc057332 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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; diff --git a/packages/app/src/agent-controls/icons.test.ts b/packages/app/src/agent-controls/icons.test.ts new file mode 100644 index 0000000000..b43e7f88e7 --- /dev/null +++ b/packages/app/src/agent-controls/icons.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/agent-controls/icons.ts b/packages/app/src/agent-controls/icons.ts index 06b2759071..ba522d170d 100644 --- a/packages/app/src/agent-controls/icons.ts +++ b/packages/app/src/agent-controls/icons.ts @@ -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; } diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts index aa2a5765c9..7956ac40dc 100644 --- a/packages/app/src/plugins/icons.ts +++ b/packages/app/src/plugins/icons.ts @@ -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" || diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index ee9fbc21c2..64f66430aa 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -116,7 +116,9 @@ export interface ProviderSessionConfig { model?: string; mode?: string; thinkingOption?: string; + /** @deprecated The same values as `providerOptions`. */ settings: Readonly>; + /** The agent's options, the provider defaults from `config.json` included. */ providerOptions?: Readonly>; 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>; + /** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */ settings?: Readonly>; } @@ -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>; + /** @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 diff --git a/packages/server/src/server/agent/plugin-provider-options.ts b/packages/server/src/server/agent/plugin-provider-options.ts new file mode 100644 index 0000000000..686baec1ac --- /dev/null +++ b/packages/server/src/server/agent/plugin-provider-options.ts @@ -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; + /** 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[number], +): AgentOptionControl { + return control.type === "toggle" + ? { ...control } + : { ...control, choices: control.choices.map((choice) => ({ ...choice })) }; +} diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 5bdb08da04..00c239eeaa 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -58,6 +58,10 @@ interface ProviderHarnessOptions { handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise; } +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, @@ -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(); }); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 0c22b26b9a..5b58e4a63f 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -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(); private terminal = false; private closePromise: Promise | 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>(); 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; - private knownSettings: Record = {}; + private knownOptions: Record = {}; /** 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, private readonly onBind: (bridge: ProviderRuntimeSession) => void, - private readonly givenSettings: Readonly>, + private readonly givenOptions: Readonly>, ) { 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 { @@ -1437,35 +1418,41 @@ class PluginAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise> { 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 = {}; 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 { - return Object.fromEntries( - this.bridge.config.settings.map((setting) => [setting.id, setting.value]), - ); + private currentOptions(): Record { + 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 { + /** + * 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 { + const { controls, options } = readPluginOptionState(this.bridge.config); const patch: Record = {}; - 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, ): AgentStreamEvent[] { return [ - ...this.reportSettingChanges(), + ...this.reportOptionChanges(), { type: "mode_changed", provider: this.provider, diff --git a/plugin-examples/provider-direct/README.md b/plugin-examples/provider-direct/README.md index 8bd5921330..dd360ac159 100644 --- a/plugin-examples/provider-direct/README.md +++ b/plugin-examples/provider-direct/README.md @@ -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. diff --git a/plugin-examples/provider-direct/server/provider.test.ts b/plugin-examples/provider-direct/server/provider.test.ts index db7282df86..0b27b24cd7 100644 --- a/plugin-examples/provider-direct/server/provider.test.ts +++ b/plugin-examples/provider-direct/server/provider.test.ts @@ -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", diff --git a/plugin-examples/provider-direct/server/provider.ts b/plugin-examples/provider-direct/server/provider.ts index 31ea177dd6..e7735dd2f5 100644 --- a/plugin-examples/provider-direct/server/provider.ts +++ b/plugin-examples/provider-direct/server/provider.ts @@ -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, +): 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 }, }; } diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md index b535821e62..788a3df112 100644 --- a/public-docs/plugins/hooks.md +++ b/public-docs/plugins/hooks.md @@ -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 diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md index eecf370a29..b971b5df76 100644 --- a/public-docs/plugins/hooks.zh-CN.md +++ b/public-docs/plugins/hooks.zh-CN.md @@ -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} diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index fdefda5971..43ab4ae6e0 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -184,6 +184,26 @@ For paced timeline text, replace `useRevealedText(text, phase)` and the surround `` 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 | diff --git a/public-docs/plugins/migration.zh-CN.md b/public-docs/plugins/migration.zh-CN.md index 600b65f4d9..3e8ff003e7 100644 --- a/public-docs/plugins/migration.zh-CN.md +++ b/public-docs/plugins/migration.zh-CN.md @@ -152,6 +152,22 @@ pill.remove(); 对于逐步显示的时间线文本,把 `useRevealedText(text, phase)` 及其外层的 `Text` 换成 `/client/ui` 中的 ``。插件设置的标题使用 `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} | 编译器或加载错误 | 含义与修复方法 | diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 9a87dfb155..bac1c19b93 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -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; diff --git a/public-docs/plugins/providers.zh-CN.md b/public-docs/plugins/providers.zh-CN.md index 6cc0a2cb14..2d5cc9abff 100644 --- a/public-docs/plugins/providers.zh-CN.md +++ b/public-docs/plugins/providers.zh-CN.md @@ -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. 在桌面和移动端宽度的布局中渲染每一种自定义时间线条目; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index a5565b71c7..f9c207d5a1 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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. diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 5375b98c68..af811a993e 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -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` 期间重新读取外部状态。