mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
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.
This commit is contained in:
parent
63a99e5fe4
commit
eece1af90e
16 changed files with 518 additions and 16 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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>
|
||||
<Input
|
||||
type="number"
|
||||
min={MODEL_CALL_LIMIT_MIN}
|
||||
max={MODEL_CALL_LIMIT_MAX}
|
||||
value={globalLimit}
|
||||
onChange={(e) => setGlobalLimit(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
className="w-24 shrink-0"
|
||||
/>
|
||||
</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>
|
||||
<Input
|
||||
type="number"
|
||||
min={MODEL_CALL_LIMIT_MIN}
|
||||
max={MODEL_CALL_LIMIT_MAX}
|
||||
value={chatLimit}
|
||||
onChange={(e) => setChatLimit(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
placeholder="Same as above"
|
||||
className="w-32 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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
|
|||
</div>
|
||||
|
||||
{/* 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" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connections" ? (
|
||||
|
|
@ -2845,6 +2971,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<AppearanceSettings />
|
||||
) : activeTab === "notifications" ? (
|
||||
<NotificationSettings dialogOpen={open} />
|
||||
) : activeTab === "advanced" ? (
|
||||
<AdvancedSettings dialogOpen={open} />
|
||||
) : activeTab === "help" ? (
|
||||
<HelpSettings />
|
||||
) : activeTab === "code-mode" ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
126
apps/x/packages/core/src/config/turn_limits.test.ts
Normal file
126
apps/x/packages/core/src/config/turn_limits.test.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
54
apps/x/packages/core/src/config/turn_limits.ts
Normal file
54
apps/x/packages/core/src/config/turn_limits.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<IToolRegistry>(() => new RealToolRegistry()).singleton(),
|
||||
permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(),
|
||||
permissionClassifier: asFunction<IPermissionClassifier>(() => new RealPermissionClassifier()).singleton(),
|
||||
turnLimitsResolver: asFunction<ITurnLimitsResolver>(() => new RealTurnLimitsResolver()).singleton(),
|
||||
turnRuntime: asClass<ITurnRuntime>(TurnRuntime).singleton(),
|
||||
sessionRepo: asClass<ISessionRepo>(FSSessionRepo).singleton(),
|
||||
sessionBus: asClass<ISessionBus>(EmitterSessionBus).singleton(),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ function fakeServices(opts: {
|
|||
parentEvents?: Array<z.infer<typeof TurnEvent>>;
|
||||
childResult?: Partial<HeadlessAgentResult>;
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
input.max_model_calls ?? DEFAULT_MAX_MODEL_CALLS,
|
||||
DEFAULT_MAX_MODEL_CALLS,
|
||||
input.max_model_calls ?? spawnCap,
|
||||
spawnCap,
|
||||
);
|
||||
|
||||
let handle: Awaited<ReturnType<typeof headlessRunner.start>>;
|
||||
|
|
@ -231,6 +236,9 @@ async function resolveServices(): Promise<
|
|||
NonNullable<SpawnedAgentCallbacks["services"]>
|
||||
> {
|
||||
const { lazyResolve } = await import("../../di/lazy-resolve.js");
|
||||
const { loadTurnLimitsSettings } = await import(
|
||||
"../../config/turn_limits.js"
|
||||
);
|
||||
return {
|
||||
turnRuntime:
|
||||
await lazyResolve<import("../turns/api.js").ITurnRuntime>(
|
||||
|
|
@ -239,6 +247,7 @@ async function resolveServices(): Promise<
|
|||
headlessRunner: await lazyResolve<
|
||||
import("./headless.js").IHeadlessAgentRunner
|
||||
>("headlessAgentRunner"),
|
||||
globalMaxModelCalls: loadTurnLimitsSettings().maxModelCalls,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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("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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof TurnEvent>;
|
||||
|
||||
|
|
@ -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<string> {
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
34
apps/x/packages/shared/src/turn-limits.ts
Normal file
34
apps/x/packages/shared/src/turn-limits.ts
Normal 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 = 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<typeof TurnLimitsSettingsSchema>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue