mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 06:33:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a5d4d970 | ||
|
|
377b06be0d | ||
|
|
25a3efe0ac | ||
|
|
5b4636b856 | ||
|
|
00ec4bc80c | ||
|
|
970c281daf | ||
|
|
389db81784 |
+11
-3
@@ -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;
|
||||
|
||||
@@ -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, 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`. */
|
||||
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 })) };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -501,6 +505,144 @@ test("combined plugin rewind replays the retained native branch", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a rewind reopens with the options as they stand, a reset included", async () => {
|
||||
const options: Record<string, boolean> = {};
|
||||
const report = (emit: (event: ProviderEvent) => void, sessionId: string) =>
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId,
|
||||
config: {
|
||||
model: "plugin-model",
|
||||
mode: "build",
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
controls: [{ type: "toggle", key: "concise", label: "Concise" }],
|
||||
providerOptions: { ...options },
|
||||
},
|
||||
});
|
||||
const harness = createRewindProviderHarness({
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.open" && input.history !== "replay") {
|
||||
options.concise = true;
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [...CAPABILITIES, "session.revert.conversation"],
|
||||
restoration: "core",
|
||||
persistence: { version: 1, data: { token: "root" } },
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
report(emit, input.sessionId);
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.configure") {
|
||||
// A plugin that represents the default by leaving the key out.
|
||||
delete options.concise;
|
||||
report(emit, input.sessionId);
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: tmpdir(),
|
||||
providerOptions: { concise: true },
|
||||
});
|
||||
await session.run("A");
|
||||
await session.applyOptions?.({ concise: null });
|
||||
|
||||
await session.revertConversation!({ messageId: "user-A" });
|
||||
|
||||
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
|
||||
expect(reopened?.config.providerOptions).toEqual({});
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
});
|
||||
|
||||
// A plugin may report no options at all while it runs the ones it was given.
|
||||
async function withUnreportedOptionSession(
|
||||
run: (
|
||||
session: Awaited<ReturnType<AgentClient["createSession"]>>,
|
||||
harness: ReturnType<typeof createRewindProviderHarness>,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const harness = createRewindProviderHarness({
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.open" && input.history !== "replay") {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [...CAPABILITIES, "session.revert.conversation"],
|
||||
restoration: "core",
|
||||
persistence: { version: 1, data: { token: "root" } },
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId: input.sessionId,
|
||||
config: {
|
||||
model: "plugin-model",
|
||||
mode: "build",
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
controls: [],
|
||||
providerOptions: {},
|
||||
},
|
||||
});
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.configure") {
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: tmpdir(),
|
||||
providerOptions: { token_budget: 1000 },
|
||||
});
|
||||
try {
|
||||
await run(session, harness);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
test("applying an option the plugin never reports leaves the stored option alone", async () => {
|
||||
await withUnreportedOptionSession(async (session) => {
|
||||
// The same value as a whole-profile patch carries it, then a new one.
|
||||
expect(await session.applyOptions?.({ token_budget: 1000 })).toEqual({});
|
||||
expect(await session.applyOptions?.({ token_budget: 2000 })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("a rewind reopens with the value the plugin accepted for an option it never reports", async () => {
|
||||
await withUnreportedOptionSession(async (session, harness) => {
|
||||
await session.run("A");
|
||||
await session.applyOptions?.({ token_budget: 2000 });
|
||||
|
||||
await session.revertConversation!({ messageId: "user-A" });
|
||||
|
||||
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
|
||||
expect(reopened?.config.providerOptions).toEqual({ token_budget: 2000 });
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
@@ -1004,27 +1146,63 @@ 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>,
|
||||
{ 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 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,
|
||||
...(state.tokenBudget === undefined ? {} : { token_budget: state.tokenBudget }),
|
||||
},
|
||||
}
|
||||
: {
|
||||
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 +1213,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: settings(),
|
||||
...reported(),
|
||||
},
|
||||
});
|
||||
const harness = createProviderHarness({
|
||||
@@ -1056,8 +1234,22 @@ describe("PluginAgentClientRegistry", () => {
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.configure") {
|
||||
const loud = input.changes.settings?.loud;
|
||||
const patch =
|
||||
shape === "settings" ? input.changes.settings : input.changes.providerOptions;
|
||||
const { loud, token_budget: tokenBudget } = patch ?? {};
|
||||
// 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;
|
||||
// Going loud returns an unrequested token budget to its default, by leaving it out.
|
||||
if (loud === true && tokenBudget === undefined) state.tokenBudget = undefined;
|
||||
emitConfig(emit, input.sessionId);
|
||||
emit(
|
||||
refuseAfterChanging
|
||||
@@ -1096,6 +1288,47 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test.runIf(shape === "controls")(
|
||||
"reports an option the plugin stops reporting as removed, alone or with another change",
|
||||
async () => {
|
||||
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
|
||||
changeOnItsOwn({ tokenBudget: 500 });
|
||||
|
||||
changeOnItsOwn({ tokenBudget: undefined });
|
||||
changeOnItsOwn({ tokenBudget: 700 });
|
||||
changeOnItsOwn({ tokenBudget: undefined, loud: true });
|
||||
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: null } },
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 700 } },
|
||||
{
|
||||
type: "options_changed",
|
||||
provider: "plugin-direct",
|
||||
patch: { token_budget: null, loud: true },
|
||||
},
|
||||
]);
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test.runIf(shape === "controls")(
|
||||
"an apply returns the option the plugin reset along the way as removed",
|
||||
async () => {
|
||||
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
|
||||
changeOnItsOwn({ tokenBudget: 500 });
|
||||
|
||||
const ended = await session.applyOptions?.({ loud: true });
|
||||
|
||||
expect(ended).toEqual({ token_budget: null });
|
||||
// The apply reports it; the plugin's later identical report is not a second change.
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
|
||||
]);
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("a rejected apply reports what the plugin moved before refusing, since nothing is stored", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({}, { refuseAfterChanging: true });
|
||||
|
||||
@@ -1115,7 +1348,7 @@ 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, defaults included", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
|
||||
@@ -1129,30 +1362,94 @@ 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"]);
|
||||
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);
|
||||
|
||||
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
|
||||
/launch_key.*cannot change on a running session/,
|
||||
// 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 } },
|
||||
);
|
||||
|
||||
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: { settings: object } };
|
||||
expect(Object.keys(configure.changes.settings)).toEqual(["loud"]);
|
||||
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, and what it ended up with is reported like any provider's.
|
||||
expect(ended).toEqual({ loud: false });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("draws a control from what the plugin reports", async () => {
|
||||
const { session } = await openStubbornVoiceSession({});
|
||||
|
||||
expect((session.controls ?? []).map(controlKey)).toEqual(["voice", "loud"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test.runIf(shape === "controls")(
|
||||
"keeps a control's icon, default and choices as the plugin declared them",
|
||||
async () => {
|
||||
const { session } = await openStubbornVoiceSession({});
|
||||
|
||||
expect(session.controls?.[0]).toMatchObject({
|
||||
icon: "Mic",
|
||||
defaultValue: "direct",
|
||||
choices: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
});
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
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 session.applyOptions?.({ token_budget: 2000 });
|
||||
expect(lastConfigure(harness.inputs)).toMatchObject({
|
||||
changes: { providerOptions: { token_budget: 2000 } },
|
||||
});
|
||||
|
||||
// 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();
|
||||
},
|
||||
);
|
||||
|
||||
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({});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1117,7 +1118,7 @@ class PluginAgentClient implements AgentClient {
|
||||
this.provider,
|
||||
bridge,
|
||||
forget,
|
||||
async (persistence, state) => {
|
||||
async (persistence, state, options) => {
|
||||
forget();
|
||||
return await this.runtime.openSession({
|
||||
sessionId: randomUUID(),
|
||||
@@ -1126,9 +1127,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: options,
|
||||
providerOptions: options,
|
||||
},
|
||||
persistence,
|
||||
history: "replay",
|
||||
@@ -1204,9 +1204,12 @@ 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> = {};
|
||||
// The options a reopened session starts with: what it launched with, then every value the
|
||||
// plugin reported or was given, minus every key that was reset.
|
||||
private readonly committedOptions: Record<string, JsonValue>;
|
||||
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
|
||||
private applyingOptions = false;
|
||||
private currentTurnId: string | null = null;
|
||||
@@ -1227,14 +1230,16 @@ class PluginAgentSession implements AgentSession {
|
||||
private readonly reopen: (
|
||||
persistence: ProviderPersistence,
|
||||
state: ProviderConfigState,
|
||||
options: Readonly<Record<string, JsonValue>>,
|
||||
) => 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.committedOptions = { ...givenOptions };
|
||||
this.openOptionsPatch = this.optionDifferences(givenOptions);
|
||||
this.commitOptions();
|
||||
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 +1263,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 +1422,50 @@ 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;
|
||||
// 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),
|
||||
});
|
||||
const before = this.currentOptions();
|
||||
const { controls, legacySettings } = readPluginOptionState(this.bridge.config);
|
||||
const settings: Record<string, JsonValue> = {};
|
||||
for (const [id, value] of Object.entries(options)) {
|
||||
if (!(id in before)) continue;
|
||||
settings[id] = toJsonValue(value, `setting '${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;
|
||||
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();
|
||||
// A requested key is a request, not a report: only a key the plugin reported before can be
|
||||
// one it has now dropped.
|
||||
const ended = this.optionChanges({ ...before, ...settings }, before);
|
||||
this.commitOptions(settings);
|
||||
return ended;
|
||||
}
|
||||
|
||||
@@ -1648,28 +1648,58 @@ 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. */
|
||||
private optionDifferences(known: Readonly<Record<string, JsonValue>>): Record<string, JsonValue> {
|
||||
const patch: Record<string, JsonValue> = {};
|
||||
for (const [id, value] of Object.entries(this.currentSettings())) {
|
||||
for (const [id, value] of Object.entries(this.currentOptions())) {
|
||||
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
private reportSettingChanges(): AgentStreamEvent[] {
|
||||
/**
|
||||
* Settle the options a reopened session starts with: what is committed, then the request the
|
||||
* plugin accepted, then what it reported back. A key it reported before and no longer reports
|
||||
* was reset.
|
||||
*/
|
||||
private commitOptions(accepted: Readonly<Record<string, JsonValue>> = {}): void {
|
||||
for (const [id, value] of Object.entries(accepted)) {
|
||||
if (value === null) delete this.committedOptions[id];
|
||||
else this.committedOptions[id] = value;
|
||||
}
|
||||
const current = this.currentOptions();
|
||||
for (const id of Object.keys(this.knownOptions)) {
|
||||
if (!(id in current)) delete this.committedOptions[id];
|
||||
}
|
||||
Object.assign(this.committedOptions, current);
|
||||
this.knownOptions = current;
|
||||
}
|
||||
|
||||
/**
|
||||
* What changed against the options `known` to be set: values that differ, and `null` for a key
|
||||
* in `reported` that the plugin no longer reports, which went back to its default.
|
||||
*/
|
||||
private optionChanges(
|
||||
known: Readonly<Record<string, JsonValue>>,
|
||||
reported: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> {
|
||||
const patch = this.optionDifferences(known);
|
||||
const current = this.currentOptions();
|
||||
for (const id of Object.keys(reported)) {
|
||||
if (known[id] !== null && !(id in current)) patch[id] = null;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
private reportOptionChanges(): AgentStreamEvent[] {
|
||||
if (this.applyingOptions) return [];
|
||||
const patch = this.settingDifferences(this.knownSettings);
|
||||
const patch = this.optionChanges(this.knownOptions, this.knownOptions);
|
||||
if (Object.keys(patch).length === 0) return [];
|
||||
this.knownSettings = this.currentSettings();
|
||||
this.commitOptions();
|
||||
return [{ type: "options_changed", provider: this.provider, patch }];
|
||||
}
|
||||
|
||||
@@ -1677,7 +1707,7 @@ class PluginAgentSession implements AgentSession {
|
||||
event: Extract<ProviderEvent, { type: "session.config" }>,
|
||||
): AgentStreamEvent[] {
|
||||
return [
|
||||
...this.reportSettingChanges(),
|
||||
...this.reportOptionChanges(),
|
||||
{
|
||||
type: "mode_changed",
|
||||
provider: this.provider,
|
||||
@@ -1828,7 +1858,7 @@ class PluginAgentSession implements AgentSession {
|
||||
await this.bridge.close();
|
||||
replay.closed = true;
|
||||
}
|
||||
const bridge = await this.reopen(replay.persistence, replay.config);
|
||||
const bridge = await this.reopen(replay.persistence, replay.config, this.committedOptions);
|
||||
this.bridge = bridge;
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,7 +89,29 @@ 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: { model: "other-model", providerOptions: { voice: "loud" } },
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "request.failed", requestId: "configure-refused" }),
|
||||
);
|
||||
// A refused request changes nothing, the model it named included.
|
||||
await connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "configure-after-refused",
|
||||
sessionId: "session-complete",
|
||||
changes: {},
|
||||
});
|
||||
expect(events.findLast((event) => event.type === "session.config")).not.toMatchObject({
|
||||
config: { model: "other-model" },
|
||||
});
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
|
||||
@@ -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,
|
||||
@@ -344,6 +372,19 @@ function configureSession(
|
||||
): void {
|
||||
const session = requireSession(state, input.sessionId);
|
||||
const changes = input.changes;
|
||||
// Work out everything first, so a refused request changes nothing.
|
||||
let options: ExampleSession["options"];
|
||||
try {
|
||||
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;
|
||||
}
|
||||
session.options = options;
|
||||
session.config = {
|
||||
...session.config,
|
||||
model: changes.model === null ? undefined : (changes.model ?? session.config.model),
|
||||
@@ -352,14 +393,31 @@ function configureSession(
|
||||
changes.thinkingOption === null
|
||||
? undefined
|
||||
: (changes.thinkingOption ?? session.config.thinkingOption),
|
||||
settings: changes.settings
|
||||
? { ...session.config.settings, ...changes.settings }
|
||||
: session.config.settings,
|
||||
};
|
||||
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<ProviderConfigChanges["providerOptions"]>,
|
||||
): 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 +426,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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -184,6 +184,26 @@ For paced timeline text, replace `useRevealedText(text, phase)` and the surround
|
||||
`<StreamedText text={text} phase={phase} style={style} />` 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 |
|
||||
|
||||
@@ -152,6 +152,22 @@ pill.remove();
|
||||
|
||||
对于逐步显示的时间线文本,把 `useRevealedText(text, phase)` 及其外层的 `Text` 换成 `/client/ui` 中的 `<StreamedText text={text} phase={phase} style={style} />`。插件设置的标题使用 `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}
|
||||
|
||||
| 编译器或加载错误 | 含义与修复方法 |
|
||||
|
||||
@@ -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,48 @@ 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 a control shows for a key that has no value.
|
||||
Paseo stores every value you report when a session opens or a change is applied, including values
|
||||
at the default and values nobody set. 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 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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -467,7 +487,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;
|
||||
|
||||
@@ -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` 是控件在键没有值时显示的值。会话打开或应用修改时,你报告的每个值(包括处于默认值的值和无人设置过的值)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 会保存它们,不会把旧值设置回去。
|
||||
|
||||
并非每个选项都需要控件。例如 Provider 专属的 token 预算应放在 `providerOptions` 中,它不是一个专门的提示词字段。它会在 `session.open` 中交付,调用方可以在运行中的 Agent 上修改它,由你的 `session.configure` 处理程序决定能否实时应用。请在 `providerOptions` 中报告它,使 Paseo 保存实际运行的值。
|
||||
|
||||
## 添加持久化与重放 {#add-persistence-and-replay}
|
||||
|
||||
@@ -405,7 +409,7 @@ export const vendorEdits: AcpTransformer = {
|
||||
用真实的 Agent 测试 Provider,而不只是模拟的数据帧:
|
||||
|
||||
1. 以目录来源安装插件;
|
||||
2. 确认它的 SVG、模型、模式和设置都会显示;
|
||||
2. 确认它的 SVG、模型、模式和选项控件都会显示;
|
||||
3. 创建会话并完成一段真实的提示词;
|
||||
4. 在支持的情况下,试用权限、引导、取消、持久化和重放;
|
||||
5. 在桌面和移动端宽度的布局中渲染每一种自定义时间线条目;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` 期间重新读取外部状态。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user