fix(agent): charge auxiliary usage to the active root token budget

Fold summary and failure-diagnosis terminal usage into the existing
context-bound root scope, including billed failures. Preserve local
normal-round telemetry and manual maintenance outside a root context.
This commit is contained in:
SivanCola committed 2026-10-26 19:27:31 +08:00
1 parent 8c2f7c163c
commit 3baa8532b9
8 files changed
+436

No files matched your search

+32
View File
@@ -391,6 +391,38 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root auxiliary budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootAuxiliary|EffectRootAuxiliary)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRootAuxiliaryAutomaticCompaction", "TestRootAuxiliaryTokenBudget",
"TestRootAuxiliaryTokenBudget/summary", "TestRootAuxiliaryTokenBudget/summary_failed",
"TestRootAuxiliaryTokenBudget/summary_missing", "TestRootAuxiliaryTokenBudget/summary_disabled",
"TestRootAuxiliaryTokenBudget/diagnosis", "TestRootAuxiliaryTokenBudget/diagnosis_failed",
"TestRootAuxiliaryTokenBudget/diagnosis_missing", "TestRootAuxiliaryTokenBudget/diagnosis_disabled",
"TestRootAuxiliaryManualCompactionOutsideRun", "TestRootAuxiliaryBudgetProjectionKeepsOldTask",
"TestEffectRootAuxiliarySummaryBudget", "TestEffectRootAuxiliarySummaryBudget/configured",
"TestEffectRootAuxiliarySummaryBudget/disabled", "TestEffectRootAuxiliarySummaryBudget/missing_usage"] - $tests | length) == 0
' "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
- name: upload Windows root auxiliary budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-auxiliary-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-auxiliary-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows estimated usage quotes)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
+86
View File
@@ -0,0 +1,86 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-26
---
# Auxiliary usage in root token budgets / 根 token 预算中的辅助用量
Summary and failure-diagnosis requests made inside an active Agent root context
now charge that context's existing token counter. Their usage events and
compaction receipts were already recorded; the root counter omitted them.
在活跃 Agent 根 context 内执行的摘要与失败诊断请求,现在计入该 context 既有
token counter。此前用量事件与压缩收据已有记录,但根计数漏掉了这些调用。
## Accounting boundary / 计账边界
Each auxiliary terminal folds its usage once, beside the existing usage-event
owner. Positive prompt and completion counts use the same saturating counter as
normal rounds, including positive estimates under that existing rule. Missing
counts remain unknown; request counts do not imply token counts.
每个辅助调用终态在既有用量事件 owner 处仅计一次。正 prompt/completion 计数使用
与正常轮次相同的饱和 counter,包括该既有规则下的正估算值。缺失计数仍未知,
请求次数不能推导为 token 数。
Usage received before a summary or diagnosis fails still counts, even when its
answer is discarded. Provider retries retain their existing aggregate-usage
semantics. No extra charge is generated from a compaction receipt or ledger
replay. Normal-round local telemetry remains separate and unchanged.
摘要或诊断失败前已收到的用量仍计入,即使答案被舍弃。供应商重试保留既有聚合
用量语义,不通过压缩收据或账本重放再次扣计。正常轮次的局部遥测保持独立且不变。
The scope comes from the request context, never from the Agent's current task
field. A budget-only projected job context retains its original scope after a
new task starts. Manual maintenance without a root context still reports its
usage but does not create or charge an unrelated root task.
scope 来自请求 context,不读取 Agent 当前 task 字段。仅投射预算的 job context
在新任务开始后仍保留原 scope。没有根 context 的手动维护仍上报用量,但不会
创建或扣计无关的根任务。
## Cooperative limits and compatibility / 协作式阈值与兼容
The explicit token gate remains cooperative after a tool round, with the
existing finalization. An auxiliary request can cross the threshold before a
normal request or tool runs; this change does not reserve or reject in advance.
Zero limits still leave execution unbounded.
显式 token 门禁仍在工具轮后协作式检查,并保留既有收尾。辅助请求可能在正常
请求或工具执行前已经越线;本改动不做提前预留或拒绝。零阈值仍不限执行。
No public API, configuration, persisted format, wire, permission or default is
added. Compaction timing, request construction, schemas and prefix reuse are
unchanged. Cost completeness and currency ledgers retain their existing rules.
没有新增公共 API、配置、持久化格式、wire、权限或默认值。压缩时机、请求构造、
schema 和前缀复用保持不变。费用完整性与币种账本沿用既有规则。
## Evidence boundary / 验证边界
Agent regressions exercise automatic pressure compaction, manual maintenance
and protected-failure diagnosis inside a real root Run. They cover billed
failure, missing usage, disabled limits and original-scope projection after a
new task. The late-scope fixture is constructed, not a production job incident.
Agent 回归通过真实根 Run 覆盖自动压力压缩、手动维护与受保护失败诊断,并检查
有用量的失败、缺失用量、禁用阈值及新任务后的原 scope 投射。延后 scope 夹具是
构造控制,不是生产 job 事故复现。
Real boot HTTP/SSE fixtures start with existing public transcript history,
trigger automatic compaction and verify actual file reads and usage provenance.
Existing cache-prefix regressions remain required; their mock results do not
measure a live provider's cache hit rate.
真实 boot HTTP/SSE 夹具通过既有公共 transcript 历史触发自动压缩,验证实际文件
读取和用量来源。既有缓存前缀回归仍须通过;mock 结果不是供应商命中率实测。
This is not complete auxiliary-provider admission, a shared Coordinator root,
currency aggregation, hard reservation, durable accounting or physical process
cancellation. See [root token budgets](ROOT_TOKEN_BUDGET_SECURITY.md).
本改动不等于完整辅助 provider 准入、共享 Coordinator 根预算、币种汇总、硬预留、
持久计账或物理进程取消。作者分析不等于独立 review 或原生平台执行。
@@ -0,0 +1,116 @@
package boot
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/runtime/agent"
"reasonix/internal/session/control"
"reasonix/internal/state/sessionstore"
)
func TestEffectRootAuxiliarySummaryBudget(t *testing.T) {
for _, tc := range []struct {
name string
limit int
missing bool
wantReads int32
}{
{name: "configured", limit: 1500, wantReads: 1},
{name: "disabled", wantReads: 4},
{name: "missing_usage", limit: 1500, missing: true, wantReads: 2},
} {
t.Run(tc.name, func(t *testing.T) {
home, root := statedBootHome(t), t.TempDir()
writeFile(t, root, "fixture.txt", "actual root auxiliary read")
var normal, summaries, reads, summaryEvents atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Tools json.RawMessage `json:"tools"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Error(err)
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
summary := len(req.Tools) == 0 || string(req.Tools) == "null"
if summary {
summaries.Add(1)
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"## Standing facts\\n- preserve public APIs and read fixture.txt\"},\"finish_reason\":\"stop\"}]}\n\n")
} else if n := normal.Add(1); n <= 4 {
_, _ = fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"read-%d\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"fixture.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", n)
} else {
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Finished.\"},\"finish_reason\":\"stop\"}]}\n\n")
}
if !summary || !tc.missing {
_, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":100,\"total_tokens\":1100}}\n\n")
}
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
t.Cleanup(srv.Close)
writeFile(t, root, "reasonix.toml", fmt.Sprintf(`default_model = "test"
[agent]
system_prompt = "BASE"
compact_ratio = 0.5
recent_keep = 2
task_token_budget = %d
[codegraph]
enabled = false
[[providers]]
name = "test"
kind = "openai"
model = "test"
context_window = 96000
max_output_tokens = 1024
base_url = %q
api_key_env = "ROOT_AUXILIARY_KEY"
`, tc.limit, srv.URL))
t.Setenv("ROOT_AUXILIARY_KEY", "test")
approveWorkspaceAt(t, home, root)
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.ToolResult && e.Tool.Name == "read_file" && e.Tool.Err == "" && strings.Contains(e.Tool.Output, "actual root auxiliary read") {
reads.Add(1)
}
if e.Kind == event.Usage && e.UsageSource == event.UsageSourceCompaction && e.Usage != nil {
if e.Usage.Estimated != tc.missing || e.Usage.RequestCount != 1 {
t.Errorf("summary usage provenance=%+v, missing=%v", e.Usage, tc.missing)
}
summaryEvents.Add(1)
}
})
ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: root, Sink: sink})
if err != nil {
t.Fatal(err)
}
t.Cleanup(ctrl.Close)
ctrl.SetSessionPath(sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label()))
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
// Shape existing public transcript history, rather than assigning a
// private clock or root counter to manufacture the budget crossing.
for range 40 {
ctrl.Executor().Session().Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("history word ", 1200)})
ctrl.Executor().Session().Add(provider.Message{Role: provider.RoleUser, Content: "continue"})
}
err = ctrl.Run(t.Context(), "read fixture.txt")
if tc.limit == 0 {
if err != nil {
t.Fatal(err)
}
} else if pause, ok := agent.InspectRunPause(err); !ok || pause.Key != "token" {
t.Fatalf("pause=%+v err=%v", pause, err)
}
if summaries.Load() != 1 || summaryEvents.Load() != 1 || reads.Load() != tc.wantReads || normal.Load() != tc.wantReads+1 {
t.Fatalf("summary HTTP/events=%d/%d normal HTTP/actual reads=%d/%d, want1/1/%d/%d", summaries.Load(), summaryEvents.Load(), normal.Load(), reads.Load(), tc.wantReads+1, tc.wantReads)
}
})
}
}
+1
View File
@@ -639,6 +639,7 @@ func (a *contextWindow) summarizeAtMode(ctx context.Context, region []provider.M
}()
defer func() {
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
observeRootTokenUsage(ctx, usage)
if usage != nil && (usage.TotalTokens > 0 || usage.RequestCount > 0) {
// Recorded beside the emit, not at the call site that keeps the
// answer: a repair whose digest is discarded was still charged.
@@ -95,6 +95,7 @@ func (a *Agent) selectDiagnosticLines(ctx context.Context, content string) ([]in
var usage *provider.Usage
defer func() {
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
observeRootTokenUsage(ctx, usage)
if usage != nil && (usage.TotalTokens > 0 || usage.RequestCount > 0) {
a.svc.sink.Emit(event.Event{Kind: event.Usage, ModelRef: a.modelRef, Usage: usage,
Pricing: a.svc.pricing, UsageSource: event.UsageSourceCompaction})
@@ -0,0 +1,191 @@
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/state/sessionstore"
)
type rootMaintenanceTool struct {
readProbe
child *Agent
diagnosis bool
calls int
}
type rootAutomaticMaintenanceProvider struct {
normal spendingProvider
summary scriptedSummarizer
}
func (p *rootAutomaticMaintenanceProvider) Name() string { return "root-automatic-maintenance" }
func (p *rootAutomaticMaintenanceProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
if len(req.Tools) == 0 {
return p.summary.Stream(ctx, req)
}
return p.normal.Stream(ctx, req)
}
func TestRootAuxiliaryAutomaticCompaction(t *testing.T) {
p := &rootAutomaticMaintenanceProvider{normal: spendingProvider{max: 4}, summary: scriptedSummarizer{replies: []scriptedReply{{text: "## Standing facts\n- never change the public API", usage: billed(1000, 100)}}}}
reg := tool.NewRegistry()
reg.Add(readProbe{})
a := New(p, reg, foldableSessionOverForce(30), Options{ContextWindow: 5000, CompactRatio: 0.5, RecentKeep: 2, ArchiveDir: t.TempDir(), TaskBudget: TaskBudget{Tokens: 1500}}, event.Discard)
err := a.Run(t.Context(), "read a few files")
if pause, ok := InspectRunPause(err); !ok || pause.Key != "token" {
t.Fatalf("pause=%+v err=%v, want token landing", pause, err)
}
if p.summary.calls != 1 || p.normal.rounds.Load() != 2 {
t.Fatalf("summary requests=%d normal requests=%d, want 1/2", p.summary.calls, p.normal.rounds.Load())
}
if got := a.task.budget.rootTokens.used.Load(); got != 3300 {
t.Fatalf("root tokens=%d, want summary and two normal reported usages=3300", got)
}
}
func (p *rootMaintenanceTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
p.calls++
if p.diagnosis {
code := 1
region := []provider.Message{{Role: provider.RoleTool, Content: strings.Join(padded("failure line ", 40), "\n"), ToolExecution: &provider.ToolExecution{State: tool.ShellStateFailed, ExitCode: &code}}}
p.child.annotateFailureDiagnostics(ctx, region, nil)
} else {
_, _ = p.child.CompactNow(ctx, CompactRequest{IgnoreEconomics: true})
}
return "maintenance attempted", nil
}
func TestRootAuxiliaryTokenBudget(t *testing.T) {
for _, tc := range []struct {
name string
diagnosis bool
fail bool
missing bool
disabled bool
wantReads int
}{
{name: "summary", wantReads: 1},
{name: "summary_failed", fail: true, wantReads: 1},
{name: "summary_missing", missing: true, wantReads: 2},
{name: "summary_disabled", disabled: true, wantReads: 4},
{name: "diagnosis", diagnosis: true, wantReads: 1},
{name: "diagnosis_failed", diagnosis: true, fail: true, wantReads: 1},
{name: "diagnosis_missing", diagnosis: true, missing: true, wantReads: 2},
{name: "diagnosis_disabled", diagnosis: true, disabled: true, wantReads: 4},
} {
t.Run(tc.name, func(t *testing.T) {
u := billed(1000, 100)
if tc.missing {
u = nil
}
var failure error
if tc.fail {
failure = errors.New("billed stream failed")
}
replies := make([]scriptedReply, 4)
for i := range replies {
replies[i] = scriptedReply{text: "## Standing facts\n- keep the public API", usage: u, fail: failure}
}
if tc.diagnosis {
for i := range replies {
replies[i].text = "1-3"
}
}
sink := &recordSink{}
child := New(&scriptedSummarizer{replies: replies}, tool.NewRegistry(), foldableSessionOverForce(30), Options{ContextWindow: 200000, RecentKeep: 2}, sink)
probe := &rootMaintenanceTool{child: child, diagnosis: tc.diagnosis}
reg := tool.NewRegistry()
reg.Add(probe)
prov := &spendingProvider{max: 4}
root := New(prov, reg, sessionstore.NewSession("root"), Options{TaskBudget: TaskBudget{Tokens: 1500}}, sink)
ctx := t.Context()
if tc.disabled {
ctx = WithTaskBudget(ctx, TaskBudget{})
}
err := root.Run(ctx, "run maintenance")
if tc.disabled {
if err != nil {
t.Fatal(err)
}
} else if pause, ok := InspectRunPause(err); !ok || pause.Key != "token" {
t.Fatalf("pause=%+v err=%v, want token landing", pause, err)
}
if probe.calls != tc.wantReads || prov.rounds.Load() != int32(tc.wantReads+1) {
t.Fatalf("tool calls=%d provider rounds=%d, want %d/%d", probe.calls, prov.rounds.Load(), tc.wantReads, tc.wantReads+1)
}
_, input, output := ledgerTotal(sink)
want := uint64(prov.rounds.Load())*1100 + uint64(input+output)
if root.task.budget.promptTokens+root.task.budget.outputTokens != int(prov.rounds.Load())*1100 {
t.Fatal("auxiliary usage changed local normal-round telemetry")
}
if got := root.task.budget.rootTokens.used.Load(); got != want {
t.Fatalf("root tokens=%d, want normal plus auxiliary emitted usage=%d", got, want)
}
if !tc.missing && input+output == 0 {
t.Fatal("fixture did not bill auxiliary usage")
}
})
}
}
func TestRootAuxiliaryManualCompactionOutsideRun(t *testing.T) {
sink := &recordSink{}
a := New(&scriptedSummarizer{replies: []scriptedReply{{text: "## Standing facts\n- keep public APIs", usage: billed(1000, 100)}}}, tool.NewRegistry(), foldableSessionOverForce(30), Options{ContextWindow: 200000, RecentKeep: 2}, sink)
if _, err := a.CompactNow(t.Context(), CompactRequest{IgnoreEconomics: true}); err != nil {
t.Fatal(err)
}
if a.task.budget.rootTokens != nil {
t.Fatal("manual maintenance created an unrelated root scope")
}
if calls, input, output := ledgerTotal(sink); calls != 1 || input+output != 1100 {
t.Fatalf("maintenance bill=%d/%d/%d", calls, input, output)
}
}
type rootMaintenanceCapture struct {
readProbe
projected context.Context
}
func (p *rootMaintenanceCapture) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
p.projected = InheritRootTokenBudget(context.Background(), ctx)
return "captured budget scope", nil
}
func TestRootAuxiliaryBudgetProjectionKeepsOldTask(t *testing.T) {
reg := tool.NewRegistry()
capture := &rootMaintenanceCapture{}
reg.Add(capture)
root := New(&spendingProvider{max: 1}, reg, sessionstore.NewSession("root"), Options{}, event.Discard)
ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel)
if err := root.Run(ctx, "capture root scope"); err != nil {
t.Fatal(err)
}
old := root.task.budget.rootTokens
before := old.used.Load()
cancel()
if err := root.Run(t.Context(), "new task"); err != nil {
t.Fatal(err)
}
current := root.task.budget.rootTokens
if current == old {
t.Fatal("new task kept old scope")
}
currentBefore := current.used.Load()
child := New(&scriptedSummarizer{replies: []scriptedReply{{text: "## Standing facts\n- keep public APIs", usage: billed(1000, 100)}}}, tool.NewRegistry(), foldableSessionOverForce(30), Options{ContextWindow: 200000, RecentKeep: 2}, event.Discard)
if _, err := child.CompactNow(capture.projected, CompactRequest{IgnoreEconomics: true}); err != nil {
t.Fatal(err)
}
if old.used.Load() != before+1100 || current.used.Load() != currentBefore {
t.Fatalf("late maintenance charged wrong scope: old=%d/%d new=%d/%d", old.used.Load(), before, current.used.Load(), currentBefore)
}
}
@@ -25,6 +25,13 @@ func InheritRootTokenBudget(ctx, parent context.Context) context.Context {
return ctx
}
// Auxiliary requests share the active context's scope. Using that scope rather
// than Agent task state also keeps late usage attached to its original task.
func observeRootTokenUsage(ctx context.Context, usage *provider.Usage) {
scope, _ := ctx.Value(rootTokenBudgetKey{}).(*rootTokenBudget)
scope.observe(usage)
}
func (a *Agent) bindRootTokenBudget(ctx context.Context, state *turnRuntime) context.Context {
scope, inherited := ctx.Value(rootTokenBudgetKey{}).(*rootTokenBudget)
if !inherited {
@@ -0,0 +1,2 @@
- 修复:活跃 Agent 根任务中的摘要与失败诊断用量现在计入既有 token 软预算,保留缺失用量、零阈值、局部遥测和无根 context 手动维护的行为。关联 #14025。
- Fixed: active-root summary and failure-diagnosis usage now counts toward its token soft budget. Missing usage, zero limits and local telemetry retain their behavior. Refs #14025.