mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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 })) };
|
||||
}
|
||||
@@ -63,6 +63,10 @@ function pause(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
function controlKey(control: { key: string }): string {
|
||||
return control.key;
|
||||
}
|
||||
|
||||
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
|
||||
return inputs.findLast((input) => input.type === "session.configure");
|
||||
}
|
||||
@@ -1009,7 +1013,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>,
|
||||
@@ -1020,19 +1026,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",
|
||||
@@ -1043,7 +1076,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: settings(),
|
||||
...reported(),
|
||||
},
|
||||
});
|
||||
const harness = createProviderHarness({
|
||||
@@ -1064,7 +1097,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);
|
||||
// A change that has taken effect but whose request is still pending.
|
||||
@@ -1159,10 +1192,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();
|
||||
});
|
||||
@@ -1173,10 +1208,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();
|
||||
});
|
||||
|
||||
@@ -1192,8 +1259,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;
|
||||
/** One apply at a time: each reconciles against the settings the one before it left. */
|
||||
@@ -1231,12 +1232,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.
|
||||
@@ -1260,28 +1261,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> {
|
||||
@@ -1445,35 +1426,41 @@ class PluginAgentSession implements AgentSession {
|
||||
|
||||
private async applyOptionsNow(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;
|
||||
}
|
||||
|
||||
@@ -1656,28 +1643,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 }];
|
||||
}
|
||||
|
||||
@@ -1685,7 +1676,7 @@ class PluginAgentSession implements AgentSession {
|
||||
event: Extract<ProviderEvent, { type: "session.config" }>,
|
||||
): AgentStreamEvent[] {
|
||||
return [
|
||||
...this.reportSettingChanges(),
|
||||
...this.reportOptionChanges(),
|
||||
{
|
||||
type: "mode_changed",
|
||||
provider: this.provider,
|
||||
|
||||
Reference in New Issue
Block a user