feat(workflows): share a per-turn token budget from a +Nk directive - #11917
Conversation
A `+500k`-style directive in the user's message now sets the turn's output-token target. Inside a workflow, budget.total is that target and budget.spent() counts every output token the session has been charged since the turn began, the main loop and every agent included, so a script can size its fan-out to what the turn may cost. Without a directive, QWEN_CODE_MAX_TOKENS_PER_WORKFLOW keeps capping each run exactly as before. The turn opens in LlmClient.sendMessageStream, which every front end goes through. The directive is read from the user's own text, with system reminders, @-referenced file and MCP resource content, and code stripped; only a user query or its retry can carry one. A side question does not move the turn, a retry keeps its starting point, and a run reads the turn once at launch. The run registry, /workflows, snapshots and the completion notification keep per-run figures; the tool result adds the turn's standing beside the run's own spend. The budget error keeps the run id, and the agent-cap error now explains the loop on budget.remaining() with no target set. Part of QwenLM#11013
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR! Template looks good ✓ — every required heading is present, the Risk & Scope bullets are filled in honestly (including the Problem: real and observed, not theoretical hardening. I checked the motivating claim against Direction: aligned. This makes an already-documented contract actually usable rather than adding a new surface. Two areas the gate normally escalates on are touched, so here is what I verified instead of escalating blind:
Size: core paths, Approach: the scope feels right, and it is better than what I would have written. My own baseline was to repoint I also wondered whether the five-regex strip pipeline could be replaced by the existing No drive-by refactors or formatting churn; every file earns its place. Risk: no elevated risk signals — none of the 21 files match the revert-correlated high-risk paths. Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 所有必需标题都在,Risk & Scope 各条填写得很实在(包括 问题: 是真实存在、可观测的问题,不是理论性加固。我对着 方向: 对齐。它是把一份已经写进文档的契约变成真正可用,而不是新增一层表面。本 PR 触及了两个通常会触发升级的领域,所以我没有盲目升级,而是逐一核实:
规模: 触及核心路径,类型为 方案: 范围合理,而且比我自己的写法更好。我的初始方案是直接把 我也考虑过:那五条正则的剥离流水线能不能换成已有的 没有顺手重构,也没有格式化噪音,每个文件都对得起它的位置。 风险: 无升级风险信号 —— 21 个文件都不匹配与回滚相关的高风险路径。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewNo critical blockers. The design holds up under reading, and the tests pin the semantics that matter rather than restating the implementation. Three non-blocking items: 1. 2. The new agent-cap message advises something its reader cannot do. 3. The strip markers duplicate string literals owned by other modules, with no pointer back to them. I verified all three families match their producers today — What I checked that a reviewer would reasonably worry about, since these are the load-bearing risks and none of them are visible from the diff alone:
The disclosed behaviour change is scoped correctly: with no directive and no env cap, sequenceDiagram
participant P1 as User
participant P2 as LlmClient
participant P3 as TurnBudget
participant P4 as UiTelemetry
participant P5 as WorkflowRunner
participant P6 as WorkflowBudget
participant P7 as Script
P1->>P2: message ending in a +500k directive
P2->>P2: strip reminders, at-refs and code
P2->>P4: getTotalOutputTokens(sessionId)
P4-->>P2: baseline, e.g. 1234
P2->>P3: beginTurn(target 500000, baseline)
P1->>P5: workflow tool call, same turn
P5->>P3: current(sessionId)
P3-->>P5: snapshot, or null on mismatch
P5->>P6: fromConfig, total 500000, source directive
P7->>P6: budget.remaining() before agent()
P6->>P4: getTotalOutputTokens(sessionId)
P4-->>P6: 151234 charged, main loop included
P6-->>P7: 348766 left, so the dispatch is admitted
Files changed (21)
Test evidenceThis is an unattended CI run, so per the gate's rules I did not build or execute any PR-derived code. The evidence below is the PR's own CI on the reviewed commit, read through the API. The two checks that matter most for this diff — the Linux unit suite and lint/typecheck — were still running when this comment was posted; the Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 No check is red, so there is no failing-job log to quote. 56 further check-runs on this commit are Not verified, and the reason: the author's "1333 tests pass locally" and "ESLint, Prettier and typecheck clean" are the author's claim, not evidence I re-ran — this gate never executes PR code, and the unit suite above had not finished. The claimed fallback-description growth to roughly 24,700 characters against a 25,000 assertion is likewise the author's measurement; I confirmed only that the assertion moved from 24,000 to 25,000 and that the cap lives in the test, not in a production constant. Sandboxed verification would settle what CI cannot: 中文说明代码审查: 没有阻断性问题。设计经得起读,测试钉住的是语义而不是把实现复述一遍。三条非阻断意见:
我替审阅者核实过的关键点(这些是真正吃重的风险,且单看 diff 看不出来):
已披露的行为变化范围划得对:既无指令也无环境变量上限时, 测试证据: 这是无人值守的 CI 运行,按门禁规则我没有构建或执行任何来自本 PR 的代码,下面的证据是本 PR 自己在被审查 commit 上的 CI,通过 API 读取。对这个 diff 最关键的两项 —— Linux 单元测试与 lint/typecheck —— 在本条评论发出时仍在运行; 没有任何检查是红的,所以没有失败日志可引。该 commit 上另有 56 项 check-run 是 未验证项及原因:作者所述"本地 1333 个测试通过""ESLint、Prettier 与类型检查干净"属于作者的说法,不是我复跑出的证据 —— 本门禁从不执行 PR 代码,而上面那轮单元测试当时尚未跑完。兜底描述增长到约 24,700 字符、对应 25,000 的断言,同样是作者的测量;我只确认了断言从 24,000 移到 25,000,以及这个上限位于测试中而非生产常量里。 沙箱验证可以补齐 CI 补不上的部分: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — clean review; the three findings are nits I would not block on, and the one thing keeping this from a 5 is that the unit suite had not finished when I wrote this. Going back to the proposal I wrote before opening the diff: this PR beats it. I would have repointed The instinct I usually have toward a 597-line core feature — that some of it must be speculative — did not survive contact with the diff. There is no config surface, no settings key, no feature flag, no abstraction for a single use. It is a parser, a snapshot holder, one telemetry getter, and a threading change. The one place I expected to find over-reach, the five strip regexes, is instead the place where the author did more than I would have: the MCP delimiter matcher back-references the nonce, which resists the exact smuggling case What actually convinced me was the ledger test. A claim like "every output token this turn, the main loop and every agent included" is easy to write and easy to fake with a mock that returns the number the assertion wants. This one drives the real The My remaining hesitation is not about correctness, it is about reach. Everything above is static reading plus unit evidence, and the end-to-end path (a human typing If I were maintaining this in six months I would thank the author, with one exception: I would find the strip markers in Approving, with the two nits above left to the author's judgement. CI is still running on this commit — the Linux unit suite and lint/typecheck had not finished — so approval is deferred until CI lands green on 中文说明置信度:4/5 —— 审查干净;三条意见都是我不会据此拦截的小问题,唯一让它没到 5 分的原因是我写下这段时单元测试还没跑完。 回到我在打开 diff 之前写下的方案:这个 PR 比我的方案好。我原本会直接把 面对 597 行核心特性我通常会有的直觉 —— 里面总该有些投机性的东西 —— 在读完 diff 后没能成立。没有配置面,没有设置项,没有特性开关,没有为单一用途造的抽象。它就是一个解析器、一个快照持有者、一个遥测取值方法,加一处串联改动。而我原以为最可能过度设计的地方,也就是那五条剥离正则,反而是作者做得比我更多的地方:MCP 分隔符的匹配回引了 nonce,正好防住 真正说服我的是账本测试。"本轮每一个输出 token,含主循环与所有 agent"这种断言很好写,也很好用一个返回期望值的 mock 糊弄过去。而这个测试驱动真实的
我剩下的犹豫不在正确性,而在覆盖范围。上面所有结论都来自静态阅读加单元证据,而端到端那条路径(真人输入 如果六个月后由我来维护这份代码,我会感谢作者,只有一处例外:我会在改 同意合并,上面两条小意见留给作者自行判断。该 commit 的 CI 仍在运行 —— Linux 单元测试与 lint/typecheck 当时尚未结束 —— 因此批准推迟到 CI 在 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
yiliang114
left a comment
There was a problem hiding this comment.
LGTM — no blocking issues. The runSpent()/runCap() split keeps the per-run registry semantics intact instead of repointing spent() and silently redefining what /workflows and snapshots report. CI green on ed4d3cb with the bot's approval on this head; this adds the second (human) vote. The three open findings are nit-level and fine as follow-ups.
What this PR does
A user can now size a whole turn by writing a token target in the message —
+500k,+1m,+2.5m, or "use 300k tokens". Inside a workflow script,budget.totalbecomes that target andbudget.spent()counts every output token the session has been charged since the turn began: the main loop and every agent of every workflow, not only the run that is asking.budget.remaining()gatesagent()the same way the existing cap does, so a script can loop until the turn's budget is spent, or scale a fan-out to it before dispatching anything.The directive is read from what the user typed. System reminders prepended to the message,
@-referenced file and MCP resource content (which ACP may place before the prompt), and code are stripped before parsing, and a slash command's arguments never count. Only a user query or its retry carries a directive; cron, goal, notification and teammate turns start with none. The turn opens in the one place every front end's turns pass through, so the TUI, ACP, the daemon and Web Shell behave the same. A side question asked while a turn is running does not move the turn, a retry of the same prompt keeps its starting point, and a workflow reads the turn once at launch, so a run that outlives its turn keeps measuring against the turn it started in.The per-run operator cap is unchanged: with no directive,
QWEN_CODE_MAX_TOKENS_PER_WORKFLOWstill caps each run andspent()still counts that run's agents. What a run shows about itself stays per run everywhere it did before —/workflows, the background tasks view, snapshots and the completion notification report the run's own spend, and a turn target is not recorded as that run's cap. The tool result adds the turn's standing beside the run's own figure (tokens: 12000 spent by this run · 150000 / 500000 this turn (+500k directive)), the usage banner and approval dialog name the target that will apply, and the tool description and theworkflow-authoringskill describe the turn semantics, with a loop-until-budget and a static-scaling pattern.Two error messages change. A refused dispatch now reads
Workflow <runId>: token budget exceeded (N / M output tokens). Stopping further agent() calls., and hitting the agent cap now explains the usual cause — a loop onbudget.remaining()with no target set, whereremaining()isInfinity— and the two ways out.Why it's needed
budgetwas an env-only, per-run cap. A user could not say in a message how much a turn may cost, and a script could only size its work to a guess, because the one number it could read measured its own run while the turn's real spend — the main loop, other runs, retries — went uncounted. The authoring reference already taughtwhile (budget.total && budget.remaining() > 50_000), but in practicebudget.totalwas almost alwaysnull, so that loop either never ran or, without the guard, ran to the 1000-agent cap. This follows Claude Code, wherebudget.totalis the turn's+500ktarget andspent()is the session's output-token delta since the turn started.Reviewer Test Plan
How to verify
Start a session and send a message ending in
+500kthat asks for a small workflow whose script returns[budget.total, budget.spent(), budget.remaining()]. Expecttotalto be 500000 andspent()to already include the tokens the main loop spent answering this turn before the workflow launched; the tool result's run block should show the run's own spend and the turn'sspent / 500000separately. In the same turn, a script that dispatches agents in a loop guarded bybudget.remaining() > 50_000should stop on its own before the target.Send the same request without a directive and confirm nothing changed:
budget.totalisnull, and the run block readstokens: N spent (no cap). WithQWEN_CODE_MAX_TOKENS_PER_WORKFLOW=1000set and no directive, the run block still readstokens: N / 1000 spentand the cap applies per run as before; with both set, the directive wins.Open
/workflowsfor the directive run and confirm it shows the run's own token spend with no cap, not the turn's numbers. Put+900konly inside an@-referenced file or inside backticks, and confirm it sets no target.Unit tests for the touched areas:
11 test files, 1333 tests pass locally (Linux, Node 22). ESLint and Prettier are clean on the changed files, and the core package type-checks.
The ledger test drives the real
LoggingContentGenerator, with nothing on the telemetry path mocked, and shows a main-loop call and a subagent call both landing in the total a turn budget is measured against.Evidence (Before & After)
N/A — no TUI layout change. The affected surfaces are the script's
budgetglobal, the model-visible run block and error messages, and the one-time usage banner text.Tested on
Environment (optional)
Unit tests only.
Risk & Scope
budget.spent()now reports the turn's spend instead of the run's, so a saved workflow that loggedbudget.spent()as "what this run cost" reads a larger number.budget.totalstaysnulland nothing is gated in that case, and the run's own spend is still reported everywhere the harness shows it. The env cap keeps its per-run meaning exactly.workflow-authoringskill) moves from 24,000 to 25,000 characters. On main that description already stood at 23,973, so no description of the turn budget fits under the old figure; the skill grows by about 740 characters and the fallback now stands near 24,700./workflowsdo not gain turn-level counters.Linked Issues
Part of #11013
中文说明
这个 PR 做了什么
用户现在可以在消息里给整轮设定 token 目标——
+500k、+1m、+2.5m,或者"use 300k tokens"。workflow 脚本里的budget.total就是这个目标,budget.spent()统计本会话从这一轮开始以来的全部输出 token:主循环和所有 workflow 的所有 agent,而不只是发问的这一次运行。budget.remaining()像现有上限一样拦截agent(),所以脚本可以一直循环到本轮预算用完,也可以在派发之前就按预算决定扇出规模。指令只从用户自己输入的文字里读取。解析前会去掉消息前面的系统提醒、
@引用的文件和 MCP 资源内容(ACP 可能把它们放在提示词之前)以及代码,slash 命令的参数永远不算。只有用户提问及其重试会携带指令;cron、goal、通知和 teammate 开启的轮次不带指令。轮次在所有前端都要经过的同一处开启,因此 TUI、ACP、daemon 和 Web Shell 行为一致。轮次进行中提出的旁支问题不会移动轮次起点,同一提示词的重试保留原起点,workflow 在启动时读取一次轮次,所以跨轮继续运行的 workflow 仍按它启动时的那一轮计量。按次运行的运维上限不变:没有指令时,
QWEN_CODE_MAX_TOKENS_PER_WORKFLOW照旧限制每次运行,spent()照旧统计本次运行的 agent。运行关于自身的展示在原来的所有位置都保持按次——/workflows、后台任务视图、快照和完成通知报告的是本次运行自己的花费,轮次目标不会被记为这次运行的上限。工具结果在本次运行的数字旁边加上本轮的进度(tokens: 12000 spent by this run · 150000 / 500000 this turn (+500k directive)),用量提示和审批对话框会说明将生效的目标,工具描述和workflow-authoringskill 讲清轮次语义,并给出"循环到预算用完"和"静态缩放"两种写法。两条错误信息有改动。被拒绝的派发现在是
Workflow <runId>: token budget exceeded (N / M output tokens). Stopping further agent() calls.;撞到 agent 数量上限时会说明最常见的原因——没设目标时对budget.remaining()循环,而这时remaining()是Infinity——以及两种解决办法。为什么需要
budget原来只是通过环境变量设置的按次上限。用户无法在消息里说明这一轮可以花多少,脚本也只能凭猜测决定工作量,因为它能读到的唯一数字只统计自己这次运行,而本轮真实的花费——主循环、其他运行、重试——都没有算进去。写作参考早就教了while (budget.total && budget.remaining() > 50_000),但实际上budget.total几乎总是null,这个循环要么根本不跑,要么在没有守卫时一直跑到 1000 个 agent 的上限。本改动对齐 Claude Code:budget.total是本轮的+500k目标,spent()是本会话自本轮开始以来的输出 token 增量。验证方式
启动会话,发送一条以
+500k结尾的消息,请求一个脚本返回[budget.total, budget.spent(), budget.remaining()]的小 workflow。预期total为 500000,spent()已经包含主循环在 workflow 启动前为本轮回答所花的 token;工具结果的运行信息里应分别显示本次运行的花费和本轮的spent / 500000。同一轮里,用budget.remaining() > 50_000守卫循环派发 agent 的脚本应该在达到目标前自行停下。不带指令发送同样的请求,确认行为不变:
budget.total为null,运行信息为tokens: N spent (no cap)。设置QWEN_CODE_MAX_TOKENS_PER_WORKFLOW=1000且不带指令时,运行信息仍为tokens: N / 1000 spent,上限照旧按次生效;两者都设时以指令为准。对带指令的运行打开
/workflows,确认显示的是本次运行自己的 token 花费且没有上限,而不是本轮的数字。只把+900k放在@引用的文件里或反引号里,确认不会设定目标。单元测试命令见英文部分,本地(Linux,Node 22)11 个测试文件共 1333 个测试通过,改动文件的 ESLint 和 Prettier 检查通过,core 包类型检查通过。账本测试驱动真实的
LoggingContentGenerator,遥测路径上不做任何 mock,证明主循环调用和子代理调用都会计入轮次预算所依据的总量。证据(前后对比)
N/A —— 没有 TUI 布局变化。受影响的是脚本的
budget全局对象、模型可见的运行信息和错误信息,以及一次性用量提示的文字。风险与范围
budget.spent()现在报告本轮花费而不是本次运行的花费,因此把budget.spent()当作"本次运行花了多少"来记录的已保存 workflow 会读到更大的数字。这种情况下budget.total仍为null,不会拦截任何调用,harness 展示本次运行花费的所有位置也仍是按次的。环境变量上限的按次含义完全不变。workflow-authoringskill 时使用)的尺寸上限从 24,000 提到 25,000 字符。main 上这段描述已经是 23,973 字符,任何关于轮次预算的说明都放不进旧上限;skill 增加约 740 字符,兜底描述现在约 24,700 字符。/workflows不增加轮次级计数。关联 issue
Part of #11013