diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index 2532755a..a2c6b6b2 100644
--- a/apps/x/apps/main/src/ipc.ts
+++ b/apps/x/apps/main/src/ipc.ts
@@ -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,
});
diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx
index cc881247..ca46c909 100644
--- a/apps/x/apps/renderer/src/components/settings-dialog.tsx
+++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx
@@ -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 (
+
+
+ Loading...
+
+ )
+ }
+
+ return (
+
+
+ Runtime cost and safety controls. A turn is stopped once it reaches its model-call limit;
+ changes apply to newly started turns only.
+
+
+
+
+
+
Model-call limit
+
+ 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).
+
+
+
setGlobalLimit(e.target.value)}
+ onBlur={handleSave}
+ className="w-24 shrink-0"
+ />
+
+
+
+
+
Chat model-call limit
+
+ Optional separate limit for interactive chat turns. Leave empty to use the
+ model-call limit above.
+
+
+
setChatLimit(e.target.value)}
+ onBlur={handleSave}
+ placeholder="Same as above"
+ className="w-32 shrink-0"
+ />
+
+
+
+ )
+}
+
// --- 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
{/* Content */}
-
+
{activeTab === "account" ? (
) : activeTab === "connections" ? (
@@ -2845,6 +2971,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
) : activeTab === "notifications" ? (
+ ) : activeTab === "advanced" ? (
+
) : activeTab === "help" ? (
) : activeTab === "code-mode" ? (
diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md
index bda228ad..3cda524b 100644
--- a/apps/x/packages/core/docs/turn-runtime-design.md
+++ b/apps/x/packages/core/docs/turn-runtime-design.md
@@ -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
diff --git a/apps/x/packages/core/src/config/turn_limits.test.ts b/apps/x/packages/core/src/config/turn_limits.test.ts
new file mode 100644
index 00000000..b9748615
--- /dev/null
+++ b/apps/x/packages/core/src/config/turn_limits.test.ts
@@ -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
{
+ 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);
+ });
+});
diff --git a/apps/x/packages/core/src/config/turn_limits.ts b/apps/x/packages/core/src/config/turn_limits.ts
new file mode 100644
index 00000000..82864075
--- /dev/null
+++ b/apps/x/packages/core/src/config/turn_limits.ts
@@ -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;
+}
diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts
index 90501f67..c5a9e469 100644
--- a/apps/x/packages/core/src/di/container.ts
+++ b/apps/x/packages/core/src/di/container.ts
@@ -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(() => new RealToolRegistry()).singleton(),
permissionChecker: asFunction(() => new RealPermissionChecker()).singleton(),
permissionClassifier: asFunction(() => new RealPermissionClassifier()).singleton(),
+ turnLimitsResolver: asFunction(() => new RealTurnLimitsResolver()).singleton(),
turnRuntime: asClass(TurnRuntime).singleton(),
sessionRepo: asClass(FSSessionRepo).singleton(),
sessionBus: asClass(EmitterSessionBus).singleton(),
diff --git a/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts b/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts
index 7ac176d8..cb0dbdd2 100644
--- a/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts
+++ b/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts
@@ -48,6 +48,7 @@ function fakeServices(opts: {
parentEvents?: Array>;
childResult?: Partial;
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 () => {
diff --git a/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts b/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts
index 3acfd24d..98e31c1c 100644
--- a/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts
+++ b/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts
@@ -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 | 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>;
@@ -231,6 +236,9 @@ async function resolveServices(): Promise<
NonNullable
> {
const { lazyResolve } = await import("../../di/lazy-resolve.js");
+ const { loadTurnLimitsSettings } = await import(
+ "../../config/turn_limits.js"
+ );
return {
turnRuntime:
await lazyResolve(
@@ -239,6 +247,7 @@ async function resolveServices(): Promise<
headlessRunner: await lazyResolve<
import("./headless.js").IHeadlessAgentRunner
>("headlessAgentRunner"),
+ globalMaxModelCalls: loadTurnLimitsSettings().maxModelCalls,
};
}
diff --git a/apps/x/packages/core/src/runtime/turns/bridges/index.ts b/apps/x/packages/core/src/runtime/turns/bridges/index.ts
index 5927554c..e93f132e 100644
--- a/apps/x/packages/core/src/runtime/turns/bridges/index.ts
+++ b/apps/x/packages/core/src/runtime/turns/bridges/index.ts
@@ -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";
diff --git a/apps/x/packages/core/src/runtime/turns/bridges/real-turn-limits-resolver.ts b/apps/x/packages/core/src/runtime/turns/bridges/real-turn-limits-resolver.ts
new file mode 100644
index 00000000..456e6029
--- /dev/null
+++ b/apps/x/packages/core/src/runtime/turns/bridges/real-turn-limits-resolver.ts
@@ -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);
+ }
+}
diff --git a/apps/x/packages/core/src/runtime/turns/runtime.test.ts b/apps/x/packages/core/src/runtime/turns/runtime.test.ts
index 8478867e..4e913ed8 100644
--- a/apps/x/packages/core/src/runtime/turns/runtime.test.ts
+++ b/apps/x/packages/core/src/runtime/turns/runtime.test.ts
@@ -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 {
+ 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);
+ });
+});
diff --git a/apps/x/packages/core/src/runtime/turns/runtime.ts b/apps/x/packages/core/src/runtime/turns/runtime.ts
index 271f68ba..017efe69 100644
--- a/apps/x/packages/core/src/runtime/turns/runtime.ts
+++ b/apps/x/packages/core/src/runtime/turns/runtime.ts
@@ -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;
@@ -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 {
@@ -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 }),
diff --git a/apps/x/packages/core/src/runtime/turns/turn-limits-resolver.ts b/apps/x/packages/core/src/runtime/turns/turn-limits-resolver.ts
new file mode 100644
index 00000000..37bbac73
--- /dev/null
+++ b/apps/x/packages/core/src/runtime/turns/turn-limits-resolver.ts
@@ -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;
+}
diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts
index a40a893a..6661420e 100644
--- a/apps/x/packages/shared/src/index.ts
+++ b/apps/x/packages/shared/src/index.ts
@@ -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';
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts
index e2ce9a62..55daa37f 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -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;
// ============================================================================
diff --git a/apps/x/packages/shared/src/turn-limits.ts b/apps/x/packages/shared/src/turn-limits.ts
new file mode 100644
index 00000000..6d390c1e
--- /dev/null
+++ b/apps/x/packages/shared/src/turn-limits.ts
@@ -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;