Merge pull request #769 from rowboatlabs/feat/configurable-model-call-limit

feat(x): configurable model-call limits, default raised to 50
This commit is contained in:
Ramnique Singh 2026-07-22 10:57:14 +05:30 committed by GitHub
commit 711aff58f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 577 additions and 20 deletions

View file

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

View file

@ -3021,6 +3021,13 @@ function App() {
// via the agent resolver; keep them session-sticky where possible so the // via the agent resolver; keep them session-sticky where possible so the
// provider prefix cache survives across turns. // provider prefix cache survives across turns.
const reasoningEffort = reasoningEffortByTabRef.current.get(submitTabId) 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 = { const sendConfig = {
agent: { agent: {
agentId, agentId,
@ -3039,6 +3046,7 @@ function App() {
}, },
autoPermission: (permissionMode ?? 'manual') === 'auto', autoPermission: (permissionMode ?? 'manual') === 'auto',
...(reasoningEffort ? { reasoningEffort } : {}), ...(reasoningEffort ? { reasoningEffort } : {}),
...(chatMaxModelCalls !== undefined ? { maxModelCalls: chatMaxModelCalls } : {}),
} }
const userMessageContextFor = (middlePane: Awaited<ReturnType<typeof buildMiddlePaneContext>>) => ({ const userMessageContextFor = (middlePane: Awaited<ReturnType<typeof buildMiddlePaneContext>>) => ({
currentDateTime: new Date().toISOString(), currentDateTime: new Date().toISOString(),

View file

@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { useState, useEffect, useCallback, useMemo } 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 { import {
Dialog, Dialog,
@ -29,12 +29,13 @@ import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings" import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" 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 type { ipc as ipcShared } from "@x/shared"
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
import { useProviderModels } from "@/hooks/use-provider-models" import { useProviderModels } from "@/hooks/use-provider-models"
import { useChatGPT } from "@/hooks/useChatGPT" 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 { interface TabConfig {
id: ConfigTab id: ConfigTab
@ -109,6 +110,12 @@ const tabs: TabConfig[] = [
path: "config/tags.json", path: "config/tags.json",
description: "Configure tags for notes and emails", description: "Configure tags for notes and emails",
}, },
{
id: "advanced",
label: "Advanced",
icon: Wrench,
description: "Advanced runtime and cost controls",
},
{ {
id: "help", id: "help",
label: "Help", label: "Help",
@ -120,7 +127,7 @@ const tabs: TabConfig[] = [
/** Sidebar nav grouping: identity first, capabilities, then app-level. */ /** Sidebar nav grouping: identity first, capabilities, then app-level. */
const NAV_SECTIONS: { label: string | null; ids: ConfigTab[] }[] = [ const NAV_SECTIONS: { label: string | null; ids: ConfigTab[] }[] = [
{ label: null, ids: ["account", "connections", "mobile"] }, { 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"] }, { label: "App", ids: ["appearance", "notifications", "help"] },
] ]
@ -2632,6 +2639,226 @@ function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
) )
} }
// --- Advanced (runtime/cost controls) tab ---
const MODEL_CALL_LIMIT_MIN = 1
const MODEL_CALL_LIMIT_MAX = 500
function parseLimit(value: string): number | null {
const n = Number(value.trim())
if (!Number.isInteger(n) || n < MODEL_CALL_LIMIT_MIN || n > MODEL_CALL_LIMIT_MAX) return null
return n
}
/**
* Compact segmented / value / + stepper. The native number-input spinners
* are replaced entirely: typing is free-form digits, stepping clamps to the
* range and commits immediately. An empty value steps from `fallback` (the
* chat field starts from the global limit).
*/
function LimitStepper({
value,
fallback,
placeholder,
onInput,
onCommit,
}: {
value: string
fallback: number
placeholder?: string
/** Every keystroke (no save). */
onInput: (next: string) => void
/** A settled change: step click or blur. */
onCommit: (next: string) => void
}) {
const current = parseLimit(value)
const step = (delta: number) => {
// From an empty/invalid field, the first step lands on the fallback so
// the override starts where the effective value already is.
const next = current === null
? Math.min(MODEL_CALL_LIMIT_MAX, Math.max(MODEL_CALL_LIMIT_MIN, fallback))
: Math.min(MODEL_CALL_LIMIT_MAX, Math.max(MODEL_CALL_LIMIT_MIN, current + delta))
onCommit(String(next))
}
return (
<div className="flex h-8 items-center overflow-hidden rounded-md border border-input bg-background shadow-xs shrink-0">
<button
type="button"
aria-label="Decrease limit"
onClick={() => step(-1)}
disabled={current !== null && current <= MODEL_CALL_LIMIT_MIN}
className="flex h-full w-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
>
<Minus className="size-3" />
</button>
<input
type="text"
inputMode="numeric"
value={value}
placeholder={placeholder}
onChange={(e) => onInput(e.target.value.replace(/[^0-9]/g, ""))}
onBlur={() => onCommit(value)}
className={cn(
"h-full border-x border-input bg-transparent text-center text-sm tabular-nums outline-none",
// The 11px placeholder sits on the 14px text baseline, so it reads
// slightly low; nudge it up for optical centering. Only applies
// while the placeholder is visible, so typed text is unaffected.
"placeholder:text-[11px] placeholder:text-muted-foreground/70 placeholder-shown:pb-1",
placeholder ? "w-24" : "w-16",
)}
/>
<button
type="button"
aria-label="Increase limit"
onClick={() => step(1)}
disabled={current !== null && current >= MODEL_CALL_LIMIT_MAX}
className="flex h-full w-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
>
<Plus className="size-3" />
</button>
</div>
)
}
function AdvancedSettings({ dialogOpen }: { dialogOpen: boolean }) {
// Inputs are kept as strings so the user can clear a field while typing;
// validation happens on commit (step click or blur).
const [globalLimit, setGlobalLimit] = useState("")
const [chatLimit, setChatLimit] = useState("")
const [loaded, setLoaded] = useState(false)
useEffect(() => {
if (!dialogOpen) return
let cancelled = false
window.ipc.invoke("turnLimits:getSettings", null)
.then((settings) => {
if (cancelled) return
setGlobalLimit(String(settings.maxModelCalls))
// A chat override equal to the global limit is no override — show
// "Same as above" (legacy files; saves already collapse this).
setChatLimit(
settings.chatMaxModelCalls !== undefined && settings.chatMaxModelCalls !== settings.maxModelCalls
? String(settings.chatMaxModelCalls)
: ""
)
setLoaded(true)
})
.catch(() => {
if (!cancelled) toast.error("Failed to load advanced settings")
})
return () => { cancelled = true }
}, [dialogOpen])
// Saves silently on success (a toast per stepper click would be noisy,
// matching the notification toggles); errors still surface.
const saveLimits = useCallback(async (globalStr: string, chatStr: string) => {
const global = parseLimit(globalStr)
if (global === null) {
toast.error(`Model-call limit must be a whole number between ${MODEL_CALL_LIMIT_MIN} and ${MODEL_CALL_LIMIT_MAX}`)
return
}
let chat: number | undefined
if (chatStr.trim() !== "") {
const parsed = parseLimit(chatStr)
if (parsed === null) {
toast.error(`Chat limit must be empty or a whole number between ${MODEL_CALL_LIMIT_MIN} and ${MODEL_CALL_LIMIT_MAX}`)
return
}
chat = parsed
}
// An override equal to the global limit is meaningless — persist it as
// "use the global limit" so the field reopens as "Same as above".
if (chat === global) chat = undefined
try {
await window.ipc.invoke("turnLimits:setSettings", {
maxModelCalls: global,
...(chat === undefined ? {} : { chatMaxModelCalls: chat }),
})
} catch {
toast.error("Failed to save model-call limits")
}
}, [])
if (!loaded) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-5">
<div className="text-sm text-muted-foreground leading-relaxed">
Runtime cost and safety controls. A turn is stopped once it reaches its model-call limit;
changes apply to newly started turns only.
</div>
<div className="space-y-2">
<div className="rounded-md border px-3 py-3 flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">Model-call limit</div>
<div className="text-xs text-muted-foreground mt-0.5">
Maximum model calls per turn. Applies to everything by default background and
knowledge work, scheduled agents, and sub-agents (it also caps sub-agent budgets).
</div>
</div>
<LimitStepper
value={globalLimit}
fallback={DEFAULT_TURN_LIMITS_SETTINGS.maxModelCalls}
onInput={setGlobalLimit}
onCommit={(next) => {
setGlobalLimit(next)
void saveLimits(next, chatLimit)
}}
/>
</div>
<div className="rounded-md border px-3 py-3 flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">Chat model-call limit</div>
<div className="text-xs text-muted-foreground mt-0.5">
Optional separate limit for interactive chat turns. Leave empty to use the
model-call limit above.
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{chatLimit.trim() !== "" && (
<button
type="button"
aria-label="Use the global limit"
title="Use the global limit"
onClick={() => {
setChatLimit("")
void saveLimits(globalLimit, "")
}}
className="flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
<LimitStepper
value={chatLimit}
fallback={parseLimit(globalLimit) ?? DEFAULT_TURN_LIMITS_SETTINGS.maxModelCalls}
placeholder="Same as above"
onInput={setChatLimit}
onCommit={(next) => {
setChatLimit(next)
// An emptied chat field on blur means "use the global
// limit" — persist the override removal.
void saveLimits(globalLimit, next)
}}
/>
</div>
</div>
</div>
</div>
)
}
// --- Main Settings Dialog --- // --- Main Settings Dialog ---
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) { export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
@ -2684,7 +2911,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
} }
const loadConfig = useCallback(async (tab: ConfigTab) => { 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)! const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return if (!tabConfig.path) return
setLoading(true) setLoading(true)
@ -2806,7 +3033,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
</div> </div>
{/* Content */} {/* Content */}
<div className={cn("flex-1 px-6 pb-5 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}> <div className={cn("flex-1 px-6 pb-5 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications" || activeTab === "advanced") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? ( {activeTab === "account" ? (
<AccountSettings dialogOpen={open} /> <AccountSettings dialogOpen={open} />
) : activeTab === "connections" ? ( ) : activeTab === "connections" ? (
@ -2845,6 +3072,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<AppearanceSettings /> <AppearanceSettings />
) : activeTab === "notifications" ? ( ) : activeTab === "notifications" ? (
<NotificationSettings dialogOpen={open} /> <NotificationSettings dialogOpen={open} />
) : activeTab === "advanced" ? (
<AdvancedSettings dialogOpen={open} />
) : activeTab === "help" ? ( ) : activeTab === "help" ? (
<HelpSettings /> <HelpSettings />
) : activeTab === "code-mode" ? ( ) : activeTab === "code-mode" ? (

View file

@ -6,6 +6,7 @@ import type {
ToolPermissionRequestEvent, ToolPermissionRequestEvent,
} from '@x/shared/src/runs.js' } from '@x/shared/src/runs.js'
import { import {
MODEL_CALL_LIMIT_ERROR_CODE,
deriveTurnStatus, deriveTurnStatus,
outstandingAsyncTools, outstandingAsyncTools,
outstandingPermissions, outstandingPermissions,
@ -331,10 +332,17 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] {
} }
if (state.terminal?.type === 'turn_failed') { if (state.terminal?.type === 'turn_failed') {
// Interactive turns normally wrap up gracefully before hitting the
// limit; if a hard limit failure still lands here, explain it and point
// at the setting instead of showing the raw runtime error.
const message = state.terminal.code === MODEL_CALL_LIMIT_ERROR_CODE
? `This turn hit its model-call limit of ${state.definition.config.maxModelCalls} before it could finish. ` +
'You can raise the limit in Settings → Advanced.'
: state.terminal.error
items.push({ items.push({
id: `${turnId}:error`, id: `${turnId}:error`,
kind: 'error', kind: 'error',
message: state.terminal.error, message,
timestamp: ts(), timestamp: ts(),
} satisfies ErrorMessage) } satisfies ErrorMessage)
} }

View file

@ -512,7 +512,13 @@ Rules:
- `input` is the user message that defines this turn boundary. - `input` is the user message that defines this turn boundary.
- `autoPermission` defaults to `false` before persistence. - `autoPermission` defaults to `false` before persistence.
- `humanAvailable` is required explicitly. - `humanAvailable` is required explicitly.
- `maxModelCalls` defaults to `20` before persistence. - `maxModelCalls`, when omitted, defaults at creation to the user's global
model-call limit (`config/turn_limits.json`, built-in default `50`). The
runtime makes no chat/headless distinction: the optional chat override is
the chat UI's job — its send path passes an explicit `maxModelCalls`.
Spawned sub-agents default to the global limit, which also caps the
budget a parent may grant them. Settings changes affect only newly
created turns.
- Persisted values are fully resolved and immutable. - Persisted values are fully resolved and immutable.
The capability is named `humanAvailable`, not `headless`. `headless` describes The capability is named `humanAvailable`, not `headless`. `headless` describes

View file

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

View file

@ -0,0 +1,42 @@
import fs from 'fs/promises';
import path from 'path';
import {
TurnLimitsSettingsSchema,
DEFAULT_TURN_LIMITS_SETTINGS,
type TurnLimitsSettings,
} from '@x/shared/dist/turn-limits.js';
import { WorkDir } from './config.js';
const TURN_LIMITS_CONFIG_PATH = path.join(WorkDir, 'config', 'turn_limits.json');
/**
* Load the model-call limit settings, falling back to the defaults (global
* limit DEFAULT_MAX_MODEL_CALLS, no chat override) when the file is absent
* or malformed.
*/
export async function loadTurnLimitsSettings(): Promise<TurnLimitsSettings> {
try {
const content = await fs.readFile(TURN_LIMITS_CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(content);
return TurnLimitsSettingsSchema.parse({
...DEFAULT_TURN_LIMITS_SETTINGS,
...(parsed && typeof parsed === 'object' ? parsed : {}),
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[TurnLimits] Error loading turn limit settings:', error);
}
return DEFAULT_TURN_LIMITS_SETTINGS;
}
}
export async function saveTurnLimitsSettings(
settings: TurnLimitsSettings,
): Promise<void> {
const validated = TurnLimitsSettingsSchema.parse(settings);
await fs.mkdir(path.dirname(TURN_LIMITS_CONFIG_PATH), { recursive: true });
await fs.writeFile(
TURN_LIMITS_CONFIG_PATH,
JSON.stringify(validated, null, 2),
);
}

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { z } from "zod"; import type { z } from "zod";
import { import {
MODEL_CALL_LIMIT_ERROR_CODE, MODEL_CALL_LIMIT_ERROR_CODE,
@ -41,6 +41,16 @@ import type {
ToolExecutionContext, ToolExecutionContext,
} from "./tool-registry.js"; } from "./tool-registry.js";
// createTurn defaults an omitted maxModelCalls from the fs-backed settings
// module; mock it so tests stay hermetic (no real WorkDir reads) and can
// steer the configured global limit.
const turnLimitsMock = vi.hoisted(() => ({ maxModelCalls: 50 }));
vi.mock("../../config/turn_limits.js", () => ({
loadTurnLimitsSettings: async () => ({
maxModelCalls: turnLimitsMock.maxModelCalls,
}),
}));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fixtures // Fixtures
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -3060,3 +3070,45 @@ describe("turn event bus", () => {
expect(durable.every((e) => e.sessionId === null)).toBe(true); expect(durable.every((e) => e.sessionId === null)).toBe(true);
}); });
}); });
// ---------------------------------------------------------------------------
// Model-call limit resolution at createTurn (issue #768)
// ---------------------------------------------------------------------------
describe("model-call limit resolution", () => {
async function createdMaxModelCalls(
runtime: TurnRuntime,
repo: InMemoryTurnRepo,
config: { humanAvailable: boolean; maxModelCalls?: number },
): Promise<number> {
const turnId = await newTurn(runtime, { config });
const [created] = await persisted(repo, turnId);
if (created.type !== "turn_created") throw new Error("no turn_created");
return created.config.maxModelCalls;
}
it("an omitted limit defaults to the configured global limit", async () => {
turnLimitsMock.maxModelCalls = 42;
try {
const { runtime, repo } = makeRuntime();
expect(
await createdMaxModelCalls(runtime, repo, { humanAvailable: true }),
).toBe(42);
expect(
await createdMaxModelCalls(runtime, repo, { humanAvailable: false }),
).toBe(42);
} finally {
turnLimitsMock.maxModelCalls = 50;
}
});
it("an explicit per-call limit wins over the setting", async () => {
const { runtime, repo } = makeRuntime();
expect(
await createdMaxModelCalls(runtime, repo, {
humanAvailable: true,
maxModelCalls: 3,
}),
).toBe(3);
});
});

View file

@ -163,7 +163,9 @@ export class TurnRuntime implements ITurnRuntime {
config: { config: {
autoPermission: input.config.autoPermission ?? false, autoPermission: input.config.autoPermission ?? false,
humanAvailable: input.config.humanAvailable, humanAvailable: input.config.humanAvailable,
maxModelCalls: input.config.maxModelCalls ?? DEFAULT_MAX_MODEL_CALLS, maxModelCalls:
input.config.maxModelCalls ??
(await defaultMaxModelCalls()),
...(input.config.reasoningEffort === undefined ...(input.config.reasoningEffort === undefined
? {} ? {}
: { reasoningEffort: input.config.reasoningEffort }), : { reasoningEffort: input.config.reasoningEffort }),
@ -1359,6 +1361,21 @@ function parseToolAdditions(
return parsed.success ? parsed.data.toolAdditions : undefined; 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<number> {
try {
const { loadTurnLimitsSettings } = await import(
"../../config/turn_limits.js"
);
return (await loadTurnLimitsSettings()).maxModelCalls;
} catch {
return DEFAULT_MAX_MODEL_CALLS;
}
}
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
// Provider API errors carry the actual upstream failure in responseBody // Provider API errors carry the actual upstream failure in responseBody

View file

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

View file

@ -25,6 +25,7 @@ import { CreditActivatedEventSchema, CreditsStateSchema, ReferralClaimResultSche
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js'; import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
import { PermissionDecision, ApprovalPolicy, CodingAgent, type CodeRunFeedEvent } from './code-mode.js'; import { PermissionDecision, ApprovalPolicy, CodingAgent, type CodeRunFeedEvent } from './code-mode.js';
import { NotificationSettingsSchema } from './notification-settings.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 { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
import { ChannelsConfig, ChannelsStatus } from './channels.js'; import { ChannelsConfig, ChannelsStatus } from './channels.js';
@ -2455,6 +2456,17 @@ const ipcSchemas = {
success: z.literal(true), 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; } as const;
// ============================================================================ // ============================================================================

View file

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