Skip to content
Merged
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
45 changes: 23 additions & 22 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1254,33 +1254,34 @@ one-time notice. This prevents a stale hidden limit from truncating automatic
progress or inherited subagent work. Use the one-off CLI `--max-steps` flag when
an explicit run budget is needed; unattended bots retain `[bot].max_steps`.

An ordinary chat task is instead bounded by what it spends. Rounds never
measured the thing worth stopping — the same hundred cost minutes or hours
depending on what each one read — so the gate is cost and wall clock. On
reaching either, the task produces one tool-free summary and pauses; the work
is saved and the next message continues it.
**An ordinary chat task has no limit of any kind by default** — not rounds, not
tokens, not time, not money. It runs until the model finishes, an adaptive
guard decides it stopped making progress, or you stop it.

An optional spend gate is available when you want one. It bounds a whole task
(every "continue" included, until you start unrelated work), and on crossing it
the task produces one tool-free summary and pauses; the work is saved and the
next message continues it.

```toml
[agent]
task_cost_budget = 5.0 # model pricing currency; off unless you set it
task_time_budget_minutes = 60 # 0 = default (30); negative disables the axis
task_cost_budget = 5.0 # in the model's pricing currency
task_time_budget_minutes = 60 # wall clock across the whole task
```

**Only wall clock has a default**, and it is deliberately loose — a gate that
fires on real work is worse than no gate. There is no cost default because no
amount of money is portable across models: a budget loose enough for a cheap
model would land a frontier model within a couple of answers. Set
`task_cost_budget` yourself when you know which model you are paying for.

Cost also applies only to a priced model. Without a price table that axis stays
inactive rather than reading the task as free, and wall clock still bounds it —
including a free or local model's loop, the one case cost cannot see at all.

Ordinary chat has no round ceiling. A turn that reaches a high round count
without crossing either budget is one whose rounds are individually cheap and
fast, which is the case least worth interrupting; rounds carry no information
the two axes above do not already have. Use the one-off `--max-steps` flag when
you specifically want a run bounded by rounds.
Both are off unless set. Neither has a default, because a stop is a judgement
only you can make: no amount of money is portable across models — a budget
loose enough for a cheap model would land a frontier model within a couple of
answers — and a long task is as often the job you asked for as it is a runaway.

Cost applies only to a priced model. Without a price table that axis stays
inactive rather than reading the task as free; use the time axis for a free or
local model.

Rounds are deliberately not an axis. A turn that reaches a high round count
without spending much is one whose rounds are individually cheap and fast,
which is the case least worth interrupting. Use the one-off `--max-steps` flag
when you specifically want a run bounded by rounds.

Subagent skills inherit the executor model by default. Set `subagent_model` to
run them on another configured model, or use `subagent_models` to override only
Expand Down
28 changes: 15 additions & 13 deletions docs/GUIDE.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,26 +974,28 @@ Reasonix 会自动管理正常执行:活跃 Todo 连续 8 个工具调用轮
迁移提示后从配置中移除,避免隐藏的旧上限截断自动进度管理或子 Agent 的继承任务。确实需要
为单次运行设置预算时使用 CLI `--max-steps`;无人值守 Bot 仍保留 `[bot].max_steps`。

普通对话任务改由**花费**约束,而不是轮数:同样一百轮,取决于每轮读了什么,可能是几分钟
也可能是几小时。任务达到成本或时长预算时,会产出一次不带工具的总结然后暂停;已完成的
工作全部保留,下一条消息即可继续。
**普通对话任务默认没有任何上限**——轮数、token、时长、花费都不限。它一直跑到模型自己
结束、自适应守卫判定它不再产生进展,或者你手动停止为止。

需要时可以自行开启花费闸门。它约束的是**整个任务**(包括每一次"继续",直到你开始不相关的
新工作);越过阈值时会产出一次不带工具的总结然后暂停,已完成的工作全部保留,下一条消息
即可继续。

```toml
[agent]
task_cost_budget = 5.0 # 模型定价货币;不设置即不启用
task_time_budget_minutes = 60 # 0 = 默认值 30;负数关闭该维度
task_cost_budget = 5.0 # 模型定价货币
task_time_budget_minutes = 60 # 整个任务累计的墙钟时长
```

**只有时长维度带默认值**,而且刻意放得很宽——会在正常工作中触发的闸门比没有闸门更糟。
成本维度没有默认值,因为**金额在不同模型之间不可移植**:对便宜模型足够宽松的额度,换成
前沿模型可能问两句就触发。知道自己在为哪个模型付费时,再自行设置 `task_cost_budget`
两个维度都默认关闭,也都没有默认值——**该不该停是只有你能下的判断**:金额在不同模型之间
不可移植(对便宜模型足够宽松的额度,换成前沿模型可能问两句就触发),而任务跑得久,既可能
是失控,也可能就是你要的活

成本维度也只对有定价的模型生效:没有价目表时该维度直接不参与判断,而不是把任务读成免费
此时仍由时长维度兜底——免费或本地模型的死循环恰恰是成本维度完全看不见的情况
成本维度只对有定价的模型生效:没有价目表时该维度直接不参与判断,而不是把任务读成免费
免费或本地模型请改用时长维度

普通对话**没有轮数上限**。能跑到很高轮数却没越过上述任一预算的任务,说明它每一轮都又便宜
又快,这恰恰是最不该打断的情况;轮数并不携带这两个维度之外的额外信息。确实想按轮数限制
某次运行时,用一次性的 `--max-steps`。
轮数刻意不作为一个维度。能跑到很高轮数却没花多少钱的任务,说明它每一轮都又便宜又快,
这恰恰是最不该打断的情况。确实想按轮数限制某次运行时,用一次性的 `--max-steps`。

Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1299,7 +1299,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
prov: prov,
tools: tools,
session: session,
taskBudget: runBudget{limit: taskBudgetOrDefault(opts.TaskBudget)},
taskBudget: runBudget{limit: normalizeTaskBudget(opts.TaskBudget)},
maxSteps: opts.MaxSteps,
maxStepsKey: maxStepsKey,
reasoningByteLimit: reasoningByteLimit,
Expand Down
28 changes: 10 additions & 18 deletions internal/agent/run_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,24 @@ import (
)

// TaskBudget bounds one task on the axes its failures are reported in.
// Crossing either yields one tool-free summary and a resumable pause.
// Crossing either yields one tool-free summary and a resumable pause. Both
// axes ship off: stopping a task is the user's call, since only they know
// which model they are paying for and whether a long task is a runaway or the
// job they asked for.
type TaskBudget struct {
// Cost is off unless set: no amount of money is portable across models.
Cost float64
Wall time.Duration
}

// DefaultTaskWall is the only axis that ships with a default, because wall
// clock is the only portable unit here: half an hour means the same thing on
// every model, while an amount of money does not. A cost default loose enough
// for a cheap model would stop a frontier model within a couple of answers, so
// cost stays off until someone who knows what they are paying sets it.
const DefaultTaskWall = 30 * time.Minute

// taskBudgetOrDefault fills the wall-clock default and reads a negative value
// on either axis as an explicit "disable this one".
func taskBudgetOrDefault(b TaskBudget) TaskBudget {
switch {
case b.Wall == 0:
b.Wall = DefaultTaskWall
case b.Wall < 0:
b.Wall = 0
}
// normalizeTaskBudget reads a negative value as unset, so a disabled axis and
// an unconfigured one behave identically.
func normalizeTaskBudget(b TaskBudget) TaskBudget {
if b.Cost < 0 {
b.Cost = 0
}
if b.Wall < 0 {
b.Wall = 0
}
return b
}

Expand Down
39 changes: 22 additions & 17 deletions internal/agent/task_budget_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,28 +98,33 @@ func TestTaskBudgetGateFiresOnWallClock(t *testing.T) {
}
}

// No amount of money is portable across models: a default loose enough for a
// cheap model lands a frontier model within a couple of answers. Only wall
// clock, which means the same everywhere, ships with a default.
func TestTaskBudgetShipsNoCostDefault(t *testing.T) {
got := taskBudgetOrDefault(TaskBudget{})
if got.Cost != 0 {
t.Fatalf("default cost = %v, want it off until the user sets it", got.Cost)
}
if got.Wall != DefaultTaskWall {
t.Fatalf("default wall = %v, want %v", got.Wall, DefaultTaskWall)
// Nothing is bounded out of the box. Stopping a task is the user's call: only
// they know which model they are paying for, and whether a long task is a
// runaway or the job they asked for.
func TestTaskBudgetShipsNoLimits(t *testing.T) {
if got := normalizeTaskBudget(TaskBudget{}); got.Cost != 0 || got.Wall != 0 {
t.Fatalf("default budget = %+v, want both axes off", got)
}
}

func TestTaskBudgetAxesDisableIndependently(t *testing.T) {
got := taskBudgetOrDefault(TaskBudget{Cost: 1.5, Wall: -1})
func TestTaskBudgetAxesSetIndependently(t *testing.T) {
got := normalizeTaskBudget(TaskBudget{Cost: 1.5, Wall: -1})
if got.Cost != 1.5 || got.Wall != 0 {
t.Fatalf("budget = %+v, want the explicit cost kept and wall clock disabled", got)
t.Fatalf("budget = %+v, want the explicit cost kept and wall clock off", got)
}
if got := normalizeTaskBudget(TaskBudget{Wall: time.Minute}); got.Wall != time.Minute || got.Cost != 0 {
t.Fatalf("budget = %+v, want the explicit wall kept and cost off", got)
}
// Setting cost alone must not drop the wall-clock default: that is the
// axis an unpriced or free model still needs.
if got := taskBudgetOrDefault(TaskBudget{Cost: 1.5}); got.Wall != DefaultTaskWall {
t.Fatalf("budget = %+v, want the wall default kept alongside an explicit cost", got)
}

// However long an unconfigured task runs and however much it spends, nothing
// lands it. This is the promise the defaults make.
func TestUnconfiguredBudgetNeverCrosses(t *testing.T) {
b := runBudget{started: time.Now().Add(-8 * time.Hour)}
b.observe(&provider.Usage{PromptTokens: 50_000_000, CompletionTokens: 5_000_000, RequestCount: 1},
&provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2})
if axis, detail := b.exceeded(normalizeTaskBudget(TaskBudget{})); axis != "" {
t.Fatalf("unconfigured budget crossed %q (%s); nothing should stop by default", axis, detail)
}
}

Expand Down
3 changes: 1 addition & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1279,9 +1279,8 @@ type AgentConfig struct {
SubagentEfforts map[string]string `toml:"subagent_efforts"`
MaxSubagentDepth int `toml:"max_subagent_depth"`
// TaskCostBudget lands a task on one summary once it spends this much.
// Off unless set: no amount of money is portable across models.
TaskCostBudget float64 `toml:"task_cost_budget"`
// TaskTimeBudgetMinutes is the same gate on wall clock. 0 = default, <0 = off.
// TaskTimeBudgetMinutes is the same gate on wall clock. Both ship off.
TaskTimeBudgetMinutes float64 `toml:"task_time_budget_minutes"`
// MaxSubagentConcurrency bounds how many sub-agents (task, fleet items,
// profile skills, nested children) may run at once in one session.
Expand Down