mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
7 files changed
+467
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user