mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +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,60 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderConfigState } from "@getpaseo/plugin/server/provider";
|
||||
import type { AgentOptionControl } from "./agent-sdk-types.js";
|
||||
|
||||
/** How a provider session's composer options look to the daemon, whichever shape it reports. */
|
||||
export interface PluginOptionState {
|
||||
controls: AgentOptionControl[];
|
||||
/** The values the session runs with, for the keys it reports. */
|
||||
options: Record<string, JsonValue>;
|
||||
/** The provider reports `settings`, so it also reads `changes.settings`. */
|
||||
legacySettings: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider that reports `controls` and `providerOptions` is read as is.
|
||||
* COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A provider still
|
||||
* reporting `settings` has each setting id read as an option key and its value as the option.
|
||||
*/
|
||||
export function readPluginOptionState(config: ProviderConfigState): PluginOptionState {
|
||||
if (config.controls || config.providerOptions) {
|
||||
return {
|
||||
controls: (config.controls ?? []).map(toControl),
|
||||
options: { ...config.providerOptions },
|
||||
legacySettings: false,
|
||||
};
|
||||
}
|
||||
const settings = config.settings ?? [];
|
||||
return {
|
||||
controls: settings.map(
|
||||
(setting): AgentOptionControl =>
|
||||
setting.type === "toggle"
|
||||
? {
|
||||
type: "toggle",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
}
|
||||
: {
|
||||
type: "select",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
choices: setting.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
},
|
||||
),
|
||||
options: Object.fromEntries(settings.map((setting) => [setting.id, setting.value])),
|
||||
legacySettings: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toControl(
|
||||
control: NonNullable<ProviderConfigState["controls"]>[number],
|
||||
): AgentOptionControl {
|
||||
return control.type === "toggle"
|
||||
? { ...control }
|
||||
: { ...control, choices: control.choices.map((choice) => ({ ...choice })) };
|
||||
}
|
||||
@@ -58,6 +58,10 @@ interface ProviderHarnessOptions {
|
||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function controlKey(control: { key: string }): string {
|
||||
return control.key;
|
||||
}
|
||||
|
||||
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
|
||||
return inputs.findLast((input) => input.type === "session.configure");
|
||||
}
|
||||
@@ -1004,7 +1008,9 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await agent.session?.close();
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
// The same contract holds for a plugin that reports `controls` and `providerOptions` and for one
|
||||
// still reporting `settings`.
|
||||
describe.each(["controls", "settings"] as const)("options of a plugin reporting %s", (shape) => {
|
||||
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
|
||||
async function openStubbornVoiceSession(
|
||||
given: Record<string, boolean | string>,
|
||||
@@ -1012,19 +1018,46 @@ describe("PluginAgentClientRegistry", () => {
|
||||
) {
|
||||
const state = { voice: "direct", loud: false };
|
||||
let openedSessionId = "";
|
||||
const settings = () => [
|
||||
{
|
||||
type: "select" as const,
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value: state.voice,
|
||||
options: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
},
|
||||
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
|
||||
];
|
||||
const reported = () =>
|
||||
shape === "controls"
|
||||
? {
|
||||
controls: [
|
||||
{
|
||||
type: "select" as const,
|
||||
key: "voice",
|
||||
label: "Voice",
|
||||
icon: "Mic",
|
||||
defaultValue: "direct",
|
||||
choices: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle" as const,
|
||||
key: "loud",
|
||||
label: "Loud",
|
||||
icon: "Volume2",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
providerOptions: { voice: state.voice, loud: state.loud },
|
||||
}
|
||||
: {
|
||||
settings: [
|
||||
{
|
||||
type: "select" as const,
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value: state.voice,
|
||||
options: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
},
|
||||
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
|
||||
],
|
||||
};
|
||||
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
|
||||
emit({
|
||||
type: "session.config",
|
||||
@@ -1035,7 +1068,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: settings(),
|
||||
...reported(),
|
||||
},
|
||||
});
|
||||
const harness = createProviderHarness({
|
||||
@@ -1056,7 +1089,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.configure") {
|
||||
const loud = input.changes.settings?.loud;
|
||||
const loud = input.changes.providerOptions?.loud;
|
||||
if (typeof loud === "boolean") state.loud = loud;
|
||||
emitConfig(emit, input.sessionId);
|
||||
emit(
|
||||
@@ -1115,10 +1148,12 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("settings the plugin opened with that nobody set are in the open patch", async () => {
|
||||
test("options the plugin opened with that nobody set are in the open patch, unless they are the control's default", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
|
||||
expect(session.openOptionsPatch).toEqual(
|
||||
shape === "controls" ? {} : { voice: "direct", loud: false },
|
||||
);
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||
await session.close();
|
||||
});
|
||||
@@ -1129,10 +1164,42 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await session.applyOptions?.({ loud: true });
|
||||
|
||||
const configure = lastConfigure(harness.inputs);
|
||||
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
|
||||
expect(
|
||||
Object.keys((configure as { changes: { settings: object } }).changes.settings),
|
||||
).toEqual(["loud"]);
|
||||
// Only a plugin that reports `settings` is also sent them.
|
||||
expect((configure as { changes: object }).changes).toEqual(
|
||||
shape === "settings"
|
||||
? { providerOptions: { loud: true }, settings: { loud: true } }
|
||||
: { providerOptions: { loud: true } },
|
||||
);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a removal reaches the plugin as null", async () => {
|
||||
const { harness, session } = await openStubbornVoiceSession({});
|
||||
|
||||
const ended = await session.applyOptions?.({ loud: null });
|
||||
|
||||
expect(lastConfigure(harness.inputs)).toMatchObject({
|
||||
changes: { providerOptions: { loud: null } },
|
||||
});
|
||||
// It went back to its default; only a plugin without a declared default pins that value.
|
||||
expect(ended).toEqual(shape === "controls" ? {} : { loud: false });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("draws a control from what the plugin reports, with its icon and default", async () => {
|
||||
const { session } = await openStubbornVoiceSession({});
|
||||
|
||||
expect((session.controls ?? []).map(controlKey)).toEqual(["voice", "loud"]);
|
||||
if (shape === "controls") {
|
||||
expect(session.controls?.[0]).toMatchObject({
|
||||
icon: "Mic",
|
||||
defaultValue: "direct",
|
||||
choices: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
});
|
||||
}
|
||||
await session.close();
|
||||
});
|
||||
|
||||
@@ -1148,8 +1215,10 @@ describe("PluginAgentClientRegistry", () => {
|
||||
|
||||
// The value it opened with is not a change, so a whole profile can be applied.
|
||||
await session.applyOptions?.({ loud: true, launch_key: "original" });
|
||||
const configure = lastConfigure(harness.inputs) as { changes: { settings: object } };
|
||||
expect(Object.keys(configure.changes.settings)).toEqual(["loud"]);
|
||||
const configure = lastConfigure(harness.inputs) as {
|
||||
changes: { providerOptions: object };
|
||||
};
|
||||
expect(Object.keys(configure.changes.providerOptions)).toEqual(["loud"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type ProviderRuntimeSettings,
|
||||
} from "./provider-launch-config.js";
|
||||
import { assertChangesAreLive } from "./provider-options.js";
|
||||
import { readPluginOptionState } from "./plugin-provider-options.js";
|
||||
import type { RegisteredProviderDefinition } from "./provider-registry.js";
|
||||
import { runProviderTurn } from "./providers/provider-runner.js";
|
||||
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
@@ -606,7 +607,7 @@ class ProviderRuntimeSession {
|
||||
private readonly activeTurnIds = new Set<string>();
|
||||
private terminal = false;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
|
||||
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [] };
|
||||
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
|
||||
persistence: ProviderPersistence | null = null;
|
||||
|
||||
@@ -1119,6 +1120,7 @@ class PluginAgentClient implements AgentClient {
|
||||
forget,
|
||||
async (persistence, state) => {
|
||||
forget();
|
||||
const { options } = readPluginOptionState(state);
|
||||
return await this.runtime.openSession({
|
||||
sessionId: randomUUID(),
|
||||
config: {
|
||||
@@ -1126,9 +1128,8 @@ class PluginAgentClient implements AgentClient {
|
||||
model: state.model,
|
||||
mode: state.mode,
|
||||
thinkingOption: state.thinkingOption,
|
||||
settings: Object.fromEntries(
|
||||
state.settings.map((setting) => [setting.id, setting.value]),
|
||||
),
|
||||
settings: { ...config.settings, ...options },
|
||||
providerOptions: { ...config.providerOptions, ...options },
|
||||
},
|
||||
persistence,
|
||||
history: "replay",
|
||||
@@ -1204,9 +1205,9 @@ class PluginAgentSession implements AgentSession {
|
||||
>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
/** How the opened session differs from the settings it was given. */
|
||||
/** How the opened session differs from the options it was given. */
|
||||
readonly openOptionsPatch: Record<string, JsonValue>;
|
||||
private knownSettings: Record<string, JsonValue> = {};
|
||||
private knownOptions: Record<string, JsonValue> = {};
|
||||
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
|
||||
private applyingOptions = false;
|
||||
private currentTurnId: string | null = null;
|
||||
@@ -1229,12 +1230,12 @@ class PluginAgentSession implements AgentSession {
|
||||
state: ProviderConfigState,
|
||||
) => Promise<ProviderRuntimeSession>,
|
||||
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
||||
private readonly givenSettings: Readonly<Record<string, JsonValue>>,
|
||||
private readonly givenOptions: Readonly<Record<string, JsonValue>>,
|
||||
) {
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
// Every setting the session opened with that was not given, including ones nobody set.
|
||||
this.openOptionsPatch = this.settingDifferences(givenSettings);
|
||||
this.knownSettings = this.currentSettings();
|
||||
this.openOptionsPatch = this.optionDifferences(givenOptions);
|
||||
this.knownOptions = this.currentOptions();
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
// Opening notices arrive before the manager subscribes to the session.
|
||||
// Commit them on registration and replay the same timestamps from history.
|
||||
@@ -1258,28 +1259,8 @@ class PluginAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
/** The plugin's `settings` are its option controls; each setting id is an option key. */
|
||||
get controls(): AgentOptionControl[] {
|
||||
return this.bridge.config.settings.map(
|
||||
(setting): AgentOptionControl =>
|
||||
setting.type === "toggle"
|
||||
? {
|
||||
type: "toggle",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
}
|
||||
: {
|
||||
type: "select",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
choices: setting.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
},
|
||||
);
|
||||
return readPluginOptionState(this.bridge.config).controls;
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
@@ -1437,35 +1418,41 @@ class PluginAgentSession implements AgentSession {
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
|
||||
this.assertReplayComplete();
|
||||
const before = this.currentSettings();
|
||||
// A key the plugin does not report as a setting is one it only reads when the session opens;
|
||||
const before = this.currentOptions();
|
||||
const { controls, legacySettings } = readPluginOptionState(this.bridge.config);
|
||||
const live = controls.map((control) => control.key);
|
||||
// A key the plugin has no control for is one it only reads when the session opens;
|
||||
// a value equal to the one it opened with is not a change.
|
||||
assertChangesAreLive({
|
||||
provider: this.provider,
|
||||
patch: options,
|
||||
current: this.givenSettings,
|
||||
live: Object.keys(before),
|
||||
current: this.givenOptions,
|
||||
live,
|
||||
});
|
||||
const settings: Record<string, JsonValue> = {};
|
||||
for (const [id, value] of Object.entries(options)) {
|
||||
if (!(id in before)) continue;
|
||||
settings[id] = toJsonValue(value, `setting '${id}'`);
|
||||
if (!live.includes(id)) continue;
|
||||
settings[id] = toJsonValue(value, `option '${id}'`);
|
||||
}
|
||||
if (Object.keys(settings).length === 0) return {};
|
||||
this.applyingOptions = true;
|
||||
try {
|
||||
await this.bridge.configure({ settings });
|
||||
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01.
|
||||
await this.bridge.configure({
|
||||
providerOptions: settings,
|
||||
...(legacySettings ? { settings } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
|
||||
// call has ended.
|
||||
this.applyingOptions = false;
|
||||
for (const event of this.reportSettingChanges()) this.publish(event);
|
||||
for (const event of this.reportOptionChanges()) this.publish(event);
|
||||
throw error;
|
||||
} finally {
|
||||
this.applyingOptions = false;
|
||||
}
|
||||
const ended = this.settingDifferences({ ...before, ...settings });
|
||||
this.knownSettings = this.currentSettings();
|
||||
const ended = this.optionDifferences({ ...before, ...settings });
|
||||
this.knownOptions = this.currentOptions();
|
||||
return ended;
|
||||
}
|
||||
|
||||
@@ -1648,28 +1635,32 @@ class PluginAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
private currentSettings(): Record<string, JsonValue> {
|
||||
return Object.fromEntries(
|
||||
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
|
||||
);
|
||||
private currentOptions(): Record<string, JsonValue> {
|
||||
return readPluginOptionState(this.bridge.config).options;
|
||||
}
|
||||
|
||||
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
|
||||
private settingDifferences(
|
||||
known: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> {
|
||||
/**
|
||||
* Options whose value differs from `known`: the plugin's own change or its opening state. A key
|
||||
* with no value in `known` that the plugin reports at its control's declared default stays
|
||||
* unset, so the agent follows the default instead of pinning it.
|
||||
*/
|
||||
private optionDifferences(known: Readonly<Record<string, JsonValue>>): Record<string, JsonValue> {
|
||||
const { controls, options } = readPluginOptionState(this.bridge.config);
|
||||
const patch: Record<string, JsonValue> = {};
|
||||
for (const [id, value] of Object.entries(this.currentSettings())) {
|
||||
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
|
||||
for (const [id, value] of Object.entries(options)) {
|
||||
if (isDeepStrictEqual(known[id], value)) continue;
|
||||
const control = controls.find((candidate) => candidate.key === id);
|
||||
if (known[id] == null && control?.defaultValue === value) continue;
|
||||
patch[id] = value;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
private reportSettingChanges(): AgentStreamEvent[] {
|
||||
private reportOptionChanges(): AgentStreamEvent[] {
|
||||
if (this.applyingOptions) return [];
|
||||
const patch = this.settingDifferences(this.knownSettings);
|
||||
const patch = this.optionDifferences(this.knownOptions);
|
||||
if (Object.keys(patch).length === 0) return [];
|
||||
this.knownSettings = this.currentSettings();
|
||||
this.knownOptions = this.currentOptions();
|
||||
return [{ type: "options_changed", provider: this.provider, patch }];
|
||||
}
|
||||
|
||||
@@ -1677,7 +1668,7 @@ class PluginAgentSession implements AgentSession {
|
||||
event: Extract<ProviderEvent, { type: "session.config" }>,
|
||||
): AgentStreamEvent[] {
|
||||
return [
|
||||
...this.reportSettingChanges(),
|
||||
...this.reportOptionChanges(),
|
||||
{
|
||||
type: "mode_changed",
|
||||
provider: this.provider,
|
||||
|
||||
Reference in New Issue
Block a user