Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ ARCHITECTURE.md
CLAW-LOOT.md
/logs/*
/tmp/*
.cursor/
.cursor/
78 changes: 78 additions & 0 deletions src/tui/apply-max-steps-request.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
53 changes: 53 additions & 0 deletions src/tui/apply-max-steps-request.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
42 changes: 40 additions & 2 deletions src/tui/chat-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>,
runTurn: (
text: string,
opts: { signal: AbortSignal; maxSteps: number },
) => Promise<unknown>,
): 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 },
Expand All @@ -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");
Expand Down
12 changes: 11 additions & 1 deletion src/tui/chat-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
});
Expand Down
54 changes: 54 additions & 0 deletions src/tui/commands/max-steps-slash-command.test.ts
Original file line number Diff line number Diff line change
@@ -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);
},
);
});
38 changes: 38 additions & 0 deletions src/tui/commands/slash-command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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 <number>` 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 },
});
}
5 changes: 5 additions & 0 deletions src/tui/menu/menu-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number>`",
},
{
name: "uninstall",
description:
Expand Down
12 changes: 12 additions & 0 deletions src/tui/menu/menu-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number>`",
rank: 39,
},
},
{
kind: "action",
id: "setup.theme",
Expand Down
Loading
Loading