mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
1 parent
8c2f7c163c
commit
3baa8532b9
8 files changed
+436
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user