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;