Add reusable agent profiles (#3208)

* Search models across all providers from the picker root

* Store agent profiles in daemon config and apply agent config in one RPC

An agent profile is a named bundle of provider, model, mode, thinking
option, feature values and notes, stored host-wide next to terminal
profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is
creation-only, so a profile holding one would apply when starting an agent
and silently do nothing when applied to a running one.

Applying a profile through the four single-field RPCs can fail halfway and
leave an agent matching no profile, so agent.config.apply takes the whole
bundle. Model runs first because a thinking option only means anything
against the model that offers it.

Old daemons parse their persisted config strictly and would drop an
unknown agentProfiles key, making a save appear to succeed and vanish, so
both capabilities are advertised on server_info for the client to gate on.

list_profiles exposes the profiles to orchestrating agents, which read the
notes to pick one and copy its values into create_agent.

* Add the agent profiles settings section

Profiles live behind one module boundary so removing the feature is a
directory plus one mount, which is what favourites never was. The picker
and composer will ask for rows and an apply function rather than learning
what a profile contains.

The edit form follows the form-model pattern instead of the terminal
profile modal's per-field state, because provider selection cascades into
which models, modes, thinking levels and features exist below it.

The section is gated on server_info.features.agentProfiles: an older daemon
drops the key on write, and a save that appears to work and then vanishes
is worse than an unavailable state.

* Document agent profiles and name the four senses of profile

The glossary reserved Profile for HostProfile, terminal profiles owned the
user-facing word, and custom-providers.md used it for provider aliases.
Agent profile is now the term, with all four cross-referenced.

data-model.md never documented terminalProfiles either, so both profile
lists are backfilled together along with the whole-list patch semantics and
why absent and empty differ.

* Pin agent profiles to the model picker and delete favourites

Applying a profile writes its values into the agent controls and is then
forgotten, so rows carry no checkmark and no selected state: after applying,
nothing is selected. Chevron still means navigate deeper, so the absence of
one is what marks a row as an action.

A running agent is its provider's process and agent.config.apply carries no
provider, so the live picker only offers profiles matching the agent's
provider rather than showing rows that cannot do what they say. The draft
path has no such limit and switches provider freely.

Draft feature values go through preferences rather than the feature
callback alone, because the draft feature hook clears local values whenever
the provider changes and a provider-switching profile would lose them.

Favourites are deleted rather than migrated: they were device-local
AsyncStorage keyed by provider and model, while profiles are host-wide
daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity
and moves to its real owner; renaming it is its own commit.

* Cover agent profiles and cross-provider search with E2E specs

Model rows expressed selection only as a checkmark glyph, which assistive
tech cannot see and a spec cannot assert. Rows that draw the indicator now
carry aria-selected; profile rows deliberately carry none, because a
profile is an action rather than a selection. That is what makes "nothing
is marked selected after applying a profile" a real assertion instead of a
tautology, with the materialized model one level down as the control.

The search fixture extends claude rather than mock: provider-config.ts
only allows the six shipped providers as extends targets, so a derived
provider cannot inherit the dev-only mock provider.

* State what applying a config bundle does on partial failure

applyBundle is not a transaction and the earlier commit message implied it
was. A step that rejects aborts the rest and the steps that already applied
stay applied, which a profile omitting modelId can reach: provider setters
validate against the agent's live model, so mode can land while a feature
that model does not support is rejected.

Nothing is hidden by that. Every setter emits agent state before returning,
so what applied is streamed to the client next to the rejected response.
Rolling back would re-invoke the setters that just failed, usually against
a session that is gone, and pre-flight validation would restate each
provider's model-dependent checks where they would drift from the setters
that own them.

What sending the bundle in one request removes is the multi-round-trip
failure class: interruption between hops, and interleaving with another
client. The test already pinned this behaviour; its name now says so.

* feat(agent-profiles): refine profile discovery and appearance

Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably.

* fix(agent-profiles): defer profile swatch styles

Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render.
This commit is contained in:
Mohamed Boudra
2026-08-11 17:42:35 +02:00
committed by GitHub
parent 07044136c8
commit 308e9af845
72 changed files with 6348 additions and 538 deletions
@@ -146,6 +146,15 @@ let parentAgentCwd: string;
let worktreeRepoCwd: string;
let launchConfigsByProvider: Record<AgentProvider, AgentSessionConfig[]>;
const seededAgentProfile = {
id: "ui-profile",
name: "UI work",
provider: "claude",
model: "claude-test-model",
modeId: "bypassPermissions",
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
};
function createRecordingAgentClients(): Record<AgentProvider, AgentClient> {
const baseClients = createTestAgentClients();
launchConfigsByProvider = {};
@@ -285,7 +294,10 @@ beforeAll(async () => {
parentAgentCwd = await makeCwd("parent-agent-cwd");
worktreeRepoCwd = await makeCwd("worktree-repo");
daemonHandle = await createTestPaseoDaemon({ agentClients: createRecordingAgentClients() });
daemonHandle = await createTestPaseoDaemon({
agentClients: createRecordingAgentClients(),
agentProfiles: [seededAgentProfile],
});
topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`);
const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
@@ -825,6 +837,12 @@ describe("Suite D: Provider Tools", () => {
expect(payload.provider).toBe("claude");
expect(Array.isArray(payload.models)).toBe(true);
});
test("list_profiles returns configured agent profiles, including notes", async () => {
const payload = await callToolStructured(topLevelClient, "list_profiles");
const profiles = recordArr(payload.profiles);
expect(profiles).toEqual([seededAgentProfile]);
});
});
describe("Suite E: Worktree Tools", () => {
@@ -52,6 +52,8 @@ import type { ForgeService } from "../../services/forge-service.js";
import { areEquivalentPaths } from "../../utils/path.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
@@ -4901,6 +4903,78 @@ describe("provider listing MCP tool", () => {
});
});
function daemonConfigStoreStub(agentProfiles?: AgentProfile[]): Pick<DaemonConfigStore, "get"> {
const config = MutableDaemonConfigSchema.parse({
relay: { enabled: true },
mcp: { injectIntoAgents: true },
...(agentProfiles !== undefined ? { agentProfiles } : {}),
});
return { get: () => config };
}
describe("agent profile listing MCP tool", () => {
const logger = createTestLogger();
it("returns configured profiles, including notes", async () => {
const { agentManager, agentStorage } = createTestDeps();
const profiles: AgentProfile[] = [
{
id: "ui-profile",
name: "UI work",
provider: "claude",
model: "claude-test-model",
modeId: "bypassPermissions",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
},
];
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
daemonConfigStore: daemonConfigStoreStub(profiles),
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles });
});
it("returns an empty array when no profiles are configured", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
daemonConfigStore: daemonConfigStoreStub(),
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles: [] });
});
it("returns an empty array when no daemon config store is provided", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles: [] });
});
});
describe("provider MCP tools", () => {
const logger = createTestLogger();
@@ -4,6 +4,8 @@ import type { Logger } from "pino";
import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js";
import type { AgentManager } from "../agent-manager.js";
import { AgentProfileSchema } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../../daemon-config-store.js";
import {
AgentFeatureSchema,
AgentPermissionRequestPayloadSchema,
@@ -98,6 +100,7 @@ export interface PaseoToolHostDependencies {
getDaemonTcpPort?: () => number | null;
scheduleService?: ScheduleService | null;
providerSnapshotManager: ProviderSnapshotManager;
daemonConfigStore?: Pick<DaemonConfigStore, "get">;
github?: ForgeService;
workspaceGitService?: Pick<
WorkspaceGitService,
@@ -543,6 +546,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
workspaceScripts,
scheduleService,
providerSnapshotManager,
daemonConfigStore,
callerAgentId,
resolveSpeakHandler,
resolveCallerContext,
@@ -2919,6 +2923,30 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
);
registerTool(
"list_profiles",
{
title: "List agent profiles",
description:
"List agent profiles: named provider/model/mode bundles a human configured for specific " +
"kinds of work. Read each profile's `notes` to pick the one that fits the task you're " +
"delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " +
"`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " +
"list if none are configured.",
inputSchema: {},
outputSchema: {
profiles: z.array(AgentProfileSchema),
},
},
async () => {
const profiles = daemonConfigStore?.get().agentProfiles ?? [];
return {
content: [],
structuredContent: ensureValidJson({ profiles }),
};
},
);
registerTool(
"inspect_provider",
{
+7 -1
View File
@@ -168,7 +168,7 @@ import type { PushNotificationSender } from "./push/index.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages";
import type { AgentProfile, FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -397,6 +397,7 @@ export interface PaseoDaemonConfig {
enableTerminalAgentHooks?: boolean;
appendSystemPrompt?: string;
terminalProfiles?: TerminalProfile[];
agentProfiles?: AgentProfile[];
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -536,6 +537,10 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
initialConfig.terminalProfiles = config.terminalProfiles;
}
if (config.agentProfiles !== undefined) {
initialConfig.agentProfiles = config.agentProfiles;
}
return initialConfig;
}
@@ -1235,6 +1240,7 @@ export async function createPaseoDaemon(
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
scheduleService,
providerSnapshotManager,
daemonConfigStore,
github,
workspaceGitService,
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
+15 -1
View File
@@ -438,6 +438,18 @@ function resolveBrowserToolsEnabled(persisted: ReturnType<typeof loadPersistedCo
return persisted.daemon?.browserTools?.enabled ?? false;
}
/**
* Both profile lists stay `undefined` when absent rather than defaulting to an
* empty array: for terminal profiles that is what selects the built-in
* defaults, so an empty array has to keep meaning "the user removed them all".
*/
function resolveProfileLists(persisted: ReturnType<typeof loadPersistedConfig>) {
return {
terminalProfiles: persisted.daemon?.terminalProfiles,
agentProfiles: persisted.daemon?.agentProfiles,
};
}
function resolveStaticLoadConfigSettings(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
@@ -450,7 +462,7 @@ function resolveStaticLoadConfigSettings(
browserToolsEnabled: resolveBrowserToolsEnabled(persisted),
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
terminalProfiles: persisted.daemon?.terminalProfiles,
...resolveProfileLists(persisted),
hostnames: mergeHostnames([
persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
@@ -479,6 +491,7 @@ export function loadConfig(
autoArchiveAfterMerge,
appendSystemPrompt,
terminalProfiles,
agentProfiles,
hostnames,
trustedProxies,
appBaseUrl,
@@ -520,6 +533,7 @@ export function loadConfig(
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
appendSystemPrompt,
terminalProfiles,
agentProfiles,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -85,6 +85,76 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
});
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
store.patch({
agentProfiles: [
{
id: "profile_ui",
name: "UI work",
icon: "🎨",
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
notes: "Use for components, layout and design tokens.",
},
],
});
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
{
id: "profile_ui",
name: "UI work",
icon: "🎨",
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
notes: "Use for components, layout and design tokens.",
},
]);
expect(store.get().agentProfiles).toHaveLength(1);
});
test("patch replaces the whole agent profile list rather than merging entries", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
agentProfiles: [
{ id: "a", name: "Keep", provider: "claude" },
{ id: "b", name: "Drop", provider: "codex" },
],
});
store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]);
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1);
});
test("rolls back config when a field transition fails", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -359,6 +359,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
...(mutable.terminalProfiles !== undefined
? { terminalProfiles: mutable.terminalProfiles }
: {}),
...(mutable.agentProfiles !== undefined ? { agentProfiles: mutable.agentProfiles } : {}),
},
agents: nextAgents,
} as PersistedConfig;
@@ -9,7 +9,7 @@ import {
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
import { TerminalProfileSchema } from "@getpaseo/protocol/messages";
import { AgentProfileSchema, TerminalProfileSchema } from "@getpaseo/protocol/messages";
import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema";
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
@@ -259,6 +259,7 @@ export const PersistedConfigSchema = z
enableTerminalAgentHooks: z.boolean().optional(),
appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(),
cors: z
.object({
allowedOrigins: z.array(z.string()).optional(),
+2
View File
@@ -2010,6 +2010,8 @@ export class Session {
return this.agentConfigSession.handleSetAgentFeatureRequest(msg);
case "set_agent_thinking_request":
return this.agentConfigSession.handleSetAgentThinkingRequest(msg);
case "agent.config.apply.request":
return this.agentConfigSession.handleAgentConfigApplyRequest(msg);
case "get_daemon_config_request":
this.emit({
type: "get_daemon_config_response",
@@ -14,6 +14,8 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = [];
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
/** Cross-operation ordering, which the per-operation arrays above cannot show. */
readonly callLog: string[] = [];
modeNotice: AgentProviderNotice | null = null;
thinkingNotice: AgentProviderNotice | null = null;
loadFailure: Error | null = null;
@@ -26,17 +28,20 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
async setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
this.modeCalls.push({ agentId, modeId });
this.callLog.push("mode");
if (this.failWith) throw this.failWith;
return this.modeNotice;
}
async setModel(agentId: string, modelId: string | null): Promise<void> {
this.modelCalls.push({ agentId, modelId });
this.callLog.push("model");
if (this.failWith) throw this.failWith;
}
async setFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
this.featureCalls.push({ agentId, featureId, value });
this.callLog.push(`feature:${featureId}`);
if (this.failWith) throw this.failWith;
}
@@ -45,6 +50,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
this.thinkingCalls.push({ agentId, thinkingOptionId });
this.callLog.push("thinking");
if (this.failWith) throw this.failWith;
return this.thinkingNotice;
}
@@ -302,4 +308,123 @@ describe("AgentConfigSession", () => {
payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "thinking boom" },
});
});
test("apply: sets model before thinking and features last, in one accepted response", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: {
modelId: "opus-5",
modeId: "plan",
thinkingOptionId: "high",
featureValues: { webSearch: true },
},
requestId: "req-1",
});
expect(operations.callLog).toEqual(["model", "mode", "thinking", "feature:webSearch"]);
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]);
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
expect(operations.featureCalls).toEqual([
{ agentId: "agent-1", featureId: "webSearch", value: true },
]);
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
expect(emitted).toEqual([
{
type: "agent.config.apply.response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: true,
error: null,
notice: null,
},
},
]);
});
test("apply: leaves omitted fields alone", async () => {
const { subsystem, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modelId: "haiku-4.5" },
requestId: "req-1",
});
expect(operations.callLog).toEqual(["model"]);
});
test("apply: clears model and thinking when they are explicitly null", async () => {
const { subsystem, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modelId: null, thinkingOptionId: null },
requestId: "req-1",
});
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: null }]);
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: null }]);
});
test("apply: reports the first notice and still runs the rest of the bundle", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.modeNotice = { type: "info", message: "Switched to plan mode" };
operations.thinkingNotice = { type: "warning", message: "Thinking budget reduced" };
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } },
requestId: "req-1",
});
expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]);
expect(emitted).toEqual([
{
type: "agent.config.apply.response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: true,
error: null,
notice: { type: "info", message: "Switched to plan mode" },
},
},
]);
});
test("apply: a mid-bundle failure rejects and leaves the earlier steps applied", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.failWith = new Error("apply boom");
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modelId: "opus-5", modeId: "plan" },
requestId: "req-1",
});
expect(operations.callLog).toEqual(["model"]);
expect(emitted.map((m) => m.type)).toEqual(["activity_log", "agent.config.apply.response"]);
expect(emitted[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to apply agent config: apply boom",
},
});
expect(emitted[1]).toEqual({
type: "agent.config.apply.response",
payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "apply boom" },
});
});
});
@@ -1,12 +1,13 @@
import type pino from "pino";
import { v4 as uuidv4 } from "uuid";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import type { AgentConfigApply } from "@getpaseo/protocol/messages";
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
/**
* The four agent-config response messages share one payload shape; deriving the
* type keeps it pinned to the protocol schema instead of restating it.
* The agent-config response messages share one payload shape; deriving the type
* keeps it pinned to the protocol schema instead of restating it.
*/
type AgentActionResponsePayload = Extract<
SessionOutboundMessage,
@@ -51,11 +52,11 @@ interface ConfigChange {
}
/**
* A client's per-agent config surface: set mode, model, feature, and thinking
* option. Each request shares one envelope — log, run the mutation, then emit the
* accepted response, or on failure emit an activity_log error frame followed by
* the rejected response. Reaches no state beyond the injected operations and the
* outbound channel.
* A client's per-agent config surface: set mode, model, feature, or thinking
* option one at a time, or apply all of them together. Each request shares one
* envelope — log, run the mutation, then emit the accepted response, or on
* failure emit an activity_log error frame followed by the rejected response.
* Reaches no state beyond the injected operations and the outbound channel.
*/
export class AgentConfigSession {
private readonly host: AgentConfigSessionHost;
@@ -134,6 +135,67 @@ export class AgentConfigSession {
});
}
handleAgentConfigApplyRequest(
msg: Extract<SessionInboundMessage, { type: "agent.config.apply.request" }>,
): Promise<void> {
const { agentId, config, requestId } = msg;
return this.applyConfigChange({
agentId,
requestId,
logLabel: "agent.config.apply.request",
logFields: { agentId, requestId, config },
failureText: "Failed to apply agent config",
run: () => this.applyBundle(agentId, config),
emitResponse: (payload) => this.host.emit({ type: "agent.config.apply.response", payload }),
});
}
/**
* Model first, because a thinking option only means anything against the model
* that offers it; features last so they settle on top of the rest. An omitted
* field is left alone. The first notice produced is the one the client sees —
* later steps still run, so a notice never truncates the bundle.
*
* This is not a transaction. A step that rejects aborts the rest, and steps
* that already applied stay applied — provider setters validate against the
* agent's live model, so a bundle that omits `modelId` can have its mode land
* and its feature rejected. Nothing is hidden by that: every AgentManager
* setter emits agent state before returning, so what applied is already
* streamed to the client alongside the rejected response and the error frame.
*
* Rolling back would re-invoke the setters that just failed, most often
* against a session that is already gone. Pre-flight validation would restate
* each provider's model-dependent checks somewhere they would drift from the
* setters that own them. What sending the bundle in one request does remove is
* the multi-round-trip failure class: interruption between hops, and
* interleaving with another client's mutation.
*/
private async applyBundle(
agentId: string,
config: AgentConfigApply,
): Promise<AgentProviderNotice | null> {
let notice: AgentProviderNotice | null = null;
if (config.modelId !== undefined) {
await this.operations.setModel(agentId, config.modelId);
}
if (config.modeId !== undefined) {
// Await first, assign second: `notice ??= await ...` would skip the call
// entirely once an earlier step produced a notice.
const modeNotice = await this.operations.setMode(agentId, config.modeId);
notice ??= modeNotice;
}
if (config.thinkingOptionId !== undefined) {
const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId);
notice ??= thinkingNotice;
}
for (const [featureId, value] of Object.entries(config.featureValues ?? {})) {
await this.operations.setFeature(agentId, featureId, value);
}
return notice;
}
private async applyConfigChange(change: ConfigChange): Promise<void> {
const { agentId, requestId, logLabel, logFields, failureText, run, emitResponse } = change;
this.logger.info(logFields, `session: ${logLabel}`);
@@ -12,6 +12,7 @@ import {
import type { AgentClient, AgentProvider } from "../agent/agent-sdk-types.js";
import { createTestAgentClients } from "./fake-agent-client.js";
import type { PushNotificationSender } from "../push/index.js";
import type { AgentProfile } from "@getpaseo/protocol/messages";
interface TestPaseoDaemonOptions {
daemonVersion?: string;
@@ -45,6 +46,7 @@ interface TestPaseoDaemonOptions {
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
webUi?: PaseoDaemonConfig["webUi"];
trustedProxies?: PaseoDaemonConfig["trustedProxies"];
agentProfiles?: AgentProfile[];
}
export interface TestPaseoDaemon {
@@ -194,6 +196,7 @@ async function prepareTestDaemonConfig(
voiceLlmModel: options.voiceLlmModel ?? null,
dictationFinalTimeoutMs: options.dictationFinalTimeoutMs,
downloadTokenTtlMs: options.downloadTokenTtlMs,
agentProfiles: options.agentProfiles,
};
return { config, paseoHomeRoot, paseoHome, staticDir };
}
@@ -1629,6 +1629,10 @@ export class VoiceAssistantWebSocketServer {
fsEntryDuplicate: true,
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
checkoutDiscardChanges: true,
// COMPAT(agentProfiles): added in v0.3.2, remove gate after 2027-02-11.
agentProfiles: true,
// COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11.
agentConfigApply: true,
},
};
}