diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2532755a..cd8c3551 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 await loadTurnLimitsSettings(); + }, + 'turnLimits:setSettings': async (_event, args) => { + await saveTurnLimitsSettings(args); + return { success: true }; + }, // Embedded browser handlers (WebContentsView + navigation) ...browserIpcHandlers, }); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 692a055a..58b78d14 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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>) => ({ currentDateTime: new Date().toISOString(), diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index cc881247..864b92ef 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, Minus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react" import { Dialog, @@ -29,12 +29,13 @@ import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings" import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" +import { DEFAULT_TURN_LIMITS_SETTINGS } from "@x/shared/src/turn-limits.js" import type { ipc as ipcShared } from "@x/shared" import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" 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 +110,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 +127,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 +2639,226 @@ function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) { ) } +// --- Advanced (runtime/cost controls) tab --- + +const MODEL_CALL_LIMIT_MIN = 1 +const MODEL_CALL_LIMIT_MAX = 500 + +function 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 +} + +/** + * Compact segmented − / value / + stepper. The native number-input spinners + * are replaced entirely: typing is free-form digits, stepping clamps to the + * range and commits immediately. An empty value steps from `fallback` (the + * chat field starts from the global limit). + */ +function LimitStepper({ + value, + fallback, + placeholder, + onInput, + onCommit, +}: { + value: string + fallback: number + placeholder?: string + /** Every keystroke (no save). */ + onInput: (next: string) => void + /** A settled change: step click or blur. */ + onCommit: (next: string) => void +}) { + const current = parseLimit(value) + + const step = (delta: number) => { + // From an empty/invalid field, the first step lands on the fallback so + // the override starts where the effective value already is. + const next = current === null + ? Math.min(MODEL_CALL_LIMIT_MAX, Math.max(MODEL_CALL_LIMIT_MIN, fallback)) + : Math.min(MODEL_CALL_LIMIT_MAX, Math.max(MODEL_CALL_LIMIT_MIN, current + delta)) + onCommit(String(next)) + } + + return ( +
+ + onInput(e.target.value.replace(/[^0-9]/g, ""))} + onBlur={() => onCommit(value)} + className={cn( + "h-full border-x border-input bg-transparent text-center text-sm tabular-nums outline-none", + // The 11px placeholder sits on the 14px text baseline, so it reads + // slightly low; nudge it up for optical centering. Only applies + // while the placeholder is visible, so typed text is unaffected. + "placeholder:text-[11px] placeholder:text-muted-foreground/70 placeholder-shown:pb-1", + placeholder ? "w-24" : "w-16", + )} + /> + +
+ ) +} + +function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { + // Inputs are kept as strings so the user can clear a field while typing; + // validation happens on commit (step click or 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)) + // A chat override equal to the global limit is no override — show + // "Same as above" (legacy files; saves already collapse this). + setChatLimit( + settings.chatMaxModelCalls !== undefined && settings.chatMaxModelCalls !== settings.maxModelCalls + ? String(settings.chatMaxModelCalls) + : "" + ) + setLoaded(true) + }) + .catch(() => { + if (!cancelled) toast.error("Failed to load advanced settings") + }) + return () => { cancelled = true } + }, [dialogOpen]) + + // Saves silently on success (a toast per stepper click would be noisy, + // matching the notification toggles); errors still surface. + const saveLimits = useCallback(async (globalStr: string, chatStr: string) => { + const global = parseLimit(globalStr) + 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 (chatStr.trim() !== "") { + const parsed = parseLimit(chatStr) + 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 + } + // An override equal to the global limit is meaningless — persist it as + // "use the global limit" so the field reopens as "Same as above". + if (chat === global) chat = undefined + try { + await window.ipc.invoke("turnLimits:setSettings", { + maxModelCalls: global, + ...(chat === undefined ? {} : { chatMaxModelCalls: chat }), + }) + } catch { + toast.error("Failed to save model-call limits") + } + }, []) + + 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(next) + void saveLimits(next, chatLimit) + }} + /> +
+ +
+
+
Chat model-call limit
+
+ Optional separate limit for interactive chat turns. Leave empty to use the + model-call limit above. +
+
+
+ {chatLimit.trim() !== "" && ( + + )} + { + setChatLimit(next) + // An emptied chat field on blur means "use the global + // limit" — persist the override removal. + void saveLimits(globalLimit, next) + }} + /> +
+
+
+
+ ) +} + // --- Main Settings Dialog --- export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) { @@ -2684,7 +2911,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 +3033,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control {/* Content */} -
+
{activeTab === "account" ? ( ) : activeTab === "connections" ? ( @@ -2845,6 +3072,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control ) : activeTab === "notifications" ? ( + ) : activeTab === "advanced" ? ( + ) : activeTab === "help" ? ( ) : activeTab === "code-mode" ? ( diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts index bb697e60..e12e47c3 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts @@ -6,6 +6,7 @@ import type { ToolPermissionRequestEvent, } from '@x/shared/src/runs.js' import { + MODEL_CALL_LIMIT_ERROR_CODE, deriveTurnStatus, outstandingAsyncTools, outstandingPermissions, @@ -331,10 +332,17 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] { } if (state.terminal?.type === 'turn_failed') { + // Interactive turns normally wrap up gracefully before hitting the + // limit; if a hard limit failure still lands here, explain it and point + // at the setting instead of showing the raw runtime error. + const message = state.terminal.code === MODEL_CALL_LIMIT_ERROR_CODE + ? `This turn hit its model-call limit of ${state.definition.config.maxModelCalls} before it could finish. ` + + 'You can raise the limit in Settings → Advanced.' + : state.terminal.error items.push({ id: `${turnId}:error`, kind: 'error', - message: state.terminal.error, + message, timestamp: ts(), } satisfies ErrorMessage) } diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index bda228ad..4f8d4e36 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, 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 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..83f19a5a --- /dev/null +++ b/apps/x/packages/core/src/config/turn_limits.test.ts @@ -0,0 +1,100 @@ +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 (50) when no file exists", async () => { + const { loadTurnLimitsSettings } = await loadTurnLimits(); + expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); + }); + + it("reads persisted settings", async () => { + await writeSettings(JSON.stringify({ maxModelCalls: 60, chatMaxModelCalls: 10 })); + const { loadTurnLimitsSettings } = await loadTurnLimits(); + expect(await 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(await loadTurnLimitsSettings()).toEqual({ + maxModelCalls: 50, + chatMaxModelCalls: 5, + }); + }); + + it("falls back to defaults on a corrupt file", async () => { + await writeSettings("{not json"); + const { loadTurnLimitsSettings } = await loadTurnLimits(); + 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(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); + }); +}); + +describe("saveTurnLimitsSettings", () => { + it("persists valid settings for the next load", async () => { + const { saveTurnLimitsSettings, loadTurnLimitsSettings } = await loadTurnLimits(); + await saveTurnLimitsSettings({ maxModelCalls: 80, chatMaxModelCalls: 15 }); + expect(await loadTurnLimitsSettings()).toEqual({ + maxModelCalls: 80, + chatMaxModelCalls: 15, + }); + }); + + it("rejects out-of-range values", async () => { + const { saveTurnLimitsSettings } = await loadTurnLimits(); + await expect(saveTurnLimitsSettings({ maxModelCalls: 0 })).rejects.toThrow(); + await expect(saveTurnLimitsSettings({ maxModelCalls: 501 })).rejects.toThrow(); + await expect( + saveTurnLimitsSettings({ maxModelCalls: 20, chatMaxModelCalls: 501 }), + ).rejects.toThrow(); + }); +}); 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..75af9e81 --- /dev/null +++ b/apps/x/packages/core/src/config/turn_limits.ts @@ -0,0 +1,42 @@ +import fs from 'fs/promises'; +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 DEFAULT_MAX_MODEL_CALLS, no chat override) when the file is absent + * or malformed. + */ +export async function loadTurnLimitsSettings(): Promise { + try { + 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) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[TurnLimits] Error loading turn limit settings:', error); + } + return DEFAULT_TURN_LIMITS_SETTINGS; + } +} + +export async function saveTurnLimitsSettings( + settings: TurnLimitsSettings, +): Promise { + const validated = TurnLimitsSettingsSchema.parse(settings); + await fs.mkdir(path.dirname(TURN_LIMITS_CONFIG_PATH), { recursive: true }); + await fs.writeFile( + TURN_LIMITS_CONFIG_PATH, + JSON.stringify(validated, null, 2), + ); +} 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 f6440301..7b61abd4 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: 51 }, + { task: "t", instructions: "x", max_model_calls: 80 }, { 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(50); + }); + + 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..2b7ad77d 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: (await loadTurnLimitsSettings()).maxModelCalls, }; } 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..60afb0e6 100644 --- a/apps/x/packages/core/src/runtime/turns/runtime.test.ts +++ b/apps/x/packages/core/src/runtime/turns/runtime.test.ts @@ -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, @@ -41,6 +41,16 @@ import type { ToolExecutionContext, } from "./tool-registry.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 // --------------------------------------------------------------------------- @@ -3060,3 +3070,45 @@ 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", () => { + 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("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 setting", async () => { + const { runtime, repo } = makeRuntime(); + expect( + await createdMaxModelCalls(runtime, repo, { + humanAvailable: true, + maxModelCalls: 3, + }), + ).toBe(3); + }); +}); diff --git a/apps/x/packages/core/src/runtime/turns/runtime.ts b/apps/x/packages/core/src/runtime/turns/runtime.ts index 271f68ba..e118f2ec 100644 --- a/apps/x/packages/core/src/runtime/turns/runtime.ts +++ b/apps/x/packages/core/src/runtime/turns/runtime.ts @@ -163,7 +163,9 @@ 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 ?? + (await defaultMaxModelCalls()), ...(input.config.reasoningEffort === undefined ? {} : { reasoningEffort: input.config.reasoningEffort }), @@ -1359,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 { + 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 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..347a1a76 --- /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 = 500; + +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;