From eece1af90e1fcfaf9736d14e3fca6e435ad7adf5 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 21 Jul 2026 15:25:21 +0530 Subject: [PATCH 1/9] feat(x): configurable model-call limits by workflow (#768) Replace the hardcoded 20-call turn budget with user settings: a global model-call limit that every turn inherits (headless/knowledge work, scheduled agents, sub-agents) plus an optional chat-only override for interactive turns. Settings live in config/turn_limits.json (1-100, default 20) and are edited from a new Advanced tab in Settings. The limit is resolved at turn creation via an ITurnLimitsResolver injected into TurnRuntime, keyed on humanAvailable; explicit per-call overrides still win, and the resolved value is persisted in turn_created.config.maxModelCalls as before, so historical and in-flight turns are unaffected by settings changes. Sub-agents now default to and are capped by the global limit instead of the constant. --- apps/x/apps/main/src/ipc.ts | 8 ++ .../src/components/settings-dialog.tsx | 136 +++++++++++++++++- .../packages/core/docs/turn-runtime-design.md | 8 +- .../core/src/config/turn_limits.test.ts | 126 ++++++++++++++++ .../x/packages/core/src/config/turn_limits.ts | 54 +++++++ apps/x/packages/core/src/di/container.ts | 3 + .../src/runtime/assembly/spawn-agent.test.ts | 41 +++++- .../core/src/runtime/assembly/spawn-agent.ts | 19 ++- .../core/src/runtime/turns/bridges/index.ts | 1 + .../bridges/real-turn-limits-resolver.ts | 12 ++ .../core/src/runtime/turns/runtime.test.ts | 58 ++++++++ .../core/src/runtime/turns/runtime.ts | 13 +- .../src/runtime/turns/turn-limits-resolver.ts | 8 ++ apps/x/packages/shared/src/index.ts | 1 + apps/x/packages/shared/src/ipc.ts | 12 ++ apps/x/packages/shared/src/turn-limits.ts | 34 +++++ 16 files changed, 518 insertions(+), 16 deletions(-) create mode 100644 apps/x/packages/core/src/config/turn_limits.test.ts create mode 100644 apps/x/packages/core/src/config/turn_limits.ts create mode 100644 apps/x/packages/core/src/runtime/turns/bridges/real-turn-limits-resolver.ts create mode 100644 apps/x/packages/core/src/runtime/turns/turn-limits-resolver.ts create mode 100644 apps/x/packages/shared/src/turn-limits.ts 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; From dd04738015184b65c8c98b5b2786b196df4940f4 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 21 Jul 2026 15:29:11 +0530 Subject: [PATCH 2/9] feat(x): raise model-call limit cap to 500, polish limit stepper UI Validation range is now 1-500. The Advanced tab's native number inputs are replaced with a segmented minus/value/plus stepper (custom buttons, digits-only typing, range clamping, immediate save on step). The chat override gets a smaller placeholder, steps from the effective global limit when empty, and has a clear button to fall back to the global limit. --- .../src/components/settings-dialog.tsx | 152 ++++++++++++++---- .../core/src/config/turn_limits.test.ts | 4 +- apps/x/packages/shared/src/turn-limits.ts | 2 +- 3 files changed, 123 insertions(+), 35 deletions(-) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index ca46c909..3efa566f 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, @@ -2641,11 +2641,86 @@ function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) { // --- Advanced (runtime/cost controls) tab --- const MODEL_CALL_LIMIT_MIN = 1 -const MODEL_CALL_LIMIT_MAX = 100 +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", + "placeholder:text-[11px] placeholder:text-muted-foreground/70", + 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 save (blur). + // validation happens on commit (step click or blur). const [globalLimit, setGlobalLimit] = useState("") const [chatLimit, setChatLimit] = useState("") const [loaded, setLoaded] = useState(false) @@ -2666,21 +2741,17 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { 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) + // 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 (chatLimit.trim() !== "") { - const parsed = parseLimit(chatLimit) + 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 @@ -2692,11 +2763,10 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { 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 ( @@ -2723,14 +2793,14 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { knowledge work, scheduled agents, and sub-agents (it also caps sub-agent budgets).
- setGlobalLimit(e.target.value)} - onBlur={handleSave} - className="w-24 shrink-0" + fallback={20} + onInput={setGlobalLimit} + onCommit={(next) => { + setGlobalLimit(next) + void saveLimits(next, chatLimit) + }} /> @@ -2742,16 +2812,34 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { model-call limit above. - setChatLimit(e.target.value)} - onBlur={handleSave} - placeholder="Same as above" - className="w-32 shrink-0" - /> +
+ {chatLimit.trim() !== "" && ( + + )} + { + setChatLimit(next) + // An emptied chat field on blur means "use the global + // limit" — persist the override removal. + void saveLimits(globalLimit, next) + }} + /> +
diff --git a/apps/x/packages/core/src/config/turn_limits.test.ts b/apps/x/packages/core/src/config/turn_limits.test.ts index b9748615..bb7c3576 100644 --- a/apps/x/packages/core/src/config/turn_limits.test.ts +++ b/apps/x/packages/core/src/config/turn_limits.test.ts @@ -92,9 +92,9 @@ describe("saveTurnLimitsSettings", () => { it("rejects out-of-range values", async () => { const { saveTurnLimitsSettings } = await loadTurnLimits(); expect(() => saveTurnLimitsSettings({ maxModelCalls: 0 })).toThrow(); - expect(() => saveTurnLimitsSettings({ maxModelCalls: 101 })).toThrow(); + expect(() => saveTurnLimitsSettings({ maxModelCalls: 501 })).toThrow(); expect(() => - saveTurnLimitsSettings({ maxModelCalls: 20, chatMaxModelCalls: 500 }), + saveTurnLimitsSettings({ maxModelCalls: 20, chatMaxModelCalls: 501 }), ).toThrow(); }); }); diff --git a/apps/x/packages/shared/src/turn-limits.ts b/apps/x/packages/shared/src/turn-limits.ts index 6d390c1e..347a1a76 100644 --- a/apps/x/packages/shared/src/turn-limits.ts +++ b/apps/x/packages/shared/src/turn-limits.ts @@ -14,7 +14,7 @@ import { DEFAULT_MAX_MODEL_CALLS } from './turns.js'; * resolved limit in turn_created.config.maxModelCalls. */ export const MIN_MODEL_CALL_LIMIT = 1; -export const MAX_MODEL_CALL_LIMIT = 100; +export const MAX_MODEL_CALL_LIMIT = 500; const limit = z .number() From af19cad43b63bfb5885ee7c40697aafb2302a65c Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 21 Jul 2026 15:33:35 +0530 Subject: [PATCH 3/9] fix(x): chat model-call field opens as 'Same as above' unless it differs A chat override equal to the global limit is behaviorally identical to no override, so persist it as absent and collapse it on load. Stepping the empty chat field (which lands on the global value) no longer pins a redundant override that makes the field open with a number forever. --- .../apps/renderer/src/components/settings-dialog.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 3efa566f..eecd140e 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2732,7 +2732,13 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { .then((settings) => { if (cancelled) return setGlobalLimit(String(settings.maxModelCalls)) - setChatLimit(settings.chatMaxModelCalls !== undefined ? String(settings.chatMaxModelCalls) : "") + // 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(() => { @@ -2758,6 +2764,9 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { } 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, From 34501ee2cfc677d759ee10e6a4786972989b7b71 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 21 Jul 2026 15:37:21 +0530 Subject: [PATCH 4/9] fix(x): optically center the 'Same as above' placeholder in the chat stepper --- apps/x/apps/renderer/src/components/settings-dialog.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index eecd140e..914ac61a 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2701,7 +2701,10 @@ function LimitStepper({ onBlur={() => onCommit(value)} className={cn( "h-full border-x border-input bg-transparent text-center text-sm tabular-nums outline-none", - "placeholder:text-[11px] placeholder:text-muted-foreground/70", + // 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-0.5", placeholder ? "w-24" : "w-16", )} /> From 47453fc33369d3d9c5b5262b565b96d83bde8641 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 21 Jul 2026 15:50:04 +0530 Subject: [PATCH 5/9] fix(x): nudge stepper placeholder up a bit more (4px) --- apps/x/apps/renderer/src/components/settings-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 914ac61a..779b5a65 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2704,7 +2704,7 @@ function LimitStepper({ // 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-0.5", + "placeholder:text-[11px] placeholder:text-muted-foreground/70 placeholder-shown:pb-1", placeholder ? "w-24" : "w-16", )} /> From f270f39e35a107ba5ce0fd9bd46c2b16cf024ede Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 21 Jul 2026 15:50:04 +0530 Subject: [PATCH 6/9] feat(x): graceful wrap-up when a chat turn hits its model-call limit Interactive turns no longer die with a raw 'Model call limit of N reached' error. The final budgeted model call is composed as a wrap-up: the durable request records wrapUp: true, and the composer strips tools and appends a notice telling the model to answer with what it has, state what's unfinished, and mention the limit is configurable in Settings -> Advanced. The turn then completes normally with that answer. Headless turns (and a wrap-up call that still emits tool calls) keep the hard model-call-limit failure so automation retains the machine-readable outcome; sub-agents already surface partial results. If a hard limit failure still reaches the chat UI, the error bubble now explains the limit and points at the setting instead of the raw error. --- .../src/lib/session-chat/turn-view.ts | 10 ++- .../packages/core/docs/turn-runtime-design.md | 7 ++ .../runtime/turns/compose-model-request.ts | 12 ++- .../core/src/runtime/turns/runtime.test.ts | 75 +++++++++++++++++++ .../core/src/runtime/turns/runtime.ts | 9 +++ apps/x/packages/shared/src/turns.ts | 18 +++++ 6 files changed, 129 insertions(+), 2 deletions(-) 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..707f3be7 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 stopped after reaching its model-call limit of ${state.definition.config.maxModelCalls}. ` + + 'Work completed so far is saved above. 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 3cda524b..30374455 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -519,6 +519,13 @@ Rules: 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. +- Budget exhaustion (§20): interactive turns (`humanAvailable: true`) spend + their final budgeted call wrapping up — the request records `wrapUp: true`, + and the composer strips tools and appends the shared `wrapUpNotice`, so + the turn completes with a real answer that admits what's unfinished. + Headless turns (and a wrap-up call that still emits tool calls) keep the + hard `turn_failed` with `model-call-limit` — automation needs the + machine-readable outcome. - Persisted values are fully resolved and immutable. The capability is named `humanAvailable`, not `headless`. `headless` describes diff --git a/apps/x/packages/core/src/runtime/turns/compose-model-request.ts b/apps/x/packages/core/src/runtime/turns/compose-model-request.ts index 308b20a2..ed2114a4 100644 --- a/apps/x/packages/core/src/runtime/turns/compose-model-request.ts +++ b/apps/x/packages/core/src/runtime/turns/compose-model-request.ts @@ -7,6 +7,7 @@ import { type TurnState, effectiveTools, requestMessagesFor, + wrapUpNotice, } from "@x/shared/dist/turns.js"; import type { IContextResolver } from "./context-resolver.js"; @@ -46,12 +47,21 @@ export function composeModelRequest( for (let index = 0; index <= modelCallIndex; index++) { structural.push(...requestMessagesFor(state, index)); } + // A wrap-up call (final budgeted call of an interactive turn) sends no + // tools and appends the budget notice, so the model must answer in text. + // Both derive from the durable request flag + config, preserving exact + // wire reproducibility. + if (call.request.wrapUp) { + structural.push(wrapUpNotice(state.definition.config.maxModelCalls)); + } return { systemPrompt: agent.systemPrompt, messages: encode(structural), // The snapshot's base tools plus any durable mid-turn extensions // recorded before this call (tools_extended events). - tools: effectiveTools(state, modelCallIndex, agent.tools), + tools: call.request.wrapUp + ? [] + : effectiveTools(state, modelCallIndex, agent.tools), parameters: call.request.parameters, }; } 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 4e913ed8..17d942bd 100644 --- a/apps/x/packages/core/src/runtime/turns/runtime.test.ts +++ b/apps/x/packages/core/src/runtime/turns/runtime.test.ts @@ -1789,6 +1789,81 @@ describe("model-call limit (26.9)", () => { }); }); +// --------------------------------------------------------------------------- +// Graceful budget wrap-up (issue #768) +// --------------------------------------------------------------------------- + +describe("model-call limit wrap-up", () => { + function persistedRequests(log: TEvent[]) { + return log.filter( + (e): e is Extract => + e.type === "model_call_requested", + ); + } + + it("spends the final budgeted call of a chat turn wrapping up without tools", async () => { + const { runtime, repo, models } = makeRuntime({ + models: [ + respond(completedResp(assistantCalls(toolCallPart("S", "echo")))), + respond(completedResp(assistantText("partial summary"))), + ], + }); + const turnId = await newTurn(runtime, { + config: { humanAvailable: true, maxModelCalls: 2 }, + }); + const { outcome } = await advanceAndSettle(runtime, turnId); + expect(outcome?.status).toBe("completed"); + + // The wrap-up rides the durable request… + const requests = persistedRequests(await persisted(repo, turnId)); + expect(requests[0].request.wrapUp).toBeUndefined(); + expect(requests[1].request.wrapUp).toBe(true); + + // …and the wire request had no tools plus the budget notice. + expect(models.requests[0].tools.length).toBeGreaterThan(0); + expect(models.requests[1].tools).toEqual([]); + const messages = models.requests[1].messages; + const last = messages[messages.length - 1] as { role: string; content: string }; + expect(last.role).toBe("user"); + expect(last.content).toMatch(/model-call limit/); + }); + + it("a 1-call chat budget answers directly as a wrap-up call", async () => { + const { runtime, models } = makeRuntime({ + models: [respond(completedResp(assistantText("hi")))], + }); + const turnId = await newTurn(runtime, { + config: { humanAvailable: true, maxModelCalls: 1 }, + }); + const { outcome } = await advanceAndSettle(runtime, turnId); + expect(outcome?.status).toBe("completed"); + expect(models.requests[0].tools).toEqual([]); + }); + + it("headless turns keep the hard model-call-limit failure with tools intact", async () => { + const { runtime, models } = makeRuntime({ + models: [ + respond(completedResp(assistantCalls(toolCallPart("S", "echo")))), + respond(completedResp(assistantCalls(toolCallPart("S2", "echo")))), + ], + }); + const turnId = await newTurn(runtime, { + config: { + humanAvailable: false, + autoPermission: true, + maxModelCalls: 2, + }, + }); + const { outcome } = await advanceAndSettle(runtime, turnId); + expect(outcome).toMatchObject({ + status: "failed", + code: MODEL_CALL_LIMIT_ERROR_CODE, + }); + // No wrap-up in headless: the final call was composed with tools. + expect(models.requests[1].tools.length).toBeGreaterThan(0); + }); +}); + // --------------------------------------------------------------------------- // Tool progress // --------------------------------------------------------------------------- diff --git a/apps/x/packages/core/src/runtime/turns/runtime.ts b/apps/x/packages/core/src/runtime/turns/runtime.ts index 017efe69..0ee3e539 100644 --- a/apps/x/packages/core/src/runtime/turns/runtime.ts +++ b/apps/x/packages/core/src/runtime/turns/runtime.ts @@ -1190,11 +1190,20 @@ class TurnAdvance { // what it ran with, and the model bridge maps the canonical value to // provider-specific options at invoke time. const reasoningEffort = this.definition.config.reasoningEffort; + // Interactive turns spend their final budgeted call wrapping up: the + // composer strips tools and appends the budget notice, so the user + // gets a real answer instead of a model-call-limit failure. Headless + // turns keep the hard failure — automation needs the machine-readable + // outcome, and sub-agents already surface partial results. + const wrapUp = + this.definition.config.humanAvailable && + index === this.definition.config.maxModelCalls - 1; const request: z.infer = { ...(isRef && index === 0 ? { contextRef: context } : {}), messages: refs, parameters: reasoningEffort === undefined ? {} : { reasoningEffort }, + ...(wrapUp ? { wrapUp: true as const } : {}), }; await this.append({ type: "model_call_requested", diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index 9a6c14cc..1626b367 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -233,8 +233,26 @@ export const ModelRequest = z.object({ contextRef: TurnContextRef.optional(), messages: z.array(ModelRequestMessageRef), parameters: z.record(z.string(), z.json()), + // Final budgeted call of an interactive turn: the composer strips tools + // and appends wrapUpNotice() so the model ends with a real answer + // instead of the turn failing with model-call-limit (issue #768). + wrapUp: z.literal(true).optional(), }); +// The message appended to a wrap-up call's request. A pure function of the +// turn's durable config, so replaying the log reproduces the wire bytes. +export function wrapUpNotice( + maxModelCalls: number, +): z.infer { + return { + role: "user", + content: + `[system notice] You are on the final model call this turn's budget allows (model-call limit: ${maxModelCalls}); tools are no longer available. ` + + "Give your best final answer using only the work above: present what you completed, state clearly what remains unfinished, " + + "and let the user know the turn was cut short by the model-call limit, which they can raise in Settings → Advanced.", + }; +} + export const ModelCallRequested = z.object({ type: z.literal("model_call_requested"), turnId: z.string(), From 180ae10e2b7814c521b544de6492cc662a7ddc6b Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 21 Jul 2026 16:32:18 +0530 Subject: [PATCH 7/9] fix(x): wrap-up call answers substantively, not with a status report The budget notice now tells the model to answer the user's request with the content it already gathered before noting what's unfinished, and the fallback limit-failure bubble drops the 'work is saved' phrasing. --- apps/x/apps/renderer/src/lib/session-chat/turn-view.ts | 4 ++-- apps/x/packages/shared/src/turns.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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 707f3be7..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 @@ -336,8 +336,8 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] { // 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 stopped after reaching its model-call limit of ${state.definition.config.maxModelCalls}. ` + - 'Work completed so far is saved above. You can raise the limit in Settings → Advanced.' + ? `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`, diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index 1626b367..7c9cf914 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -248,8 +248,9 @@ export function wrapUpNotice( role: "user", content: `[system notice] You are on the final model call this turn's budget allows (model-call limit: ${maxModelCalls}); tools are no longer available. ` + - "Give your best final answer using only the work above: present what you completed, state clearly what remains unfinished, " + - "and let the user know the turn was cut short by the model-call limit, which they can raise in Settings → Advanced.", + "Now answer the user's request as fully and substantively as you can from the tool results and information already gathered above — " + + "give them the actual content, not a status report. Then briefly note which parts you didn't get to, " + + "and mention the turn was cut short by the model-call limit, which they can raise in Settings → Advanced.", }; } From 7cb9c478ac6b5e48d99e701768d621d5071756de Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 21 Jul 2026 16:51:36 +0530 Subject: [PATCH 8/9] feat(x): drop limit wrap-up special-casing, raise default limit to 50 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: no special handling in the agent loop when the model-call limit is hit — the turn fails as before. Instead the default limit rises from 20 to 50 (global, and chat inherits it) so the limit rarely triggers in practice; users can tune it in Settings > Advanced. The friendly limit-failure message in the chat error bubble stays, since it points users at the setting. --- .../src/components/settings-dialog.tsx | 5 +- .../packages/core/docs/turn-runtime-design.md | 9 +-- .../core/src/config/turn_limits.test.ts | 16 ++-- .../x/packages/core/src/config/turn_limits.ts | 4 +- .../src/runtime/assembly/spawn-agent.test.ts | 6 +- .../runtime/turns/compose-model-request.ts | 12 +-- .../core/src/runtime/turns/runtime.test.ts | 77 +------------------ .../core/src/runtime/turns/runtime.ts | 9 --- apps/x/packages/shared/src/turns.ts | 21 +---- 9 files changed, 20 insertions(+), 139 deletions(-) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 779b5a65..864b92ef 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -29,6 +29,7 @@ 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" @@ -2807,7 +2808,7 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { { setGlobalLimit(next) @@ -2841,7 +2842,7 @@ function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) { )} { diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index 30374455..7a865499 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -516,16 +516,9 @@ Rules: 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 + the global limit. Without a resolver (tests) it defaults to `50`. Spawned sub-agents default to the global limit, which also caps the budget a parent may grant them. Settings changes affect only newly created turns. -- Budget exhaustion (§20): interactive turns (`humanAvailable: true`) spend - their final budgeted call wrapping up — the request records `wrapUp: true`, - and the composer strips tools and appends the shared `wrapUpNotice`, so - the turn completes with a real answer that admits what's unfinished. - Headless turns (and a wrap-up call that still emits tool calls) keep the - hard `turn_failed` with `model-call-limit` — automation needs the - machine-readable outcome. - 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 index bb7c3576..88afffc4 100644 --- a/apps/x/packages/core/src/config/turn_limits.test.ts +++ b/apps/x/packages/core/src/config/turn_limits.test.ts @@ -43,9 +43,9 @@ async function writeSettings(content: string): Promise { } describe("loadTurnLimitsSettings", () => { - it("defaults to the built-in limit (20) when no file exists", async () => { + it("defaults to the built-in limit (50) when no file exists", async () => { const { loadTurnLimitsSettings } = await loadTurnLimits(); - expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 20 }); + expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); }); it("reads persisted settings", async () => { @@ -61,7 +61,7 @@ describe("loadTurnLimitsSettings", () => { await writeSettings(JSON.stringify({ chatMaxModelCalls: 5 })); const { loadTurnLimitsSettings } = await loadTurnLimits(); expect(loadTurnLimitsSettings()).toEqual({ - maxModelCalls: 20, + maxModelCalls: 50, chatMaxModelCalls: 5, }); }); @@ -69,13 +69,13 @@ describe("loadTurnLimitsSettings", () => { it("falls back to defaults on a corrupt file", async () => { await writeSettings("{not json"); const { loadTurnLimitsSettings } = await loadTurnLimits(); - expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 20 }); + expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); }); it("falls back to defaults on out-of-range values", async () => { await writeSettings(JSON.stringify({ maxModelCalls: 5000 })); const { loadTurnLimitsSettings } = await loadTurnLimits(); - expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 20 }); + expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); }); }); @@ -118,9 +118,9 @@ describe("resolveMaxModelCalls", () => { expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(60); }); - it("resolves 20 everywhere with no settings file", async () => { + it("resolves 50 everywhere with no settings file", async () => { const { resolveMaxModelCalls } = await loadTurnLimits(); - expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(20); - expect(resolveMaxModelCalls({ humanAvailable: false })).toBe(20); + expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(50); + expect(resolveMaxModelCalls({ humanAvailable: false })).toBe(50); }); }); diff --git a/apps/x/packages/core/src/config/turn_limits.ts b/apps/x/packages/core/src/config/turn_limits.ts index 82864075..8f77803e 100644 --- a/apps/x/packages/core/src/config/turn_limits.ts +++ b/apps/x/packages/core/src/config/turn_limits.ts @@ -11,8 +11,8 @@ 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. + * limit DEFAULT_MAX_MODEL_CALLS, no chat override) when the file is absent + * or malformed. */ export function loadTurnLimitsSettings(): TurnLimitsSettings { try { 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 cb0dbdd2..1afb5757 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 @@ -116,7 +116,7 @@ describe("runSpawnedAgent", () => { model: { provider: "parent-p", model: "parent-m" }, }, }); - expect(started[0].maxModelCalls).toBe(20); + expect(started[0].maxModelCalls).toBe(50); expect(started[0].signal).toBe(signal); expect(progress).toEqual([ { childTurnId: "child-1", agentName: "researcher", task: "find things" }, @@ -194,11 +194,11 @@ describe("runSpawnedAgent", () => { 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 }, + { task: "t", instructions: "x", max_model_calls: 80 }, { parentTurnId: "parent-1", signal, services }, ); expect(result.isError).toBe(false); - expect(started[0].maxModelCalls).toBe(20); + expect(started[0].maxModelCalls).toBe(50); }); it("uses the configured global limit as the sub-agent default and cap", async () => { diff --git a/apps/x/packages/core/src/runtime/turns/compose-model-request.ts b/apps/x/packages/core/src/runtime/turns/compose-model-request.ts index ed2114a4..308b20a2 100644 --- a/apps/x/packages/core/src/runtime/turns/compose-model-request.ts +++ b/apps/x/packages/core/src/runtime/turns/compose-model-request.ts @@ -7,7 +7,6 @@ import { type TurnState, effectiveTools, requestMessagesFor, - wrapUpNotice, } from "@x/shared/dist/turns.js"; import type { IContextResolver } from "./context-resolver.js"; @@ -47,21 +46,12 @@ export function composeModelRequest( for (let index = 0; index <= modelCallIndex; index++) { structural.push(...requestMessagesFor(state, index)); } - // A wrap-up call (final budgeted call of an interactive turn) sends no - // tools and appends the budget notice, so the model must answer in text. - // Both derive from the durable request flag + config, preserving exact - // wire reproducibility. - if (call.request.wrapUp) { - structural.push(wrapUpNotice(state.definition.config.maxModelCalls)); - } return { systemPrompt: agent.systemPrompt, messages: encode(structural), // The snapshot's base tools plus any durable mid-turn extensions // recorded before this call (tools_extended events). - tools: call.request.wrapUp - ? [] - : effectiveTools(state, modelCallIndex, agent.tools), + tools: effectiveTools(state, modelCallIndex, agent.tools), parameters: call.request.parameters, }; } 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 17d942bd..5b49e1b0 100644 --- a/apps/x/packages/core/src/runtime/turns/runtime.test.ts +++ b/apps/x/packages/core/src/runtime/turns/runtime.test.ts @@ -1789,81 +1789,6 @@ describe("model-call limit (26.9)", () => { }); }); -// --------------------------------------------------------------------------- -// Graceful budget wrap-up (issue #768) -// --------------------------------------------------------------------------- - -describe("model-call limit wrap-up", () => { - function persistedRequests(log: TEvent[]) { - return log.filter( - (e): e is Extract => - e.type === "model_call_requested", - ); - } - - it("spends the final budgeted call of a chat turn wrapping up without tools", async () => { - const { runtime, repo, models } = makeRuntime({ - models: [ - respond(completedResp(assistantCalls(toolCallPart("S", "echo")))), - respond(completedResp(assistantText("partial summary"))), - ], - }); - const turnId = await newTurn(runtime, { - config: { humanAvailable: true, maxModelCalls: 2 }, - }); - const { outcome } = await advanceAndSettle(runtime, turnId); - expect(outcome?.status).toBe("completed"); - - // The wrap-up rides the durable request… - const requests = persistedRequests(await persisted(repo, turnId)); - expect(requests[0].request.wrapUp).toBeUndefined(); - expect(requests[1].request.wrapUp).toBe(true); - - // …and the wire request had no tools plus the budget notice. - expect(models.requests[0].tools.length).toBeGreaterThan(0); - expect(models.requests[1].tools).toEqual([]); - const messages = models.requests[1].messages; - const last = messages[messages.length - 1] as { role: string; content: string }; - expect(last.role).toBe("user"); - expect(last.content).toMatch(/model-call limit/); - }); - - it("a 1-call chat budget answers directly as a wrap-up call", async () => { - const { runtime, models } = makeRuntime({ - models: [respond(completedResp(assistantText("hi")))], - }); - const turnId = await newTurn(runtime, { - config: { humanAvailable: true, maxModelCalls: 1 }, - }); - const { outcome } = await advanceAndSettle(runtime, turnId); - expect(outcome?.status).toBe("completed"); - expect(models.requests[0].tools).toEqual([]); - }); - - it("headless turns keep the hard model-call-limit failure with tools intact", async () => { - const { runtime, models } = makeRuntime({ - models: [ - respond(completedResp(assistantCalls(toolCallPart("S", "echo")))), - respond(completedResp(assistantCalls(toolCallPart("S2", "echo")))), - ], - }); - const turnId = await newTurn(runtime, { - config: { - humanAvailable: false, - autoPermission: true, - maxModelCalls: 2, - }, - }); - const { outcome } = await advanceAndSettle(runtime, turnId); - expect(outcome).toMatchObject({ - status: "failed", - code: MODEL_CALL_LIMIT_ERROR_CODE, - }); - // No wrap-up in headless: the final call was composed with tools. - expect(models.requests[1].tools.length).toBeGreaterThan(0); - }); -}); - // --------------------------------------------------------------------------- // Tool progress // --------------------------------------------------------------------------- @@ -3190,6 +3115,6 @@ describe("model-call limit resolution", () => { const { runtime, repo } = makeRuntime(); expect( await createdMaxModelCalls(runtime, repo, { humanAvailable: true }), - ).toBe(20); + ).toBe(50); }); }); diff --git a/apps/x/packages/core/src/runtime/turns/runtime.ts b/apps/x/packages/core/src/runtime/turns/runtime.ts index 0ee3e539..017efe69 100644 --- a/apps/x/packages/core/src/runtime/turns/runtime.ts +++ b/apps/x/packages/core/src/runtime/turns/runtime.ts @@ -1190,20 +1190,11 @@ class TurnAdvance { // what it ran with, and the model bridge maps the canonical value to // provider-specific options at invoke time. const reasoningEffort = this.definition.config.reasoningEffort; - // Interactive turns spend their final budgeted call wrapping up: the - // composer strips tools and appends the budget notice, so the user - // gets a real answer instead of a model-call-limit failure. Headless - // turns keep the hard failure — automation needs the machine-readable - // outcome, and sub-agents already surface partial results. - const wrapUp = - this.definition.config.humanAvailable && - index === this.definition.config.maxModelCalls - 1; const request: z.infer = { ...(isRef && index === 0 ? { contextRef: context } : {}), messages: refs, parameters: reasoningEffort === undefined ? {} : { reasoningEffort }, - ...(wrapUp ? { wrapUp: true as const } : {}), }; await this.append({ type: "model_call_requested", diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index 7c9cf914..424ff38b 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -16,7 +16,7 @@ import { ReasoningEffort } from "./models.js"; export type JsonValue = z.infer>; -export const DEFAULT_MAX_MODEL_CALLS = 20; +export const DEFAULT_MAX_MODEL_CALLS = 50; export const MODEL_CALL_LIMIT_ERROR_CODE = "model-call-limit"; // --------------------------------------------------------------------------- @@ -233,27 +233,8 @@ export const ModelRequest = z.object({ contextRef: TurnContextRef.optional(), messages: z.array(ModelRequestMessageRef), parameters: z.record(z.string(), z.json()), - // Final budgeted call of an interactive turn: the composer strips tools - // and appends wrapUpNotice() so the model ends with a real answer - // instead of the turn failing with model-call-limit (issue #768). - wrapUp: z.literal(true).optional(), }); -// The message appended to a wrap-up call's request. A pure function of the -// turn's durable config, so replaying the log reproduces the wire bytes. -export function wrapUpNotice( - maxModelCalls: number, -): z.infer { - return { - role: "user", - content: - `[system notice] You are on the final model call this turn's budget allows (model-call limit: ${maxModelCalls}); tools are no longer available. ` + - "Now answer the user's request as fully and substantively as you can from the tool results and information already gathered above — " + - "give them the actual content, not a status report. Then briefly note which parts you didn't get to, " + - "and mention the turn was cut short by the model-call limit, which they can raise in Settings → Advanced.", - }; -} - export const ModelCallRequested = z.object({ type: z.literal("model_call_requested"), turnId: z.string(), From 7b05bf80cb2f35142ab5bd1d2e4a19fe795a9a4d Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 21 Jul 2026 20:58:53 +0530 Subject: [PATCH 9/9] refactor(x): drop limits DI, read setting at default site; async fs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: remove ITurnLimitsResolver, its bridge and container registration. createTurn now defaults an omitted maxModelCalls straight from the turn-limits config (lazy import, built-in fallback), with no chat/headless distinction in the runtime — the chat UI passes its optional chat override explicitly via the existing sessions:sendMessage maxModelCalls field. turn_limits.ts now uses fs/promises throughout. --- apps/x/apps/main/src/ipc.ts | 4 +- apps/x/apps/renderer/src/App.tsx | 8 +++ .../packages/core/docs/turn-runtime-design.md | 14 ++--- .../core/src/config/turn_limits.test.ts | 48 ++++----------- .../x/packages/core/src/config/turn_limits.ts | 52 +++++++---------- apps/x/packages/core/src/di/container.ts | 3 - .../core/src/runtime/assembly/spawn-agent.ts | 2 +- .../core/src/runtime/turns/bridges/index.ts | 1 - .../bridges/real-turn-limits-resolver.ts | 12 ---- .../core/src/runtime/turns/runtime.test.ts | 58 +++++++++---------- .../core/src/runtime/turns/runtime.ts | 26 +++++---- .../src/runtime/turns/turn-limits-resolver.ts | 8 --- 12 files changed, 91 insertions(+), 145 deletions(-) delete mode 100644 apps/x/packages/core/src/runtime/turns/bridges/real-turn-limits-resolver.ts delete mode 100644 apps/x/packages/core/src/runtime/turns/turn-limits-resolver.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a2c6b6b2..cd8c3551 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -2561,10 +2561,10 @@ export function setupIpcHandlers() { return { success: true }; }, 'turnLimits:getSettings': async () => { - return loadTurnLimitsSettings(); + return await loadTurnLimitsSettings(); }, 'turnLimits:setSettings': async (_event, args) => { - saveTurnLimitsSettings(args); + await saveTurnLimitsSettings(args); return { success: true }; }, // Embedded browser handlers (WebContentsView + navigation) 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/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index 7a865499..4f8d4e36 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -512,13 +512,13 @@ Rules: - `input` is the user message that defines this turn boundary. - `autoPermission` defaults to `false` before persistence. - `humanAvailable` is required explicitly. -- `maxModelCalls`, when omitted, is resolved at creation from the user's - turn-limit settings (`config/turn_limits.json`) via the injected - `ITurnLimitsResolver`: turns with `humanAvailable: true` use the chat - override when set, otherwise the global limit; headless turns always use - the global limit. Without a resolver (tests) it defaults to `50`. Spawned - sub-agents default to the global limit, which also caps the budget a - parent may grant them. Settings changes affect only newly created turns. +- `maxModelCalls`, when omitted, defaults at creation to the user's global + model-call limit (`config/turn_limits.json`, built-in default `50`). The + runtime makes no chat/headless distinction: the optional chat override is + the chat UI's job — its send path passes an explicit `maxModelCalls`. + Spawned sub-agents default to the global limit, which also caps the + budget a parent may grant them. Settings changes affect only newly + created turns. - Persisted values are fully resolved and immutable. The capability is named `humanAvailable`, not `headless`. `headless` describes diff --git a/apps/x/packages/core/src/config/turn_limits.test.ts b/apps/x/packages/core/src/config/turn_limits.test.ts index 88afffc4..83f19a5a 100644 --- a/apps/x/packages/core/src/config/turn_limits.test.ts +++ b/apps/x/packages/core/src/config/turn_limits.test.ts @@ -45,13 +45,13 @@ async function writeSettings(content: string): Promise { describe("loadTurnLimitsSettings", () => { it("defaults to the built-in limit (50) when no file exists", async () => { const { loadTurnLimitsSettings } = await loadTurnLimits(); - expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); + expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); }); it("reads persisted settings", async () => { await writeSettings(JSON.stringify({ maxModelCalls: 60, chatMaxModelCalls: 10 })); const { loadTurnLimitsSettings } = await loadTurnLimits(); - expect(loadTurnLimitsSettings()).toEqual({ + expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 60, chatMaxModelCalls: 10, }); @@ -60,7 +60,7 @@ describe("loadTurnLimitsSettings", () => { it("fills a missing global limit from the default", async () => { await writeSettings(JSON.stringify({ chatMaxModelCalls: 5 })); const { loadTurnLimitsSettings } = await loadTurnLimits(); - expect(loadTurnLimitsSettings()).toEqual({ + expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50, chatMaxModelCalls: 5, }); @@ -69,21 +69,21 @@ describe("loadTurnLimitsSettings", () => { it("falls back to defaults on a corrupt file", async () => { await writeSettings("{not json"); const { loadTurnLimitsSettings } = await loadTurnLimits(); - expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); + expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); }); it("falls back to defaults on out-of-range values", async () => { await writeSettings(JSON.stringify({ maxModelCalls: 5000 })); const { loadTurnLimitsSettings } = await loadTurnLimits(); - expect(loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); + expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 50 }); }); }); describe("saveTurnLimitsSettings", () => { it("persists valid settings for the next load", async () => { const { saveTurnLimitsSettings, loadTurnLimitsSettings } = await loadTurnLimits(); - saveTurnLimitsSettings({ maxModelCalls: 80, chatMaxModelCalls: 15 }); - expect(loadTurnLimitsSettings()).toEqual({ + await saveTurnLimitsSettings({ maxModelCalls: 80, chatMaxModelCalls: 15 }); + expect(await loadTurnLimitsSettings()).toEqual({ maxModelCalls: 80, chatMaxModelCalls: 15, }); @@ -91,36 +91,10 @@ describe("saveTurnLimitsSettings", () => { it("rejects out-of-range values", async () => { const { saveTurnLimitsSettings } = await loadTurnLimits(); - expect(() => saveTurnLimitsSettings({ maxModelCalls: 0 })).toThrow(); - expect(() => saveTurnLimitsSettings({ maxModelCalls: 501 })).toThrow(); - expect(() => + await expect(saveTurnLimitsSettings({ maxModelCalls: 0 })).rejects.toThrow(); + await expect(saveTurnLimitsSettings({ maxModelCalls: 501 })).rejects.toThrow(); + await expect( saveTurnLimitsSettings({ maxModelCalls: 20, chatMaxModelCalls: 501 }), - ).toThrow(); - }); -}); - -describe("resolveMaxModelCalls", () => { - it("uses the global limit for headless work", async () => { - await writeSettings(JSON.stringify({ maxModelCalls: 60, chatMaxModelCalls: 10 })); - const { resolveMaxModelCalls } = await loadTurnLimits(); - expect(resolveMaxModelCalls({ humanAvailable: false })).toBe(60); - }); - - it("uses the chat override for interactive turns when set", async () => { - await writeSettings(JSON.stringify({ maxModelCalls: 60, chatMaxModelCalls: 10 })); - const { resolveMaxModelCalls } = await loadTurnLimits(); - expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(10); - }); - - it("falls back to the global limit for chat when no override is set", async () => { - await writeSettings(JSON.stringify({ maxModelCalls: 60 })); - const { resolveMaxModelCalls } = await loadTurnLimits(); - expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(60); - }); - - it("resolves 50 everywhere with no settings file", async () => { - const { resolveMaxModelCalls } = await loadTurnLimits(); - expect(resolveMaxModelCalls({ humanAvailable: true })).toBe(50); - expect(resolveMaxModelCalls({ humanAvailable: false })).toBe(50); + ).rejects.toThrow(); }); }); diff --git a/apps/x/packages/core/src/config/turn_limits.ts b/apps/x/packages/core/src/config/turn_limits.ts index 8f77803e..75af9e81 100644 --- a/apps/x/packages/core/src/config/turn_limits.ts +++ b/apps/x/packages/core/src/config/turn_limits.ts @@ -1,4 +1,4 @@ -import fs from 'fs'; +import fs from 'fs/promises'; import path from 'path'; import { TurnLimitsSettingsSchema, @@ -14,41 +14,29 @@ const TURN_LIMITS_CONFIG_PATH = path.join(WorkDir, 'config', 'turn_limits.json') * limit DEFAULT_MAX_MODEL_CALLS, no chat override) when the file is absent * or malformed. */ -export function loadTurnLimitsSettings(): TurnLimitsSettings { +export async function loadTurnLimitsSettings(): Promise { try { - if (fs.existsSync(TURN_LIMITS_CONFIG_PATH)) { - const content = fs.readFileSync(TURN_LIMITS_CONFIG_PATH, 'utf-8'); - const parsed = JSON.parse(content); - return TurnLimitsSettingsSchema.parse({ - ...DEFAULT_TURN_LIMITS_SETTINGS, - ...(parsed && typeof parsed === 'object' ? parsed : {}), - }); - } + const content = await fs.readFile(TURN_LIMITS_CONFIG_PATH, 'utf-8'); + const parsed = JSON.parse(content); + return TurnLimitsSettingsSchema.parse({ + ...DEFAULT_TURN_LIMITS_SETTINGS, + ...(parsed && typeof parsed === 'object' ? parsed : {}), + }); } catch (error) { - console.error('[TurnLimits] Error loading turn limit settings:', error); + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[TurnLimits] Error loading turn limit settings:', error); + } + return DEFAULT_TURN_LIMITS_SETTINGS; } - return DEFAULT_TURN_LIMITS_SETTINGS; } -export function saveTurnLimitsSettings(settings: TurnLimitsSettings): void { +export async function saveTurnLimitsSettings( + settings: TurnLimitsSettings, +): Promise { const validated = TurnLimitsSettingsSchema.parse(settings); - const dir = path.dirname(TURN_LIMITS_CONFIG_PATH); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(TURN_LIMITS_CONFIG_PATH, JSON.stringify(validated, null, 2)); -} - -/** - * The effective model-call limit for a new turn, by execution context. - * Interactive chat turns (a human is present) use the chat override when - * set; everything else — headless/knowledge work, sub-agents — uses the - * global limit. - */ -export function resolveMaxModelCalls(context: { humanAvailable: boolean }): number { - const settings = loadTurnLimitsSettings(); - if (context.humanAvailable && settings.chatMaxModelCalls !== undefined) { - return settings.chatMaxModelCalls; - } - return settings.maxModelCalls; + await fs.mkdir(path.dirname(TURN_LIMITS_CONFIG_PATH), { recursive: true }); + await fs.writeFile( + TURN_LIMITS_CONFIG_PATH, + JSON.stringify(validated, null, 2), + ); } diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index c5a9e469..90501f67 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -50,8 +50,6 @@ import { RealModelRegistry } from "../runtime/turns/bridges/real-model-registry. import { RealToolRegistry } from "../runtime/turns/bridges/real-tool-registry.js"; import { RealPermissionChecker } from "../runtime/turns/bridges/real-permission-checker.js"; import { RealPermissionClassifier } from "../runtime/turns/bridges/real-permission-classifier.js"; -import { RealTurnLimitsResolver } from "../runtime/turns/bridges/real-turn-limits-resolver.js"; -import type { ITurnLimitsResolver } from "../runtime/turns/turn-limits-resolver.js"; import { FSSessionRepo } from "../runtime/sessions/fs-repo.js"; import type { ISessionRepo } from "../runtime/sessions/repo.js"; import { EmitterSessionBus, type ISessionBus } from "../runtime/sessions/bus.js"; @@ -142,7 +140,6 @@ container.register({ toolRegistry: asFunction(() => 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.ts b/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts index 98e31c1c..2b7ad77d 100644 --- a/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts +++ b/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts @@ -247,7 +247,7 @@ async function resolveServices(): Promise< headlessRunner: await lazyResolve< import("./headless.js").IHeadlessAgentRunner >("headlessAgentRunner"), - globalMaxModelCalls: loadTurnLimitsSettings().maxModelCalls, + globalMaxModelCalls: (await 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 e93f132e..5927554c 100644 --- a/apps/x/packages/core/src/runtime/turns/bridges/index.ts +++ b/apps/x/packages/core/src/runtime/turns/bridges/index.ts @@ -3,4 +3,3 @@ export * from "./real-model-registry.js"; export * from "./real-permission-checker.js"; export * from "./real-permission-classifier.js"; export * from "./real-tool-registry.js"; -export * from "./real-turn-limits-resolver.js"; 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 deleted file mode 100644 index 456e6029..00000000 --- a/apps/x/packages/core/src/runtime/turns/bridges/real-turn-limits-resolver.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { resolveMaxModelCalls } from "../../../config/turn_limits.js"; -import type { ITurnLimitsResolver } from "../turn-limits-resolver.js"; - -// Settings-backed limits resolver: reads config/turn_limits.json on every -// resolve so a settings change applies to the next created turn without a -// restart. Turns that already exist keep the limit persisted in their -// turn_created event. -export class RealTurnLimitsResolver implements ITurnLimitsResolver { - resolve(context: { humanAvailable: boolean }): number { - return resolveMaxModelCalls(context); - } -} 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 5b49e1b0..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, @@ -40,7 +40,16 @@ import type { SyncRuntimeTool, ToolExecutionContext, } from "./tool-registry.js"; -import type { ITurnLimitsResolver } from "./turn-limits-resolver.js"; + +// createTurn defaults an omitted maxModelCalls from the fs-backed settings +// module; mock it so tests stay hermetic (no real WorkDir reads) and can +// steer the configured global limit. +const turnLimitsMock = vi.hoisted(() => ({ maxModelCalls: 50 })); +vi.mock("../../config/turn_limits.js", () => ({ + loadTurnLimitsSettings: async () => ({ + maxModelCalls: turnLimitsMock.maxModelCalls, + }), +})); // --------------------------------------------------------------------------- // Fixtures @@ -323,7 +332,6 @@ function makeRuntime(opts: { idStart?: number; modelRegistry?: IModelRegistry; toolRegistry?: IToolRegistry; - turnLimitsResolver?: ITurnLimitsResolver; } = {}) { const repo = opts.repo ?? new InMemoryTurnRepo(); const models = new FakeModelRegistry(opts.models ?? []); @@ -345,7 +353,6 @@ function makeRuntime(opts: { lifecycleBus: bus, turnEventBus, usageReporter: usage, - turnLimitsResolver: opts.turnLimitsResolver, }); return { runtime, repo, models, checker, classifier, bus, turnEventBus, usage }; } @@ -3069,13 +3076,6 @@ describe("turn event bus", () => { // --------------------------------------------------------------------------- describe("model-call limit resolution", () => { - // Chat turns (humanAvailable) and headless turns resolve through the - // injected settings-backed resolver; an explicit maxModelCalls always - // wins; without a resolver the built-in default (20) applies. - const contextualResolver: ITurnLimitsResolver = { - resolve: ({ humanAvailable }) => (humanAvailable ? 7 : 42), - }; - async function createdMaxModelCalls( runtime: TurnRuntime, repo: InMemoryTurnRepo, @@ -3087,22 +3087,23 @@ describe("model-call limit resolution", () => { return created.config.maxModelCalls; } - it("resolves an omitted limit by execution context", async () => { - const { runtime, repo } = makeRuntime({ - turnLimitsResolver: contextualResolver, - }); - expect( - await createdMaxModelCalls(runtime, repo, { humanAvailable: true }), - ).toBe(7); - expect( - await createdMaxModelCalls(runtime, repo, { humanAvailable: false }), - ).toBe(42); + it("an omitted limit defaults to the configured global limit", async () => { + turnLimitsMock.maxModelCalls = 42; + try { + const { runtime, repo } = makeRuntime(); + expect( + await createdMaxModelCalls(runtime, repo, { humanAvailable: true }), + ).toBe(42); + expect( + await createdMaxModelCalls(runtime, repo, { humanAvailable: false }), + ).toBe(42); + } finally { + turnLimitsMock.maxModelCalls = 50; + } }); - it("an explicit per-call limit wins over the resolver", async () => { - const { runtime, repo } = makeRuntime({ - turnLimitsResolver: contextualResolver, - }); + it("an explicit per-call limit wins over the setting", async () => { + const { runtime, repo } = makeRuntime(); expect( await createdMaxModelCalls(runtime, repo, { humanAvailable: true, @@ -3110,11 +3111,4 @@ describe("model-call limit resolution", () => { }), ).toBe(3); }); - - it("falls back to the built-in default without a resolver", async () => { - const { runtime, repo } = makeRuntime(); - expect( - await createdMaxModelCalls(runtime, repo, { humanAvailable: true }), - ).toBe(50); - }); }); diff --git a/apps/x/packages/core/src/runtime/turns/runtime.ts b/apps/x/packages/core/src/runtime/turns/runtime.ts index 017efe69..e118f2ec 100644 --- a/apps/x/packages/core/src/runtime/turns/runtime.ts +++ b/apps/x/packages/core/src/runtime/turns/runtime.ts @@ -54,7 +54,6 @@ import type { IPermissionChecker, IPermissionClassifier } from "./permission.js" import type { ITurnRepo } from "./repo.js"; import { HotStream } from "./stream.js"; import type { IToolRegistry, RuntimeTool, SyncRuntimeTool } from "./tool-registry.js"; -import type { ITurnLimitsResolver } from "./turn-limits-resolver.js"; type TEvent = z.infer; @@ -74,8 +73,6 @@ export interface TurnRuntimeDependencies { lifecycleBus: ITurnLifecycleBus; turnEventBus: ITurnEventBus; usageReporter: IUsageReporter; - // Optional: absent (tests) falls back to DEFAULT_MAX_MODEL_CALLS. - turnLimitsResolver?: ITurnLimitsResolver; } // Immutable dependency container: holds no mutable per-turn state. All active @@ -93,7 +90,6 @@ export class TurnRuntime implements ITurnRuntime { private readonly lifecycleBus: ITurnLifecycleBus; private readonly turnEventBus: ITurnEventBus; private readonly usageReporter: IUsageReporter; - private readonly turnLimitsResolver?: ITurnLimitsResolver; constructor({ turnRepo, @@ -108,7 +104,6 @@ export class TurnRuntime implements ITurnRuntime { lifecycleBus, turnEventBus, usageReporter, - turnLimitsResolver, }: TurnRuntimeDependencies) { this.turnRepo = turnRepo; this.idGenerator = idGenerator; @@ -122,7 +117,6 @@ export class TurnRuntime implements ITurnRuntime { this.lifecycleBus = lifecycleBus; this.turnEventBus = turnEventBus; this.usageReporter = usageReporter; - this.turnLimitsResolver = turnLimitsResolver; } async createTurn(input: CreateTurnInput): Promise { @@ -171,10 +165,7 @@ export class TurnRuntime implements ITurnRuntime { humanAvailable: input.config.humanAvailable, maxModelCalls: input.config.maxModelCalls ?? - this.turnLimitsResolver?.resolve({ - humanAvailable: input.config.humanAvailable, - }) ?? - DEFAULT_MAX_MODEL_CALLS, + (await defaultMaxModelCalls()), ...(input.config.reasoningEffort === undefined ? {} : { reasoningEffort: input.config.reasoningEffort }), @@ -1370,6 +1361,21 @@ function parseToolAdditions( return parsed.success ? parsed.data.toolAdditions : undefined; } +// The default budget for turns created without an explicit maxModelCalls: +// the user's configured global limit (config/turn_limits.json). Loaded +// lazily so this module doesn't statically pull in the fs-backed config; +// any load problem falls back to the built-in default. +async function defaultMaxModelCalls(): Promise { + 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/core/src/runtime/turns/turn-limits-resolver.ts b/apps/x/packages/core/src/runtime/turns/turn-limits-resolver.ts deleted file mode 100644 index 37bbac73..00000000 --- a/apps/x/packages/core/src/runtime/turns/turn-limits-resolver.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Resolves the effective model-call limit for a new turn when the caller -// didn't pass an explicit maxModelCalls. The execution context is the -// humanAvailable flag: true for interactive chat turns, false for -// headless/autonomous work. The real bridge reads the user's settings; -// tests construct TurnRuntime without one and get the built-in default. -export interface ITurnLimitsResolver { - resolve(context: { humanAvailable: boolean }): number; -}