mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
fix(server): a plugin decides which options it applies live
A plugin reporting `controls` and `providerOptions` receives every key a live change touches, with or without a control, and applies them or rejects the whole request. The daemon's live-key guard now runs only for a plugin still reporting `settings`, which cannot refuse a key.
This commit is contained in:
@@ -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<Record<string, JsonValue>>;
|
||||
/** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */
|
||||
|
||||
@@ -1016,7 +1016,10 @@ describe("PluginAgentClientRegistry", () => {
|
||||
given: Record<string, boolean | string>,
|
||||
{ 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({});
|
||||
|
||||
@@ -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<string, JsonValue> = {};
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user