diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index 64f66430aa..5b9511f1f7 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -129,9 +129,9 @@ export interface ProviderConfigChanges { 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`. + * The option keys to change on the running session, with or without a control. Apply every key + * or fail the whole request, for a key you only read when a session opens. `null` returns the + * key to the provider's own default. The committed values come back in `session.config`. */ providerOptions?: Readonly>; /** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */ diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 2772cd1030..986e65fbeb 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1016,7 +1016,10 @@ describe("PluginAgentClientRegistry", () => { given: Record, { refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {}, ) { - const state = { voice: "direct", loud: false }; + const state: { voice: string; loud: boolean; tokenBudget?: number } = { + voice: "direct", + loud: false, + }; let openedSessionId = ""; const reported = () => shape === "controls" @@ -1041,7 +1044,11 @@ describe("PluginAgentClientRegistry", () => { defaultValue: false, }, ], - providerOptions: { voice: state.voice, loud: state.loud }, + providerOptions: { + voice: state.voice, + loud: state.loud, + ...(state.tokenBudget === undefined ? {} : { token_budget: state.tokenBudget }), + }, } : { settings: [ @@ -1089,8 +1096,18 @@ describe("PluginAgentClientRegistry", () => { return true; } if (input.type === "session.configure") { - const loud = input.changes.providerOptions?.loud; + const { loud, token_budget: tokenBudget } = input.changes.providerOptions ?? {}; + // A plugin that takes `token_budget` live refuses the whole request on a bad value. + if (tokenBudget !== undefined && typeof tokenBudget !== "number") { + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "bad budget" }, + }); + return true; + } if (typeof loud === "boolean") state.loud = loud; + if (typeof tokenBudget === "number") state.tokenBudget = tokenBudget; emitConfig(emit, input.sessionId); emit( refuseAfterChanging @@ -1201,24 +1218,47 @@ describe("PluginAgentClientRegistry", () => { await session.close(); }); - test("rejects a key the plugin does not report as a setting, and configures none of a mixed patch", async () => { - const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" }); - const configureBefore = lastConfigure(harness.inputs); + test.runIf(shape === "controls")( + "sends a live option that has no control, and the plugin's answer decides", + async () => { + const { harness, session, events } = await openStubbornVoiceSession({}); - await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow( - /launch_key.*cannot change on a running session/, - ); + await session.applyOptions?.({ token_budget: 2000 }); + expect(lastConfigure(harness.inputs)).toMatchObject({ + changes: { providerOptions: { token_budget: 2000 } }, + }); - expect(lastConfigure(harness.inputs)).toBe(configureBefore); + // The plugin refuses the request as a whole, so the other key it was given is not applied. + await expect(session.applyOptions?.({ loud: true, token_budget: "lots" })).rejects.toThrow( + "bad budget", + ); + expect(eventsOfType(events, "options_changed")).toEqual([]); + expect(await session.applyOptions?.({ token_budget: 3000 })).toEqual({}); + await session.close(); + }, + ); - // 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: { providerOptions: object }; - }; - expect(Object.keys(configure.changes.providerOptions)).toEqual(["loud"]); - await session.close(); - }); + test.runIf(shape === "settings")( + "rejects a key the plugin does not report as a setting, and configures none of a mixed patch", + async () => { + const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" }); + const configureBefore = lastConfigure(harness.inputs); + + await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow( + /launch_key.*cannot change on a running session/, + ); + + expect(lastConfigure(harness.inputs)).toBe(configureBefore); + + // 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: { providerOptions: object }; + }; + expect(Object.keys(configure.changes.providerOptions)).toEqual(["loud"]); + await session.close(); + }, + ); test("applying options returns the settings that ended up different, and emits nothing", async () => { const { session, events } = await openStubbornVoiceSession({}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 8a0fe677b1..f06f70079b 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1420,19 +1420,26 @@ class PluginAgentSession implements AgentSession { this.assertReplayComplete(); 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.givenOptions, - live, - }); const settings: Record = {}; - for (const [id, value] of Object.entries(options)) { - if (!live.includes(id)) continue; - settings[id] = toJsonValue(value, `option '${id}'`); + // COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A plugin + // reporting `settings` cannot refuse a key, so only its settings are sent and any other + // changed key is rejected here; a value equal to the one it opened with is not a change. + if (legacySettings) { + const live = controls.map((control) => control.key); + assertChangesAreLive({ + provider: this.provider, + patch: options, + current: this.givenOptions, + live, + }); + for (const [id, value] of Object.entries(options)) { + if (live.includes(id)) settings[id] = toJsonValue(value, `option '${id}'`); + } + } else { + // The plugin applies every key it is given or rejects the whole request. + for (const [id, value] of Object.entries(options)) { + settings[id] = toJsonValue(value, `option '${id}'`); + } } if (Object.keys(settings).length === 0) return {}; this.applyingOptions = true; diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 75c12abdd6..5495405bfd 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -275,9 +275,11 @@ at the default and values nobody set. The same controls appear in the New worksp 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. -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 +A change arrives as `session.configure` with `changes.providerOptions` holding only the keys +that changed, whether or not they have a control. Apply every key and publish the committed +`session.config` before `request.completed`. Fail the request with `request.failed` and leave +every key as it was when you cannot apply one, for example a key you read only when a session +opens. 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. @@ -286,8 +288,10 @@ If the provider changes an option on its own, for example a model switch turns F 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. +Not every option needs a control. A provider-specific token budget, for example, belongs in +`providerOptions` and is not a dedicated prompt field. It is delivered in `session.open`, a caller +can change it on a running agent, and your `session.configure` handler decides whether it can +apply it live. Report it in `providerOptions` so Paseo stores what runs. ## Add persistence and replay diff --git a/public-docs/plugins/providers.zh-CN.md b/public-docs/plugins/providers.zh-CN.md index 5f9b4a5a06..e917ba0538 100644 --- a/public-docs/plugins/providers.zh-CN.md +++ b/public-docs/plugins/providers.zh-CN.md @@ -244,11 +244,11 @@ emit({ `icon` 是 Lucide 图标名称。`defaultValue` 是控件在键没有值时显示的值。会话打开或应用修改时,你报告的每个值(包括处于默认值的值和无人设置过的值)Paseo 都会保存。相同的控件会出现在“新建工作区”界面、草稿标签页和运行中的 Agent 上。只为你能在运行中的会话里修改的键提供控件。Provider 可以在运行时改变控件,包括在模型变化之后。 -用户的修改会以 `session.configure` 的形式到达,`changes.providerOptions` 只包含发生变化的键。应用它,并在 `request.completed` 之前发布已确定的 `session.config`;或者用 `request.failed` 使请求失败,并让每个键保持原样。值为 `null` 表示把该键恢复为 Provider 的默认值。请发布会话最终实际使用的值,而不是请求的副本:Paseo 会保存你发布的值,使保存的选项与实际运行的一致。 +修改会以 `session.configure` 的形式到达,`changes.providerOptions` 只包含发生变化的键,无论这些键是否有控件。应用每一个键,并在 `request.completed` 之前发布已确定的 `session.config`。如果无法应用其中某个键(例如只在会话打开时读取的键),用 `request.failed` 使请求失败,并让每个键保持原样。值为 `null` 表示把该键恢复为 Provider 的默认值。请发布会话最终实际使用的值,而不是请求的副本:Paseo 会保存你发布的值,使保存的选项与实际运行的一致。 如果 Provider 自行改变了某个选项(例如切换模型后关闭了 Fast),请发布带有新值的 `session.config`。Paseo 会保存它们,不会把旧值设置回去。 -没有控件的键仍会在 `session.open` 中交付并被保存。例如 Provider 专属的 token 预算应放在 `providerOptions` 中,它不是一个专门的提示词字段。 +并非每个选项都需要控件。例如 Provider 专属的 token 预算应放在 `providerOptions` 中,它不是一个专门的提示词字段。它会在 `session.open` 中交付,调用方可以在运行中的 Agent 上修改它,由你的 `session.configure` 处理程序决定能否实时应用。请在 `providerOptions` 中报告它,使 Paseo 保存实际运行的值。 ## 添加持久化与重放 {#add-persistence-and-replay}