mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
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:
commit
711aff58f2
14 changed files with 577 additions and 20 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 { 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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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" ? (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
100
apps/x/packages/core/src/config/turn_limits.test.ts
Normal file
100
apps/x/packages/core/src/config/turn_limits.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
42
apps/x/packages/core/src/config/turn_limits.ts
Normal file
42
apps/x/packages/core/src/config/turn_limits.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
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 = 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>;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue