Merge pull request #14631 from SivanCola/codex/skill-entry-root-budget-14025

fix(control): share root budget with explicit skills / 显式技能共享任务根预算
This commit is contained in:
YHH authored and GitHub committed 2026-10-27 10:56:48 -07:00
commit 7059b4ac45
7 files changed
+467 -14

No files matched your search

+32
View File
@@ -668,6 +668,38 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: test (Windows explicit skill root 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 '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/explicit-skill-root-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/session/control", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestProfileRootBudgetLifetime", "TestProfileRootBudgetLifetime/new_task",
"TestProfileRootBudgetLifetime/same_scope", "TestProfileRootBudgetLifetime/new_scope",
"TestProfileRootBudgetLifetime/reset", "TestProfileRootBudgetLifetime/new_session",
"TestProfileRootBudgetLifetime/inherited", "TestProfileRootBudgetLifetime/context_zero",
"TestProfileBudgetPreparationUsesTurnAdmission", "TestEffectExplicitSkillRootBudget",
"TestEffectExplicitSkillRootBudget/profile", "TestEffectExplicitSkillRootBudget/preview",
"TestEffectExplicitSkillRootBudget/slash", "TestEffectExplicitSkillRootBudget/chips",
"TestEffectExplicitSkillRootBudget/goal", "TestEffectExplicitSkillRootBudget/context_zero",
"TestEffectExplicitSkillRootBudget/unconfigured"] - $tests | length) == 0
' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
- name: upload Windows explicit skill root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: explicit-skill-root-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/explicit-skill-root-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root triage budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
+79
View File
@@ -0,0 +1,79 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-27
---
# Explicit skill root budget / 显式技能根预算
## Behavior / 行为
Explicit subagent slash commands and composer selections share the executor's
existing token/wall root. Preparation occurs after checkpoint and blocking
prompt hooks. An active Goal uses its existing budget and usage recorder.
显式子代理 slash 命令和 composer 选择共享 executor 已有的 token/wall 根预算。
在检查点及可阻止提交的 hook 之后准备;活跃 Goal 使用已有预算和用量 recorder。
Synchronous CLI profile run and preview use the Controller's existing blocking
turn admission before root preparation. Busy, closed and draining controllers
refuse entry; Controller cancellation reaches the admitted profile. Existing
workspace activity and write-authority checks now apply to this entry too.
同步 CLI profile run 和 preview 在准备根预算前走 Controller 已有同步准入。
忙碌、关闭或 draining 时拒绝进入;Controller 取消传给已准入的 profile。
这一入口也遵守已有工作区活动和写入 authority 检查。
## Lifetime and limits / 生命周期与边界
Same delivery scope retains accumulated spend. New tasks, sessions, scope IDs
and explicit budget resets start a fresh root. An inherited root remains the
owner; projecting an old context into a job retains the old counter without
copying its cancellation. Multiple selected skills share one root.
同 delivery scope 保留累计用量;新任务、session、scope ID 和显式预算 reset
开始新根。继承的根保持所有权;旧 context 投射给 job 保留旧计数但不复制取消。
一次选择的多个技能共享同一根。
Zero remains unlimited, including an explicit context override. Checks are
cooperative after tools and keep existing finalization.
A paused child returns
its existing typed error; subsequent selected skills are not started after
that error. A later child may still perform its first tool round on an already
spent root. This is not request admission or a hard reservation.
零额度仍不限,包括显式 context 覆盖。在工具后协作检查并保留已有收尾。
暂停的 child 返回已有类型化错误,之后的选中技能不再启动;后续 child 仍可能
在已耗尽的根上执行首个工具轮。这不是请求准入或硬预留。
The clock starts at preparation, not boot or earlier queueing. Local Agent
budgets remain stricter independent gates. Money, durable recovery, all
auxiliary consumers and physical process quiescence remain separate work.
时钟从准备根开始,不含 boot 或更早排队。Agent 本地额度仍是独立的更严格 gate。
金额、持久恢复、全部辅助消费者及物理进程静默仍是独立工作。
## Evidence / 证据
Boot HTTP/SSE tests exercise profile, preview, slash, composer multi-selection
and Goal against real file reads. Zero configuration and explicit zero remain
controls.
Real Controller/Agent sequences cover root lifetime, old job
projection and cancellation/admission. These are constructed local tests, not
an observed production incident, frontend interaction or restart proof.
Boot HTTP/SSE 测试通过真实文件读取覆盖 profile、preview、slash、composer
多选和 Goal,保留零配置与显式零值对照。真实 Controller/Agent 序列覆盖根寿命、
旧 job 投射及取消/准入。这些是本机构造测试,不是线上事故、前端交互或重启证明。
No new API, configuration, wire or persisted schema is introduced. Model
selection, tool permissions and request construction stay with their owners.
The HTTP fixture checks stable child prefix/schema bytes within a run; it does
not measure live provider cache hit rate. The complete #14025 plan remains open.
没有新增 API、配置、传输或持久化 schema。模型选择、工具权限和请求构造保留
原有 owner。HTTP 夹具只核对同轮 child 前缀/schema 字节稳定,不测线上缓存命中率。
完整 #14025 计划仍未闭环。
@@ -0,0 +1,183 @@
package boot
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"reasonix/internal/contract/event"
"reasonix/internal/runtime/agent"
"reasonix/internal/session/control"
)
func TestEffectExplicitSkillRootBudget(t *testing.T) {
for _, tc := range []struct {
name string
limit int
wantReads int32
}{
{"profile", 1500, 2},
{"preview", 1500, 2},
{"slash", 1500, 2},
{"chips", 3000, 2},
{"goal", 0, 2},
{"context_zero", 1500, 3},
{"unconfigured", 0, 3},
} {
t.Run(tc.name, func(t *testing.T) {
home, root := statedBootHome(t), t.TempDir()
writeFile(t, root, "fixture.txt", "explicit skill budget fixture")
for _, name := range []string{"probe", "second"} {
writeFile(t, home, "skills/"+name+"/SKILL.md", "---\nname: "+name+"\ndescription: Read the fixture\nrunAs: subagent\nread-only: true\nallowed-tools: read_file\nmodel: "+name+"\n---\nRead fixture.txt and report it.\n")
}
var calls [2]atomic.Int32
var reads, receipts atomic.Int32
var observed sync.Map
var prefixes sync.Map
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Model string
Tools []json.RawMessage
Messages []struct {
Role string
Content json.RawMessage
ToolCallID string `json:"tool_call_id"`
}
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Error(err)
w.WriteHeader(http.StatusBadRequest)
return
}
for _, message := range req.Messages {
if message.Role == "tool" && strings.Contains(string(message.Content), "explicit skill budget fixture") {
if _, loaded := observed.LoadOrStore(message.ToolCallID, true); !loaded {
reads.Add(1)
}
}
}
if len(req.Tools) > 0 && len(req.Messages) > 0 {
stable, _ := json.Marshal(struct {
Prefix json.RawMessage
Tools []json.RawMessage
}{req.Messages[0].Content, req.Tools})
previous, loaded := prefixes.LoadOrStore(req.Model, stable)
if loaded && !bytes.Equal(previous.([]byte), stable) {
t.Error("child prefix/schema changed")
}
}
i := 0
if req.Model == "second" {
i = 1
}
n := calls[i].Add(1)
w.Header().Set("Content-Type", "text/event-stream")
maxReads := int32(3)
if tc.name == "chips" && i == 0 {
maxReads = 1
}
if len(req.Tools) > 0 && n <= maxReads {
args, _ := json.Marshal(map[string]string{"path": "fixture.txt"})
payload, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": fmt.Sprintf("read-%d-%d", i, n), "type": "function", "function": map[string]any{"name": "read_file", "arguments": string(args)}}}}, "finish_reason": "tool_calls"}}})
_, _ = fmt.Fprintf(w, "data: %s\n\n", payload)
} else {
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Fixture read complete.\"},\"finish_reason\":\"stop\"}]}\n\n")
}
_, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":100,\"total_tokens\":1100}}\n\ndata: [DONE]\n\n")
}))
t.Cleanup(srv.Close)
goalLimit := 0
if tc.name == "goal" {
goalLimit = 1500
}
writeFile(t, root, "reasonix.toml", fmt.Sprintf(`default_model = "probe"
[agent]
task_token_budget = %d
goal_token_budget = %d
[codegraph]
enabled = false
[[providers]]
name = "probe"
kind = "openai"
model = "probe"
base_url = %q
api_key_env = "SKILL_ROOT_EFFECT_KEY"
[[providers]]
name = "second"
kind = "openai"
model = "second"
base_url = %q
api_key_env = "SKILL_ROOT_EFFECT_KEY"
`, tc.limit, goalLimit, srv.URL, srv.URL))
t.Setenv("SKILL_ROOT_EFFECT_KEY", "test")
approveWorkspaceAt(t, home, root)
done := make(chan struct{}, 1)
ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: root, Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Usage && e.UsageSource == event.UsageSourceSubagent {
receipts.Add(1)
if e.Usage == nil || e.Usage.RequestCount != 1 || e.Usage.Estimated {
t.Errorf("usage=%+v", e.Usage)
}
}
if e.Kind == event.TurnDone {
select {
case done <- struct{}{}:
default:
}
}
})})
if err != nil {
t.Fatal(err)
}
t.Cleanup(ctrl.Close)
ctrl.EnsureSessionPath()
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
ctx := t.Context()
if tc.name == "context_zero" {
ctx = agent.WithTaskBudget(ctx, agent.TaskBudget{})
}
switch tc.name {
case "slash":
ctrl.Submit("/probe read the fixture")
case "goal":
ctrl.SetGoal("Read the fixture")
ctrl.Submit("/probe read the fixture")
case "chips":
ctrl.SubmitInvocationDisplay("/probe /second read the fixture", "read the fixture", []control.InvocationRequest{{Name: "probe", Kind: "subagent"}, {Name: "second", Kind: "subagent"}})
default:
_, err = ctrl.RunSubagentProfile(ctx, "probe", "read the fixture", tc.name == "preview")
if tc.limit > 0 && tc.name != "context_zero" {
if pause, ok := agent.InspectRunPause(err); !ok || pause.Key != "token" {
t.Fatalf("pause=%+v err=%v", pause, err)
}
} else if err != nil {
t.Fatal(err)
}
}
if tc.name == "slash" || tc.name == "chips" || tc.name == "goal" {
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("skill turn did not finish")
}
}
if tc.name == "goal" && ctrl.GoalStatus() != control.GoalStatusBlocked {
t.Errorf("goal status=%s", ctrl.GoalStatus())
}
if reads.Load() != tc.wantReads {
t.Errorf("real reads=%d, want %d; HTTP=%d/%d", reads.Load(), tc.wantReads, calls[0].Load(), calls[1].Load())
}
if (tc.name == "slash" || tc.name == "chips") && receipts.Load() != calls[0].Load()+calls[1].Load() {
t.Errorf("receipts=%d HTTP=%d/%d", receipts.Load(), calls[0].Load(), calls[1].Load())
}
})
}
}
@@ -0,0 +1,139 @@
package control
import (
"context"
"errors"
"fmt"
"path/filepath"
"sync/atomic"
"testing"
"time"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/skill"
"reasonix/internal/runtime/agent"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/state/sessionstore"
)
func TestProfileRootBudgetLifetime(t *testing.T) {
for _, mode := range []string{"new_task", "same_scope", "new_scope", "reset", "new_session", "inherited", "context_zero"} {
t.Run(mode, func(t *testing.T) {
parent := agent.New(nil, tool.NewRegistry(), sessionstore.NewSession("parent"), agent.Options{TaskBudget: agent.TaskBudget{Tokens: 1500}}, event.Discard)
var reads int
var oldContext context.Context
runner := func(ctx context.Context, _ skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) {
oldContext = ctx
reg := tool.NewRegistry()
reg.Add(fakeControlTool{name: "read_file"})
var turns []testutil.Turn
for i := range 3 {
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{ID: fmt.Sprintf("read-%d", i), Name: "read_file", Arguments: `{}`}}, Usage: &provider.Usage{PromptTokens: 1000, CompletionTokens: 100, RequestCount: 1}})
}
turns = append(turns, testutil.Turn{Text: "done"})
child := agent.New(testutil.NewMock("child", turns...), reg, sessionstore.NewSession("child"), agent.Options{}, event.FuncSink(func(e event.Event) {
if e.Kind == event.ToolResult && e.Tool.Name == "read_file" && e.Tool.Err == "" {
reads++
}
}))
err := child.Run(ctx, task)
if _, paused := agent.InspectRunPause(err); paused {
err = nil
}
return "done", err
}
dir := t.TempDir()
c := New(Options{Runner: parent, Executor: parent, SkillRunner: runner, SessionDir: dir, SessionPath: filepath.Join(dir, "s.jsonl"), Skills: []skill.Skill{{Name: "probe", RunAs: skill.RunSubagent}}})
t.Cleanup(c.Close)
ctx := t.Context()
if mode != "new_task" && mode != "context_zero" {
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: "one", TaskText: "read"})
}
if mode == "inherited" {
owner := agent.New(nil, tool.NewRegistry(), sessionstore.NewSession("outer"), agent.Options{TaskBudget: agent.TaskBudget{Tokens: 1500}}, event.Discard)
ctx = owner.PrepareRootTaskBudget(ctx)
}
if mode == "context_zero" {
ctx = agent.WithTaskBudget(ctx, agent.TaskBudget{})
}
if _, err := c.RunSubagentProfile(ctx, "probe", "read", false); err != nil {
t.Fatal(err)
}
first := reads
wantFirst, wantSecond := 2, 2
if mode == "same_scope" || mode == "inherited" {
wantSecond = 1
}
switch mode {
case "new_scope":
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: "two", TaskText: "next"})
case "reset":
parent.ResetTaskBudget()
case "new_session":
parent.SetSession(sessionstore.NewSession("new"))
case "context_zero":
wantFirst, wantSecond = 3, 3
}
old := oldContext
if _, err := c.RunSubagentProfile(ctx, "probe", "next", false); err != nil {
t.Fatal(err)
}
if first != wantFirst || reads-first != wantSecond {
t.Fatalf("reads=%d/%d want=%d/%d", first, reads-first, wantFirst, wantSecond)
}
if mode == "reset" {
// A projected managed job has independent cancellation but retains its old spend.
jobCtx := agent.InheritRootTokenBudget(t.Context(), old)
before := reads
if _, err := runner(jobCtx, skill.Skill{}, "old job", skill.SubagentRunOptions{}); err != nil {
t.Fatal(err)
}
if reads-before != 1 {
t.Fatalf("old job reads=%d", reads-before)
}
}
})
}
}
func TestProfileBudgetPreparationUsesTurnAdmission(t *testing.T) {
entered, release := make(chan struct{}), make(chan struct{})
var calls atomic.Int32
parent := agent.New(nil, tool.NewRegistry(), sessionstore.NewSession("parent"), agent.Options{}, event.Discard)
c := New(Options{Runner: parent, Executor: parent, Skills: []skill.Skill{{Name: "probe", RunAs: skill.RunSubagent}}, SkillRunner: func(ctx context.Context, _ skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) {
if calls.Add(1) != 1 {
return "unexpected concurrent run", nil
}
close(entered)
select {
case <-ctx.Done():
return "", ctx.Err()
case <-release:
return "released", nil
}
}})
t.Cleanup(c.Close)
defer close(release)
done := make(chan error, 1)
go func() { _, err := c.RunSubagentProfile(t.Context(), "probe", "first", false); done <- err }()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("runner did not enter")
}
_, second := c.RunSubagentProfile(t.Context(), "probe", "second", false)
if !errors.Is(second, ErrTurnRunning) || calls.Load() != 1 {
t.Errorf("second=%v calls=%d", second, calls.Load())
}
c.Cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Errorf("cancel=%v", err)
}
case <-time.After(5 * time.Second):
t.Error("controller cancellation did not reach profile")
}
}
+15 -9
View File
@@ -297,15 +297,21 @@ func (c *Controller) RunSubagentProfile(ctx context.Context, name, task string,
return "", fmt.Errorf("subagent skill runner is unavailable for %q", name)
}
c.maybeSessionStart(ctx)
parentSession := c.parentSessionID()
ctx = agent.WithParentSession(ctx, parentSession)
ctx = jobs.WithSession(ctx, parentSession)
ctx, turnImgs := c.withTurnImages(ctx, task)
ctx = langpref.WithResponseLanguagePreference(ctx, c.display.responseLanguage)
ctx = langpref.WithReasoningLanguagePreference(ctx, c.display.reasoningLanguage)
ctx = agent.WithSubagentDepth(ctx, 0)
answer, err := runner(ctx, sk, c.imageRoutingPrefix(turnImgs)+task, skill.SubagentRunOptions{HostInitiated: true})
var answer string
err := c.runSynchronousTurn(ctx, nil, func(ctx context.Context) error {
c.maybeSessionStart(ctx)
parentSession := c.parentSessionID()
ctx = agent.WithParentSession(ctx, parentSession)
ctx = jobs.WithSession(ctx, parentSession)
ctx, turnImgs := c.withTurnImages(ctx, task)
ctx = langpref.WithResponseLanguagePreference(ctx, c.display.responseLanguage)
ctx = langpref.WithReasoningLanguagePreference(ctx, c.display.reasoningLanguage)
ctx = agent.WithSubagentDepth(ctx, 0)
ctx = c.prepareTurnRootBudget(ctx)
var err error
answer, err = runner(ctx, sk, c.imageRoutingPrefix(turnImgs)+task, skill.SubagentRunOptions{HostInitiated: true})
return err
})
if err != nil {
return "", err
}
@@ -82,13 +82,8 @@ func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, sk
expectedContinuationEpoch := o.c.goals.continuationToken()
images := o.c.resolveTurnImages(raw)
ctx = o.c.withVisionRouting(agent.WithSubagentImageCandidates(ctx, images.candidates))
// The skill turn's model requests count against the active goal's token
// budget, so bind a recorder for the span even though the sub-agent cannot
// call update_goal itself.
if scopeID, goal, ok := o.c.goals.deliveryScope(); ok {
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: scopeID, TaskText: goal})
recorder := o.c.goals.newTurnRecorder(scopeID, o.c.goals.continuationToken())
o.c.goalUsageTee.setActiveRecorder(recorder)
}
startMessages := o.c.sessionMessageCount()
err := o.runSubagentSkillTurns(ctx, skills, task, raw, display, runner, planMode, images)
@@ -151,6 +146,8 @@ func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []s
}
ctx, marker = c.beginTurn(ctx, startMessages, true)
ctx = c.announceAuthoredTurn(ctx, firstNonEmpty(raw, task), startMessages)
ctx = c.bindTurnScope(ctx, nil)
ctx = c.prepareTurnRootBudget(ctx)
c.executor.LandAuthoredUserMessage(ctx, provider.Message{Role: provider.RoleUser, Content: input, Images: images.userImages, CreatedAt: time.Now().UnixMilli()})
for _, sk := range skills {
@@ -0,0 +1,17 @@
显式子代理技能调用遵守已有任务根预算。
## 修复
- slash、composer 多选及 Goal 技能调用共享已有 token/wall 根预算,保留零额度不限和原有收尾。关联 #14025。
- CLI profile run/preview 复用同步回合准入及取消;忙碌、关闭、draining 和工作区 authority 检查同样适用。关联 #14025。
- 本次不提供硬预留、根金额汇总或完整隔离恢复保障。关联 #14025。
---
<!-- lang:en -->
## Fixed
- Explicit slash, composer and Goal subagent skills share the existing token/wall root, preserving unlimited zero and finalization. Refs #14025.
- CLI profile run/preview use synchronous turn admission and cancellation, including busy, closed, draining and workspace authority checks. Refs #14025.
- Hard reservations, root money accounting and complete isolation/recovery remain outside this change. Refs #14025.