Configure agent thinking from the CLI (#2533)

* feat(cli): update agent thinking from the CLI

* fix(cli): harden agent thinking updates

* feat(cli): configure thinking for schedules

* fix(cli): report applied thinking updates

* fix(cli): report current agent thinking state
This commit is contained in:
Mohamed Boudra
2026-07-28 22:30:50 +08:00
committed by GitHub
parent e241e02afb
commit 963d4f9240
13 changed files with 319 additions and 31 deletions
+13
View File
@@ -35,6 +35,19 @@ describe("canonical CLI surface", () => {
expect(run?.helpInformation()).not.toContain("--detach");
});
it("offers thinking configuration when running, updating, and scheduling agents", () => {
const cli = createCli();
const run = cli.commands.find((command) => command.name() === "run");
const agent = cli.commands.find((command) => command.name() === "agent");
const update = agent?.commands.find((command) => command.name() === "update");
const schedule = cli.commands.find((command) => command.name() === "schedule");
const scheduleCreate = schedule?.commands.find((command) => command.name() === "create");
expect(run?.helpInformation()).toContain("--thinking <id>");
expect(update?.helpInformation()).toContain("--thinking <id>");
expect(scheduleCreate?.helpInformation()).toContain("--thinking <id>");
});
it("offers opening an existing agent in the desktop app", () => {
const agent = createCli().commands.find((command) => command.name() === "agent");
const open = agent?.commands.find((command) => command.name() === "open");
+2 -1
View File
@@ -92,9 +92,10 @@ export function createAgentCommand(): Command {
addJsonAndDaemonHostOptions(
agent
.command("update")
.description("Update an agent's metadata")
.description("Update an agent's settings or metadata")
.argument("<id>", "Agent ID (or prefix)")
.option("--name <name>", "Update the agent's display name")
.option("--thinking <id>", "Update the agent's thinking option ID")
.option(
"--label <label>",
"Add/set label(s) on the agent (can be used multiple times or comma-separated)",
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import {
applyAgentChanges,
toAgentUpdateResult,
type AgentMetadataChanges,
type AgentUpdateClient,
} from "./update.js";
class RecordingAgentUpdateClient implements AgentUpdateClient {
readonly metadataUpdates: Array<{
agentId: string;
updates: AgentMetadataChanges;
}> = [];
readonly thinkingUpdates: Array<{ agentId: string; thinkingOptionId: string }> = [];
thinkingNotice: AgentProviderNotice | null = null;
constructor(private readonly supportsThinkingUpdate = true) {}
getLastServerInfoMessage() {
return { features: { agentThinkingUpdate: this.supportsThinkingUpdate } };
}
async updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void> {
this.metadataUpdates.push({ agentId, updates });
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string,
): Promise<AgentProviderNotice | null> {
this.thinkingUpdates.push({ agentId, thinkingOptionId });
return this.thinkingNotice;
}
}
describe("applyAgentChanges", () => {
it("updates an agent's thinking without issuing an empty metadata update", async () => {
const client = new RecordingAgentUpdateClient();
const result = await applyAgentChanges(client, "agent-1", {
type: "thinking",
thinkingOptionId: "high",
});
expect(client.metadataUpdates).toEqual([]);
expect(client.thinkingUpdates).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
expect(result).toEqual({
notice: null,
});
});
it("requires a daemon that advertises thinking updates", async () => {
const client = new RecordingAgentUpdateClient(false);
await expect(
applyAgentChanges(client, "agent-1", { type: "thinking", thinkingOptionId: "high" }),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use agent thinking updates.",
});
expect(client.metadataUpdates).toEqual([]);
expect(client.thinkingUpdates).toEqual([]);
});
it("returns the provider notice from a thinking update", async () => {
const client = new RecordingAgentUpdateClient();
client.thinkingNotice = {
type: "warning",
message: "Thinking changes apply to the next turn.",
};
const notice = await applyAgentChanges(client, "agent-1", {
type: "thinking",
thinkingOptionId: "high",
});
expect(notice).toEqual({
notice: {
type: "warning",
message: "Thinking changes apply to the next turn.",
},
});
});
});
describe("toAgentUpdateResult", () => {
it("reports the current thinking option after a metadata update", () => {
const result = toAgentUpdateResult(
{
id: "agent-1",
title: "Renamed agent",
labels: { team: "platform" },
effectiveThinkingOptionId: "high",
},
{ notice: null },
);
expect(result).toEqual({
agentId: "agent-1",
name: "Renamed agent",
labels: "team=platform",
thinkingOptionId: "high",
noticeType: null,
notice: null,
});
});
});
+120 -28
View File
@@ -1,4 +1,6 @@
import type { Command } from "commander";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
@@ -12,6 +14,9 @@ export interface AgentUpdateResult {
agentId: string;
name: string | null;
labels: string;
thinkingOptionId: string | null;
noticeType: AgentProviderNotice["type"] | null;
notice: string | null;
}
/** Schema for update command output */
@@ -21,17 +26,80 @@ export const updateSchema: OutputSchema<AgentUpdateResult> = {
{ header: "AGENT ID", field: "agentId" },
{ header: "NAME", field: "name" },
{ header: "LABELS", field: "labels" },
{ header: "THINKING", field: "thinkingOptionId" },
{ header: "NOTICE", field: "notice" },
],
};
export interface AgentUpdateOptions extends CommandOptions {
name?: string;
label?: string[];
thinking?: string;
host?: string;
}
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>;
export interface AgentMetadataChanges {
name?: string;
labels?: Record<string, string>;
}
interface AgentUpdateServerInfo {
features?: { agentThinkingUpdate?: boolean };
}
export interface AgentUpdateClient {
getLastServerInfoMessage(): AgentUpdateServerInfo | null;
updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void>;
setAgentThinkingOption(
agentId: string,
thinkingOptionId: string,
): Promise<AgentProviderNotice | null>;
}
export type AgentChanges =
| { type: "metadata"; updates: AgentMetadataChanges }
| { type: "thinking"; thinkingOptionId: string };
export interface AppliedAgentChanges {
notice: AgentProviderNotice | null;
}
export function toAgentUpdateResult(
agent: Pick<AgentSnapshotPayload, "id" | "title" | "labels" | "effectiveThinkingOptionId">,
appliedChanges: AppliedAgentChanges,
): AgentUpdateResult {
return {
agentId: agent.id,
name: agent.title,
labels: formatLabels(agent.labels),
thinkingOptionId: agent.effectiveThinkingOptionId ?? null,
noticeType: appliedChanges.notice?.type ?? null,
notice: appliedChanges.notice?.message ?? null,
};
}
export async function applyAgentChanges(
client: AgentUpdateClient,
agentId: string,
changes: AgentChanges,
): Promise<AppliedAgentChanges> {
if (changes.type === "thinking") {
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
if (client.getLastServerInfoMessage()?.features?.agentThinkingUpdate !== true) {
throw {
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use agent thinking updates.",
} satisfies CommandError;
}
const notice = await client.setAgentThinkingOption(agentId, changes.thinkingOptionId);
return { notice };
}
await client.updateAgent(agentId, changes.updates);
return { notice: null };
}
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
const parsed: Record<string, string> = {};
if (!labels) {
@@ -81,6 +149,55 @@ function formatLabels(labels: Record<string, string>): string {
return entries.map(([key, value]) => `${key}=${value}`).join(",");
}
function parseAgentChanges(options: AgentUpdateOptions): AgentChanges {
const name = options.name?.trim();
if (options.name !== undefined && !name) {
throw {
code: "INVALID_NAME",
message: "Name cannot be empty",
details: "Use --name <name> with a non-empty value",
} satisfies CommandError;
}
const labels = parseLabelOptions(options.label);
const thinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !thinkingOptionId) {
throw {
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
details:
'Provide a thinking option ID. Use "paseo provider models <provider> --thinking" to list valid IDs.',
} satisfies CommandError;
}
const hasMetadataUpdates = Boolean(name) || Object.keys(labels).length > 0;
if (hasMetadataUpdates && thinkingOptionId) {
throw {
code: "INVALID_OPTIONS",
message: "--thinking cannot be combined with --name or --label",
details: "Run separate agent update commands for runtime settings and metadata.",
} satisfies CommandError;
}
if (!hasMetadataUpdates && !thinkingOptionId) {
throw {
code: "NO_CHANGES_PROVIDED",
message: "Nothing to update",
details: "Provide at least one of: --name <name>, --label <key=value>, --thinking <id>",
} satisfies CommandError;
}
if (thinkingOptionId) {
return { type: "thinking", thinkingOptionId };
}
return {
type: "metadata",
updates: {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
},
};
}
export async function runUpdateCommand(
agentIdArg: string,
options: AgentUpdateOptions,
@@ -98,25 +215,7 @@ export async function runUpdateCommand(
throw error;
}
const name = options.name?.trim();
if (options.name !== undefined && !name) {
const error: CommandError = {
code: "INVALID_NAME",
message: "Name cannot be empty",
details: "Use --name <name> with a non-empty value",
};
throw error;
}
const labels = parseLabelOptions(options.label);
if (!name && Object.keys(labels).length === 0) {
const error: CommandError = {
code: "NO_CHANGES_PROVIDED",
message: "Nothing to update",
details: "Provide at least one of: --name <name>, --label <key=value>",
};
throw error;
}
const changes = parseAgentChanges(options);
let client;
try {
@@ -143,10 +242,7 @@ export async function runUpdateCommand(
}
const agentId = fetchResult.agent.id;
await client.updateAgent(agentId, {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
});
const appliedChanges = await applyAgentChanges(client, agentId, changes);
const updatedResult = await client.fetchAgent({ agentId });
if (!updatedResult) {
@@ -157,11 +253,7 @@ export async function runUpdateCommand(
return {
type: "single",
data: {
agentId,
name: updatedResult.agent.title,
labels: formatLabels(updatedResult.agent.labels),
},
data: toAgentUpdateResult(updatedResult.agent, appliedChanges),
schema: updateSchema,
};
} catch (err) {
@@ -18,6 +18,7 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
target?: string;
provider?: string;
mode?: string;
thinking?: string;
cwd?: string;
maxRuns?: string;
expiresIn?: string;
@@ -40,6 +41,7 @@ export async function runCreateCommand(
target: options.target,
provider: options.provider,
mode: options.mode,
thinking: options.thinking,
cwd: options.cwd,
host: options.host,
maxRuns: options.maxRuns,
@@ -32,6 +32,7 @@ export function createScheduleCommand(): Command {
"--mode <mode>",
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
)
.option("--thinking <id>", "Thinking option ID for new-agent runs")
.option("--cwd <path>", "Working directory (default: current; required with --host)")
.option("--run-now", "Fire one immediate run on creation")
.option("--max-runs <n>", "Maximum number of runs")
@@ -116,6 +116,25 @@ describe("parseScheduleCreateInput first-run timing", () => {
});
});
describe("parseScheduleCreateInput thinking", () => {
test("sets the thinking option for each scheduled new-agent run", () => {
const input = parseScheduleCreateInput({
...baseOptions,
cwd: "/project",
thinking: " high ",
});
expect(input.target).toEqual({
type: "new-agent",
config: {
provider: "claude",
cwd: "/project",
thinkingOptionId: "high",
},
});
});
});
describe("parseScheduleUpdateInput", () => {
test("rejects calls with no fields to update", () => {
expect(() => parseScheduleUpdateInput({ id: "abc" })).toThrow(
+12 -2
View File
@@ -114,7 +114,7 @@ function resolveScheduleTarget(args: {
if (hasExplicitNewAgentOption) {
throw {
code: "INVALID_TARGET",
message: "--provider/--mode can only be used with a new-agent target",
message: "--provider/--mode/--thinking can only be used with a new-agent target",
details: "Use --target new-agent or omit --target to create a new agent schedule",
} satisfies CommandError;
}
@@ -144,6 +144,7 @@ export function parseScheduleCreateInput(options: {
target?: string;
provider?: string;
mode?: string;
thinking?: string;
cwd?: string;
host?: string;
maxRuns?: string;
@@ -179,7 +180,15 @@ export function parseScheduleCreateInput(options: {
const targetValue = options.target?.trim();
const modeId = options.mode?.trim();
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
const thinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !thinkingOptionId) {
throw {
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
} satisfies CommandError;
}
const hasExplicitNewAgentOption =
options.provider !== undefined || options.mode !== undefined || options.thinking !== undefined;
const createNewAgentTarget = (): ScheduleTarget => {
const resolvedProviderModel = resolveProviderAndModel({
provider: options.provider,
@@ -191,6 +200,7 @@ export function parseScheduleCreateInput(options: {
cwd: cwdInput ?? process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
...(modeId ? { modeId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
},
};
};
@@ -33,6 +33,7 @@ try {
assert.strictEqual(result.exitCode, 0, "agent update --help should exit 0");
assert(result.stdout.includes("--name"), "help should mention --name flag");
assert(result.stdout.includes("--label"), "help should mention --label flag");
assert(result.stdout.includes("--thinking"), "help should mention --thinking flag");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<id>"), "help should mention required id argument");
console.log("✓ agent update --help shows options\n");
@@ -103,6 +104,30 @@ try {
assert(result.stdout.includes("update"), "help should mention update subcommand");
console.log("✓ agent --help shows update subcommand\n");
}
// Test 7: agent update accepts thinking as an update field
{
console.log("Test 7: agent update accepts thinking as an update field");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --thinking high`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("Nothing to update"), "should treat --thinking as an update field");
console.log("✓ agent update accepts thinking as an update field\n");
}
// Test 8: thinking cannot be combined with metadata updates
{
console.log("Test 8: thinking cannot be combined with metadata updates");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name renamed --thinking high`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should reject a combined update");
const output = result.stdout + result.stderr;
assert(
output.includes("--thinking cannot be combined with --name or --label"),
"should explain that thinking and metadata updates are separate operations",
);
console.log("✓ thinking cannot be combined with metadata updates\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true });
@@ -100,6 +100,8 @@ try {
"10m",
"--provider",
"codex/gpt-5.4",
"--thinking",
"high",
"--json",
],
{ timeout: 30000 },
@@ -113,6 +115,7 @@ try {
const inspectedJson = JSON.parse(inspected.stdout);
assert.strictEqual(inspectedJson.target.config.provider, "codex");
assert.strictEqual(inspectedJson.target.config.model, "gpt-5.4");
assert.strictEqual(inspectedJson.target.config.thinkingOptionId, "high");
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
@@ -168,4 +168,14 @@ describe("project command-center protocol", () => {
expect(parsed.features?.projectGithubClone).toBeUndefined();
expect(parsed.features?.projectCreateDirectory).toBeUndefined();
});
it("parses the agent thinking update capability", () => {
const parsed = parseServerInfoStatusPayload({
status: "server_info",
serverId: "server-new",
features: { agentThinkingUpdate: true },
});
expect(parsed.features?.agentThinkingUpdate).toBe(true);
});
});
+2
View File
@@ -2792,6 +2792,8 @@ export const ServerInfoStatusPayloadSchema = z
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: z.boolean().optional(),
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
agentThinkingUpdate: z.boolean().optional(),
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
daemonDiagnostics: z.boolean().optional(),
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
@@ -1540,6 +1540,8 @@ export class VoiceAssistantWebSocketServer {
providerUsageList: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: true,
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
agentThinkingUpdate: true,
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
daemonDiagnostics: true,
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.