refactor(x): drop limits DI, read setting at default site; async fs

Address review: remove ITurnLimitsResolver, its bridge and container
registration. createTurn now defaults an omitted maxModelCalls straight
from the turn-limits config (lazy import, built-in fallback), with no
chat/headless distinction in the runtime — the chat UI passes its
optional chat override explicitly via the existing sessions:sendMessage
maxModelCalls field. turn_limits.ts now uses fs/promises throughout.
This commit is contained in:
Gagan 2026-07-21 20:58:53 +05:30
parent 7cb9c478ac
commit 7b05bf80cb
12 changed files with 91 additions and 145 deletions

View file

@ -2561,10 +2561,10 @@ export function setupIpcHandlers() {
return { success: true };
},
'turnLimits:getSettings': async () => {
return loadTurnLimitsSettings();
return await loadTurnLimitsSettings();
},
'turnLimits:setSettings': async (_event, args) => {
saveTurnLimitsSettings(args);
await saveTurnLimitsSettings(args);
return { success: true };
},
// Embedded browser handlers (WebContentsView + navigation)

View file

@ -3021,6 +3021,13 @@ function App() {
// via the agent resolver; keep them session-sticky where possible so the
// provider prefix cache survives across turns.
const reasoningEffort = reasoningEffortByTabRef.current.get(submitTabId)
// The runtime defaults omitted maxModelCalls to the global limit; the
// chat-specific override is the UI's job to pass explicitly. A failed
// settings read just falls back to the global limit.
const chatMaxModelCalls = await window.ipc
.invoke('turnLimits:getSettings', null)
.then((settings) => settings.chatMaxModelCalls)
.catch(() => undefined)
const sendConfig = {
agent: {
agentId,
@ -3039,6 +3046,7 @@ function App() {
},
autoPermission: (permissionMode ?? 'manual') === 'auto',
...(reasoningEffort ? { reasoningEffort } : {}),
...(chatMaxModelCalls !== undefined ? { maxModelCalls: chatMaxModelCalls } : {}),
}
const userMessageContextFor = (middlePane: Awaited<ReturnType<typeof buildMiddlePaneContext>>) => ({
currentDateTime: new Date().toISOString(),

View file

@ -512,13 +512,13 @@ Rules:
- `input` is the user message that defines this turn boundary.
- `autoPermission` defaults to `false` before persistence.
- `humanAvailable` is required explicitly.
- `maxModelCalls`, when omitted, is resolved at creation from the user's
turn-limit settings (`config/turn_limits.json`) via the injected
`ITurnLimitsResolver`: turns with `humanAvailable: true` use the chat
override when set, otherwise the global limit; headless turns always use
the global limit. Without a resolver (tests) it defaults to `50`. Spawned
sub-agents default to the global limit, which also caps the budget a
parent may grant them. Settings changes affect only newly created turns.
- `maxModelCalls`, when omitted, defaults at creation to the user's global
model-call limit (`config/turn_limits.json`, built-in default `50`). The
runtime makes no chat/headless distinction: the optional chat override is
the chat UI's job — its send path passes an explicit `maxModelCalls`.
Spawned sub-agents default to the global limit, which also caps the
budget a parent may grant them. Settings changes affect only newly
created turns.
- Persisted values are fully resolved and immutable.
The capability is named `humanAvailable`, not `headless`. `headless` describes

View file

@ -45,13 +45,13 @@ async function writeSettings(content: string): Promise<void> {
describe("loadTurnLimitsSettings", () => {
it("defaults to the built-in limit (50) when no file exists", async () => {
const { loadTurnLimitsSettings } = await loadTurnLimits();
expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 });
expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 });
});
it("reads persisted settings", async () => {
await writeSettings(JSON.stringify({ maxModelCalls: 60, chatMaxModelCalls: 10 }));
const { loadTurnLimitsSettings } = await loadTurnLimits();
expect(loadTurnLimitsSettings()).toEqual({
expect(await loadTurnLimitsSettings()).toEqual({
maxModelCalls: 60,
chatMaxModelCalls: 10,
});
@ -60,7 +60,7 @@ describe("loadTurnLimitsSettings", () => {
it("fills a missing global limit from the default", async () => {
await writeSettings(JSON.stringify({ chatMaxModelCalls: 5 }));
const { loadTurnLimitsSettings } = await loadTurnLimits();
expect(loadTurnLimitsSettings()).toEqual({
expect(await loadTurnLimitsSettings()).toEqual({
maxModelCalls: 50,
chatMaxModelCalls: 5,
});
@ -69,21 +69,21 @@ describe("loadTurnLimitsSettings", () => {
it("falls back to defaults on a corrupt file", async () => {
await writeSettings("{not json");
const { loadTurnLimitsSettings } = await loadTurnLimits();
expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 });
expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 });
});
it("falls back to defaults on out-of-range values", async () => {
await writeSettings(JSON.stringify({ maxModelCalls: 5000 }));
const { loadTurnLimitsSettings } = await loadTurnLimits();
expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 });
expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 });
});
});
describe("saveTurnLimitsSettings", () => {
it("persists valid settings for the next load", async () => {
const { saveTurnLimitsSettings, loadTurnLimitsSettings } = await loadTurnLimits();
saveTurnLimitsSettings({ maxModelCalls: 80, chatMaxModelCalls: 15 });
expect(loadTurnLimitsSettings()).toEqual({
await saveTurnLimitsSettings({ maxModelCalls: 80, chatMaxModelCalls: 15 });
expect(await loadTurnLimitsSettings()).toEqual({
maxModelCalls: 80,
chatMaxModelCalls: 15,
});
@ -91,36 +91,10 @@ describe("saveTurnLimitsSettings", () => {
it("rejects out-of-range values", async () => {
const { saveTurnLimitsSettings } = await loadTurnLimits();
expect(() => saveTurnLimitsSettings({ maxModelCalls: 0 })).toThrow();
expect(() => saveTurnLimitsSettings({ maxModelCalls: 501 })).toThrow();
expect(() =>
await expect(saveTurnLimitsSettings({ maxModelCalls: 0 })).rejects.toThrow();
await expect(saveTurnLimitsSettings({ maxModelCalls: 501 })).rejects.toThrow();
await expect(
saveTurnLimitsSettings({ maxModelCalls: 20, chatMaxModelCalls: 501 }),
).toThrow();
});
});
describe("resolveMaxModelCalls", () => {
it("uses the global limit for headless work", async () => {
await writeSettings(JSON.stringify({ maxModelCalls: 60, chatMaxModelCalls: 10 }));
const { resolveMaxModelCalls } = await loadTurnLimits();
expect(resolveMaxModelCalls({ humanAvailable: false })).toBe(60);
});
it("uses the chat override for interactive turns when set", async () => {
await writeSettings(JSON.stringify({ maxModelCalls: 60, chatMaxModelCalls: 10 }));
const { resolveMaxModelCalls } = await loadTurnLimits();
expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(10);
});
it("falls back to the global limit for chat when no override is set", async () => {
await writeSettings(JSON.stringify({ maxModelCalls: 60 }));
const { resolveMaxModelCalls } = await loadTurnLimits();
expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(60);
});
it("resolves 50 everywhere with no settings file", async () => {
const { resolveMaxModelCalls } = await loadTurnLimits();
expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(50);
expect(resolveMaxModelCalls({ humanAvailable: false })).toBe(50);
).rejects.toThrow();
});
});

View file

@ -1,4 +1,4 @@
import fs from 'fs';
import fs from 'fs/promises';
import path from 'path';
import {
TurnLimitsSettingsSchema,
@ -14,41 +14,29 @@ const TURN_LIMITS_CONFIG_PATH = path.join(WorkDir, 'config', 'turn_limits.json')
* limit DEFAULT_MAX_MODEL_CALLS, no chat override) when the file is absent
* or malformed.
*/
export function loadTurnLimitsSettings(): TurnLimitsSettings {
export async function loadTurnLimitsSettings(): Promise<TurnLimitsSettings> {
try {
if (fs.existsSync(TURN_LIMITS_CONFIG_PATH)) {
const content = fs.readFileSync(TURN_LIMITS_CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(content);
return TurnLimitsSettingsSchema.parse({
...DEFAULT_TURN_LIMITS_SETTINGS,
...(parsed && typeof parsed === 'object' ? parsed : {}),
});
}
const content = await fs.readFile(TURN_LIMITS_CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(content);
return TurnLimitsSettingsSchema.parse({
...DEFAULT_TURN_LIMITS_SETTINGS,
...(parsed && typeof parsed === 'object' ? parsed : {}),
});
} catch (error) {
console.error('[TurnLimits] Error loading turn limit settings:', error);
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[TurnLimits] Error loading turn limit settings:', error);
}
return DEFAULT_TURN_LIMITS_SETTINGS;
}
return DEFAULT_TURN_LIMITS_SETTINGS;
}
export function saveTurnLimitsSettings(settings: TurnLimitsSettings): void {
export async function saveTurnLimitsSettings(
settings: TurnLimitsSettings,
): Promise<void> {
const validated = TurnLimitsSettingsSchema.parse(settings);
const dir = path.dirname(TURN_LIMITS_CONFIG_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(TURN_LIMITS_CONFIG_PATH, JSON.stringify(validated, null, 2));
}
/**
* The effective model-call limit for a new turn, by execution context.
* Interactive chat turns (a human is present) use the chat override when
* set; everything else headless/knowledge work, sub-agents uses the
* global limit.
*/
export function resolveMaxModelCalls(context: { humanAvailable: boolean }): number {
const settings = loadTurnLimitsSettings();
if (context.humanAvailable && settings.chatMaxModelCalls !== undefined) {
return settings.chatMaxModelCalls;
}
return settings.maxModelCalls;
await fs.mkdir(path.dirname(TURN_LIMITS_CONFIG_PATH), { recursive: true });
await fs.writeFile(
TURN_LIMITS_CONFIG_PATH,
JSON.stringify(validated, null, 2),
);
}

View file

@ -50,8 +50,6 @@ import { RealModelRegistry } from "../runtime/turns/bridges/real-model-registry.
import { RealToolRegistry } from "../runtime/turns/bridges/real-tool-registry.js";
import { RealPermissionChecker } from "../runtime/turns/bridges/real-permission-checker.js";
import { RealPermissionClassifier } from "../runtime/turns/bridges/real-permission-classifier.js";
import { RealTurnLimitsResolver } from "../runtime/turns/bridges/real-turn-limits-resolver.js";
import type { ITurnLimitsResolver } from "../runtime/turns/turn-limits-resolver.js";
import { FSSessionRepo } from "../runtime/sessions/fs-repo.js";
import type { ISessionRepo } from "../runtime/sessions/repo.js";
import { EmitterSessionBus, type ISessionBus } from "../runtime/sessions/bus.js";
@ -142,7 +140,6 @@ container.register({
toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(),
permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(),
permissionClassifier: asFunction<IPermissionClassifier>(() => new RealPermissionClassifier()).singleton(),
turnLimitsResolver: asFunction<ITurnLimitsResolver>(() => new RealTurnLimitsResolver()).singleton(),
turnRuntime: asClass<ITurnRuntime>(TurnRuntime).singleton(),
sessionRepo: asClass<ISessionRepo>(FSSessionRepo).singleton(),
sessionBus: asClass<ISessionBus>(EmitterSessionBus).singleton(),

View file

@ -247,7 +247,7 @@ async function resolveServices(): Promise<
headlessRunner: await lazyResolve<
import("./headless.js").IHeadlessAgentRunner
>("headlessAgentRunner"),
globalMaxModelCalls: loadTurnLimitsSettings().maxModelCalls,
globalMaxModelCalls: (await loadTurnLimitsSettings()).maxModelCalls,
};
}

View file

@ -3,4 +3,3 @@ export * from "./real-model-registry.js";
export * from "./real-permission-checker.js";
export * from "./real-permission-classifier.js";
export * from "./real-tool-registry.js";
export * from "./real-turn-limits-resolver.js";

View file

@ -1,12 +0,0 @@
import { resolveMaxModelCalls } from "../../../config/turn_limits.js";
import type { ITurnLimitsResolver } from "../turn-limits-resolver.js";
// Settings-backed limits resolver: reads config/turn_limits.json on every
// resolve so a settings change applies to the next created turn without a
// restart. Turns that already exist keep the limit persisted in their
// turn_created event.
export class RealTurnLimitsResolver implements ITurnLimitsResolver {
resolve(context: { humanAvailable: boolean }): number {
return resolveMaxModelCalls(context);
}
}

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import {
MODEL_CALL_LIMIT_ERROR_CODE,
@ -40,7 +40,16 @@ import type {
SyncRuntimeTool,
ToolExecutionContext,
} from "./tool-registry.js";
import type { ITurnLimitsResolver } from "./turn-limits-resolver.js";
// createTurn defaults an omitted maxModelCalls from the fs-backed settings
// module; mock it so tests stay hermetic (no real WorkDir reads) and can
// steer the configured global limit.
const turnLimitsMock = vi.hoisted(() => ({ maxModelCalls: 50 }));
vi.mock("../../config/turn_limits.js", () => ({
loadTurnLimitsSettings: async () => ({
maxModelCalls: turnLimitsMock.maxModelCalls,
}),
}));
// ---------------------------------------------------------------------------
// Fixtures
@ -323,7 +332,6 @@ function makeRuntime(opts: {
idStart?: number;
modelRegistry?: IModelRegistry;
toolRegistry?: IToolRegistry;
turnLimitsResolver?: ITurnLimitsResolver;
} = {}) {
const repo = opts.repo ?? new InMemoryTurnRepo();
const models = new FakeModelRegistry(opts.models ?? []);
@ -345,7 +353,6 @@ function makeRuntime(opts: {
lifecycleBus: bus,
turnEventBus,
usageReporter: usage,
turnLimitsResolver: opts.turnLimitsResolver,
});
return { runtime, repo, models, checker, classifier, bus, turnEventBus, usage };
}
@ -3069,13 +3076,6 @@ describe("turn event bus", () => {
// ---------------------------------------------------------------------------
describe("model-call limit resolution", () => {
// Chat turns (humanAvailable) and headless turns resolve through the
// injected settings-backed resolver; an explicit maxModelCalls always
// wins; without a resolver the built-in default (20) applies.
const contextualResolver: ITurnLimitsResolver = {
resolve: ({ humanAvailable }) => (humanAvailable ? 7 : 42),
};
async function createdMaxModelCalls(
runtime: TurnRuntime,
repo: InMemoryTurnRepo,
@ -3087,22 +3087,23 @@ describe("model-call limit resolution", () => {
return created.config.maxModelCalls;
}
it("resolves an omitted limit by execution context", async () => {
const { runtime, repo } = makeRuntime({
turnLimitsResolver: contextualResolver,
});
expect(
await createdMaxModelCalls(runtime, repo, { humanAvailable: true }),
).toBe(7);
expect(
await createdMaxModelCalls(runtime, repo, { humanAvailable: false }),
).toBe(42);
it("an omitted limit defaults to the configured global limit", async () => {
turnLimitsMock.maxModelCalls = 42;
try {
const { runtime, repo } = makeRuntime();
expect(
await createdMaxModelCalls(runtime, repo, { humanAvailable: true }),
).toBe(42);
expect(
await createdMaxModelCalls(runtime, repo, { humanAvailable: false }),
).toBe(42);
} finally {
turnLimitsMock.maxModelCalls = 50;
}
});
it("an explicit per-call limit wins over the resolver", async () => {
const { runtime, repo } = makeRuntime({
turnLimitsResolver: contextualResolver,
});
it("an explicit per-call limit wins over the setting", async () => {
const { runtime, repo } = makeRuntime();
expect(
await createdMaxModelCalls(runtime, repo, {
humanAvailable: true,
@ -3110,11 +3111,4 @@ describe("model-call limit resolution", () => {
}),
).toBe(3);
});
it("falls back to the built-in default without a resolver", async () => {
const { runtime, repo } = makeRuntime();
expect(
await createdMaxModelCalls(runtime, repo, { humanAvailable: true }),
).toBe(50);
});
});

View file

@ -54,7 +54,6 @@ import type { IPermissionChecker, IPermissionClassifier } from "./permission.js"
import type { ITurnRepo } from "./repo.js";
import { HotStream } from "./stream.js";
import type { IToolRegistry, RuntimeTool, SyncRuntimeTool } from "./tool-registry.js";
import type { ITurnLimitsResolver } from "./turn-limits-resolver.js";
type TEvent = z.infer<typeof TurnEvent>;
@ -74,8 +73,6 @@ export interface TurnRuntimeDependencies {
lifecycleBus: ITurnLifecycleBus;
turnEventBus: ITurnEventBus;
usageReporter: IUsageReporter;
// Optional: absent (tests) falls back to DEFAULT_MAX_MODEL_CALLS.
turnLimitsResolver?: ITurnLimitsResolver;
}
// Immutable dependency container: holds no mutable per-turn state. All active
@ -93,7 +90,6 @@ export class TurnRuntime implements ITurnRuntime {
private readonly lifecycleBus: ITurnLifecycleBus;
private readonly turnEventBus: ITurnEventBus;
private readonly usageReporter: IUsageReporter;
private readonly turnLimitsResolver?: ITurnLimitsResolver;
constructor({
turnRepo,
@ -108,7 +104,6 @@ export class TurnRuntime implements ITurnRuntime {
lifecycleBus,
turnEventBus,
usageReporter,
turnLimitsResolver,
}: TurnRuntimeDependencies) {
this.turnRepo = turnRepo;
this.idGenerator = idGenerator;
@ -122,7 +117,6 @@ export class TurnRuntime implements ITurnRuntime {
this.lifecycleBus = lifecycleBus;
this.turnEventBus = turnEventBus;
this.usageReporter = usageReporter;
this.turnLimitsResolver = turnLimitsResolver;
}
async createTurn(input: CreateTurnInput): Promise<string> {
@ -171,10 +165,7 @@ export class TurnRuntime implements ITurnRuntime {
humanAvailable: input.config.humanAvailable,
maxModelCalls:
input.config.maxModelCalls ??
this.turnLimitsResolver?.resolve({
humanAvailable: input.config.humanAvailable,
}) ??
DEFAULT_MAX_MODEL_CALLS,
(await defaultMaxModelCalls()),
...(input.config.reasoningEffort === undefined
? {}
: { reasoningEffort: input.config.reasoningEffort }),
@ -1370,6 +1361,21 @@ function parseToolAdditions(
return parsed.success ? parsed.data.toolAdditions : undefined;
}
// The default budget for turns created without an explicit maxModelCalls:
// the user's configured global limit (config/turn_limits.json). Loaded
// lazily so this module doesn't statically pull in the fs-backed config;
// any load problem falls back to the built-in default.
async function defaultMaxModelCalls(): Promise<number> {
try {
const { loadTurnLimitsSettings } = await import(
"../../config/turn_limits.js"
);
return (await loadTurnLimitsSettings()).maxModelCalls;
} catch {
return DEFAULT_MAX_MODEL_CALLS;
}
}
function errorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
// Provider API errors carry the actual upstream failure in responseBody

View file

@ -1,8 +0,0 @@
// Resolves the effective model-call limit for a new turn when the caller
// didn't pass an explicit maxModelCalls. The execution context is the
// humanAvailable flag: true for interactive chat turns, false for
// headless/autonomous work. The real bridge reads the user's settings;
// tests construct TurnRuntime without one and get the built-in default.
export interface ITurnLimitsResolver {
resolve(context: { humanAvailable: boolean }): number;
}