feat(x): configurable model-call limits by workflow (#768)

Replace the hardcoded 20-call turn budget with user settings: a global
model-call limit that every turn inherits (headless/knowledge work,
scheduled agents, sub-agents) plus an optional chat-only override for
interactive turns. Settings live in config/turn_limits.json (1-100,
default 20) and are edited from a new Advanced tab in Settings.

The limit is resolved at turn creation via an ITurnLimitsResolver
injected into TurnRuntime, keyed on humanAvailable; explicit per-call
overrides still win, and the resolved value is persisted in
turn_created.config.maxModelCalls as before, so historical and
in-flight turns are unaffected by settings changes. Sub-agents now
default to and are capped by the global limit instead of the constant.
This commit is contained in:
Gagan 2026-07-21 15:25:21 +05:30
parent 63a99e5fe4
commit eece1af90e
16 changed files with 518 additions and 16 deletions

View file

@ -69,6 +69,7 @@ import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
import { loadTurnLimitsSettings, saveTurnLimitsSettings } from '@x/core/dist/config/turn_limits.js';
import { saveAppSettings } from '@x/core/dist/config/app_settings.js';
import { setSelfCaptureActive } from '@x/core/dist/meetings/detector.js';
import { notifyIfEnabled } from '@x/core/dist/application/notification/notifier.js';
@ -2559,6 +2560,13 @@ export function setupIpcHandlers() {
saveNotificationSettings(args);
return { success: true };
},
'turnLimits:getSettings': async () => {
return loadTurnLimitsSettings();
},
'turnLimits:setSettings': async (_event, args) => {
saveTurnLimitsSettings(args);
return { success: true };
},
// Embedded browser handlers (WebContentsView + navigation)
...browserIpcHandlers,
});

View file

@ -34,7 +34,7 @@ import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus
import { useProviderModels } from "@/hooks/use-provider-models"
import { useChatGPT } from "@/hooks/useChatGPT"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "advanced" | "help"
interface TabConfig {
id: ConfigTab
@ -109,6 +109,12 @@ const tabs: TabConfig[] = [
path: "config/tags.json",
description: "Configure tags for notes and emails",
},
{
id: "advanced",
label: "Advanced",
icon: Wrench,
description: "Advanced runtime and cost controls",
},
{
id: "help",
label: "Help",
@ -120,7 +126,7 @@ const tabs: TabConfig[] = [
/** Sidebar nav grouping: identity first, capabilities, then app-level. */
const NAV_SECTIONS: { label: string | null; ids: ConfigTab[] }[] = [
{ label: null, ids: ["account", "connections", "mobile"] },
{ label: "Configure", ids: ["models", "mcp", "security", "code-mode", "note-tagging"] },
{ label: "Configure", ids: ["models", "mcp", "security", "code-mode", "note-tagging", "advanced"] },
{ label: "App", ids: ["appearance", "notifications", "help"] },
]
@ -2632,6 +2638,126 @@ function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
// --- Advanced (runtime/cost controls) tab ---
const MODEL_CALL_LIMIT_MIN = 1
const MODEL_CALL_LIMIT_MAX = 100
function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) {
// Inputs are kept as strings so the user can clear a field while typing;
// validation happens on save (blur).
const [globalLimit, setGlobalLimit] = useState("")
const [chatLimit, setChatLimit] = useState("")
const [loaded, setLoaded] = useState(false)
useEffect(() => {
if (!dialogOpen) return
let cancelled = false
window.ipc.invoke("turnLimits:getSettings", null)
.then((settings) => {
if (cancelled) return
setGlobalLimit(String(settings.maxModelCalls))
setChatLimit(settings.chatMaxModelCalls !== undefined ? String(settings.chatMaxModelCalls) : "")
setLoaded(true)
})
.catch(() => {
if (!cancelled) toast.error("Failed to load advanced settings")
})
return () => { cancelled = true }
}, [dialogOpen])
const parseLimit = (value: string): number | null => {
const n = Number(value.trim())
if (!Number.isInteger(n) || n < MODEL_CALL_LIMIT_MIN || n > MODEL_CALL_LIMIT_MAX) return null
return n
}
const handleSave = useCallback(async () => {
const global = parseLimit(globalLimit)
if (global === null) {
toast.error(`Model-call limit must be a whole number between ${MODEL_CALL_LIMIT_MIN} and ${MODEL_CALL_LIMIT_MAX}`)
return
}
let chat: number | undefined
if (chatLimit.trim() !== "") {
const parsed = parseLimit(chatLimit)
if (parsed === null) {
toast.error(`Chat limit must be empty or a whole number between ${MODEL_CALL_LIMIT_MIN} and ${MODEL_CALL_LIMIT_MAX}`)
return
}
chat = parsed
}
try {
await window.ipc.invoke("turnLimits:setSettings", {
maxModelCalls: global,
...(chat === undefined ? {} : { chatMaxModelCalls: chat }),
})
toast.success("Model-call limits saved. Applies to new turns.")
} catch {
toast.error("Failed to save model-call limits")
}
}, [globalLimit, chatLimit])
if (!loaded) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-5">
<div className="text-sm text-muted-foreground leading-relaxed">
Runtime cost and safety controls. A turn is stopped once it reaches its model-call limit;
changes apply to newly started turns only.
</div>
<div className="space-y-2">
<div className="rounded-md border px-3 py-3 flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">Model-call limit</div>
<div className="text-xs text-muted-foreground mt-0.5">
Maximum model calls per turn. Applies to everything by default background and
knowledge work, scheduled agents, and sub-agents (it also caps sub-agent budgets).
</div>
</div>
<Input
type="number"
min={MODEL_CALL_LIMIT_MIN}
max={MODEL_CALL_LIMIT_MAX}
value={globalLimit}
onChange={(e) => setGlobalLimit(e.target.value)}
onBlur={handleSave}
className="w-24 shrink-0"
/>
</div>
<div className="rounded-md border px-3 py-3 flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">Chat model-call limit</div>
<div className="text-xs text-muted-foreground mt-0.5">
Optional separate limit for interactive chat turns. Leave empty to use the
model-call limit above.
</div>
</div>
<Input
type="number"
min={MODEL_CALL_LIMIT_MIN}
max={MODEL_CALL_LIMIT_MAX}
value={chatLimit}
onChange={(e) => setChatLimit(e.target.value)}
onBlur={handleSave}
placeholder="Same as above"
className="w-32 shrink-0"
/>
</div>
</div>
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
@ -2684,7 +2810,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications") return
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications" || tab === "advanced") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@ -2806,7 +2932,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
</div>
{/* Content */}
<div className={cn("flex-1 px-6 pb-5 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 px-6 pb-5 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications" || activeTab === "advanced") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connections" ? (
@ -2845,6 +2971,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<AppearanceSettings />
) : activeTab === "notifications" ? (
<NotificationSettings dialogOpen={open} />
) : activeTab === "advanced" ? (
<AdvancedSettings dialogOpen={open} />
) : activeTab === "help" ? (
<HelpSettings />
) : activeTab === "code-mode" ? (

View file

@ -512,7 +512,13 @@ Rules:
- `input` is the user message that defines this turn boundary.
- `autoPermission` defaults to `false` before persistence.
- `humanAvailable` is required explicitly.
- `maxModelCalls` defaults to `20` before persistence.
- `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 `20`. 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

@ -0,0 +1,126 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// WorkDir is resolved at module load, so each test gets a fresh temp workdir
// via ROWBOAT_WORKDIR + resetModules + dynamic import (same pattern as
// app_version.test.ts).
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rowboat-turn-limits-test-"));
process.env.ROWBOAT_WORKDIR = tmpDir;
vi.resetModules();
// config.js fire-and-forgets a git init + Today.md migration on import;
// mock them out so no repo appears (and races teardown) in the temp workdir.
vi.doMock("../knowledge/version_history.js", () => ({
commitAll: vi.fn(async () => undefined),
initRepo: vi.fn(async () => undefined),
}));
vi.doMock("../knowledge/deprecate_today_note.js", () => ({
deprecateTodayNote: vi.fn(async () => undefined),
}));
});
afterEach(async () => {
delete process.env.ROWBOAT_WORKDIR;
vi.doUnmock("../knowledge/version_history.js");
vi.doUnmock("../knowledge/deprecate_today_note.js");
vi.resetModules();
await fs.rm(tmpDir, { recursive: true, force: true });
});
async function loadTurnLimits() {
return import("./turn_limits.js");
}
const settingsPath = () => path.join(tmpDir, "config", "turn_limits.json");
async function writeSettings(content: string): Promise<void> {
await fs.mkdir(path.dirname(settingsPath()), { recursive: true });
await fs.writeFile(settingsPath(), content);
}
describe("loadTurnLimitsSettings", () => {
it("defaults to the built-in limit (20) when no file exists", async () => {
const { loadTurnLimitsSettings } = await loadTurnLimits();
expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 20 });
});
it("reads persisted settings", async () => {
await writeSettings(JSON.stringify({ maxModelCalls: 60, chatMaxModelCalls: 10 }));
const { loadTurnLimitsSettings } = await loadTurnLimits();
expect(loadTurnLimitsSettings()).toEqual({
maxModelCalls: 60,
chatMaxModelCalls: 10,
});
});
it("fills a missing global limit from the default", async () => {
await writeSettings(JSON.stringify({ chatMaxModelCalls: 5 }));
const { loadTurnLimitsSettings } = await loadTurnLimits();
expect(loadTurnLimitsSettings()).toEqual({
maxModelCalls: 20,
chatMaxModelCalls: 5,
});
});
it("falls back to defaults on a corrupt file", async () => {
await writeSettings("{not json");
const { loadTurnLimitsSettings } = await loadTurnLimits();
expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 20 });
});
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: 20 });
});
});
describe("saveTurnLimitsSettings", () => {
it("persists valid settings for the next load", async () => {
const { saveTurnLimitsSettings, loadTurnLimitsSettings } = await loadTurnLimits();
saveTurnLimitsSettings({ maxModelCalls: 80, chatMaxModelCalls: 15 });
expect(loadTurnLimitsSettings()).toEqual({
maxModelCalls: 80,
chatMaxModelCalls: 15,
});
});
it("rejects out-of-range values", async () => {
const { saveTurnLimitsSettings } = await loadTurnLimits();
expect(() => saveTurnLimitsSettings({ maxModelCalls: 0 })).toThrow();
expect(() => saveTurnLimitsSettings({ maxModelCalls: 101 })).toThrow();
expect(() =>
saveTurnLimitsSettings({ maxModelCalls: 20, chatMaxModelCalls: 500 }),
).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 20 everywhere with no settings file", async () => {
const { resolveMaxModelCalls } = await loadTurnLimits();
expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(20);
expect(resolveMaxModelCalls({ humanAvailable: false })).toBe(20);
});
});

View file

@ -0,0 +1,54 @@
import fs from 'fs';
import path from 'path';
import {
TurnLimitsSettingsSchema,
DEFAULT_TURN_LIMITS_SETTINGS,
type TurnLimitsSettings,
} from '@x/shared/dist/turn-limits.js';
import { WorkDir } from './config.js';
const TURN_LIMITS_CONFIG_PATH = path.join(WorkDir, 'config', 'turn_limits.json');
/**
* Load the model-call limit settings, falling back to the defaults (global
* limit 20, no chat override) when the file is absent or malformed so
* existing installations keep today's behavior until the user changes it.
*/
export function loadTurnLimitsSettings(): 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 : {}),
});
}
} catch (error) {
console.error('[TurnLimits] Error loading turn limit settings:', error);
}
return DEFAULT_TURN_LIMITS_SETTINGS;
}
export function saveTurnLimitsSettings(settings: TurnLimitsSettings): 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;
}

View file

@ -50,6 +50,8 @@ 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";
@ -140,6 +142,7 @@ 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

@ -48,6 +48,7 @@ function fakeServices(opts: {
parentEvents?: Array<z.infer<typeof TurnEvent>>;
childResult?: Partial<HeadlessAgentResult>;
startError?: string;
globalMaxModelCalls?: number;
}) {
const started: HeadlessAgentOptions[] = [];
const turnRuntime = {
@ -79,7 +80,16 @@ function fakeServices(opts: {
throw new Error("unused");
},
};
return { services: { turnRuntime, headlessRunner }, started };
return {
services: {
turnRuntime,
headlessRunner,
...(opts.globalMaxModelCalls === undefined
? {}
: { globalMaxModelCalls: opts.globalMaxModelCalls }),
},
started,
};
}
const signal = new AbortController().signal;
@ -181,14 +191,35 @@ describe("runSpawnedAgent", () => {
expect(agent.inline.instructions).toMatch(/headlessly/);
});
it("rejects a model-call budget above the cap at the schema boundary", async () => {
const { services } = fakeServices({});
it("clamps a model-call budget above the global limit to it", async () => {
const { services, started } = fakeServices({});
const result = await runSpawnedAgent(
{ task: "t", instructions: "x", max_model_calls: 50 },
{ parentTurnId: "parent-1", signal, services },
);
expect(result.isError).toBe(true);
expect(result.output).toMatch(/invalid input/);
expect(result.isError).toBe(false);
expect(started[0].maxModelCalls).toBe(20);
});
it("uses the configured global limit as the sub-agent default and cap", async () => {
const { services, started } = fakeServices({ globalMaxModelCalls: 60 });
await runSpawnedAgent(
{ task: "t", instructions: "x" },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[0].maxModelCalls).toBe(60);
await runSpawnedAgent(
{ task: "t", instructions: "x", max_model_calls: 80 },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[1].maxModelCalls).toBe(60);
await runSpawnedAgent(
{ task: "t", instructions: "x", max_model_calls: 5 },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[2].maxModelCalls).toBe(5);
});
it("refuses to spawn from a child parent (depth cap)", async () => {

View file

@ -54,10 +54,9 @@ export const SpawnAgentInput = z.object({
.number()
.int()
.min(1)
.max(DEFAULT_MAX_MODEL_CALLS)
.optional()
.describe(
`Model-call budget for the sub-agent (default and cap: ${DEFAULT_MAX_MODEL_CALLS}).`,
"Model-call budget for the sub-agent. Defaults to the configured global limit, which is also the cap — higher values are clamped to it.",
),
reasoning_effort: z
.enum(["low", "medium", "high"])
@ -89,6 +88,9 @@ export interface SpawnedAgentCallbacks {
services?: {
turnRuntime: import("../turns/api.js").ITurnRuntime;
headlessRunner: import("./headless.js").IHeadlessAgentRunner;
// Default and cap for the child's model-call budget; production
// reads the user's global limit setting.
globalMaxModelCalls?: number;
};
}
@ -112,7 +114,7 @@ export async function runSpawnedAgent(
// Lazy: this module is imported by the BuiltinTools catalog, which the
// DI container's bridges import at startup.
const { turnRuntime, headlessRunner } =
const { turnRuntime, headlessRunner, globalMaxModelCalls } =
opts.services ?? (await resolveServices());
let parentModel: z.infer<typeof ModelDescriptor> | undefined;
@ -167,9 +169,12 @@ export async function runSpawnedAgent(
},
};
// The user's global limit is both the default and the cap: a parent can
// grant a child less budget than the setting allows, never more.
const spawnCap = globalMaxModelCalls ?? DEFAULT_MAX_MODEL_CALLS;
const maxModelCalls = Math.min(
input.max_model_calls ?? DEFAULT_MAX_MODEL_CALLS,
DEFAULT_MAX_MODEL_CALLS,
input.max_model_calls ?? spawnCap,
spawnCap,
);
let handle: Awaited<ReturnType<typeof headlessRunner.start>>;
@ -231,6 +236,9 @@ async function resolveServices(): Promise<
NonNullable<SpawnedAgentCallbacks["services"]>
> {
const { lazyResolve } = await import("../../di/lazy-resolve.js");
const { loadTurnLimitsSettings } = await import(
"../../config/turn_limits.js"
);
return {
turnRuntime:
await lazyResolve<import("../turns/api.js").ITurnRuntime>(
@ -239,6 +247,7 @@ async function resolveServices(): Promise<
headlessRunner: await lazyResolve<
import("./headless.js").IHeadlessAgentRunner
>("headlessAgentRunner"),
globalMaxModelCalls: loadTurnLimitsSettings().maxModelCalls,
};
}

View file

@ -3,3 +3,4 @@ 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

@ -0,0 +1,12 @@
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

@ -40,6 +40,7 @@ import type {
SyncRuntimeTool,
ToolExecutionContext,
} from "./tool-registry.js";
import type { ITurnLimitsResolver } from "./turn-limits-resolver.js";
// ---------------------------------------------------------------------------
// Fixtures
@ -322,6 +323,7 @@ function makeRuntime(opts: {
idStart?: number;
modelRegistry?: IModelRegistry;
toolRegistry?: IToolRegistry;
turnLimitsResolver?: ITurnLimitsResolver;
} = {}) {
const repo = opts.repo ?? new InMemoryTurnRepo();
const models = new FakeModelRegistry(opts.models ?? []);
@ -343,6 +345,7 @@ function makeRuntime(opts: {
lifecycleBus: bus,
turnEventBus,
usageReporter: usage,
turnLimitsResolver: opts.turnLimitsResolver,
});
return { runtime, repo, models, checker, classifier, bus, turnEventBus, usage };
}
@ -3060,3 +3063,58 @@ describe("turn event bus", () => {
expect(durable.every((e) => e.sessionId === null)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Model-call limit resolution at createTurn (issue #768)
// ---------------------------------------------------------------------------
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,
config: { humanAvailable: boolean; maxModelCalls?: number },
): Promise<number> {
const turnId = await newTurn(runtime, { config });
const [created] = await persisted(repo, turnId);
if (created.type !== "turn_created") throw new Error("no turn_created");
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 explicit per-call limit wins over the resolver", async () => {
const { runtime, repo } = makeRuntime({
turnLimitsResolver: contextualResolver,
});
expect(
await createdMaxModelCalls(runtime, repo, {
humanAvailable: true,
maxModelCalls: 3,
}),
).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(20);
});
});

View file

@ -54,6 +54,7 @@ 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>;
@ -73,6 +74,8 @@ 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
@ -90,6 +93,7 @@ export class TurnRuntime implements ITurnRuntime {
private readonly lifecycleBus: ITurnLifecycleBus;
private readonly turnEventBus: ITurnEventBus;
private readonly usageReporter: IUsageReporter;
private readonly turnLimitsResolver?: ITurnLimitsResolver;
constructor({
turnRepo,
@ -104,6 +108,7 @@ export class TurnRuntime implements ITurnRuntime {
lifecycleBus,
turnEventBus,
usageReporter,
turnLimitsResolver,
}: TurnRuntimeDependencies) {
this.turnRepo = turnRepo;
this.idGenerator = idGenerator;
@ -117,6 +122,7 @@ export class TurnRuntime implements ITurnRuntime {
this.lifecycleBus = lifecycleBus;
this.turnEventBus = turnEventBus;
this.usageReporter = usageReporter;
this.turnLimitsResolver = turnLimitsResolver;
}
async createTurn(input: CreateTurnInput): Promise<string> {
@ -163,7 +169,12 @@ export class TurnRuntime implements ITurnRuntime {
config: {
autoPermission: input.config.autoPermission ?? false,
humanAvailable: input.config.humanAvailable,
maxModelCalls: input.config.maxModelCalls ?? DEFAULT_MAX_MODEL_CALLS,
maxModelCalls:
input.config.maxModelCalls ??
this.turnLimitsResolver?.resolve({
humanAvailable: input.config.humanAvailable,
}) ??
DEFAULT_MAX_MODEL_CALLS,
...(input.config.reasoningEffort === undefined
? {}
: { reasoningEffort: input.config.reasoningEffort }),

View file

@ -0,0 +1,8 @@
// 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;
}

View file

@ -19,6 +19,7 @@ export * as browserControl from './browser-control.js';
export * as billing from './billing.js';
export * as credits from './credits.js';
export * as notificationSettings from './notification-settings.js';
export * as turnLimits from './turn-limits.js';
export * as codeSessions from './code-sessions.js';
export * as channels from './channels.js';
export * as rowboatApp from './rowboat-app.js';

View file

@ -25,6 +25,7 @@ import { CreditActivatedEventSchema, CreditsStateSchema, ReferralClaimResultSche
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
import { PermissionDecision, ApprovalPolicy, CodingAgent, type CodeRunFeedEvent } from './code-mode.js';
import { NotificationSettingsSchema } from './notification-settings.js';
import { TurnLimitsSettingsSchema } from './turn-limits.js';
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
import { ChannelsConfig, ChannelsStatus } from './channels.js';
@ -2455,6 +2456,17 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
// Model-call limit settings channels
'turnLimits:getSettings': {
req: z.null(),
res: TurnLimitsSettingsSchema,
},
'turnLimits:setSettings': {
req: TurnLimitsSettingsSchema,
res: z.object({
success: z.literal(true),
}),
},
} as const;
// ============================================================================

View file

@ -0,0 +1,34 @@
import { z } from 'zod';
import { DEFAULT_MAX_MODEL_CALLS } from './turns.js';
/**
* User-configurable model-call budgets (see issue #768).
*
* - maxModelCalls: the global limit every turn inherits by default,
* including headless/knowledge work and spawned sub-agents (it is also
* the cap a parent can grant a sub-agent).
* - chatMaxModelCalls: optional override for interactive chat turns only;
* when absent, chat uses the global limit.
*
* Changing these affects only newly created turns each turn persists its
* resolved limit in turn_created.config.maxModelCalls.
*/
export const MIN_MODEL_CALL_LIMIT = 1;
export const MAX_MODEL_CALL_LIMIT = 100;
const limit = z
.number()
.int()
.min(MIN_MODEL_CALL_LIMIT)
.max(MAX_MODEL_CALL_LIMIT);
export const TurnLimitsSettingsSchema = z.object({
maxModelCalls: limit,
chatMaxModelCalls: limit.optional(),
});
export const DEFAULT_TURN_LIMITS_SETTINGS: TurnLimitsSettings = {
maxModelCalls: DEFAULT_MAX_MODEL_CALLS,
};
export type TurnLimitsSettings = z.infer<typeof TurnLimitsSettingsSchema>;