diff --git a/.gitignore b/.gitignore index 4574ad0e..eeab107e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ ARCHITECTURE.md CLAW-LOOT.md /logs/* /tmp/* -.cursor/ \ No newline at end of file +.cursor/ diff --git a/src/tui/apply-max-steps-request.test.ts b/src/tui/apply-max-steps-request.test.ts new file mode 100644 index 00000000..84d74bc7 --- /dev/null +++ b/src/tui/apply-max-steps-request.test.ts @@ -0,0 +1,78 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { getConfig, resetConfigCache } from "../config/index.js"; +import { applyMaxStepsRequest } from "./apply-max-steps-request.js"; + +interface MaxStepsTarget { + getMaxSteps(): number; + setMaxSteps(maxSteps: number): void; +} + +function target(initial: number): MaxStepsTarget { + let current = initial; + return { + getMaxSteps: () => current, + setMaxSteps: (maxSteps) => { + current = maxSteps; + }, + }; +} + +describe("applyMaxStepsRequest", () => { + let previousStateDir: string | undefined; + let stateDir: string; + + beforeEach(() => { + previousStateDir = process.env.ATOMIC_AGENT_STATE_DIR; + stateDir = mkdtempSync(join(tmpdir(), "atomic-apply-max-steps-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + resetConfigCache(); + }); + + afterEach(() => { + resetConfigCache(); + if (previousStateDir === undefined) { + delete process.env.ATOMIC_AGENT_STATE_DIR; + } else { + process.env.ATOMIC_AGENT_STATE_DIR = previousStateDir; + } + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("reports the active value without touching config", () => { + const result = applyMaxStepsRequest(null, target(7)); + + expect(result).toEqual({ + message: "current max_steps: 7", + warning: false, + }); + expect(existsSync(join(stateDir, "config.json"))).toBe(false); + }); + + it("updates the live target and persists the validated value", () => { + const runtime = target(7); + + const result = applyMaxStepsRequest(41, runtime); + + expect(runtime.getMaxSteps()).toBe(41); + expect(result).toEqual({ + message: "max_steps updated from 7 to 41", + warning: false, + }); + expect(getConfig().agent.maxSteps).toBe(41); + }); + + it("keeps the live update when persistence fails", () => { + mkdirSync(join(stateDir, "config.json")); + const runtime = target(7); + + const result = applyMaxStepsRequest(41, runtime); + + expect(runtime.getMaxSteps()).toBe(41); + expect(result.warning).toBe(true); + expect(result.message).toContain("max_steps updated to 41 (runtime only"); + }); +}); diff --git a/src/tui/apply-max-steps-request.ts b/src/tui/apply-max-steps-request.ts new file mode 100644 index 00000000..7f594f07 --- /dev/null +++ b/src/tui/apply-max-steps-request.ts @@ -0,0 +1,53 @@ +import { + ensureUserConfigFileSync, + getConfig, + parseUserConfigFile, + resetConfigCache, + writeUserConfigFileSync, +} from "../config/index.js"; + +export interface MaxStepsTarget { + getMaxSteps(): number; + setMaxSteps(maxSteps: number): void; +} + +export interface ApplyMaxStepsResult { + readonly message: string; + readonly warning: boolean; +} + +export function applyMaxStepsRequest( + maxSteps: number | null, + target: MaxStepsTarget, +): ApplyMaxStepsResult { + if (maxSteps === null) { + return { + message: `current max_steps: ${target.getMaxSteps()}`, + warning: false, + }; + } + + const previous = target.getMaxSteps(); + target.setMaxSteps(maxSteps); + try { + const path = getConfig().paths.userConfigFile; + const current = ensureUserConfigFileSync(path); + const next = parseUserConfigFile({ + ...current, + agent: { ...current.agent, maxSteps }, + }); + writeUserConfigFileSync(path, next); + resetConfigCache(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + message: `max_steps updated to ${maxSteps} (runtime only - failed to persist: ${message})`, + warning: true, + }; + } + + return { + message: `max_steps updated from ${previous} to ${maxSteps}`, + warning: false, + }; +} diff --git a/src/tui/chat-orchestrator.test.ts b/src/tui/chat-orchestrator.test.ts index d413b127..4500d452 100644 --- a/src/tui/chat-orchestrator.test.ts +++ b/src/tui/chat-orchestrator.test.ts @@ -43,14 +43,21 @@ function deferred(id: string): Deferred { * subscribes to the bus, so nothing here needs to do I/O. */ function stubRuntime( - runTurn: (text: string, opts: { signal: AbortSignal }) => Promise, + runTurn: ( + text: string, + opts: { signal: AbortSignal; maxSteps: number }, + ) => Promise, ): AgentRuntime { return { createSession: () => session(), // The queue tests exercise the fallback path: a steer that is always // refused parks every mid-run submission in the orchestrator queue. steer: () => false, - runTurn: (_s: unknown, text: string, opts: { signal: AbortSignal }) => + runTurn: ( + _s: unknown, + text: string, + opts: { signal: AbortSignal; maxSteps: number }, + ) => runTurn(text, opts), sessionStore: { listRecent: () => [], load: () => null }, approvals: { clearSessionGrants: () => undefined }, @@ -60,6 +67,37 @@ function stubRuntime( } as unknown as AgentRuntime; } +describe("ChatOrchestrator max steps", () => { + it("uses an updated step budget on the next turn", async () => { + const turn = deferred("s1"); + const runTurn = vi.fn( + (_text: string, _opts: { signal: AbortSignal; maxSteps: number }) => + turn.promise, + ); + const orchestrator = new ChatOrchestrator( + stubRuntime(runTurn), + makeTuiEventBus(), + { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", + readGateFacts: cloudGateFacts, + }, + ); + + expect(orchestrator.getMaxSteps()).toBe(5); + orchestrator.setMaxSteps(41); + expect(orchestrator.getMaxSteps()).toBe(41); + orchestrator.sendMessage("next turn"); + + expect(runTurn).toHaveBeenCalledWith( + "next turn", + expect.objectContaining({ maxSteps: 41 }), + ); + turn.resolve(); + await turn.promise; + }); +}); + describe("ChatOrchestrator message queue", () => { it("runs the first message and parks the second until the first settles", async () => { const first = deferred("s1"); diff --git a/src/tui/chat-orchestrator.ts b/src/tui/chat-orchestrator.ts index 8d77d517..38df4c0c 100644 --- a/src/tui/chat-orchestrator.ts +++ b/src/tui/chat-orchestrator.ts @@ -130,6 +130,7 @@ function formatSkillCatalogSystemMessage( export class ChatOrchestrator { private session: SessionState | null = null; private currentController: AbortController | null = null; + private maxSteps: number; private quitting = false; private started = false; /** Latest release version captured by `checkForUpdate`, used by `runUpdate`. */ @@ -201,6 +202,7 @@ export class ChatOrchestrator { private readonly bus: TuiEventBus & { emit(action: unknown): void }, private readonly options: ChatOrchestratorOptions, ) { + this.maxSteps = options.maxSteps; this.tasks = new TasksOrchestrator(runtime, bus, { getCurrentSessionId: () => this.session?.id ?? null, switchSession: (id) => this.switchSession(id), @@ -385,6 +387,14 @@ export class ChatOrchestrator { }); } + getMaxSteps(): number { + return this.maxSteps; + } + + setMaxSteps(maxSteps: number): void { + this.maxSteps = maxSteps; + } + /** * The rail lists threads, not allocations. A session exists the moment * `+ new` mints it — `runtime.createSession` persists it immediately, @@ -1063,7 +1073,7 @@ export class ChatOrchestrator { this.steeredAhead = 0; try { const result = await this.runtime.runTurn(this.session, text, { - maxSteps: this.options.maxSteps, + maxSteps: this.maxSteps, signal: controller.signal, origin: "tui", }); diff --git a/src/tui/commands/max-steps-slash-command.test.ts b/src/tui/commands/max-steps-slash-command.test.ts new file mode 100644 index 00000000..fe82b143 --- /dev/null +++ b/src/tui/commands/max-steps-slash-command.test.ts @@ -0,0 +1,54 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resetConfigCache } from "../../config/index.js"; +import { dispatchSlashCommand } from "./slash-command-handler.js"; + +describe("/max_steps dispatch", () => { + let previousStateDir: string | undefined; + let stateDir: string; + + beforeEach(() => { + previousStateDir = process.env.ATOMIC_AGENT_STATE_DIR; + stateDir = mkdtempSync(join(tmpdir(), "atomic-max-steps-dispatch-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + resetConfigCache(); + }); + + afterEach(() => { + resetConfigCache(); + if (previousStateDir === undefined) { + delete process.env.ATOMIC_AGENT_STATE_DIR; + } else { + process.env.ATOMIC_AGENT_STATE_DIR = previousStateDir; + } + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("requests the active value without reading or writing config", () => { + const result = dispatchSlashCommand("/max_steps"); + + expect(result.maxStepsRequest).toEqual({ kind: "status" }); + expect(existsSync(join(stateDir, "config.json"))).toBe(false); + }); + + it("parses a new value without applying or persisting it", () => { + const result = dispatchSlashCommand("/max_steps 41"); + + expect(result.maxStepsRequest).toEqual({ kind: "set", value: 41 }); + expect(existsSync(join(stateDir, "config.json"))).toBe(false); + }); + + it.each(["0", "-1", "1.5", "nope"])( + "rejects invalid value %s without requesting a change", + (value) => { + const result = dispatchSlashCommand(`/max_steps ${value}`); + + expect(result.maxStepsRequest).toBeUndefined(); + expect(result.systemMessage).toContain("positive integer"); + expect(existsSync(join(stateDir, "config.json"))).toBe(false); + }, + ); +}); diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 121ad895..e641712f 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -3,6 +3,8 @@ import { codingModeLook, type CodingMode, } from "../coding-mode.js"; +import { ConfigValidationError } from "../../config/config-validation-error.js"; +import { parsePositiveInt } from "../../config/config-schema.js"; import type { WhileBusySubmitMode } from "../../config/index.js"; import type { TuiAction } from "../tui-action.js"; import { normalizeLocalLlmBaseUrl } from "../persist-user-local-models-config.js"; @@ -119,6 +121,10 @@ export interface SlashDispatchResult { * React (see `tui-command.ts`). */ readonly mouseVerb?: "on" | "off" | "status"; + /** `/max_steps [number]` — report or replace the active per-turn step budget. */ + readonly maxStepsRequest?: + | { readonly kind: "status" } + | { readonly kind: "set"; readonly value: number }; /** * `/uninstall`: measure the install and feed the result back as * `uninstall_plan_loaded`. Nothing is removed on this path — the @@ -280,6 +286,8 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { return dispatchLlmSub(parsed.args); case "model": return dispatchModelsSub(parsed.args, parsed.name); + case "max_steps": + return dispatchMaxStepsSub(parsed.args); case "tasks": return pureActions([ { type: "ui_mode_set", mode: "debug" }, @@ -886,3 +894,33 @@ function dispatchAnalyticsSub(rawArgs: string): SlashDispatchResult { systemMessage: "usage: /analytics on | off | status", }); } + +/** + * Sub-dispatcher for `/max_steps [number]`. Bare `/max_steps` shows the + * current value. `/max_steps ` sets a new positive integer value. + */ +function dispatchMaxStepsSub(rawArgs: string): SlashDispatchResult { + const args = rawArgs.trim(); + if (args.length === 0) { + return pureActions([], { + maxStepsRequest: { kind: "status" }, + }); + } + + let newValue: number; + try { + newValue = parsePositiveInt(args, "max_steps"); + } catch (err) { + if (err instanceof ConfigValidationError) { + return pureActions([], { + systemMessage: err.message, + }); + } + return pureActions([], { + systemMessage: `invalid max_steps value: ${args}`, + }); + } + return pureActions([], { + maxStepsRequest: { kind: "set", value: newValue }, + }); +} diff --git a/src/tui/menu/menu-registry.test.ts b/src/tui/menu/menu-registry.test.ts index a05da31e..dfee5eb4 100644 --- a/src/tui/menu/menu-registry.test.ts +++ b/src/tui/menu/menu-registry.test.ts @@ -226,6 +226,11 @@ const V0_2_2_SLASH_COMMANDS = [ description: "hide or show the session rail (the rail's « does the same)", }, + { + name: "max_steps", + description: + "get or set the agent's max_steps configuration: `/max_steps` | `/max_steps `", + }, { name: "uninstall", description: diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts index b63242ba..74ec2009 100644 --- a/src/tui/menu/menu-registry.ts +++ b/src/tui/menu/menu-registry.ts @@ -529,6 +529,18 @@ export const MENU: readonly MenuNode[] = [ rank: 17, }, }, + { + kind: "action", + id: "run.max_steps", + label: "Max steps…", + group: "run", + slash: { + name: "max_steps", + description: + "get or set the agent's max_steps configuration: `/max_steps` | `/max_steps `", + rank: 39, + }, + }, { kind: "action", id: "setup.theme", diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 6d4a0b1c..f07df46b 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -209,6 +209,16 @@ describe("handleEditorSubmit", () => { expect(onDebugBundleExportRequested).toHaveBeenCalledWith(state); }); + it("routes /max_steps status and set requests to the host", () => { + const state = createInitialTuiState(fakeSession()); + const onMaxStepsRequested = vi.fn(); + const callbacks = stubCallbacks({ onMaxStepsRequested }); + + handleEditorSubmit("/max_steps", state, vi.fn(), callbacks); + handleEditorSubmit("/max_steps 41", state, vi.fn(), callbacks); + + expect(onMaxStepsRequested.mock.calls).toEqual([[null], [41]]); + }); }); describe("handleEditorSubmit while a turn is running", () => { @@ -495,4 +505,3 @@ describe("steer vs queue while a turn is running", () => { expect(dispatched.some((a) => a.type === "message_submitted")).toBe(true); }); }); - diff --git a/src/tui/submit-handler.ts b/src/tui/submit-handler.ts index 64dcbbd7..d55098bd 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -297,6 +297,13 @@ export function runSlashCommand( result.mouseVerb === "status" ? null : result.mouseVerb === "on", ); } + if (result.maxStepsRequest) { + callbacks.onMaxStepsRequested?.( + result.maxStepsRequest.kind === "status" + ? null + : result.maxStepsRequest.value, + ); + } } /** diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index bc531809..482e7e33 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -252,6 +252,8 @@ export interface TuiAppCallbacks { * handler owns the escape sequences and the config write. */ onMouseSupportRequested?(enabled: boolean | null): void; + /** `/max_steps [number]` — `null` reports the active value. */ + onMaxStepsRequested?(maxSteps: number | null): void; /** * A drag began on a cell no mouse target claims — message text, panel * prose, empty rail space. Dragging across inert content is @@ -2099,4 +2101,3 @@ export function TuiApp({ ); } - diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 4ce9e2b5..d6915939 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -19,6 +19,7 @@ import type { MetricSample, MetricSink } from "../tracing/metrics-collector.js"; import { isKnownLocalModelId } from "../local-llm/index.js"; import { registerSession } from "../local-llm/session-registry.js"; import { enterAltScreen } from "./alt-screen.js"; +import { applyMaxStepsRequest } from "./apply-max-steps-request.js"; import { enableSynchronizedOutput } from "./synchronized-output.js"; import { legacyConhostStartupHint } from "./legacy-conhost.js"; import { ChatOrchestrator } from "./chat-orchestrator.js"; @@ -427,6 +428,15 @@ export async function tuiCommand(args: string[]): Promise { onMessageSteered: (text) => orchestrator.steerMessage(text), onWhileBusyModePersistRequested: (mode) => persistWhileBusyMode(mode, bus), + onMaxStepsRequested: (next) => { + const result = applyMaxStepsRequest(next, orchestrator); + bus.emit({ type: "runtime_info", line: result.message }); + bus.emit({ + type: "system_message", + text: result.message, + ...(result.warning ? { variant: "warn" as const } : {}), + }); + }, // The mode is a stance for this session, so it moves the live // ladder and the live plan flag and writes neither to // `config.json`. The persisted baseline stays whatever