mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 14:43:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77f90d3460 | ||
|
|
7a651fceb3 | ||
|
|
4095d3784d | ||
|
|
21365c571b | ||
|
|
f4dea7413e | ||
|
|
6bf7a47a96 | ||
|
|
d0a5d4d970 | ||
|
|
377b06be0d | ||
|
|
25a3efe0ac | ||
|
|
5b4636b856 | ||
|
|
00ec4bc80c | ||
|
|
970c281daf | ||
|
|
389db81784 |
@@ -92,6 +92,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `agent.metadata.update.request`; `null` in a patch removes a key |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
@@ -478,6 +479,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `workspace.metadata.update.request`. |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
||||
|
||||
|
||||
+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" ||
|
||||
|
||||
@@ -3727,6 +3727,35 @@ test("rejects an internal create before sending when the host predates internal
|
||||
expect(mock.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("metadata needs a host that advertises entityMetadata and sends nothing to one that does not", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: {} });
|
||||
await connectPromise;
|
||||
|
||||
const update = "Update the host to use metadata.";
|
||||
await expect(
|
||||
client.createAgent({ provider: "codex", cwd: "/tmp/project", metadata: { key: "value" } }),
|
||||
).rejects.toThrow(update);
|
||||
await expect(
|
||||
client.createWorkspace({
|
||||
source: { kind: "directory", path: "/tmp/project" },
|
||||
metadata: { key: "value" },
|
||||
}),
|
||||
).rejects.toThrow(update);
|
||||
await expect(client.setAgentMetadata("agent-1", { key: null })).rejects.toThrow(update);
|
||||
await expect(client.setWorkspaceMetadata("workspace-1", { key: null })).rejects.toThrow(update);
|
||||
expect(mock.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sends internal workspace creation and internal listings only to hosts that support them", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./connection/index.js";
|
||||
import { CreationClient } from "./creation/index.js";
|
||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { z } from "zod";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
|
||||
@@ -509,11 +510,15 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
labels?: Record<string, string>;
|
||||
/** Plugin-owned data stored on the agent. Needs a host with `entityMetadata`. */
|
||||
metadata?: CreateAgentRequestMessage["metadata"];
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequestOptions {
|
||||
source: WorkspaceCreateRequest["source"];
|
||||
title?: string;
|
||||
/** Plugin-owned data stored on the workspace. Needs a host with `entityMetadata`. */
|
||||
metadata?: WorkspaceCreateRequest["metadata"];
|
||||
/** Workspace discovery visibility; contents retain normal durability. */
|
||||
background?: boolean;
|
||||
callerAgentId?: string;
|
||||
@@ -3251,6 +3256,13 @@ export class DaemonClient {
|
||||
legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
|
||||
});
|
||||
|
||||
// Entity metadata is gated once at the transport boundary.
|
||||
private requireEntityMetadata(): void {
|
||||
if (this.lastServerInfoMessage?.features?.entityMetadata !== true) {
|
||||
throw new Error("Update the host to use metadata.");
|
||||
}
|
||||
}
|
||||
|
||||
// Background workspaces are gated once at the transport boundary.
|
||||
private requireBackgroundWorkspaces(): void {
|
||||
if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) {
|
||||
@@ -3269,6 +3281,7 @@ export class DaemonClient {
|
||||
);
|
||||
}
|
||||
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
||||
if (options.metadata !== undefined) this.requireEntityMetadata();
|
||||
const config = resolveAgentConfig(options);
|
||||
const result = await this.creations.createAgent({
|
||||
...options,
|
||||
@@ -3505,6 +3518,42 @@ export class DaemonClient {
|
||||
return { removedWorkspaceIds: payload.removedWorkspaceIds };
|
||||
}
|
||||
|
||||
/** Top-level keys replace; `null` removes the key. Resolves to the agent's metadata afterwards. */
|
||||
async setAgentMetadata(
|
||||
agentId: string,
|
||||
patch: Record<string, JsonValue>,
|
||||
requestId?: string,
|
||||
): Promise<Record<string, JsonValue>> {
|
||||
this.requireEntityMetadata();
|
||||
const payload =
|
||||
await this.sendNamespacedCorrelatedSessionRequest<"agent.metadata.update.response">({
|
||||
requestId,
|
||||
message: { type: "agent.metadata.update.request", agentId, patch },
|
||||
});
|
||||
if (!payload.accepted || !payload.metadata) {
|
||||
throw new Error(payload.error ?? "setAgentMetadata rejected");
|
||||
}
|
||||
return payload.metadata;
|
||||
}
|
||||
|
||||
/** Top-level keys replace; `null` removes the key. Resolves to the workspace's metadata afterwards. */
|
||||
async setWorkspaceMetadata(
|
||||
workspaceId: string,
|
||||
patch: Record<string, JsonValue>,
|
||||
requestId?: string,
|
||||
): Promise<Record<string, JsonValue>> {
|
||||
this.requireEntityMetadata();
|
||||
const payload =
|
||||
await this.sendNamespacedCorrelatedSessionRequest<"workspace.metadata.update.response">({
|
||||
requestId,
|
||||
message: { type: "workspace.metadata.update.request", workspaceId, patch },
|
||||
});
|
||||
if (!payload.accepted || !payload.metadata) {
|
||||
throw new Error(payload.error ?? "setWorkspaceMetadata rejected");
|
||||
}
|
||||
return payload.metadata;
|
||||
}
|
||||
|
||||
async setWorkspaceTitle(
|
||||
workspaceId: string,
|
||||
title: string | null,
|
||||
@@ -5058,6 +5107,8 @@ export class DaemonClient {
|
||||
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
|
||||
if (input.agent?.background !== undefined)
|
||||
throw new Error("Configure background on the workspace, not its initial agent.");
|
||||
if (input.metadata !== undefined || input.agent?.metadata !== undefined)
|
||||
this.requireEntityMetadata();
|
||||
let agentConfig = input.agent ? resolveAgentConfig(input.agent) : undefined;
|
||||
if (agentConfig && this.carriesLegacyOptions(agentConfig)) {
|
||||
agentConfig = await this.routeLegacyCreateConfig(agentConfig);
|
||||
@@ -7687,6 +7738,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
||||
worktreeName: _worktreeName,
|
||||
requestId: _requestId,
|
||||
labels: _labels,
|
||||
metadata: _metadata,
|
||||
...overrides
|
||||
} = options;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/
|
||||
import type { OwnedSubscription } from "./connection/index.js";
|
||||
export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js";
|
||||
import type { DaemonClientConfig } from "./daemon-client.js";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentPermissionResponse, JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
CreationSnapshot,
|
||||
@@ -184,6 +184,8 @@ export interface PaseoWorkspaceHandle {
|
||||
readonly directory: string | null;
|
||||
readonly name: string | null;
|
||||
readonly status: PaseoWorkspace["status"] | null;
|
||||
/** Data plugins stored on the workspace. Paseo never reads a key. */
|
||||
readonly metadata: NonNullable<PaseoWorkspace["metadata"]> | null;
|
||||
readonly agents: {
|
||||
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
|
||||
};
|
||||
@@ -191,6 +193,8 @@ export interface PaseoWorkspaceHandle {
|
||||
current(): PaseoWorkspace | null;
|
||||
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
|
||||
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
|
||||
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
|
||||
/**
|
||||
* Subscribes to already-emitted daemon workspace_update events for this id.
|
||||
@@ -274,6 +278,8 @@ export interface PaseoAgentCreateOptions {
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
requestId?: string;
|
||||
labels?: Record<string, string>;
|
||||
/** Data plugins store on the agent: JSON values under keys such as `"linear.issue"`. */
|
||||
metadata?: CreateAgentRequestMessage["metadata"];
|
||||
}
|
||||
|
||||
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd">;
|
||||
@@ -376,6 +382,8 @@ export interface PaseoAgentHandle {
|
||||
readonly options: NonNullable<PaseoAgent["options"]> | null;
|
||||
/** How the provider wants its options drawn. Controls hold no value; read `options`. */
|
||||
readonly controls: NonNullable<PaseoAgent["controls"]> | null;
|
||||
/** Data plugins stored on the agent. Paseo never reads a key. */
|
||||
readonly metadata: NonNullable<PaseoAgent["metadata"]> | null;
|
||||
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
|
||||
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
|
||||
readonly timeline: PaseoAgentTimelineHandle;
|
||||
@@ -394,6 +402,8 @@ export interface PaseoAgentHandle {
|
||||
* provider has applied the patch; a provider that cannot apply a key live rejects it.
|
||||
*/
|
||||
setOptions(patch: Record<string, unknown>): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
/** Sends a prompt and resolves when that turn finishes or needs attention. */
|
||||
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
|
||||
/** Waits for the current turn, including one started with `prompt`. */
|
||||
@@ -899,6 +909,9 @@ function createWorkspaceHandleFactory(
|
||||
get status() {
|
||||
return current?.status ?? null;
|
||||
},
|
||||
get metadata() {
|
||||
return current?.metadata ?? null;
|
||||
},
|
||||
agents: {
|
||||
create: async (options) => {
|
||||
const snapshot = current ?? (await refresh());
|
||||
@@ -918,6 +931,10 @@ function createWorkspaceHandleFactory(
|
||||
current: () => current,
|
||||
refresh,
|
||||
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
|
||||
setMetadata: async (patch) => {
|
||||
const metadata = await daemonClient.setWorkspaceMetadata(id, patch);
|
||||
if (current) current = { ...current, metadata };
|
||||
},
|
||||
archive: async (requestId) => {
|
||||
const result = await daemonClient.archiveWorkspace(id, requestId);
|
||||
if (current) {
|
||||
@@ -1025,6 +1042,9 @@ function createAgentHandleFactory(
|
||||
get controls() {
|
||||
return current?.controls ?? null;
|
||||
},
|
||||
get metadata() {
|
||||
return current?.metadata ?? null;
|
||||
},
|
||||
get runtimeInfo() {
|
||||
return current?.runtimeInfo ?? null;
|
||||
},
|
||||
@@ -1049,6 +1069,10 @@ function createAgentHandleFactory(
|
||||
setOptions: async (patch) => {
|
||||
await daemonClient.setAgentOptions(id, patch);
|
||||
},
|
||||
setMetadata: async (patch) => {
|
||||
const metadata = await daemonClient.setAgentMetadata(id, patch);
|
||||
if (current) current = { ...current, metadata };
|
||||
},
|
||||
respondToPermission: async ({ requestId, response }) => {
|
||||
await daemonClient.respondToPermission(id, requestId, response);
|
||||
},
|
||||
|
||||
@@ -283,6 +283,10 @@ describe("lifecycle hook SDK types", () => {
|
||||
server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" }));
|
||||
server.addHook("workspace.create.before", async () => {});
|
||||
server.addHook("agent.open.before", async () => {});
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
const { metadata, ...withoutMetadata } = request;
|
||||
return withoutMetadata;
|
||||
});
|
||||
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
||||
const kind: "completed" | "failed" | "canceled" = outcome.kind;
|
||||
// @ts-expect-error Notification payloads have no creation request.
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AgentPermissionResponse,
|
||||
AgentTimelineItem,
|
||||
AgentSessionConfig,
|
||||
JsonValue,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
|
||||
@@ -19,6 +20,8 @@ export interface PluginHookWorkspace {
|
||||
cwd: string;
|
||||
name: string | null;
|
||||
archivedAt: string | null;
|
||||
/** Data plugins stored on the workspace. Paseo never reads a key. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface PluginHookWorktree {
|
||||
@@ -68,6 +71,8 @@ export interface PluginHookAgent {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
/** Data plugins stored on the agent. Paseo never reads a key. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface PluginSessionOpenRequest {
|
||||
@@ -79,6 +84,7 @@ export interface PluginSessionOpenRequest {
|
||||
purpose: "interactive" | "history";
|
||||
readonly title: string | null;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
tools: AgentToolDefinition[];
|
||||
env: Record<string, string>;
|
||||
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
|
||||
@@ -116,15 +122,27 @@ export interface PluginHookEvents {
|
||||
|
||||
export interface PluginHookRequests {
|
||||
"worktree.create.before": PluginWorktreeCreateRequest;
|
||||
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
|
||||
"agent.create.before": {
|
||||
config: AgentSessionConfig;
|
||||
env?: Record<string, string>;
|
||||
/** Stored on the new agent. Always present in the payload; return it changed to change it. */
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
"agent.open.before": PluginSessionOpenRequest;
|
||||
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
|
||||
}
|
||||
|
||||
/** What a hook may return. An omitted `metadata` stays as it was. */
|
||||
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
|
||||
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
|
||||
metadata?: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginHooks = {
|
||||
[Name in keyof PluginHookRequests]: {
|
||||
request: PluginHookRequests[Name];
|
||||
result: PluginHookRequests[Name];
|
||||
result: PluginHookRequestResults[Name];
|
||||
};
|
||||
} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & {
|
||||
"worktree.create.after": { payload: { worktree: PluginHookWorktree } };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -303,6 +303,9 @@ import type {
|
||||
// unknown avoids zod-aot's recursive z.json() object-codegen regression.
|
||||
const JsonWireValueSchema = z.unknown() as z.ZodType<JsonValue>;
|
||||
|
||||
/** Plugin-owned data on an agent or workspace. Paseo stores it and never reads a key. */
|
||||
const EntityMetadataSchema = z.record(z.string(), JsonWireValueSchema);
|
||||
|
||||
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
|
||||
|
||||
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
||||
@@ -922,6 +925,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
lastError: z.string().optional(),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
@@ -1068,6 +1072,22 @@ export const WorkspaceTitleSetRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AgentMetadataUpdateRequestSchema = z.object({
|
||||
type: z.literal("agent.metadata.update.request"),
|
||||
agentId: z.string(),
|
||||
// Top-level keys replace; null removes the key.
|
||||
patch: EntityMetadataSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceMetadataUpdateRequestSchema = z.object({
|
||||
type: z.literal("workspace.metadata.update.request"),
|
||||
workspaceId: z.string(),
|
||||
// Top-level keys replace; null removes the key.
|
||||
patch: EntityMetadataSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspacePinSetRequestSchema = z.object({
|
||||
type: z.literal("workspace.pin.set.request"),
|
||||
workspaceId: z.string(),
|
||||
@@ -1826,6 +1846,7 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
// Workspace creation intent, rejected when selecting an existing workspace.
|
||||
background: z.boolean().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -2204,6 +2225,28 @@ export const WorkspaceTitleSetResponseSchema = z.object({
|
||||
payload: WorkspaceTitleSetResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const AgentMetadataUpdateResponseSchema = z.object({
|
||||
type: z.literal("agent.metadata.update.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
metadata: EntityMetadataSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceMetadataUpdateResponseSchema = z.object({
|
||||
type: z.literal("workspace.metadata.update.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
metadata: EntityMetadataSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspacePinSetResponsePayloadSchema = z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
@@ -2759,6 +2802,7 @@ export const WorkspaceCreateRequestSchema = z.object({
|
||||
idempotencyKey: z.string().min(1).max(512).optional(),
|
||||
// Optional user-set title applied to the created workspace.
|
||||
title: z.string().optional(),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
// Optional prompt context for workspace-level name/branch generation.
|
||||
firstAgentContext: FirstAgentContextSchema.optional(),
|
||||
// Retained for wire parsing; public internal creation is no longer supported.
|
||||
@@ -3355,6 +3399,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectIconSetRequestSchema,
|
||||
ProjectRemoveRequestSchema,
|
||||
WorkspaceTitleSetRequestSchema,
|
||||
AgentMetadataUpdateRequestSchema,
|
||||
WorkspaceMetadataUpdateRequestSchema,
|
||||
WorkspacePinSetRequestSchema,
|
||||
WorkspaceLabelListRequestSchema,
|
||||
WorkspaceLabelAssignmentSetRequestSchema,
|
||||
@@ -3888,6 +3934,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
explicitEventSubscriptions: z.boolean().optional(),
|
||||
ownedSubscriptions: z.boolean().optional(),
|
||||
providerOptionControls: z.boolean().optional(),
|
||||
entityMetadata: z.boolean().optional(),
|
||||
binaryChunkingV1: z.boolean().optional(),
|
||||
workspaceSetupObservation: z.boolean().optional(),
|
||||
workspaceSortTimes: z.boolean().optional(),
|
||||
@@ -4222,6 +4269,7 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
pinnedAt: z.string().nullable().optional(),
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14.
|
||||
labels: z.array(z.string()).optional(),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
// Retained for wire parsing of older daemon descriptors.
|
||||
internal: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
@@ -7200,6 +7248,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectIconSetResponseSchema,
|
||||
ProjectRemoveResponseSchema,
|
||||
WorkspaceTitleSetResponseSchema,
|
||||
AgentMetadataUpdateResponseSchema,
|
||||
WorkspaceMetadataUpdateResponseSchema,
|
||||
WorkspacePinSetResponseSchema,
|
||||
WorkspaceRecoveryInspectResponseSchema,
|
||||
WorkspaceRecoveryRestoreResponseSchema,
|
||||
@@ -7566,6 +7616,10 @@ export type ProjectIconSetRequest = z.infer<typeof ProjectIconSetRequestSchema>;
|
||||
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
|
||||
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
|
||||
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
|
||||
export type AgentMetadataUpdateRequest = z.infer<typeof AgentMetadataUpdateRequestSchema>;
|
||||
export type WorkspaceMetadataUpdateRequest = z.infer<typeof WorkspaceMetadataUpdateRequestSchema>;
|
||||
export type AgentMetadataUpdateResponse = z.infer<typeof AgentMetadataUpdateResponseSchema>;
|
||||
export type WorkspaceMetadataUpdateResponse = z.infer<typeof WorkspaceMetadataUpdateResponseSchema>;
|
||||
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
|
||||
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
|
||||
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function ensureAgentLoaded(
|
||||
}
|
||||
snapshot = await deps.agentManager.createAgent(config, agentId, {
|
||||
labels: record.labels,
|
||||
metadata: record.metadata,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
env: record.launchEnv,
|
||||
|
||||
@@ -12475,6 +12475,7 @@ test.each([
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: null,
|
||||
metadata: {},
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||
import { applyRecordPatch } from "../record-patch.js";
|
||||
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, resolve } from "node:path";
|
||||
@@ -60,7 +61,7 @@ import {
|
||||
type ListImportableSessionsOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import {
|
||||
applyProviderOptionsPatch,
|
||||
@@ -324,6 +325,7 @@ type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
@@ -465,6 +467,8 @@ interface ManagedAgentBase {
|
||||
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
||||
*/
|
||||
labels: Record<string, string>;
|
||||
/** Plugin-owned data. Paseo persists and forwards it and never reads a key. */
|
||||
metadata: AgentMetadata;
|
||||
}
|
||||
|
||||
type ManagedAgentWithSession = ManagedAgentBase & {
|
||||
@@ -524,6 +528,7 @@ type ActiveManagedAgent =
|
||||
|
||||
type LiveManagedAgent = ActiveManagedAgent;
|
||||
type AgentLabelPatch = Record<string, string | null>;
|
||||
type AgentMetadata = Record<string, JsonValue>;
|
||||
|
||||
function attachManagedTurnIdentity(
|
||||
agent: ActiveManagedAgent,
|
||||
@@ -571,6 +576,7 @@ interface WriteLabelsResult {
|
||||
interface AgentMetadataPatch {
|
||||
title?: string;
|
||||
labels?: AgentLabelPatch;
|
||||
metadata?: Record<string, JsonValue | null>;
|
||||
}
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
@@ -629,6 +635,11 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
|
||||
return Object.assign(new Error(message), { name: "AbortError" });
|
||||
}
|
||||
|
||||
/** The user labels and plugin metadata an agent carries; both default to empty. */
|
||||
function agentEntityData(source?: { labels?: Record<string, string>; metadata?: AgentMetadata }) {
|
||||
return { labels: source?.labels ?? {}, metadata: source?.metadata ?? {} };
|
||||
}
|
||||
|
||||
function validateAgentId(agentId: string, source: string): string {
|
||||
const result = AgentIdSchema.safeParse(agentId);
|
||||
if (!result.success) {
|
||||
@@ -637,21 +648,6 @@ function validateAgentId(agentId: string, source: string): string {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function applyLabelPatch(
|
||||
labels: Record<string, string>,
|
||||
patch: AgentLabelPatch,
|
||||
): Record<string, string> {
|
||||
const nextLabels = { ...labels };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete nextLabels[key];
|
||||
} else {
|
||||
nextLabels[key] = value;
|
||||
}
|
||||
}
|
||||
return nextLabels;
|
||||
}
|
||||
|
||||
function buildExplicitTimelineSeedForRegister(
|
||||
now: Date,
|
||||
options:
|
||||
@@ -1386,6 +1382,7 @@ export class AgentManager {
|
||||
const request = await this.pluginLifecycle.before("agent.create.before", {
|
||||
config: sent,
|
||||
env: options.env,
|
||||
metadata: options.metadata ?? {},
|
||||
});
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
||||
// against the previous SDK edits `config.featureValues`.
|
||||
@@ -1397,7 +1394,7 @@ export class AgentManager {
|
||||
}),
|
||||
internal: config.internal,
|
||||
};
|
||||
options = { ...options, env: request.env };
|
||||
options = { ...options, env: request.env, metadata: request.metadata ?? options.metadata };
|
||||
}
|
||||
await this.deleteAgentState(resolvedAgentId);
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
@@ -1422,6 +1419,7 @@ export class AgentManager {
|
||||
workspaceId: options.workspaceId ?? null,
|
||||
title: options.initialTitle ?? storedConfig.title ?? null,
|
||||
labels: options.labels ?? {},
|
||||
metadata: options.metadata ?? {},
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1432,6 +1430,7 @@ export class AgentManager {
|
||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
launchedOptions,
|
||||
labels: options.labels,
|
||||
metadata: options.metadata,
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
@@ -1465,6 +1464,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
attention?: AttentionState;
|
||||
@@ -1497,6 +1497,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
attention?: AttentionState;
|
||||
@@ -1549,7 +1550,7 @@ export class AgentManager {
|
||||
purpose,
|
||||
workspaceId: options?.workspaceId ?? null,
|
||||
title: record?.title ?? mergedConfig.title ?? null,
|
||||
labels: record?.labels ?? options?.labels ?? {},
|
||||
...agentEntityData(record ?? options),
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1720,6 +1721,7 @@ export class AgentManager {
|
||||
workspaceId: existing.workspaceId,
|
||||
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
|
||||
labels: existing.labels,
|
||||
metadata: existing.metadata,
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1764,6 +1766,7 @@ export class AgentManager {
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
launchedOptions,
|
||||
labels: existing.labels,
|
||||
metadata: existing.metadata,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
launchEnv: existing.launchEnv,
|
||||
@@ -2224,6 +2227,7 @@ export class AgentManager {
|
||||
attention,
|
||||
internal: record.internal,
|
||||
labels: record.labels,
|
||||
metadata: record.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2425,10 +2429,30 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
/** Applies a plugin-metadata patch (null removes a key) and returns the stored result. */
|
||||
async setMetadata(
|
||||
agentId: string,
|
||||
patch: Record<string, JsonValue | null>,
|
||||
): Promise<AgentMetadata> {
|
||||
return this.runLifecycleMutation(agentId, async () => {
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
if (liveAgent) {
|
||||
liveAgent.metadata = applyRecordPatch<JsonValue>(liveAgent.metadata, patch);
|
||||
this.touchUpdatedAt(liveAgent);
|
||||
await this.persistSnapshot(liveAgent);
|
||||
this.emitState(liveAgent, { persist: false });
|
||||
return liveAgent.metadata;
|
||||
}
|
||||
const record = await this.writeStoredMetadata(agentId, { metadata: patch });
|
||||
if (!record.internal) this.dispatchStoredAgentState(record);
|
||||
return record.metadata;
|
||||
});
|
||||
}
|
||||
|
||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
if (liveAgent) {
|
||||
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
|
||||
liveAgent.labels = applyRecordPatch(liveAgent.labels, patch);
|
||||
this.touchUpdatedAt(liveAgent);
|
||||
await this.persistSnapshot(liveAgent);
|
||||
this.emitState(liveAgent, { persist: false });
|
||||
@@ -2453,7 +2477,8 @@ export class AgentManager {
|
||||
const nextRecord = {
|
||||
...record,
|
||||
...(patch.title ? { title: patch.title } : {}),
|
||||
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
|
||||
...(patch.labels ? { labels: applyRecordPatch(record.labels, patch.labels) } : {}),
|
||||
...(patch.metadata ? { metadata: applyRecordPatch(record.metadata, patch.metadata) } : {}),
|
||||
updatedAt: this.nextStoredUpdatedAt(record),
|
||||
};
|
||||
await registry.upsert(nextRecord);
|
||||
@@ -2632,7 +2657,7 @@ export class AgentManager {
|
||||
await registry.upsert({
|
||||
...record,
|
||||
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
|
||||
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
|
||||
...(updates?.labels ? { labels: applyRecordPatch(record.labels, updates.labels) } : {}),
|
||||
archivedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
@@ -3919,6 +3944,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
timeline?: AgentTimelineItem[];
|
||||
timelineRows?: AgentTimelineRow[];
|
||||
timelineNextSeq?: number;
|
||||
@@ -4128,6 +4154,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
historyPrimed?: boolean;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
@@ -4186,7 +4213,7 @@ export class AgentManager {
|
||||
lastError: options?.lastError,
|
||||
attention: resolveInitialAttention(options?.attention),
|
||||
internal: config.internal ?? false,
|
||||
labels: options?.labels ?? {},
|
||||
...agentEntityData(options),
|
||||
} as ActiveManagedAgent;
|
||||
}
|
||||
|
||||
@@ -5770,6 +5797,7 @@ export class AgentManager {
|
||||
workspaceId?: string | null;
|
||||
title?: string | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
},
|
||||
): Promise<AgentLaunchContext> {
|
||||
// A plugin changing during this open must still make the resulting session stale.
|
||||
@@ -5787,7 +5815,7 @@ export class AgentManager {
|
||||
purpose: opening?.purpose ?? "interactive",
|
||||
env: { ...env },
|
||||
title: opening?.title ?? null,
|
||||
labels: opening?.labels ?? {},
|
||||
...agentEntityData(opening),
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
@@ -94,6 +94,7 @@ export function toStoredAgentRecord(
|
||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
metadata: agent.metadata,
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
@@ -155,6 +156,7 @@ export function toAgentPayload(
|
||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
metadata: agent.metadata,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
};
|
||||
@@ -284,6 +286,7 @@ export function buildStoredAgentPayload(
|
||||
archivedAt: record.archivedAt ?? null,
|
||||
...(record.internal ? { internal: true } : {}),
|
||||
labels: normalizeLabels(record.labels),
|
||||
metadata: record.metadata,
|
||||
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
|
||||
...(providerAvailable ? {} : { providerUnavailable: true }),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { promises as fs, type Dirent } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
|
||||
@@ -56,6 +57,7 @@ const RAW_STORED_AGENT_SCHEMA = z.object({
|
||||
lastUserMessageAt: z.string().nullable().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
resumeOnStartup: z.boolean().optional(),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface CreateAgentFromSessionInput {
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: AgentAttachment[];
|
||||
labels: Record<string, string>;
|
||||
metadata?: CreateAgentOptions["metadata"];
|
||||
env?: Record<string, string>;
|
||||
provisionalTitle: string | null;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
@@ -251,6 +252,7 @@ async function resolveSessionCreateAgent(
|
||||
config: sessionConfig,
|
||||
createOptions: {
|
||||
labels: input.labels,
|
||||
metadata: input.metadata,
|
||||
initialPrompt: trimmedPrompt,
|
||||
env: input.env,
|
||||
initialTitle: input.provisionalTitle,
|
||||
|
||||
@@ -562,6 +562,7 @@ function createManagedAgent(overrides: Partial<ManagedAgent> = {}): ManagedAgent
|
||||
pendingPermissions: new Map(),
|
||||
persistence: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
attention: { requiresAttention: false },
|
||||
...overrides,
|
||||
} as ManagedAgent;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -214,6 +214,8 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.label.delete.request": "workspace.manage",
|
||||
"workspace.label.list.request": "workspace.read",
|
||||
"workspace.label.update.request": "workspace.manage",
|
||||
"agent.metadata.update.request": "workspace.write",
|
||||
"workspace.metadata.update.request": "workspace.manage",
|
||||
"workspace.pin.set.request": "workspace.manage",
|
||||
"workspace.recovery.inspect.request": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.request": ["workspace.manage", "hub.execute"],
|
||||
@@ -453,6 +455,8 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.label.list.response": "workspace.read",
|
||||
"workspace.label.update": "workspace.read",
|
||||
"workspace.label.update.response": "workspace.manage",
|
||||
"agent.metadata.update.response": "workspace.write",
|
||||
"workspace.metadata.update.response": "workspace.manage",
|
||||
"workspace.pin.set.response": "workspace.manage",
|
||||
"workspace.recovery.inspect.response": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.response": ["workspace.manage", "hub.execute"],
|
||||
|
||||
@@ -1109,6 +1109,7 @@ test("disabled recovery closes stale active records without resuming sessions",
|
||||
lastStatus: status as StoredAgentRecord["lastStatus"],
|
||||
title: `Keep ${status}`,
|
||||
labels: { "paseo.parent-agent-id": "parent", custom: "keep" },
|
||||
metadata: {},
|
||||
persistence: { provider: "codex", sessionId: `session-${index}` },
|
||||
config: { modeId: "full-access" },
|
||||
requiresAttention: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginLifecycle } from "./plugins/lifecycle/index.js";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
@@ -34,6 +35,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
workspaceId?: string;
|
||||
projectId?: string;
|
||||
title?: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}
|
||||
@@ -113,6 +115,7 @@ async function createPaseoWorktreeWithPriority(
|
||||
branch: createdWorktree.worktree.branchName || null,
|
||||
baseBranch: createdWorktree.worktree.comparisonBaseRef,
|
||||
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
metadata: input.metadata,
|
||||
expectsInitialAgent: Boolean(input.firstAgentContext),
|
||||
background: input.background,
|
||||
callerWorkspaceId: input.callerWorkspaceId,
|
||||
|
||||
@@ -137,6 +137,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
updatedAt: Date;
|
||||
lastUserMessageAt: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: StoredAgentRecord["metadata"];
|
||||
workspaceId?: string;
|
||||
owner?: StoredAgentRecord["owner"];
|
||||
} {
|
||||
@@ -145,6 +146,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
updatedAt: new Date(resolveStoredAgentUpdatedAt(record)),
|
||||
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
|
||||
labels: record.labels,
|
||||
metadata: record.metadata,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
|
||||
test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
|
||||
@@ -240,6 +241,193 @@ export default function contribute(server) {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("metadata set at creation reaches the hooks, a before hook can add to it, and snapshots carry it", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-hooks-"));
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "metadata-hooks",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
const log = (hook, metadata) => console.log(JSON.stringify({ hook, metadata }));
|
||||
server.addHook("workspace.create.before", async ({ request }) => {
|
||||
log("workspace.create.before", request.metadata);
|
||||
return request;
|
||||
});
|
||||
server.addHook("workspace.create.after", async ({ workspace }) => {
|
||||
log("workspace.create.after", workspace.metadata);
|
||||
});
|
||||
server.addHook("agent.create.before", async ({ request }) => {
|
||||
log("agent.create.before", request.metadata);
|
||||
return { ...request, metadata: { ...request.metadata, "hook.added": true } };
|
||||
});
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
log("agent.open.before", request.metadata);
|
||||
return request;
|
||||
});
|
||||
server.addHook("agent.create.after", async ({ agent }) => {
|
||||
log("agent.create.after", agent.metadata);
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
const workspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: directory },
|
||||
metadata: { "linear.issue": { id: "ENG-123" } },
|
||||
});
|
||||
expect(workspace.workspace?.metadata).toEqual({ "linear.issue": { id: "ENG-123" } });
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: directory,
|
||||
workspaceId: workspace.workspace!.id,
|
||||
metadata: { "linear.issue": { id: "ENG-123" } },
|
||||
});
|
||||
expect(agent.metadata).toEqual({ "linear.issue": { id: "ENG-123" }, "hook.added": true });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const logs = await client.getPluginLogs("metadata-hooks");
|
||||
return logs
|
||||
.filter((entry) => entry.message.startsWith('{"hook":'))
|
||||
.map((entry) => JSON.parse(entry.message));
|
||||
})
|
||||
.toMatchObject([
|
||||
{ hook: "workspace.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
|
||||
{ hook: "workspace.create.after", metadata: { "linear.issue": { id: "ENG-123" } } },
|
||||
{ hook: "agent.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
|
||||
{
|
||||
hook: "agent.open.before",
|
||||
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
|
||||
},
|
||||
{
|
||||
hook: "agent.create.after",
|
||||
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("metadata set at creation and through setMetadata survives a daemon restart", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-metadata-restart-"));
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-cwd-"));
|
||||
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-plugin-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "metadata-restart",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
// Returns an explicit object without `metadata`, as a plugin written before metadata existed.
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
console.log(JSON.stringify({ hook: "agent.open.before", metadata: request.metadata }));
|
||||
const { metadata, ...old } = request;
|
||||
return { ...old, env: { ...old.env, HOOK_OPEN: "opened" } };
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
const firstClient = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws` });
|
||||
const firstApi = createPaseoApi(firstClient);
|
||||
let agentId: string;
|
||||
let workspaceId: string;
|
||||
try {
|
||||
await firstClient.connect();
|
||||
await firstClient.fetchAgents({ subscribe: {} });
|
||||
await firstClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await firstClient.installDirectoryPlugin(pluginDirectory);
|
||||
const workspace = await firstClient.createWorkspace({
|
||||
source: { kind: "directory", path: directory },
|
||||
metadata: { "a.workspace": 1, "b.workspace": "to remove" },
|
||||
});
|
||||
workspaceId = workspace.workspace!.id;
|
||||
const agent = await firstClient.createAgent({
|
||||
provider: "claude",
|
||||
cwd: directory,
|
||||
workspaceId,
|
||||
metadata: { "a.agent": { nested: [1, 2] }, "b.agent": "to remove" },
|
||||
});
|
||||
agentId = agent.id;
|
||||
const agentHandle = firstApi.agents.ref(agentId);
|
||||
await agentHandle.setMetadata({ "b.agent": null, "c.agent": true });
|
||||
await firstApi.workspaces.ref(workspaceId).setMetadata({
|
||||
"b.workspace": null,
|
||||
"c.workspace": [],
|
||||
});
|
||||
expect((await firstClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
|
||||
"a.agent": { nested: [1, 2] },
|
||||
"c.agent": true,
|
||||
});
|
||||
} finally {
|
||||
await firstApi.dispose();
|
||||
await firstClient.close();
|
||||
await first.close();
|
||||
}
|
||||
|
||||
const second = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
const secondClient = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws` });
|
||||
try {
|
||||
await secondClient.connect();
|
||||
const agent = await secondClient.fetchAgent({ agentId });
|
||||
expect(agent?.agent.metadata).toEqual({ "a.agent": { nested: [1, 2] }, "c.agent": true });
|
||||
const workspaces = await secondClient.fetchWorkspaces({});
|
||||
expect(workspaces.entries.find((entry) => entry.id === workspaceId)?.metadata).toEqual({
|
||||
"a.workspace": 1,
|
||||
"c.workspace": [],
|
||||
});
|
||||
await secondClient.fetchAgents({ subscribe: {} });
|
||||
await secondClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await secondClient.installDirectoryPlugin(pluginDirectory);
|
||||
await secondClient.refreshAgent(agentId);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const logs = await secondClient.getPluginLogs("metadata-restart");
|
||||
return logs
|
||||
.filter((entry) => entry.message.startsWith('{"hook":'))
|
||||
.map((entry) => JSON.parse(entry.message));
|
||||
})
|
||||
.toContainEqual({
|
||||
hook: "agent.open.before",
|
||||
metadata: { "a.agent": { nested: [1, 2] }, "c.agent": true },
|
||||
});
|
||||
expect((await secondClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
|
||||
"a.agent": { nested: [1, 2] },
|
||||
"c.agent": true,
|
||||
});
|
||||
} finally {
|
||||
await secondClient.close();
|
||||
await second.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await rm(pluginDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("agent creation hooks change the provider and environment before the session opens", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-"));
|
||||
const pluginDirectory = await createProviderPlugin(directory, {
|
||||
|
||||
@@ -92,6 +92,7 @@ test("session-open hooks reject changes to session identity instead of silently
|
||||
env: {},
|
||||
title: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
},
|
||||
@@ -100,6 +101,105 @@ test("session-open hooks reject changes to session identity instead of silently
|
||||
).rejects.toThrow("agent.open.before hooks can only change env");
|
||||
});
|
||||
|
||||
test("a create hook that omits metadata passes on what an earlier hook added", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("agent.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
|
||||
}));
|
||||
hooks.addHook("agent.create.before", async ({ request }) => ({
|
||||
config: request.config,
|
||||
env: request.env,
|
||||
}));
|
||||
const seen: unknown[] = [];
|
||||
hooks.addHook("agent.create.before", async ({ request }) => {
|
||||
seen.push(request.metadata);
|
||||
});
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"agent.create.before",
|
||||
{ config: { provider: "codex", cwd: "/project" }, env: {}, metadata: { original: true } },
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
|
||||
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
|
||||
});
|
||||
|
||||
test("a workspace create hook that omits metadata passes on what an earlier hook added", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("workspace.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
|
||||
}));
|
||||
hooks.addHook("workspace.create.before", async ({ request }) => ({ source: request.source }));
|
||||
const seen: unknown[] = [];
|
||||
hooks.addHook("workspace.create.before", async ({ request }) => {
|
||||
seen.push(request.metadata);
|
||||
});
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"workspace.create.before",
|
||||
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
|
||||
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
|
||||
|
||||
const replacing = new PluginHookHandlers(() => {});
|
||||
replacing.addHook("workspace.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
metadata: {},
|
||||
}));
|
||||
await expect(
|
||||
replacing.invoke(
|
||||
"operation",
|
||||
"workspace.create.before",
|
||||
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
|
||||
paseo,
|
||||
),
|
||||
).resolves.toMatchObject({ metadata: {} });
|
||||
});
|
||||
|
||||
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("agent.open.before", async ({ request }) => ({
|
||||
agentId: request.agentId,
|
||||
workspaceId: request.workspaceId,
|
||||
provider: request.provider,
|
||||
cwd: request.cwd,
|
||||
reason: request.reason,
|
||||
purpose: request.purpose,
|
||||
title: request.title,
|
||||
labels: request.labels,
|
||||
tools: request.tools,
|
||||
env: { HOOK: "1" },
|
||||
mcpServers: request.mcpServers,
|
||||
}));
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"agent.open.before",
|
||||
{
|
||||
agentId: "agent",
|
||||
workspaceId: null,
|
||||
provider: "codex",
|
||||
cwd: "/project",
|
||||
title: null,
|
||||
labels: {},
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
env: {},
|
||||
reason: "resume",
|
||||
purpose: "interactive",
|
||||
},
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
env: { HOOK: "1" },
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
});
|
||||
});
|
||||
|
||||
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
"session-open targeting metadata is read-only: %j",
|
||||
async (patch) => {
|
||||
@@ -116,6 +216,7 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
cwd: "/project",
|
||||
title: "Original",
|
||||
labels: {},
|
||||
metadata: {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
env: {},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { AgentToolDefinition } from "@getpaseo/plugin/server";
|
||||
import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js";
|
||||
import { z } from "zod";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PluginHookAgent,
|
||||
@@ -91,6 +92,7 @@ const beforeSchemas = {
|
||||
"agent.create.before": CreateAgentRequestMessageSchema.pick({
|
||||
config: true,
|
||||
env: true,
|
||||
metadata: true,
|
||||
}).strict(),
|
||||
"agent.open.before": z
|
||||
.object({
|
||||
@@ -102,6 +104,8 @@ const beforeSchemas = {
|
||||
purpose: z.enum(["interactive", "history"]),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string(), z.string()),
|
||||
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
|
||||
tools: z.array(agentToolMetadataSchema),
|
||||
env: z.record(z.string(), z.string()),
|
||||
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
|
||||
@@ -152,6 +156,7 @@ export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): Plug
|
||||
cwd: workspace.cwd,
|
||||
name: workspace.title,
|
||||
archivedAt: workspace.archivedAt,
|
||||
metadata: workspace.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,6 +167,7 @@ export function describeHookAgent(agent: {
|
||||
cwd: string;
|
||||
title?: string | null;
|
||||
labels: Record<string, string>;
|
||||
metadata: Record<string, JsonValue>;
|
||||
}): PluginHookAgent {
|
||||
return {
|
||||
id: agent.id,
|
||||
@@ -170,6 +176,7 @@ export function describeHookAgent(agent: {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
title: agent.title ?? null,
|
||||
metadata: agent.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,10 +236,12 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
|
||||
previous.reason !== next.reason ||
|
||||
previous.purpose !== next.purpose ||
|
||||
previous.title !== next.title ||
|
||||
!isDeepStrictEqual(previous.labels, next.labels)
|
||||
!isDeepStrictEqual(previous.labels, next.labels) ||
|
||||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
|
||||
) {
|
||||
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
|
||||
}
|
||||
return { ...result, metadata: previous.metadata };
|
||||
}
|
||||
if (name === "agent.create.before") {
|
||||
const previous = beforeSchemas["agent.create.before"].parse(input);
|
||||
@@ -240,6 +249,14 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
|
||||
if (previous.config.cwd !== next.config.cwd) {
|
||||
throw new Error("agent.create.before hooks cannot change the workspace directory");
|
||||
}
|
||||
// Omitted means unchanged: the next hook sees what this one was handed.
|
||||
return { ...result, metadata: next.metadata ?? previous.metadata };
|
||||
}
|
||||
if (name === "workspace.create.before") {
|
||||
const previous = beforeSchemas["workspace.create.before"].parse(input);
|
||||
const next = beforeSchemas["workspace.create.before"].parse(result);
|
||||
// Omitted means unchanged; an explicit {} still replaces.
|
||||
return { ...result, metadata: next.metadata ?? previous.metadata };
|
||||
}
|
||||
if (name === "worktree.create.before") {
|
||||
const previous = beforeSchemas["worktree.create.before"].parse(input);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginHookAgent } from "@getpaseo/plugin/server";
|
||||
import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage";
|
||||
import type {
|
||||
@@ -157,6 +158,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
title: z.string().nullable(),
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()),
|
||||
})
|
||||
.strict(),
|
||||
name: z.string(),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Top-level keys replace; a null value removes the key. */
|
||||
export function applyRecordPatch<Value>(
|
||||
record: Record<string, Value>,
|
||||
patch: Record<string, Value | null>,
|
||||
): Record<string, Value> {
|
||||
const next = { ...record };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -85,6 +85,7 @@ import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-regist
|
||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import { applyRecordPatch } from "./record-patch.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
|
||||
import {
|
||||
@@ -3093,6 +3094,10 @@ export class Session {
|
||||
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
|
||||
case "workspace.pin.set.request":
|
||||
return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId);
|
||||
case "agent.metadata.update.request":
|
||||
return this.handleAgentMetadataUpdateRequest(msg);
|
||||
case "workspace.metadata.update.request":
|
||||
return this.handleWorkspaceMetadataUpdateRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -3942,6 +3947,79 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAgentMetadataUpdateRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "agent.metadata.update.request" }>,
|
||||
): Promise<void> {
|
||||
const { agentId, patch, requestId } = request;
|
||||
this.sessionLogger.info({ agentId, requestId }, "session: agent.metadata.update.request");
|
||||
try {
|
||||
const metadata = await this.agentManager.setMetadata(agentId, patch);
|
||||
this.emit({
|
||||
type: "agent.metadata.update.response",
|
||||
payload: { requestId, agentId, accepted: true, metadata, error: null },
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId, requestId },
|
||||
"session: agent.metadata.update.request error",
|
||||
);
|
||||
this.emit({
|
||||
type: "agent.metadata.update.response",
|
||||
payload: {
|
||||
requestId,
|
||||
agentId,
|
||||
accepted: false,
|
||||
metadata: null,
|
||||
error: getErrorMessageOr(error, "Failed to update agent metadata"),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceMetadataUpdateRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.metadata.update.request" }>,
|
||||
): Promise<void> {
|
||||
const { workspaceId, patch, requestId } = request;
|
||||
this.sessionLogger.info(
|
||||
{ workspaceId, requestId },
|
||||
"session: workspace.metadata.update.request",
|
||||
);
|
||||
const reject = (error: string) =>
|
||||
this.emit({
|
||||
type: "workspace.metadata.update.response",
|
||||
payload: { requestId, workspaceId, accepted: false, metadata: null, error },
|
||||
});
|
||||
try {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({
|
||||
...existing,
|
||||
metadata: applyRecordPatch(existing.metadata, patch),
|
||||
updatedAt,
|
||||
}));
|
||||
if (!updated) {
|
||||
reject("Workspace not found");
|
||||
return;
|
||||
}
|
||||
this.emit({
|
||||
type: "workspace.metadata.update.response",
|
||||
payload: {
|
||||
requestId,
|
||||
workspaceId,
|
||||
accepted: true,
|
||||
metadata: updated.metadata,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]);
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, workspaceId, requestId },
|
||||
"session: workspace.metadata.update.request error",
|
||||
);
|
||||
reject(getErrorMessageOr(error, "Failed to update workspace metadata"));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspacePinSetRequest(
|
||||
workspaceId: string,
|
||||
pinned: boolean,
|
||||
@@ -4510,6 +4588,7 @@ export class Session {
|
||||
images,
|
||||
attachments,
|
||||
labels: resolvedIntent.intent.labels,
|
||||
metadata: msg.metadata,
|
||||
env,
|
||||
provisionalTitle,
|
||||
firstAgentContext,
|
||||
@@ -5710,6 +5789,7 @@ export class Session {
|
||||
title: workspace.title,
|
||||
pinnedAt: workspace.pinnedAt,
|
||||
...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}),
|
||||
metadata: workspace.metadata,
|
||||
background: workspace.background,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
@@ -5808,6 +5888,7 @@ export class Session {
|
||||
...(result.workspace.labels && result.workspace.labels.length > 0
|
||||
? { labels: result.workspace.labels }
|
||||
: {}),
|
||||
metadata: result.workspace.metadata,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: result.workspace.createdAt,
|
||||
@@ -6870,6 +6951,7 @@ export class Session {
|
||||
{
|
||||
expectsInitialAgent: Boolean(request.firstAgentContext),
|
||||
workspaceId,
|
||||
metadata: request.metadata,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
},
|
||||
@@ -6934,6 +7016,7 @@ export class Session {
|
||||
githubPrNumber: source.githubPrNumber,
|
||||
firstAgentContext: request.firstAgentContext,
|
||||
title: request.title,
|
||||
metadata: request.metadata,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
},
|
||||
|
||||
+6
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
|
||||
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
|
||||
import { basename, resolve } from "node:path";
|
||||
@@ -50,6 +51,7 @@ export interface CreateWorktreeWorkspaceInput {
|
||||
branch: string | null;
|
||||
baseBranch: string | null;
|
||||
title: string | null;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
expectsInitialAgent?: boolean;
|
||||
untrustedSource?: UntrustedWorkspaceSource;
|
||||
background?: boolean;
|
||||
@@ -70,6 +72,7 @@ export interface WorkspaceProvisioningService {
|
||||
context?: {
|
||||
expectsInitialAgent?: boolean;
|
||||
workspaceId?: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
},
|
||||
@@ -242,6 +245,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
context?: {
|
||||
expectsInitialAgent?: boolean;
|
||||
workspaceId?: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
},
|
||||
@@ -258,6 +262,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
|
||||
title: title?.trim() || null,
|
||||
metadata: context?.metadata,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
background: await resolveBackground(context),
|
||||
@@ -294,6 +299,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
mainRepoRoot: repoRoot,
|
||||
}),
|
||||
title: input.title,
|
||||
metadata: input.metadata,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
|
||||
|
||||
@@ -2012,6 +2012,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
usageSources: true,
|
||||
ownedSubscriptions: true,
|
||||
providerOptionControls: true,
|
||||
entityMetadata: true,
|
||||
binaryChunkingV1: true,
|
||||
agentRequestReceipts: true,
|
||||
workspaceRequestReceipts: true,
|
||||
|
||||
@@ -1100,6 +1100,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
baseBranch: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
metadata: {},
|
||||
createdAt: timestamp,
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: expect.any(String),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
@@ -101,6 +102,8 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
labels: z.array(z.string()).optional(),
|
||||
// Plugin-owned data. Paseo stores it and never reads a key.
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
|
||||
untrustedSource: UntrustedWorkspaceSourceSchema.optional(),
|
||||
// Public discovery visibility. Contents retain their ordinary lifecycle.
|
||||
background: z.boolean().optional().default(false),
|
||||
@@ -678,6 +681,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
autoArchivedChangeRequestUrl?: string | null;
|
||||
pinnedAt?: string | null;
|
||||
labels?: string[];
|
||||
metadata?: Record<string, JsonValue>;
|
||||
untrustedSource?: UntrustedWorkspaceSource;
|
||||
background?: boolean;
|
||||
}): PersistedWorkspaceRecord {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,22 +49,22 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
|
||||
|
||||
## Hooks
|
||||
|
||||
| Hook | Waits | Payload | Handler may change |
|
||||
| ------------------------- | ----- | -------------------------------------- | ---------------------------------------------- |
|
||||
| `workspace.create.before` | yes | `{ request }` | Entire create request |
|
||||
| `workspace.create.after` | no | `{ workspace }` | |
|
||||
| `workspace.archive.after` | no | `{ workspace }` | |
|
||||
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
|
||||
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
|
||||
| `agent.create.before` | yes | `{ request: { config, env? } }` | Saved config except `cwd`; `env` |
|
||||
| `agent.create.after` | no | `{ agent }` | |
|
||||
| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` |
|
||||
| `agent.close.after` | no | `{ agent }` | |
|
||||
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | no | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | |
|
||||
| `agent.permission.before` | no | `{ agent, request }` | |
|
||||
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
|
||||
| Hook | Waits | Payload | Handler may change |
|
||||
| ------------------------- | ----- | ----------------------------------------- | ---------------------------------------------- |
|
||||
| `workspace.create.before` | yes | `{ request }` | Entire create request |
|
||||
| `workspace.create.after` | no | `{ workspace }` | |
|
||||
| `workspace.archive.after` | no | `{ workspace }` | |
|
||||
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
|
||||
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
|
||||
| `agent.create.before` | yes | `{ request: { config, env?, metadata } }` | Saved config except `cwd`; `env`; `metadata` |
|
||||
| `agent.create.after` | no | `{ agent }` | |
|
||||
| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` |
|
||||
| `agent.close.after` | no | `{ agent }` | |
|
||||
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | no | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | |
|
||||
| `agent.permission.before` | no | `{ agent, request }` | |
|
||||
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
|
||||
|
||||
## Shared payloads
|
||||
|
||||
@@ -76,6 +76,7 @@ interface PluginHookAgent {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
interface PluginHookWorkspace {
|
||||
@@ -84,6 +85,7 @@ interface PluginHookWorkspace {
|
||||
cwd: string;
|
||||
name: string | null;
|
||||
archivedAt: string | null;
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
type PluginTurnOutcome =
|
||||
@@ -100,9 +102,29 @@ type PluginTurnOutcome =
|
||||
| `resolution` | SDK `AgentPermissionResponse` |
|
||||
| `archivedAt` | Timestamp string |
|
||||
|
||||
## Metadata
|
||||
|
||||
Agents and workspaces carry a `metadata` record for plugin data. Keys are strings, values are any JSON value, and Paseo stores the record without reading a key. Prefix keys with your plugin id so plugins do not collide. Labels stay strings for filtering and tagging; use metadata for structured data.
|
||||
|
||||
Set metadata when you create the agent or workspace, or change it later. `null` removes a key, and any other value replaces the key's whole value:
|
||||
|
||||
```ts
|
||||
const agent = await paseo.agents.create({
|
||||
config: { provider: "codex/gpt-5.5" },
|
||||
cwd: "/projects/shop",
|
||||
metadata: { "linear.issue": { id: "ENG-123" } },
|
||||
});
|
||||
|
||||
agent.metadata; // { "linear.issue": { id: "ENG-123" } }
|
||||
await agent.setMetadata({ "linear.issue": null });
|
||||
await paseo.workspaces.ref(workspaceId).setMetadata({ "linear.issue": { id: "ENG-123" } });
|
||||
```
|
||||
|
||||
Metadata survives a daemon restart. Every hook payload carries it: `workspace.create.before` and `agent.create.before` receive the metadata the caller sent, and the `after` hooks and `agent.open.before` receive what is stored. Against a host that predates metadata, these calls throw "Update the host to use metadata."
|
||||
|
||||
## workspace.create.before
|
||||
|
||||
Receives `{ request }` with `source`, optional `title`, and `firstAgentContext`. You may replace the entire explicit create request; a throw, invalid result, or timeout fails creation. Existing workspaces and directory lookup/import are unaffected.
|
||||
Receives `{ request }` with `source`, optional `title`, optional `metadata`, and `firstAgentContext`. You may replace the entire explicit create request; a throw, invalid result, or timeout fails creation. Existing workspaces and directory lookup/import are unaffected.
|
||||
|
||||
### Choose workspace isolation
|
||||
|
||||
@@ -202,18 +224,18 @@ server.addHook("worktree.create.after", async ({ worktree }) => {
|
||||
|
||||
## agent.create.before
|
||||
|
||||
Receives `{ request: { config, env? } }`. Config changes are saved and remain after the plugin is disabled or removed; `env` applies to the initial launch and is not saved. You cannot change `config.cwd`. A throw, invalid result, or timeout fails creation.
|
||||
Receives `{ request: { config, env?, metadata } }`. Config and `metadata` changes are saved and remain after the plugin is disabled or removed; `env` applies to the initial launch and is not saved. You cannot change `config.cwd`. A throw, invalid result, or timeout fails creation.
|
||||
|
||||
**`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
|
||||
|
||||
@@ -271,6 +293,7 @@ Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for th
|
||||
"cwd": "/projects/shop",
|
||||
"title": "Workspace notes",
|
||||
"labels": { "notes/enabled": "true" },
|
||||
"metadata": { "linear.issue": { "id": "ENG-123" } },
|
||||
"tools": [],
|
||||
"mcpServers": {},
|
||||
"reason": "resume",
|
||||
@@ -284,6 +307,7 @@ Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for th
|
||||
| `workspaceId` | String or `null` |
|
||||
| `title` | Read-only string or `null`; creation title on create, stored title on resume. |
|
||||
| `labels` | Read-only string map; creation labels on create, stored labels on resume. |
|
||||
| `metadata` | Read-only JSON map; the [metadata](#metadata) stored on the agent. |
|
||||
| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). |
|
||||
| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). |
|
||||
| `reason` | `create`, `resume`, `refresh`, `import` |
|
||||
|
||||
@@ -47,22 +47,22 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
|
||||
|
||||
## 钩子一览 {#hooks}
|
||||
|
||||
| 钩子 | 等待 | 载荷 | 可修改 |
|
||||
| ------------------------- | ---- | -------------------------------------- | ----------------------------------------- |
|
||||
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
|
||||
| `workspace.create.after` | 否 | `{ workspace }` | |
|
||||
| `workspace.archive.after` | 否 | `{ workspace }` | |
|
||||
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
|
||||
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
|
||||
| `agent.create.before` | 是 | `{ request: { config, env? } }` | 除 `cwd` 外的已保存配置;`env` |
|
||||
| `agent.create.after` | 否 | `{ agent }` | |
|
||||
| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` |
|
||||
| `agent.close.after` | 否 | `{ agent }` | |
|
||||
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | |
|
||||
| `agent.permission.before` | 否 | `{ agent, request }` | |
|
||||
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
|
||||
| 钩子 | 等待 | 载荷 | 可修改 |
|
||||
| ------------------------- | ---- | ----------------------------------------- | ------------------------------------------ |
|
||||
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
|
||||
| `workspace.create.after` | 否 | `{ workspace }` | |
|
||||
| `workspace.archive.after` | 否 | `{ workspace }` | |
|
||||
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
|
||||
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
|
||||
| `agent.create.before` | 是 | `{ request: { config, env?, metadata } }` | 除 `cwd` 外的已保存配置;`env`;`metadata` |
|
||||
| `agent.create.after` | 否 | `{ agent }` | |
|
||||
| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` |
|
||||
| `agent.close.after` | 否 | `{ agent }` | |
|
||||
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | |
|
||||
| `agent.permission.before` | 否 | `{ agent, request }` | |
|
||||
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
|
||||
|
||||
## 共享载荷 {#shared-payloads}
|
||||
|
||||
@@ -74,6 +74,7 @@ interface PluginHookAgent {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
interface PluginHookWorkspace {
|
||||
@@ -82,6 +83,7 @@ interface PluginHookWorkspace {
|
||||
cwd: string;
|
||||
name: string | null;
|
||||
archivedAt: string | null;
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
type PluginTurnOutcome =
|
||||
@@ -98,9 +100,29 @@ type PluginTurnOutcome =
|
||||
| `resolution` | SDK 的 `AgentPermissionResponse` |
|
||||
| `archivedAt` | 时间戳字符串 |
|
||||
|
||||
## 元数据 {#metadata}
|
||||
|
||||
Agent 和工作区带有 `metadata` 记录,用于保存插件数据。键是字符串,值可以是任意 JSON 值,Paseo 只保存、不读取任何键。给键加上插件 ID 前缀,避免不同插件冲突。标签仍是用于筛选和标记的字符串;结构化数据请用元数据。
|
||||
|
||||
可以在创建 Agent 或工作区时设置元数据,也可以之后修改。`null` 删除该键,其他值会替换该键的整个值:
|
||||
|
||||
```ts
|
||||
const agent = await paseo.agents.create({
|
||||
config: { provider: "codex/gpt-5.5" },
|
||||
cwd: "/projects/shop",
|
||||
metadata: { "linear.issue": { id: "ENG-123" } },
|
||||
});
|
||||
|
||||
agent.metadata; // { "linear.issue": { id: "ENG-123" } }
|
||||
await agent.setMetadata({ "linear.issue": null });
|
||||
await paseo.workspaces.ref(workspaceId).setMetadata({ "linear.issue": { id: "ENG-123" } });
|
||||
```
|
||||
|
||||
元数据在 daemon 重启后仍然保留。所有钩子载荷都带有元数据:`workspace.create.before` 和 `agent.create.before` 收到调用方发送的元数据,`after` 钩子和 `agent.open.before` 收到已保存的元数据。主机版本过旧时,这些调用会抛出 “Update the host to use metadata.”。
|
||||
|
||||
## workspace.create.before {#workspacecreatebefore}
|
||||
|
||||
收到 `{ request }`,其中包含 `source`、可选的 `title` 和 `firstAgentContext`。可替换整个显式创建请求;抛出错误、无效返回或超时会让创建失败。已有工作区和目录查找、导入不受影响。
|
||||
收到 `{ request }`,其中包含 `source`、可选的 `title`、可选的 `metadata` 和 `firstAgentContext`。可替换整个显式创建请求;抛出错误、无效返回或超时会让创建失败。已有工作区和目录查找、导入不受影响。
|
||||
|
||||
### 选择工作区隔离方式 {#choose-workspace-isolation}
|
||||
|
||||
@@ -199,18 +221,18 @@ server.addHook("worktree.create.after", async ({ worktree }) => {
|
||||
|
||||
## agent.create.before {#agentcreatebefore}
|
||||
|
||||
收到 `{ request: { config, env? } }`。配置修改会保存,即使插件以后被停用或移除;`env` 只用于首次启动,不会保存。不能修改 `config.cwd`。抛出错误、无效返回或超时会让创建失败。
|
||||
收到 `{ request: { config, env?, metadata } }`。配置和 `metadata` 的修改会保存,即使插件以后被停用或移除;`env` 只用于首次启动,不会保存。不能修改 `config.cwd`。抛出错误、无效返回或超时会让创建失败。
|
||||
|
||||
**`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}
|
||||
|
||||
@@ -267,6 +289,7 @@ server.addHook("agent.create.after", async ({ agent }) => {
|
||||
"cwd": "/projects/shop",
|
||||
"title": "Workspace notes",
|
||||
"labels": { "notes/enabled": "true" },
|
||||
"metadata": { "linear.issue": { "id": "ENG-123" } },
|
||||
"tools": [],
|
||||
"mcpServers": {},
|
||||
"reason": "resume",
|
||||
@@ -280,6 +303,7 @@ server.addHook("agent.create.after", async ({ agent }) => {
|
||||
| `workspaceId` | 字符串或 `null` |
|
||||
| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 |
|
||||
| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 |
|
||||
| `metadata` | 只读的 JSON 映射;Agent 上已保存的[元数据](#metadata)。 |
|
||||
| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 |
|
||||
| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 |
|
||||
| `reason` | `create`、`resume`、`refresh`、`import` |
|
||||
|
||||
@@ -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