Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -1430,3 +1430,9 @@ XAGENT_TASK_RUNTIME_SECRETS_TTL_SECONDS=86400
# Seconds to pause between batches while a backlog remains. This is the rate
# limit: raise it if bulk deletion pressures autovacuum or replication.
# XAGENT_RETENTION_BATCH_PAUSE_SECONDS=5
## Execution token budget (reported input + output, per execution turn).
## Leave token limits unset to preserve unlimited execution. Personal settings
## may override the default but cannot exceed the administrator's maximum.
# XAGENT_EXECUTION_BUDGET_DEFAULT_TOKENS=200000
# XAGENT_EXECUTION_BUDGET_MAX_TOKENS=500000
# XAGENT_EXECUTION_BUDGET_SOFT_PERCENT=80
72 changes: 72 additions & 0 deletions frontend/src/app/settings/execution-budget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from "react"
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ExecutionBudgetSettings } from "@/components/settings/execution-budget"

const apiRequest = vi.hoisted(() => vi.fn())
const updateUserPreferences = vi.hoisted(() => vi.fn())
const t = (key: string) => key
vi.mock("@/contexts/i18n-context", () => ({ useI18n: () => ({ t }) }))
vi.mock("@/lib/api-wrapper", () => ({ apiRequest }))
vi.mock("@/lib/user-preferences", () => ({ updateUserPreferences }))

const effective = { max_tokens: 10000, soft_limit_percent: 80, source: "system", soft_limit_source: "system" }
function response(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } })
}
afterEach(cleanup)
beforeEach(() => {
apiRequest.mockReset()
updateUserPreferences.mockReset().mockResolvedValue({ ok: true })
apiRequest.mockImplementation(async (url: string) => url.endsWith("/defaults")
? response({ default_tokens: 10000, max_tokens: 20000, soft_limit_percent: 80 })
: response({ effective, preferences: { execution_budget_tokens: null, execution_budget_soft_percent: null } }))
})

describe("execution budget settings", () => {
it("shows effective inherited values and saves both personal settings", async () => {
render(<ExecutionBudgetSettings />)
await screen.findByTestId("effective-budget")
expect(screen.getByTestId("effective-budget")).toHaveTextContent("80%")
expect(screen.queryByText("settings.budget.admin")).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText("settings.budget.tokens"), { target: { value: "8000" } })
fireEvent.change(screen.getByLabelText("settings.budget.percent"), { target: { value: "65" } })
fireEvent.click(screen.getByText("settings.budget.savePersonal"))
await waitFor(() => expect(updateUserPreferences).toHaveBeenCalledWith({ execution_budget_tokens: 8000, execution_budget_soft_percent: 65 }))
await screen.findByText("settings.budget.saved")
})

it("uses null to restore inheritance, not zero or unlimited", async () => {
render(<ExecutionBudgetSettings />)
await screen.findByTestId("effective-budget")
fireEvent.click(screen.getByText("settings.budget.savePersonal"))
await waitFor(() => expect(updateUserPreferences).toHaveBeenCalledWith({ execution_budget_tokens: null, execution_budget_soft_percent: null }))
})

it("allows administrators to save defaults and the separate maximum", async () => {
render(<ExecutionBudgetSettings isAdmin />)
const button = screen.getByText("settings.budget.saveDefaults")
await waitFor(() => expect(button).toBeEnabled())
fireEvent.change(screen.getByLabelText("settings.budget.maximumTokens"), { target: { value: "12000" } })
fireEvent.click(button)
await waitFor(() => expect(apiRequest).toHaveBeenCalledWith(expect.stringContaining("/execution-budget/defaults"), expect.objectContaining({ method: "PUT", body: JSON.stringify({ default_tokens: 10000, max_tokens: 12000, soft_limit_percent: 80 }) })))
await screen.findByText("settings.budget.saved")
})

it("does not present an unavailable policy as unlimited or enable saving", async () => {
apiRequest.mockResolvedValue(response({}, 503))
render(<ExecutionBudgetSettings />)
await screen.findByText("settings.budget.failed")
expect(screen.queryByTestId("effective-budget")).not.toBeInTheDocument()
expect(screen.getByText("settings.budget.savePersonal")).toBeDisabled()
})

it("does not claim a failed save succeeded", async () => {
updateUserPreferences.mockResolvedValue({ ok: false })
render(<ExecutionBudgetSettings />)
await screen.findByTestId("effective-budget")
fireEvent.click(screen.getByText("settings.budget.savePersonal"))
await screen.findByText("settings.budget.failed")
expect(screen.queryByText("settings.budget.saved")).not.toBeInTheDocument()
})
})
3 changes: 3 additions & 0 deletions frontend/src/app/settings/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ vi.mock("@/contexts/i18n-context", () => ({
useI18n: () => ({ t: i18nState.t, locale: "en", setLocale: i18nState.setLocale }),
}))
vi.mock("@/lib/api-wrapper", () => ({ apiRequest }))
vi.mock("@/components/settings/execution-budget", () => ({
ExecutionBudgetSettings: () => <section data-testid="execution-budget-settings" />,
}))
vi.mock("@/lib/task-runtime-ui-extension", async () => {
const actual = await vi.importActual<
typeof import("@/lib/task-runtime-ui-extension")
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useI18n } from "@/contexts/i18n-context"
import { Select } from "@/components/ui/select"
import { inspectAuthSession, updateAuthSessionUser } from "@/lib/auth-cache"
import { TaskRuntimeSettingsExtension } from "@/lib/task-runtime-ui-extension"
import { ExecutionBudgetSettings } from "@/components/settings/execution-budget"

export default function SettingsPage() {
const { user, session } = useAuth()
Expand Down Expand Up @@ -85,6 +86,7 @@ export default function SettingsPage() {

<div className="space-y-6">
<TaskRuntimeSettingsExtension />
<ExecutionBudgetSettings key={session.sessionId} isAdmin={user?.is_admin} />

{/* Language Section */}
<Card>
Expand Down
127 changes: 127 additions & 0 deletions frontend/src/components/settings/execution-budget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"use client"

import React, { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useI18n } from "@/contexts/i18n-context"
import { apiRequest } from "@/lib/api-wrapper"
import { getApiUrl } from "@/lib/utils"
import { updateUserPreferences } from "@/lib/user-preferences"

type Defaults = { default_tokens: number | null; max_tokens: number | null; soft_limit_percent: number }
type Budget = {
effective: { max_tokens: number | null; soft_limit_percent: number; source: string; soft_limit_source: string }
preferences: { execution_budget_tokens: number | null; execution_budget_soft_percent: number | null }
}

const numberOrNull = (value: string) => value.trim() === "" ? null : Number(value)

export function ExecutionBudgetSettings({ isAdmin = false }: { isAdmin?: boolean }) {
const { t } = useI18n()
const [budget, setBudget] = useState<Budget | null>(null)
const [tokens, setTokens] = useState("")
const [percent, setPercent] = useState("")
const [defaults, setDefaults] = useState<Defaults | null>(null)
const [defaultTokens, setDefaultTokens] = useState("")
const [maximumTokens, setMaximumTokens] = useState("")
const [defaultPercent, setDefaultPercent] = useState("80")
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState("")

useEffect(() => {
let active = true
async function load() {
try {
const response = await apiRequest(`${getApiUrl()}/api/execution-budget/me`)
if (!response.ok) throw new Error()
const data: Budget = await response.json()
if (!data.effective || !data.preferences) throw new Error()
if (!active) return
setBudget(data)
setTokens(String(data.preferences.execution_budget_tokens ?? ""))
setPercent(String(data.preferences.execution_budget_soft_percent ?? ""))
if (isAdmin) {
const adminResponse = await apiRequest(`${getApiUrl()}/api/execution-budget/defaults`)
if (!adminResponse.ok) throw new Error()
const adminData: Defaults = await adminResponse.json()
if (!active) return
setDefaults(adminData)
setDefaultTokens(String(adminData.default_tokens ?? ""))
setMaximumTokens(String(adminData.max_tokens ?? ""))
setDefaultPercent(String(adminData.soft_limit_percent))
}
} catch {
if (active) setStatus(t("settings.budget.failed"))
}
}
void load()
return () => { active = false }
}, [isAdmin, t])

async function save(admin: boolean) {
setSaving(true)
setStatus("")
try {
if (admin) {
const response = await apiRequest(`${getApiUrl()}/api/execution-budget/defaults`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_tokens: numberOrNull(defaultTokens), max_tokens: numberOrNull(maximumTokens), soft_limit_percent: Number(defaultPercent) }),
})
if (!response.ok) throw new Error()
} else {
const result = await updateUserPreferences({ execution_budget_tokens: numberOrNull(tokens), execution_budget_soft_percent: numberOrNull(percent) })
if (!result.ok) throw new Error()
}
const response = await apiRequest(`${getApiUrl()}/api/execution-budget/me`)
if (!response.ok) throw new Error()
const data: Budget = await response.json()
if (!data.effective) throw new Error()
setBudget(data)
setStatus(t("settings.budget.saved"))
} catch {
setStatus(t("settings.budget.failed"))
} finally {
setSaving(false)
}
}

const source = (value: string) => {
if (value === "personal") return t("settings.budget.sources.personal")
if (value === "system") return t("settings.budget.sources.system")
if (value === "system_maximum") return t("settings.budget.sources.system_maximum")
return t("settings.budget.sources.external")
}

return <Card>
<CardHeader>
<CardTitle>{t("settings.budget.title")}</CardTitle>
<CardDescription>{t("settings.budget.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{budget && <p className="text-sm" data-testid="effective-budget">
{t("settings.budget.effective")}: {budget.effective.max_tokens?.toLocaleString() ?? t("settings.budget.unlimited")} ({source(budget.effective.source)}); {budget.effective.soft_limit_percent}% ({source(budget.effective.soft_limit_source)})
</p>}
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void save(false) }}>
<Label htmlFor="personal-budget-tokens">{t("settings.budget.tokens")}</Label>
<Input id="personal-budget-tokens" type="number" min={1} step={1} value={tokens} onChange={(event) => setTokens(event.target.value)} placeholder={t("settings.budget.inherit")} disabled={!budget || saving} />
<Label htmlFor="personal-budget-percent">{t("settings.budget.percent")}</Label>
<Input id="personal-budget-percent" type="number" min={1} max={99} step={1} value={percent} onChange={(event) => setPercent(event.target.value)} placeholder={t("settings.budget.inherit")} disabled={!budget || saving} />
<p className="text-sm text-muted-foreground">{t("settings.budget.personalHelp")}</p>
<Button type="submit" disabled={!budget || saving}>{t("settings.budget.savePersonal")}</Button>
</form>
{isAdmin && <form className="space-y-3 border-t pt-4" onSubmit={(event) => { event.preventDefault(); void save(true) }}>
<h3>{t("settings.budget.admin")}</h3>
<Label htmlFor="default-budget-tokens">{t("settings.budget.defaultTokens")}</Label>
<Input id="default-budget-tokens" type="number" min={1} step={1} value={defaultTokens} onChange={(event) => setDefaultTokens(event.target.value)} disabled={!defaults || saving} placeholder={t("settings.budget.unlimited")} />
<Label htmlFor="maximum-budget-tokens">{t("settings.budget.maximumTokens")}</Label>
<Input id="maximum-budget-tokens" type="number" min={1} step={1} value={maximumTokens} onChange={(event) => setMaximumTokens(event.target.value)} disabled={!defaults || saving} placeholder={t("settings.budget.unlimited")} />
<Label htmlFor="default-budget-percent">{t("settings.budget.percent")}</Label>
<Input id="default-budget-percent" type="number" min={1} max={99} step={1} required value={defaultPercent} onChange={(event) => setDefaultPercent(event.target.value)} disabled={!defaults || saving} />
<Button type="submit" disabled={!defaults || saving}>{t("settings.budget.saveDefaults")}</Button>
</form>}
{status && <p role="status">{status}</p>}
</CardContent>
</Card>
}
18 changes: 18 additions & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,24 @@ Build when you need.`,
hero_description: "Plan, run and review your agents in one workspace, from instant tasks to fully built workflows.",
},
settings: {
budget: {
title: "Execution token budget",
description: "Limits cumulative input and output tokens for one execution turn, including cached input. Calls already in flight may exceed the limit. This is not a monthly quota.",
effective: "Effective budget",
unlimited: "No limit",
inherit: "Inherit default",
tokens: "Personal token limit",
percent: "Soft warning threshold (%)",
personalHelp: "Leave blank to inherit. Personal settings cannot exceed the administrator maximum. At the soft threshold the agent is asked to wrap up; at the hard limit further work stops. Changes apply on the next execution; resuming never resets usage or increases its allowance.",
admin: "Administrator defaults",
defaultTokens: "Default token limit",
maximumTokens: "Maximum allowed token limit",
savePersonal: "Save personal budget",
saveDefaults: "Save administrator defaults",
saved: "Saved; effective settings refreshed.",
failed: "Could not load or save budget settings. Please retry.",
sources: { personal: "Personal", system: "System default", system_maximum: "Administrator maximum", external: "Managed policy" },
},
title: "System Settings",
description: "Manage basic configuration and advanced options",
language: {
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,24 @@ const zh = {
hero_description: "在同一个工作空间中规划、运行并审查你的智能体,从即时任务到完整工作流。",
},
settings: {
budget: {
title: "执行 token 预算",
description: "限制单次执行累计的输入和输出 token(含缓存输入)。已发出的请求可能导致超额。这不是月度配额。",
effective: "当前生效预算",
unlimited: "不限制",
inherit: "继承默认值",
tokens: "个人 token 上限",
percent: "软提醒阈值(%)",
personalHelp: "留空表示继承。个人设置不能突破管理员上限。达到软阈值提醒模型收尾,达到硬上限停止继续工作。设置在下次执行时读取;恢复任务不会重置消耗,也不会提高本次额度。",
admin: "管理员默认设置",
defaultTokens: "默认 token 上限",
maximumTokens: "允许设置的最高 token 上限",
savePersonal: "保存个人预算",
saveDefaults: "保存管理员默认值",
saved: "已保存,并刷新实际生效设置。",
failed: "预算设置读取或保存失败,请重试。",
sources: { personal: "个人设置", system: "系统默认", system_maximum: "管理员上限", external: "托管策略" },
},
title: "系统设置",
description: "管理系统的基本配置和高级选项",
language: {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/user-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { OnboardingVoiceId } from "@/lib/onboarding-data";
* the fields it has. `voice` must be one of VALID_USER_VOICES or the
* backend 422s. */
export interface UserPreferences {
execution_budget_tokens?: number | null;
execution_budget_soft_percent?: number | null;
onboarded?: boolean;
department?: string;
// null is this endpoint's merge-PATCH "clear this field" signal (see
Expand Down
26 changes: 26 additions & 0 deletions src/xagent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,32 @@ def get_celery_enabled() -> bool:
return _get_bool_env(CELERY_ENABLED, False)


EXECUTION_BUDGET_DEFAULT_TOKENS = "XAGENT_EXECUTION_BUDGET_DEFAULT_TOKENS"
EXECUTION_BUDGET_MAX_TOKENS = "XAGENT_EXECUTION_BUDGET_MAX_TOKENS"
EXECUTION_BUDGET_SOFT_PERCENT = "XAGENT_EXECUTION_BUDGET_SOFT_PERCENT"


def get_execution_budget_defaults() -> dict[str, int | None]:
"""Read execution-budget defaults; absent limits preserve unlimited runs.

Invalid limits are configuration errors, never silently unlimited budgets.
"""
values: dict[str, int | None] = {}
for key, env_name in (
("default_tokens", EXECUTION_BUDGET_DEFAULT_TOKENS),
("max_tokens", EXECUTION_BUDGET_MAX_TOKENS),
("soft_limit_percent", EXECUTION_BUDGET_SOFT_PERCENT),
):
raw = os.getenv(env_name)
value = int(raw) if raw else (80 if key == "soft_limit_percent" else None)
if value is not None and (
value <= 0 or (key == "soft_limit_percent" and value >= 100)
):
raise ValueError(f"Invalid {env_name}")
values[key] = value
return values


def get_tool_parallel_enabled() -> bool:
"""Whether independent tool calls in a ReAct turn run concurrently.

Expand Down
Loading
Loading