detached b482fc2

fix(subagents): use child model context budget (#169)

* fix(subagents): use child model context budget

* fix(subagents): preserve legacy budget source

* fix(subagents): reject incompatible budget journals
commit
b482fc2f34f0e198b59ae7a1357f1ae6a3b874b4
author
bnema · · 2026-09-03 15:56 UTC
committer
GitHub · · 2026-09-03 15:56 UTC
parent
cce7649
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
19 files · +199 -476
M README.md +1 -1
@@ -424,7 +424,7 @@ Resident subagents run alongside the main session as independent in-process agen
424424
425425 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.
426426
427-Each child receives a cumulative rollout budget equal to 75% of the active parent model's context window. `subagents.budget_ratio`, profile frontmatter (`budgetRatio` or `budgetTokens`), and spawn arguments (`budget_ratio` or `budget_tokens`) can override it. The child and dashboard show progressive budget states; at 90% zut disables tools and reserves the remainder for finalization, and at 100% it makes the current response terminal. Budget-exhausted children reject follow-ups.
427+Each child receives a cumulative rollout budget equal to its resolved model's context window. The dashboard shows its usage, and zut allows normal work until the hard limit is reached. Budget-exhausted children reject follow-ups.
428428
429429 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.
430430
@@ -424,7 +424,7 @@ Resident subagents run alongside the main session as independent in-process agen
424 424
425 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.425 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.
426 426
427-Each child receives a cumulative rollout budget equal to 75% of the active parent model's context window. `subagents.budget_ratio`, profile frontmatter (`budgetRatio` or `budgetTokens`), and spawn arguments (`budget_ratio` or `budget_tokens`) can override it. The child and dashboard show progressive budget states; at 90% zut disables tools and reserves the remainder for finalization, and at 100% it makes the current response terminal. Budget-exhausted children reject follow-ups.427+Each child receives a cumulative rollout budget equal to its resolved model's context window. The dashboard shows its usage, and zut allows normal work until the hard limit is reached. Budget-exhausted children reject follow-ups.
428 428
429 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.429 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.
430 430
M docs/subagents.md +17 -22
@@ -21,7 +21,6 @@ description: Read-only code reviewer
2121 tools: [read, grep]
2222 model: openai-codex/gpt-5.6-luna
2323 thinking: high
24-budgetRatio: 0.6
2524 systemPromptMode: replace
2625 inheritProjectContext: false
2726 inheritSkills: false
@@ -36,11 +35,9 @@ catalogue, `tools: []` grants no tools, and an explicit list replaces the
3635 default. Names unavailable to the child (including host-only extensions) and
3736 tools denied by `subagents.allowed_tools` are omitted from an explicit list;
3837 they never grant the child additional access. `model`, `provider`, `thinking`,
39-the prompt mode, project/skill inheritance, `fastMode`, and optional
40-`budgetRatio` or `budgetTokens` override are resolved before the child is
41-accepted. Spawn-level model, provider, reasoning, fast-mode, or budget values
42-take precedence where supplied. `budgetRatio` and `budgetTokens` are mutually
43-exclusive.
38+the prompt mode, project/skill inheritance, and `fastMode` are resolved before
39+the child is accepted. Spawn-level model, provider, reasoning, and fast-mode
40+values take precedence where supplied.
4441
4542 Children are always fresh conversations: they receive the delegated task and
4643 resolved host/profile context, never the parent transcript or hidden reasoning.
@@ -89,23 +86,21 @@ one turn for each child, and defaults to six concurrent turns. Set
8986 unlimited by default; a positive `subagents.queue_timeout` cancels an accepted
9087 prompt that has not received a slot and records its terminal failure durably.
9188 `subagents.allowed_tools` is an allowlist for child-visible tools and
92-`subagents.allowed_roots` limits eligible child workspaces. Each child also
93-inherits a cumulative weighted-token budget equal to 75% of the active parent
94-model's context window. Set `subagents.budget_ratio` to another value greater
95-than zero and at most one, or override one spawn/profile with `budget_ratio` or
96-`budget_tokens`. Removed legacy settings, including `tui_subagent_position`,
97-are ignored.
89+`subagents.allowed_roots` limits eligible child workspaces. Each child receives
90+a cumulative weighted-token budget equal to its resolved model's context window.
91+Removed legacy settings, including `tui_subagent_position` and
92+`subagents.budget_ratio`, are ignored. Resident journals created before this
93+budget policy are incompatible: zut leaves them untouched, reports a recovery
94+error, and requires a new child rather than resuming them.
9895
9996 Budget accounting charges uncached input, cache writes, and output fully; cache
10097 reads count at 25%. It is separate from active context-window usage and survives
101-follow-up turns and compaction. The child and TUI receive progressive states at
102-50% (`notice`), 70% (`focused`), 85% (`verifying`), and 90% (`finalizing`). At
103-90%, zut disables further tool use and reserves the remaining budget for the
104-final response. Every request's output cap is also clamped to the remaining
105-budget. Providers report exact input usage only after a response, so the
106-terminal response can make displayed usage exceed 100%; no later request is
107-sent. An exhausted child cannot accept follow-up turns; spawn a replacement
108-with an explicitly larger override when more work is required.
98+follow-up turns and compaction. The TUI shows the child's current budget usage,
99+but does not alter its instructions, tools, or output cap before exhaustion.
100+Providers report exact input usage only after a response, so the terminal
101+response can make displayed usage exceed 100%; no later request is sent. An
102+exhausted child cannot accept follow-up turns; spawn a replacement when more
103+work is required.
109104
110105 `required: true` makes a delegated result an obligation of the parent turn.
111106 Failed, cancelled, and interrupted required work remains unresolved until an
@@ -139,8 +134,8 @@ required capability.
139134 The model-facing tools retain their logical names:
140135
141136 - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,
142- `reasoning`, `fast_mode`, `required`, `wait`, `isolation` (`shared` or
143- `worktree`), and mutually exclusive `budget_ratio` or `budget_tokens`.
137+ `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
138+ `worktree`).
144139 `wait` is an explicit whole-second value from 1 through 300; when omitted,
145140 spawning returns immediately. A timed-out wait leaves the
146141 accepted child active, whether it is queued or running. Do not retry that
@@ -21,7 +21,6 @@ description: Read-only code reviewer
21 tools: [read, grep]21 tools: [read, grep]
22 model: openai-codex/gpt-5.6-luna22 model: openai-codex/gpt-5.6-luna
23 thinking: high23 thinking: high
24-budgetRatio: 0.6
25 systemPromptMode: replace24 systemPromptMode: replace
26 inheritProjectContext: false25 inheritProjectContext: false
27 inheritSkills: false26 inheritSkills: false
@@ -36,11 +35,9 @@ catalogue, `tools: []` grants no tools, and an explicit list replaces the
36 default. Names unavailable to the child (including host-only extensions) and35 default. Names unavailable to the child (including host-only extensions) and
37 tools denied by `subagents.allowed_tools` are omitted from an explicit list;36 tools denied by `subagents.allowed_tools` are omitted from an explicit list;
38 they never grant the child additional access. `model`, `provider`, `thinking`,37 they never grant the child additional access. `model`, `provider`, `thinking`,
39-the prompt mode, project/skill inheritance, `fastMode`, and optional38+the prompt mode, project/skill inheritance, and `fastMode` are resolved before
40-`budgetRatio` or `budgetTokens` override are resolved before the child is39+the child is accepted. Spawn-level model, provider, reasoning, and fast-mode
41-accepted. Spawn-level model, provider, reasoning, fast-mode, or budget values40+values take precedence where supplied.
42-take precedence where supplied. `budgetRatio` and `budgetTokens` are mutually
43-exclusive.
44 41
45 Children are always fresh conversations: they receive the delegated task and42 Children are always fresh conversations: they receive the delegated task and
46 resolved host/profile context, never the parent transcript or hidden reasoning.43 resolved host/profile context, never the parent transcript or hidden reasoning.
@@ -89,23 +86,21 @@ one turn for each child, and defaults to six concurrent turns. Set
89 unlimited by default; a positive `subagents.queue_timeout` cancels an accepted86 unlimited by default; a positive `subagents.queue_timeout` cancels an accepted
90 prompt that has not received a slot and records its terminal failure durably.87 prompt that has not received a slot and records its terminal failure durably.
91 `subagents.allowed_tools` is an allowlist for child-visible tools and88 `subagents.allowed_tools` is an allowlist for child-visible tools and
92-`subagents.allowed_roots` limits eligible child workspaces. Each child also89+`subagents.allowed_roots` limits eligible child workspaces. Each child receives
93-inherits a cumulative weighted-token budget equal to 75% of the active parent90+a cumulative weighted-token budget equal to its resolved model's context window.
94-model's context window. Set `subagents.budget_ratio` to another value greater91+Removed legacy settings, including `tui_subagent_position` and
95-than zero and at most one, or override one spawn/profile with `budget_ratio` or92+`subagents.budget_ratio`, are ignored. Resident journals created before this
96-`budget_tokens`. Removed legacy settings, including `tui_subagent_position`,93+budget policy are incompatible: zut leaves them untouched, reports a recovery
97-are ignored.94+error, and requires a new child rather than resuming them.
98 95
99 Budget accounting charges uncached input, cache writes, and output fully; cache96 Budget accounting charges uncached input, cache writes, and output fully; cache
100 reads count at 25%. It is separate from active context-window usage and survives97 reads count at 25%. It is separate from active context-window usage and survives
101-follow-up turns and compaction. The child and TUI receive progressive states at98+follow-up turns and compaction. The TUI shows the child's current budget usage,
102-50% (`notice`), 70% (`focused`), 85% (`verifying`), and 90% (`finalizing`). At99+but does not alter its instructions, tools, or output cap before exhaustion.
103-90%, zut disables further tool use and reserves the remaining budget for the100+Providers report exact input usage only after a response, so the terminal
104-final response. Every request's output cap is also clamped to the remaining101+response can make displayed usage exceed 100%; no later request is sent. An
105-budget. Providers report exact input usage only after a response, so the102+exhausted child cannot accept follow-up turns; spawn a replacement when more
106-terminal response can make displayed usage exceed 100%; no later request is103+work is required.
107-sent. An exhausted child cannot accept follow-up turns; spawn a replacement
108-with an explicitly larger override when more work is required.
109 104
110 `required: true` makes a delegated result an obligation of the parent turn.105 `required: true` makes a delegated result an obligation of the parent turn.
111 Failed, cancelled, and interrupted required work remains unresolved until an106 Failed, cancelled, and interrupted required work remains unresolved until an
@@ -139,8 +134,8 @@ required capability.
139 The model-facing tools retain their logical names:134 The model-facing tools retain their logical names:
140 135
141 - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,136 - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,
142- `reasoning`, `fast_mode`, `required`, `wait`, `isolation` (`shared` or137+ `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
143- `worktree`), and mutually exclusive `budget_ratio` or `budget_tokens`.138+ `worktree`).
144 `wait` is an explicit whole-second value from 1 through 300; when omitted,139 `wait` is an explicit whole-second value from 1 through 300; when omitted,
145 spawning returns immediately. A timed-out wait leaves the140 spawning returns immediately. A timed-out wait leaves the
146 accepted child active, whether it is queued or running. Do not retry that141 accepted child active, whether it is queued or running. Do not retry that
M packages/agent/config.go +2 -8
@@ -6,7 +6,6 @@ import (
66 "encoding/json"
77 "errors"
88 "fmt"
9- "math"
109 "os"
1110 "path/filepath"
1211 "runtime"
@@ -25,15 +24,13 @@ type QuickModelShortcut struct {
2524
2625 // SubagentsConfig contains the resident manager policy. max_concurrent limits
2726 // simultaneous turns, queue_timeout bounds admission waits, allowed_tools
28-// limits child capabilities, allowed_roots limits eligible workspaces, and
29-// budget_ratio derives each child's cumulative rollout budget from the active
30-// parent model's context window. Obsolete keys are ignored.
27+// limits child capabilities and allowed_roots limits eligible workspaces.
28+// Obsolete keys are ignored.
3129 type SubagentsConfig struct {
3230 MaxConcurrent int `json:"max_concurrent,omitempty"`
3331 QueueTimeout string `json:"queue_timeout,omitempty"`
3432 AllowedTools []string `json:"allowed_tools,omitempty"`
3533 AllowedRoots []string `json:"allowed_roots,omitempty"`
36- BudgetRatio *float64 `json:"budget_ratio,omitempty"`
3734 }
3835
3936 // GoalsConfig controls optional resource limits for autonomous goals. A nil
@@ -325,9 +322,6 @@ func LoadConfig() (Config, error) {
325322 }
326323
327324 func validateSubagentConfig(cfg SubagentsConfig) error {
328- if cfg.BudgetRatio != nil && (math.IsNaN(*cfg.BudgetRatio) || math.IsInf(*cfg.BudgetRatio, 0) || *cfg.BudgetRatio <= 0 || *cfg.BudgetRatio > 1) {
329- return fmt.Errorf("parse config: subagents.budget_ratio must be greater than 0 and at most 1")
330- }
331325 values := []struct {
332326 name string
333327 value string
@@ -6,7 +6,6 @@ import (
6 "encoding/json"6 "encoding/json"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9- "math"
10 "os"9 "os"
11 "path/filepath"10 "path/filepath"
12 "runtime"11 "runtime"
@@ -25,15 +24,13 @@ type QuickModelShortcut struct {
25 24
26 // SubagentsConfig contains the resident manager policy. max_concurrent limits25 // SubagentsConfig contains the resident manager policy. max_concurrent limits
27 // simultaneous turns, queue_timeout bounds admission waits, allowed_tools26 // simultaneous turns, queue_timeout bounds admission waits, allowed_tools
28-// limits child capabilities, allowed_roots limits eligible workspaces, and27+// limits child capabilities and allowed_roots limits eligible workspaces.
29-// budget_ratio derives each child's cumulative rollout budget from the active28+// Obsolete keys are ignored.
30-// parent model's context window. Obsolete keys are ignored.
31 type SubagentsConfig struct {29 type SubagentsConfig struct {
32 MaxConcurrent int `json:"max_concurrent,omitempty"`30 MaxConcurrent int `json:"max_concurrent,omitempty"`
33 QueueTimeout string `json:"queue_timeout,omitempty"`31 QueueTimeout string `json:"queue_timeout,omitempty"`
34 AllowedTools []string `json:"allowed_tools,omitempty"`32 AllowedTools []string `json:"allowed_tools,omitempty"`
35 AllowedRoots []string `json:"allowed_roots,omitempty"`33 AllowedRoots []string `json:"allowed_roots,omitempty"`
36- BudgetRatio *float64 `json:"budget_ratio,omitempty"`
37 }34 }
38 35
39 // GoalsConfig controls optional resource limits for autonomous goals. A nil36 // GoalsConfig controls optional resource limits for autonomous goals. A nil
@@ -325,9 +322,6 @@ func LoadConfig() (Config, error) {
325 }322 }
326 323
327 func validateSubagentConfig(cfg SubagentsConfig) error {324 func validateSubagentConfig(cfg SubagentsConfig) error {
328- if cfg.BudgetRatio != nil && (math.IsNaN(*cfg.BudgetRatio) || math.IsInf(*cfg.BudgetRatio, 0) || *cfg.BudgetRatio <= 0 || *cfg.BudgetRatio > 1) {
329- return fmt.Errorf("parse config: subagents.budget_ratio must be greater than 0 and at most 1")
330- }
331 values := []struct {325 values := []struct {
332 name string326 name string
333 value string327 value string
M packages/agent/config_subagents_test.go +9 -22
@@ -2,27 +2,14 @@ package agent
22
33 import "testing"
44
5-func TestSubagentPolicyDefaultsBudgetRatio(t *testing.T) {
6- policy := subagentPolicyFromConfig(SubagentsConfig{})
7- if policy.BudgetRatio != 0.75 || policy.BudgetRatioConfigured {
8- t.Fatalf("default budget policy = %#v", policy)
9- }
10- ratio := 0.75
11- policy = subagentPolicyFromConfig(SubagentsConfig{BudgetRatio: &ratio})
12- if policy.BudgetRatio != 0.75 || !policy.BudgetRatioConfigured {
13- t.Fatalf("configured budget policy = %#v", policy)
14- }
15-}
16-
17-func TestValidateSubagentBudgetRatio(t *testing.T) {
18- valid := 0.6
19- if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &valid}); err != nil {
20- t.Fatal(err)
21- }
22- for _, value := range []float64{0, -0.1, 1.1} {
23- value := value
24- if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &value}); err == nil {
25- t.Fatalf("budget ratio %v accepted", value)
26- }
5+func TestSubagentPolicyFromConfigRetainsSchedulingAndToolSettings(t *testing.T) {
6+ policy := subagentPolicyFromConfig(SubagentsConfig{
7+ MaxConcurrent: 3,
8+ QueueTimeout: "2s",
9+ AllowedTools: []string{"read"},
10+ AllowedRoots: []string{"/workspace"},
11+ })
12+ if policy.MaxConcurrent != 3 || policy.QueueTimeout.String() != "2s" || len(policy.AllowedTools) != 1 || len(policy.AllowedRoots) != 1 {
13+ t.Fatalf("policy = %#v", policy)
2714 }
2815 }
@@ -2,27 +2,14 @@ package agent
2 2
3 import "testing"3 import "testing"
4 4
5-func TestSubagentPolicyDefaultsBudgetRatio(t *testing.T) {5+func TestSubagentPolicyFromConfigRetainsSchedulingAndToolSettings(t *testing.T) {
6- policy := subagentPolicyFromConfig(SubagentsConfig{})6+ policy := subagentPolicyFromConfig(SubagentsConfig{
7- if policy.BudgetRatio != 0.75 || policy.BudgetRatioConfigured {7+ MaxConcurrent: 3,
8- t.Fatalf("default budget policy = %#v", policy)8+ QueueTimeout: "2s",
9- }9+ AllowedTools: []string{"read"},
10- ratio := 0.7510+ AllowedRoots: []string{"/workspace"},
11- policy = subagentPolicyFromConfig(SubagentsConfig{BudgetRatio: &ratio})11+ })
12- if policy.BudgetRatio != 0.75 || !policy.BudgetRatioConfigured {12+ if policy.MaxConcurrent != 3 || policy.QueueTimeout.String() != "2s" || len(policy.AllowedTools) != 1 || len(policy.AllowedRoots) != 1 {
13- t.Fatalf("configured budget policy = %#v", policy)13+ t.Fatalf("policy = %#v", policy)
14- }
15-}
16-
17-func TestValidateSubagentBudgetRatio(t *testing.T) {
18- valid := 0.6
19- if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &valid}); err != nil {
20- t.Fatal(err)
21- }
22- for _, value := range []float64{0, -0.1, 1.1} {
23- value := value
24- if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &value}); err == nil {
25- t.Fatalf("budget ratio %v accepted", value)
26- }
27 }14 }
28 }15 }
M packages/agent/modes/resident_subagent_indicator_test.go +2 -2
@@ -235,9 +235,9 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t
235235
236236 func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) {
237237 metadata := residentUsageMetadata(subagents.ResidentSnapshot{
238- Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 375_000, Percent: 90, State: subagents.BudgetFinalizing},
238+ Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 500_000, Percent: 67, State: subagents.BudgetNormal},
239239 })
240- if metadata != "budget:finalizing 90%/375k" {
240+ if metadata != "budget:normal 67%/500k" {
241241 t.Fatalf("budget metadata = %q", metadata)
242242 }
243243 }
@@ -235,9 +235,9 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t
235 235
236 func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) {236 func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) {
237 metadata := residentUsageMetadata(subagents.ResidentSnapshot{237 metadata := residentUsageMetadata(subagents.ResidentSnapshot{
238- Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 375_000, Percent: 90, State: subagents.BudgetFinalizing},238+ Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 500_000, Percent: 67, State: subagents.BudgetNormal},
239 })239 })
240- if metadata != "budget:finalizing 90%/375k" {240+ if metadata != "budget:normal 67%/500k" {
241 t.Fatalf("budget metadata = %q", metadata)241 t.Fatalf("budget metadata = %q", metadata)
242 }242 }
243 }243 }
M packages/agent/subagent_factory.go +5 -36
@@ -2,7 +2,6 @@ package agent
22
33 import (
44 "context"
5- "encoding/json"
65 "errors"
76 "fmt"
87 "strings"
@@ -11,7 +10,6 @@ import (
1110 "github.com/bnema/zut/packages/agent/subagents"
1211 "github.com/bnema/zut/packages/agent/tools"
1312 "github.com/bnema/zut/packages/core"
14- "github.com/bnema/zut/packages/provider"
1513 )
1614
1715 // newResidentChildRunner is the host-owned construction boundary for one
@@ -39,10 +37,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
3937 system = strings.TrimSpace(system) + "\n\n" + extra
4038 }
4139 }
42- budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow)
43- if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" {
44- system = strings.TrimSpace(system) + "\n\n" + guidance
40+ if err := subagents.ValidateResidentBudget(spec); err != nil {
41+ return nil, fmt.Errorf("resident child %q: %w", spec.ID, err)
4542 }
43+ budgetLimit := spec.BudgetLimit
4644 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
4745 agent.MaxSteps = resolved.MaxSteps
4846 agent.ContextWindow = resolved.ContextWindow
@@ -120,40 +118,11 @@ func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget)
120118 return
121119 }
122120 agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
123- instruction, exceeded := budget.TurnContext()
124- if exceeded {
121+ if budget.Snapshot().State == subagents.BudgetExceeded {
125122 return false, subagents.ErrBudgetExceeded.Error(), ""
126123 }
127- snapshot := budget.Snapshot()
128- if snapshot.Limit > 0 {
129- remaining := residentBudgetOutputLimit(snapshot.Limit - snapshot.Used)
130- if agent.MaxTokens <= 0 || agent.MaxTokens > remaining {
131- agent.MaxTokens = remaining
132- }
133- }
134- if snapshot.State == subagents.BudgetFinalizing {
135- agent.SetTools(core.Registry{})
136- }
137- return true, "", instruction
138- }
139- agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
140- state := budget.Snapshot().State
141- if state == subagents.BudgetFinalizing || state == subagents.BudgetExceeded {
142- return false, "subagent rollout budget is reserved for the final response; tools are disabled", nil
143- }
144- return true, "", nil
145- }
146-}
147-
148-func residentBudgetOutputLimit(remaining int64) int {
149- if remaining <= 1 {
150- return 1
151- }
152- maxInt := int64(^uint(0) >> 1)
153- if remaining > maxInt {
154- return int(maxInt)
124+ return true, "", ""
155125 }
156- return int(remaining)
157126 }
158127
159128 // residentChildArgs applies the complete durable child specification without
@@ -2,7 +2,6 @@ package agent
2 2
3 import (3 import (
4 "context"4 "context"
5- "encoding/json"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
8 "strings"7 "strings"
@@ -11,7 +10,6 @@ import (
11 "github.com/bnema/zut/packages/agent/subagents"10 "github.com/bnema/zut/packages/agent/subagents"
12 "github.com/bnema/zut/packages/agent/tools"11 "github.com/bnema/zut/packages/agent/tools"
13 "github.com/bnema/zut/packages/core"12 "github.com/bnema/zut/packages/core"
14- "github.com/bnema/zut/packages/provider"
15 )13 )
16 14
17 // newResidentChildRunner is the host-owned construction boundary for one15 // newResidentChildRunner is the host-owned construction boundary for one
@@ -39,10 +37,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
39 system = strings.TrimSpace(system) + "\n\n" + extra37 system = strings.TrimSpace(system) + "\n\n" + extra
40 }38 }
41 }39 }
42- budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow)40+ if err := subagents.ValidateResidentBudget(spec); err != nil {
43- if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" {41+ return nil, fmt.Errorf("resident child %q: %w", spec.ID, err)
44- system = strings.TrimSpace(system) + "\n\n" + guidance
45 }42 }
43+ budgetLimit := spec.BudgetLimit
46 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)44 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
47 agent.MaxSteps = resolved.MaxSteps45 agent.MaxSteps = resolved.MaxSteps
48 agent.ContextWindow = resolved.ContextWindow46 agent.ContextWindow = resolved.ContextWindow
@@ -120,40 +118,11 @@ func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget)
120 return118 return
121 }119 }
122 agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {120 agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
123- instruction, exceeded := budget.TurnContext()121+ if budget.Snapshot().State == subagents.BudgetExceeded {
124- if exceeded {
125 return false, subagents.ErrBudgetExceeded.Error(), ""122 return false, subagents.ErrBudgetExceeded.Error(), ""
126 }123 }
127- snapshot := budget.Snapshot()124+ return true, "", ""
128- if snapshot.Limit > 0 {
129- remaining := residentBudgetOutputLimit(snapshot.Limit - snapshot.Used)
130- if agent.MaxTokens <= 0 || agent.MaxTokens > remaining {
131- agent.MaxTokens = remaining
132- }
133- }
134- if snapshot.State == subagents.BudgetFinalizing {
135- agent.SetTools(core.Registry{})
136- }
137- return true, "", instruction
138- }
139- agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
140- state := budget.Snapshot().State
141- if state == subagents.BudgetFinalizing || state == subagents.BudgetExceeded {
142- return false, "subagent rollout budget is reserved for the final response; tools are disabled", nil
143- }
144- return true, "", nil
145- }
146-}
147-
148-func residentBudgetOutputLimit(remaining int64) int {
149- if remaining <= 1 {
150- return 1
151- }
152- maxInt := int64(^uint(0) >> 1)
153- if remaining > maxInt {
154- return int(maxInt)
155 }125 }
156- return int(remaining)
157 }126 }
158 127
159 // residentChildArgs applies the complete durable child specification without128 // residentChildArgs applies the complete durable child specification without
M packages/agent/subagent_factory_test.go +15 -21
@@ -37,24 +37,18 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
3737 }
3838 }
3939
40-func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) {
40+func TestConfigureResidentBudgetOnlyStopsAtExhaustion(t *testing.T) {
4141 agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
4242 budget := subagents.NewRolloutBudget(1_000, provider.Usage{})
4343 configureResidentBudget(agent, budget)
4444
4545 budget.Observe(provider.Usage{InputTokens: 900})
4646 allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1)
47- if !allowed || reason != "" || !strings.Contains(contextText, "Finalization is required") {
48- t.Fatalf("finalization context = allowed=%t reason=%q context=%q", allowed, reason, contextText)
47+ if !allowed || reason != "" || contextText != "" {
48+ t.Fatalf("near-limit context = allowed=%t reason=%q context=%q", allowed, reason, contextText)
4949 }
50- if len(agent.ToolsSnapshot()) != 0 {
51- t.Fatalf("finalization tools = %#v, want none", agent.ToolsSnapshot())
52- }
53- if agent.MaxTokens != 100 {
54- t.Fatalf("finalization max tokens = %d, want 100", agent.MaxTokens)
55- }
56- if allowed, reason, _ := agent.BeforeToolExecute(provider.ToolCallBlock{Name: "read"}); allowed || !strings.Contains(reason, "tools are disabled") {
57- t.Fatalf("finalization tool guard = allowed=%t reason=%q", allowed, reason)
50+ if len(agent.ToolsSnapshot()) != 1 || agent.MaxTokens != 0 {
51+ t.Fatalf("near-limit agent = tools=%#v max_tokens=%d", agent.ToolsSnapshot(), agent.MaxTokens)
5852 }
5953
6054 budget.Observe(provider.Usage{InputTokens: 1_000})
@@ -64,7 +58,7 @@ func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T)
6458 }
6559 }
6660
67-func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) {
61+func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) {
6862 var requests atomic.Int32
6963 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
7064 requestNumber := requests.Add(1)
@@ -74,8 +68,8 @@ func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) {
7468 }
7569 w.Header().Set("content-type", "text/event-stream")
7670 if requestNumber == 1 {
77- if got := requestMaxTokens(t, body); got != 1_000 {
78- t.Errorf("initial max_tokens = %d, want 1000", got)
71+ if got := requestMaxTokens(t, body); got != 16_384 {
72+ t.Errorf("initial max_tokens = %d, want 16384", got)
7973 }
8074 writeOpenAIChunk(t, w, map[string]any{
8175 "choices": []any{map[string]any{
@@ -90,11 +84,11 @@ func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) {
9084 })
9185 return
9286 }
93- if tools, ok := body["tools"].([]any); ok && len(tools) != 0 {
94- t.Errorf("finalization request tools = %#v, want none", tools)
87+ if tools, ok := body["tools"].([]any); !ok || len(tools) == 0 {
88+ t.Errorf("near-limit request tools = %#v, want registered tools", body["tools"])
9589 }
96- if got := requestMaxTokens(t, body); got != 100 {
97- t.Errorf("finalization max_tokens = %d, want 100", got)
90+ if got := requestMaxTokens(t, body); got <= 100 {
91+ t.Errorf("near-limit max_tokens = %d, want model output limit", got)
9892 }
9993 writeOpenAIChunk(t, w, map[string]any{
10094 "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}},
@@ -123,8 +117,8 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
123117 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
124118 t.Errorf("decode request: %v", err)
125119 }
126- if got := requestMaxTokens(t, body); got != 1_000 {
127- t.Errorf("initial max_tokens = %d, want 1000", got)
120+ if got := requestMaxTokens(t, body); got != 16_384 {
121+ t.Errorf("initial max_tokens = %d, want 16384", got)
128122 }
129123 w.Header().Set("content-type", "text/event-stream")
130124 writeOpenAIChunk(t, w, map[string]any{
@@ -156,7 +150,7 @@ func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (s
156150 t.Helper()
157151 spec := subagents.ResidentChildSpec{
158152 ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o",
159- Tools: []string{"read"}, BudgetLimit: limit,
153+ Tools: []string{"read"}, BudgetLimit: limit, BudgetSource: "model_context",
160154 }
161155 journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
162156 if err != nil {
@@ -37,24 +37,18 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
37 }37 }
38 }38 }
39 39
40-func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) {40+func TestConfigureResidentBudgetOnlyStopsAtExhaustion(t *testing.T) {
41 agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})41 agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
42 budget := subagents.NewRolloutBudget(1_000, provider.Usage{})42 budget := subagents.NewRolloutBudget(1_000, provider.Usage{})
43 configureResidentBudget(agent, budget)43 configureResidentBudget(agent, budget)
44 44
45 budget.Observe(provider.Usage{InputTokens: 900})45 budget.Observe(provider.Usage{InputTokens: 900})
46 allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1)46 allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1)
47- if !allowed || reason != "" || !strings.Contains(contextText, "Finalization is required") {47+ if !allowed || reason != "" || contextText != "" {
48- t.Fatalf("finalization context = allowed=%t reason=%q context=%q", allowed, reason, contextText)48+ t.Fatalf("near-limit context = allowed=%t reason=%q context=%q", allowed, reason, contextText)
49 }49 }
50- if len(agent.ToolsSnapshot()) != 0 {50+ if len(agent.ToolsSnapshot()) != 1 || agent.MaxTokens != 0 {
51- t.Fatalf("finalization tools = %#v, want none", agent.ToolsSnapshot())51+ t.Fatalf("near-limit agent = tools=%#v max_tokens=%d", agent.ToolsSnapshot(), agent.MaxTokens)
52- }
53- if agent.MaxTokens != 100 {
54- t.Fatalf("finalization max tokens = %d, want 100", agent.MaxTokens)
55- }
56- if allowed, reason, _ := agent.BeforeToolExecute(provider.ToolCallBlock{Name: "read"}); allowed || !strings.Contains(reason, "tools are disabled") {
57- t.Fatalf("finalization tool guard = allowed=%t reason=%q", allowed, reason)
58 }52 }
59 53
60 budget.Observe(provider.Usage{InputTokens: 1_000})54 budget.Observe(provider.Usage{InputTokens: 1_000})
@@ -64,7 +58,7 @@ func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T)
64 }58 }
65 }59 }
66 60
67-func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) {61+func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) {
68 var requests atomic.Int3262 var requests atomic.Int32
69 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {63 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70 requestNumber := requests.Add(1)64 requestNumber := requests.Add(1)
@@ -74,8 +68,8 @@ func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) {
74 }68 }
75 w.Header().Set("content-type", "text/event-stream")69 w.Header().Set("content-type", "text/event-stream")
76 if requestNumber == 1 {70 if requestNumber == 1 {
77- if got := requestMaxTokens(t, body); got != 1_000 {71+ if got := requestMaxTokens(t, body); got != 16_384 {
78- t.Errorf("initial max_tokens = %d, want 1000", got)72+ t.Errorf("initial max_tokens = %d, want 16384", got)
79 }73 }
80 writeOpenAIChunk(t, w, map[string]any{74 writeOpenAIChunk(t, w, map[string]any{
81 "choices": []any{map[string]any{75 "choices": []any{map[string]any{
@@ -90,11 +84,11 @@ func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) {
90 })84 })
91 return85 return
92 }86 }
93- if tools, ok := body["tools"].([]any); ok && len(tools) != 0 {87+ if tools, ok := body["tools"].([]any); !ok || len(tools) == 0 {
94- t.Errorf("finalization request tools = %#v, want none", tools)88+ t.Errorf("near-limit request tools = %#v, want registered tools", body["tools"])
95 }89 }
96- if got := requestMaxTokens(t, body); got != 100 {90+ if got := requestMaxTokens(t, body); got <= 100 {
97- t.Errorf("finalization max_tokens = %d, want 100", got)91+ t.Errorf("near-limit max_tokens = %d, want model output limit", got)
98 }92 }
99 writeOpenAIChunk(t, w, map[string]any{93 writeOpenAIChunk(t, w, map[string]any{
100 "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}},94 "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}},
@@ -123,8 +117,8 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
123 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {117 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
124 t.Errorf("decode request: %v", err)118 t.Errorf("decode request: %v", err)
125 }119 }
126- if got := requestMaxTokens(t, body); got != 1_000 {120+ if got := requestMaxTokens(t, body); got != 16_384 {
127- t.Errorf("initial max_tokens = %d, want 1000", got)121+ t.Errorf("initial max_tokens = %d, want 16384", got)
128 }122 }
129 w.Header().Set("content-type", "text/event-stream")123 w.Header().Set("content-type", "text/event-stream")
130 writeOpenAIChunk(t, w, map[string]any{124 writeOpenAIChunk(t, w, map[string]any{
@@ -156,7 +150,7 @@ func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (s
156 t.Helper()150 t.Helper()
157 spec := subagents.ResidentChildSpec{151 spec := subagents.ResidentChildSpec{
158 ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o",152 ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o",
159- Tools: []string{"read"}, BudgetLimit: limit,153+ Tools: []string{"read"}, BudgetLimit: limit, BudgetSource: "model_context",
160 }154 }
161 journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)155 journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
162 if err != nil {156 if err != nil {
M packages/agent/subagent_runtime.go +15 -39
@@ -3,6 +3,7 @@ package agent
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "path/filepath"
78 "sort"
89 "strings"
@@ -307,7 +308,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
307308 providerID, model, reasoning := activeProvider, rt.model, rt.reasoning
308309 baseURL, insecureTLS := rt.baseURL, rt.insecureTLS
309310 workspace, parentSession := rt.repoRoot, rt.activeSession
310- fastMode, policy, parentContextWindow := rt.fastMode, rt.policy, rt.contextWindow
311+ fastMode, policy := rt.fastMode, rt.policy
311312 rt.settingsMu.RUnlock()
312313 if strings.TrimSpace(request.Provider) != "" {
313314 providerID = strings.TrimSpace(request.Provider)
@@ -364,16 +365,11 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
364365 if workspaceMode == "" {
365366 workspaceMode = subagents.WorkspaceShared
366367 }
367- budgetLimit, resolvedBudgetRatio, budgetSource, err := resolveResidentBudget(parentContextWindow, policy.BudgetRatio, policy.BudgetRatioConfigured, request.Profile, request.BudgetRatio, request.BudgetTokens)
368- if err != nil {
369- return subagents.ResidentChildSpec{}, err
370- }
371368 spec := subagents.ResidentChildSpec{
372369 ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,
373370 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
374371 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
375372 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
376- BudgetLimit: budgetLimit, BudgetRatio: resolvedBudgetRatio, BudgetSource: budgetSource,
377373 }
378374 if request.Profile != nil {
379375 spec.Profile = request.Profile.Name
@@ -382,31 +378,17 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
382378 spec.InheritProjectContext = request.Profile.InheritProjectContext
383379 spec.InheritSkills = request.Profile.InheritSkills
384380 }
385- return spec, nil
386-}
387-
388-func resolveResidentBudget(parentContextWindow int, policyRatio float64, policyConfigured bool, profile *subagents.Profile, spawnRatio *float64, spawnTokens *int64) (int64, float64, string, error) {
389- ratio, source := policyRatio, "default_ratio"
390- if policyConfigured {
391- source = "config_ratio"
392- }
393- var tokens int64
394- if profile != nil {
395- if profile.BudgetTokens != nil {
396- tokens, source = *profile.BudgetTokens, "profile_tokens"
397- } else if profile.BudgetRatio != nil {
398- ratio, source = *profile.BudgetRatio, "profile_ratio"
399- }
381+ resolved, err := Resolve(residentChildArgs(rt.args, rt.credentialProvider, spec), false)
382+ if err != nil {
383+ return subagents.ResidentChildSpec{}, fmt.Errorf("resolve resident child model: %w", err)
400384 }
401- if spawnTokens != nil {
402- tokens, source = *spawnTokens, "spawn_tokens"
403- ratio = 0
404- } else if spawnRatio != nil {
405- ratio, source = *spawnRatio, "spawn_ratio"
406- tokens = 0
385+ budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow)
386+ if err != nil {
387+ return subagents.ResidentChildSpec{}, err
407388 }
408- limit, resolvedRatio, err := subagents.BudgetLimit(parentContextWindow, ratio, tokens)
409- return limit, resolvedRatio, source, err
389+ spec.BudgetLimit = budgetLimit
390+ spec.BudgetSource = "model_context"
391+ return spec, nil
410392 }
411393
412394 func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {
@@ -568,16 +550,10 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
568550 }
569551 return parsed
570552 }
571- budgetRatio := subagents.DefaultBudgetRatio
572- if cfg.BudgetRatio != nil {
573- budgetRatio = *cfg.BudgetRatio
574- }
575553 return subagents.SubagentPolicy{
576- MaxConcurrent: cfg.MaxConcurrent,
577- QueueTimeout: parseDuration(cfg.QueueTimeout),
578- AllowedTools: append([]string(nil), cfg.AllowedTools...),
579- AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
580- BudgetRatio: budgetRatio,
581- BudgetRatioConfigured: cfg.BudgetRatio != nil,
554+ MaxConcurrent: cfg.MaxConcurrent,
555+ QueueTimeout: parseDuration(cfg.QueueTimeout),
556+ AllowedTools: append([]string(nil), cfg.AllowedTools...),
557+ AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
582558 }
583559 }
@@ -3,6 +3,7 @@ package agent
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "path/filepath"7 "path/filepath"
7 "sort"8 "sort"
8 "strings"9 "strings"
@@ -307,7 +308,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
307 providerID, model, reasoning := activeProvider, rt.model, rt.reasoning308 providerID, model, reasoning := activeProvider, rt.model, rt.reasoning
308 baseURL, insecureTLS := rt.baseURL, rt.insecureTLS309 baseURL, insecureTLS := rt.baseURL, rt.insecureTLS
309 workspace, parentSession := rt.repoRoot, rt.activeSession310 workspace, parentSession := rt.repoRoot, rt.activeSession
310- fastMode, policy, parentContextWindow := rt.fastMode, rt.policy, rt.contextWindow311+ fastMode, policy := rt.fastMode, rt.policy
311 rt.settingsMu.RUnlock()312 rt.settingsMu.RUnlock()
312 if strings.TrimSpace(request.Provider) != "" {313 if strings.TrimSpace(request.Provider) != "" {
313 providerID = strings.TrimSpace(request.Provider)314 providerID = strings.TrimSpace(request.Provider)
@@ -364,16 +365,11 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
364 if workspaceMode == "" {365 if workspaceMode == "" {
365 workspaceMode = subagents.WorkspaceShared366 workspaceMode = subagents.WorkspaceShared
366 }367 }
367- budgetLimit, resolvedBudgetRatio, budgetSource, err := resolveResidentBudget(parentContextWindow, policy.BudgetRatio, policy.BudgetRatioConfigured, request.Profile, request.BudgetRatio, request.BudgetTokens)
368- if err != nil {
369- return subagents.ResidentChildSpec{}, err
370- }
371 spec := subagents.ResidentChildSpec{368 spec := subagents.ResidentChildSpec{
372 ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,369 ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,
373 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),370 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
374 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,371 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
375 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,372 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
376- BudgetLimit: budgetLimit, BudgetRatio: resolvedBudgetRatio, BudgetSource: budgetSource,
377 }373 }
378 if request.Profile != nil {374 if request.Profile != nil {
379 spec.Profile = request.Profile.Name375 spec.Profile = request.Profile.Name
@@ -382,31 +378,17 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
382 spec.InheritProjectContext = request.Profile.InheritProjectContext378 spec.InheritProjectContext = request.Profile.InheritProjectContext
383 spec.InheritSkills = request.Profile.InheritSkills379 spec.InheritSkills = request.Profile.InheritSkills
384 }380 }
385- return spec, nil381+ resolved, err := Resolve(residentChildArgs(rt.args, rt.credentialProvider, spec), false)
386-}382+ if err != nil {
387-383+ return subagents.ResidentChildSpec{}, fmt.Errorf("resolve resident child model: %w", err)
388-func resolveResidentBudget(parentContextWindow int, policyRatio float64, policyConfigured bool, profile *subagents.Profile, spawnRatio *float64, spawnTokens *int64) (int64, float64, string, error) {
389- ratio, source := policyRatio, "default_ratio"
390- if policyConfigured {
391- source = "config_ratio"
392- }
393- var tokens int64
394- if profile != nil {
395- if profile.BudgetTokens != nil {
396- tokens, source = *profile.BudgetTokens, "profile_tokens"
397- } else if profile.BudgetRatio != nil {
398- ratio, source = *profile.BudgetRatio, "profile_ratio"
399- }
400 }384 }
401- if spawnTokens != nil {385+ budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow)
402- tokens, source = *spawnTokens, "spawn_tokens"386+ if err != nil {
403- ratio = 0387+ return subagents.ResidentChildSpec{}, err
404- } else if spawnRatio != nil {
405- ratio, source = *spawnRatio, "spawn_ratio"
406- tokens = 0
407 }388 }
408- limit, resolvedRatio, err := subagents.BudgetLimit(parentContextWindow, ratio, tokens)389+ spec.BudgetLimit = budgetLimit
409- return limit, resolvedRatio, source, err390+ spec.BudgetSource = "model_context"
391+ return spec, nil
410 }392 }
411 393
412 func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {394 func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {
@@ -568,16 +550,10 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
568 }550 }
569 return parsed551 return parsed
570 }552 }
571- budgetRatio := subagents.DefaultBudgetRatio
572- if cfg.BudgetRatio != nil {
573- budgetRatio = *cfg.BudgetRatio
574- }
575 return subagents.SubagentPolicy{553 return subagents.SubagentPolicy{
576- MaxConcurrent: cfg.MaxConcurrent,554+ MaxConcurrent: cfg.MaxConcurrent,
577- QueueTimeout: parseDuration(cfg.QueueTimeout),555+ QueueTimeout: parseDuration(cfg.QueueTimeout),
578- AllowedTools: append([]string(nil), cfg.AllowedTools...),556+ AllowedTools: append([]string(nil), cfg.AllowedTools...),
579- AllowedRoots: append([]string(nil), cfg.AllowedRoots...),557+ AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
580- BudgetRatio: budgetRatio,
581- BudgetRatioConfigured: cfg.BudgetRatio != nil,
582 }558 }
583 }559 }
M packages/agent/subagent_runtime_test.go +4 -57
@@ -4,7 +4,6 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/zut/packages/agent/subagents"
87 "github.com/bnema/zut/packages/agent/tools"
98 "github.com/bnema/zut/packages/core"
109 )
@@ -68,12 +67,10 @@ func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T
6867 }
6968 }
7069
71-func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) {
72- ratio := 0.6
70+func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) {
7371 runtime := newSubagentRuntime(subagentRuntimeConfig{
7472 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
75- Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 500_000,
76- Policy: subagents.SubagentPolicy{BudgetRatio: ratio, BudgetRatioConfigured: true},
73+ Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000,
7774 })
7875 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
7976
@@ -81,58 +78,8 @@ func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) {
8178 if err != nil {
8279 t.Fatal(err)
8380 }
84- if spec.BudgetLimit != 300_000 || spec.BudgetRatio != ratio || spec.BudgetSource != "config_ratio" {
85- t.Fatalf("inherited budget = %#v", spec)
86- }
87-
88- tokens := int64(42_000)
89- spec, err = runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
90- Task: "deep review", BudgetTokens: &tokens,
91- }, core.Registry{"read": nil})
92- if err != nil {
93- t.Fatal(err)
94- }
95- if spec.BudgetLimit != tokens || spec.BudgetRatio != 0 || spec.BudgetSource != "spawn_tokens" {
96- t.Fatalf("absolute budget = %#v", spec)
97- }
98-}
99-
100-func TestResolveResidentBudgetPrecedence(t *testing.T) {
101- configRatio, profileRatio, spawnRatio := 0.6, 0.5, 0.4
102- profileTokens, spawnTokens := int64(200_000), int64(150_000)
103- for _, tc := range []struct {
104- name string
105- profile *subagents.Profile
106- spawnRatio *float64
107- spawnTokens *int64
108- wantLimit int64
109- wantRatio float64
110- wantSource string
111- }{
112- {name: "default", wantLimit: 375_000, wantRatio: 0.75, wantSource: "default_ratio"},
113- {name: "config ratio", wantLimit: 300_000, wantRatio: configRatio, wantSource: "config_ratio"},
114- {name: "profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, wantLimit: 250_000, wantRatio: profileRatio, wantSource: "profile_ratio"},
115- {name: "profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, wantLimit: profileTokens, wantSource: "profile_tokens"},
116- {name: "spawn ratio over profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, spawnRatio: &spawnRatio, wantLimit: 200_000, wantRatio: spawnRatio, wantSource: "spawn_ratio"},
117- {name: "spawn tokens over profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, spawnTokens: &spawnTokens, wantLimit: spawnTokens, wantSource: "spawn_tokens"},
118- } {
119- t.Run(tc.name, func(t *testing.T) {
120- policyRatio := configRatio
121- if tc.name == "default" {
122- policyRatio = 0
123- }
124- limit, ratio, source, err := resolveResidentBudget(500_000, policyRatio, tc.name != "default", tc.profile, tc.spawnRatio, tc.spawnTokens)
125- if err != nil {
126- t.Fatal(err)
127- }
128- if limit != tc.wantLimit || ratio != tc.wantRatio || source != tc.wantSource {
129- t.Fatalf("resolved budget = (%d, %v, %q), want (%d, %v, %q)", limit, ratio, source, tc.wantLimit, tc.wantRatio, tc.wantSource)
130- }
131- })
132- }
133- limit, ratio, source, err := resolveResidentBudget(500_000, 0.75, true, nil, nil, nil)
134- if err != nil || limit != 375_000 || ratio != 0.75 || source != "config_ratio" {
135- t.Fatalf("explicit default ratio = (%d, %v, %q, %v)", limit, ratio, source, err)
81+ if spec.BudgetLimit != 500_000 || spec.BudgetSource != "model_context" {
82+ t.Fatalf("child model budget = %#v", spec)
13683 }
13784 }
13885
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/zut/packages/agent/subagents"
8 "github.com/bnema/zut/packages/agent/tools"7 "github.com/bnema/zut/packages/agent/tools"
9 "github.com/bnema/zut/packages/core"8 "github.com/bnema/zut/packages/core"
10 )9 )
@@ -68,12 +67,10 @@ func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T
68 }67 }
69 }68 }
70 69
71-func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) {70+func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) {
72- ratio := 0.6
73 runtime := newSubagentRuntime(subagentRuntimeConfig{71 runtime := newSubagentRuntime(subagentRuntimeConfig{
74 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),72 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
75- Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 500_000,73+ Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000,
76- Policy: subagents.SubagentPolicy{BudgetRatio: ratio, BudgetRatioConfigured: true},
77 })74 })
78 t.Cleanup(func() { _ = runtime.Close(context.Background()) })75 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
79 76
@@ -81,58 +78,8 @@ func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) {
81 if err != nil {78 if err != nil {
82 t.Fatal(err)79 t.Fatal(err)
83 }80 }
84- if spec.BudgetLimit != 300_000 || spec.BudgetRatio != ratio || spec.BudgetSource != "config_ratio" {81+ if spec.BudgetLimit != 500_000 || spec.BudgetSource != "model_context" {
85- t.Fatalf("inherited budget = %#v", spec)82+ t.Fatalf("child model budget = %#v", spec)
86- }
87-
88- tokens := int64(42_000)
89- spec, err = runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
90- Task: "deep review", BudgetTokens: &tokens,
91- }, core.Registry{"read": nil})
92- if err != nil {
93- t.Fatal(err)
94- }
95- if spec.BudgetLimit != tokens || spec.BudgetRatio != 0 || spec.BudgetSource != "spawn_tokens" {
96- t.Fatalf("absolute budget = %#v", spec)
97- }
98-}
99-
100-func TestResolveResidentBudgetPrecedence(t *testing.T) {
101- configRatio, profileRatio, spawnRatio := 0.6, 0.5, 0.4
102- profileTokens, spawnTokens := int64(200_000), int64(150_000)
103- for _, tc := range []struct {
104- name string
105- profile *subagents.Profile
106- spawnRatio *float64
107- spawnTokens *int64
108- wantLimit int64
109- wantRatio float64
110- wantSource string
111- }{
112- {name: "default", wantLimit: 375_000, wantRatio: 0.75, wantSource: "default_ratio"},
113- {name: "config ratio", wantLimit: 300_000, wantRatio: configRatio, wantSource: "config_ratio"},
114- {name: "profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, wantLimit: 250_000, wantRatio: profileRatio, wantSource: "profile_ratio"},
115- {name: "profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, wantLimit: profileTokens, wantSource: "profile_tokens"},
116- {name: "spawn ratio over profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, spawnRatio: &spawnRatio, wantLimit: 200_000, wantRatio: spawnRatio, wantSource: "spawn_ratio"},
117- {name: "spawn tokens over profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, spawnTokens: &spawnTokens, wantLimit: spawnTokens, wantSource: "spawn_tokens"},
118- } {
119- t.Run(tc.name, func(t *testing.T) {
120- policyRatio := configRatio
121- if tc.name == "default" {
122- policyRatio = 0
123- }
124- limit, ratio, source, err := resolveResidentBudget(500_000, policyRatio, tc.name != "default", tc.profile, tc.spawnRatio, tc.spawnTokens)
125- if err != nil {
126- t.Fatal(err)
127- }
128- if limit != tc.wantLimit || ratio != tc.wantRatio || source != tc.wantSource {
129- t.Fatalf("resolved budget = (%d, %v, %q), want (%d, %v, %q)", limit, ratio, source, tc.wantLimit, tc.wantRatio, tc.wantSource)
130- }
131- })
132- }
133- limit, ratio, source, err := resolveResidentBudget(500_000, 0.75, true, nil, nil, nil)
134- if err != nil || limit != 375_000 || ratio != 0.75 || source != "config_ratio" {
135- t.Fatalf("explicit default ratio = (%d, %v, %q, %v)", limit, ratio, source, err)
136 }83 }
137 }84 }
138 85
M packages/agent/subagents/budget.go +16 -123
@@ -2,28 +2,21 @@ package subagents
22
33 import (
44 "errors"
5- "fmt"
65 "math"
76 "sync"
87
98 "github.com/bnema/zut/packages/provider"
109 )
1110
12-const (
13- DefaultBudgetRatio = 0.75
14- cachedInputBudgetWeight = 0.25
15-)
11+const cachedInputBudgetWeight = 0.25
1612
17-// BudgetState is the progressive constraint applied to a resident child.
13+// BudgetState is the host-visible state of a resident child's cumulative
14+// rollout budget.
1815 type BudgetState string
1916
2017 const (
21- BudgetNormal BudgetState = "normal"
22- BudgetNotice BudgetState = "notice"
23- BudgetFocused BudgetState = "focused"
24- BudgetVerifying BudgetState = "verifying"
25- BudgetFinalizing BudgetState = "finalizing"
26- BudgetExceeded BudgetState = "exceeded"
18+ BudgetNormal BudgetState = "normal"
19+ BudgetExceeded BudgetState = "exceeded"
2720 )
2821
2922 var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded")
@@ -37,27 +30,13 @@ type BudgetSnapshot struct {
3730 State BudgetState `json:"state"`
3831 }
3932
40-func BudgetLimit(contextWindow int, ratio float64, tokens int64) (int64, float64, error) {
41- if tokens < 0 {
42- return 0, 0, errors.New("budget_tokens must be positive")
43- }
44- if tokens > 0 {
45- return tokens, 0, nil
46- }
47- if ratio == 0 {
48- ratio = DefaultBudgetRatio
49- }
50- if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio <= 0 || ratio > 1 {
51- return 0, 0, errors.New("budget_ratio must be greater than 0 and at most 1")
52- }
33+// ContextBudgetLimit derives the deterministic rollout limit for a child from
34+// its resolved model metadata.
35+func ContextBudgetLimit(contextWindow int) (int64, error) {
5336 if contextWindow <= 0 {
54- return 0, ratio, errors.New("cannot derive a subagent budget without a parent context window")
55- }
56- limit := int64(math.Floor(float64(contextWindow) * ratio))
57- if limit <= 0 {
58- return 0, ratio, errors.New("derived subagent budget is empty")
37+ return 0, errors.New("cannot derive a subagent budget without a model context window")
5938 }
60- return limit, ratio, nil
39+ return int64(contextWindow), nil
6140 }
6241
6342 // WeightedBudgetUsage charges uncached and cache-write input fully, cache-read
@@ -71,26 +50,6 @@ func WeightedBudgetUsage(usage provider.Usage) int64 {
7150 return int64(math.Ceil(weighted))
7251 }
7352
74-// EffectiveBudgetLimit preserves budgets accepted by current versions and
75-// derives the default for resident specs written before budgets were stored.
76-func EffectiveBudgetLimit(limit int64, contextWindow int) int64 {
77- if limit > 0 {
78- return limit
79- }
80- derived, _, err := BudgetLimit(contextWindow, DefaultBudgetRatio, 0)
81- if err != nil {
82- return 0
83- }
84- return derived
85-}
86-
87-func BudgetSystemPrompt(limit int64) string {
88- if limit <= 0 {
89- return ""
90- }
91- return fmt.Sprintf("Your cumulative rollout budget is %d weighted tokens. At 50%% prioritize concrete evidence; at 70%% stop broad exploration; at 85%% verify existing candidates only; at 90%% stop using tools and return the best verified final result. The host enforces the finalization reserve and terminates work at 100%%.", limit)
92-}
93-
9453 func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot {
9554 if limit <= 0 {
9655 return BudgetSnapshot{}
@@ -104,30 +63,17 @@ func budgetState(used, limit int64) BudgetState {
10463 if limit <= 0 {
10564 return ""
10665 }
107- ratio := float64(used) / float64(limit)
108- switch {
109- case ratio >= 1:
66+ if used >= limit {
11067 return BudgetExceeded
111- case ratio >= 0.90:
112- return BudgetFinalizing
113- case ratio >= 0.85:
114- return BudgetVerifying
115- case ratio >= 0.70:
116- return BudgetFocused
117- case ratio >= 0.50:
118- return BudgetNotice
119- default:
120- return BudgetNormal
12168 }
69+ return BudgetNormal
12270 }
12371
124-// RolloutBudget accounts one resident child's cumulative provider usage and
125-// produces progressive model instructions at threshold transitions.
72+// RolloutBudget accounts one resident child's cumulative provider usage.
12673 type RolloutBudget struct {
127- mu sync.Mutex
128- limit int64
129- snapshot BudgetSnapshot
130- delivered BudgetState
74+ mu sync.Mutex
75+ limit int64
76+ snapshot BudgetSnapshot
13177 }
13278
13379 func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget {
@@ -156,56 +102,3 @@ func (b *RolloutBudget) Snapshot() BudgetSnapshot {
156102 b.mu.Unlock()
157103 return snapshot
158104 }
159-
160-// TurnContext returns a one-shot model-visible transition alert. Core stores
161-// the resulting developer context in the transcript, including across compaction.
162-func (b *RolloutBudget) TurnContext() (string, bool) {
163- if b == nil {
164- return "", false
165- }
166- b.mu.Lock()
167- defer b.mu.Unlock()
168- state := b.snapshot.State
169- if state == BudgetExceeded {
170- return "", true
171- }
172- if budgetStateRank(state) <= budgetStateRank(b.delivered) {
173- return "", false
174- }
175- b.delivered = state
176- return budgetInstruction(b.snapshot), false
177-}
178-
179-func budgetStateRank(state BudgetState) int {
180- switch state {
181- case BudgetNotice:
182- return 1
183- case BudgetFocused:
184- return 2
185- case BudgetVerifying:
186- return 3
187- case BudgetFinalizing:
188- return 4
189- case BudgetExceeded:
190- return 5
191- default:
192- return 0
193- }
194-}
195-
196-func budgetInstruction(snapshot BudgetSnapshot) string {
197- remaining := max(snapshot.Limit-snapshot.Used, 0)
198- prefix := fmt.Sprintf("[subagent budget: %d%% used, %d weighted tokens remaining] ", snapshot.Percent, remaining)
199- switch snapshot.State {
200- case BudgetNotice:
201- return prefix + "Keep the remaining investigation bounded and prioritize concrete evidence."
202- case BudgetFocused:
203- return prefix + "Stop broad exploration. Investigate only concrete unresolved hypotheses."
204- case BudgetVerifying:
205- return prefix + "Do not identify new candidates. Verify or reject current candidates and prepare the final result."
206- case BudgetFinalizing:
207- return prefix + "Finalization is required now. Return the best verified result without using tools or starting new investigations."
208- default:
209- return ""
210- }
211-}
@@ -2,28 +2,21 @@ package subagents
2 2
3 import (3 import (
4 "errors"4 "errors"
5- "fmt"
6 "math"5 "math"
7 "sync"6 "sync"
8 7
9 "github.com/bnema/zut/packages/provider"8 "github.com/bnema/zut/packages/provider"
10 )9 )
11 10
12-const (11+const cachedInputBudgetWeight = 0.25
13- DefaultBudgetRatio = 0.75
14- cachedInputBudgetWeight = 0.25
15-)
16 12
17-// BudgetState is the progressive constraint applied to a resident child.13+// BudgetState is the host-visible state of a resident child's cumulative
14+// rollout budget.
18 type BudgetState string15 type BudgetState string
19 16
20 const (17 const (
21- BudgetNormal BudgetState = "normal"18+ BudgetNormal BudgetState = "normal"
22- BudgetNotice BudgetState = "notice"19+ BudgetExceeded BudgetState = "exceeded"
23- BudgetFocused BudgetState = "focused"
24- BudgetVerifying BudgetState = "verifying"
25- BudgetFinalizing BudgetState = "finalizing"
26- BudgetExceeded BudgetState = "exceeded"
27 )20 )
28 21
29 var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded")22 var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded")
@@ -37,27 +30,13 @@ type BudgetSnapshot struct {
37 State BudgetState `json:"state"`30 State BudgetState `json:"state"`
38 }31 }
39 32
40-func BudgetLimit(contextWindow int, ratio float64, tokens int64) (int64, float64, error) {33+// ContextBudgetLimit derives the deterministic rollout limit for a child from
41- if tokens < 0 {34+// its resolved model metadata.
42- return 0, 0, errors.New("budget_tokens must be positive")35+func ContextBudgetLimit(contextWindow int) (int64, error) {
43- }
44- if tokens > 0 {
45- return tokens, 0, nil
46- }
47- if ratio == 0 {
48- ratio = DefaultBudgetRatio
49- }
50- if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio <= 0 || ratio > 1 {
51- return 0, 0, errors.New("budget_ratio must be greater than 0 and at most 1")
52- }
53 if contextWindow <= 0 {36 if contextWindow <= 0 {
54- return 0, ratio, errors.New("cannot derive a subagent budget without a parent context window")37+ return 0, errors.New("cannot derive a subagent budget without a model context window")
55- }
56- limit := int64(math.Floor(float64(contextWindow) * ratio))
57- if limit <= 0 {
58- return 0, ratio, errors.New("derived subagent budget is empty")
59 }38 }
60- return limit, ratio, nil39+ return int64(contextWindow), nil
61 }40 }
62 41
63 // WeightedBudgetUsage charges uncached and cache-write input fully, cache-read42 // WeightedBudgetUsage charges uncached and cache-write input fully, cache-read
@@ -71,26 +50,6 @@ func WeightedBudgetUsage(usage provider.Usage) int64 {
71 return int64(math.Ceil(weighted))50 return int64(math.Ceil(weighted))
72 }51 }
73 52
74-// EffectiveBudgetLimit preserves budgets accepted by current versions and
75-// derives the default for resident specs written before budgets were stored.
76-func EffectiveBudgetLimit(limit int64, contextWindow int) int64 {
77- if limit > 0 {
78- return limit
79- }
80- derived, _, err := BudgetLimit(contextWindow, DefaultBudgetRatio, 0)
81- if err != nil {
82- return 0
83- }
84- return derived
85-}
86-
87-func BudgetSystemPrompt(limit int64) string {
88- if limit <= 0 {
89- return ""
90- }
91- return fmt.Sprintf("Your cumulative rollout budget is %d weighted tokens. At 50%% prioritize concrete evidence; at 70%% stop broad exploration; at 85%% verify existing candidates only; at 90%% stop using tools and return the best verified final result. The host enforces the finalization reserve and terminates work at 100%%.", limit)
92-}
93-
94 func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot {53 func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot {
95 if limit <= 0 {54 if limit <= 0 {
96 return BudgetSnapshot{}55 return BudgetSnapshot{}
@@ -104,30 +63,17 @@ func budgetState(used, limit int64) BudgetState {
104 if limit <= 0 {63 if limit <= 0 {
105 return ""64 return ""
106 }65 }
107- ratio := float64(used) / float64(limit)66+ if used >= limit {
108- switch {
109- case ratio >= 1:
110 return BudgetExceeded67 return BudgetExceeded
111- case ratio >= 0.90:
112- return BudgetFinalizing
113- case ratio >= 0.85:
114- return BudgetVerifying
115- case ratio >= 0.70:
116- return BudgetFocused
117- case ratio >= 0.50:
118- return BudgetNotice
119- default:
120- return BudgetNormal
121 }68 }
69+ return BudgetNormal
122 }70 }
123 71
124-// RolloutBudget accounts one resident child's cumulative provider usage and72+// RolloutBudget accounts one resident child's cumulative provider usage.
125-// produces progressive model instructions at threshold transitions.
126 type RolloutBudget struct {73 type RolloutBudget struct {
127- mu sync.Mutex74+ mu sync.Mutex
128- limit int6475+ limit int64
129- snapshot BudgetSnapshot76+ snapshot BudgetSnapshot
130- delivered BudgetState
131 }77 }
132 78
133 func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget {79 func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget {
@@ -156,56 +102,3 @@ func (b *RolloutBudget) Snapshot() BudgetSnapshot {
156 b.mu.Unlock()102 b.mu.Unlock()
157 return snapshot103 return snapshot
158 }104 }
159-
160-// TurnContext returns a one-shot model-visible transition alert. Core stores
161-// the resulting developer context in the transcript, including across compaction.
162-func (b *RolloutBudget) TurnContext() (string, bool) {
163- if b == nil {
164- return "", false
165- }
166- b.mu.Lock()
167- defer b.mu.Unlock()
168- state := b.snapshot.State
169- if state == BudgetExceeded {
170- return "", true
171- }
172- if budgetStateRank(state) <= budgetStateRank(b.delivered) {
173- return "", false
174- }
175- b.delivered = state
176- return budgetInstruction(b.snapshot), false
177-}
178-
179-func budgetStateRank(state BudgetState) int {
180- switch state {
181- case BudgetNotice:
182- return 1
183- case BudgetFocused:
184- return 2
185- case BudgetVerifying:
186- return 3
187- case BudgetFinalizing:
188- return 4
189- case BudgetExceeded:
190- return 5
191- default:
192- return 0
193- }
194-}
195-
196-func budgetInstruction(snapshot BudgetSnapshot) string {
197- remaining := max(snapshot.Limit-snapshot.Used, 0)
198- prefix := fmt.Sprintf("[subagent budget: %d%% used, %d weighted tokens remaining] ", snapshot.Percent, remaining)
199- switch snapshot.State {
200- case BudgetNotice:
201- return prefix + "Keep the remaining investigation bounded and prioritize concrete evidence."
202- case BudgetFocused:
203- return prefix + "Stop broad exploration. Investigate only concrete unresolved hypotheses."
204- case BudgetVerifying:
205- return prefix + "Do not identify new candidates. Verify or reject current candidates and prepare the final result."
206- case BudgetFinalizing:
207- return prefix + "Finalization is required now. Return the best verified result without using tools or starting new investigations."
208- default:
209- return ""
210- }
211-}
M packages/agent/subagents/budget_test.go +24 -44
@@ -1,38 +1,36 @@
11 package subagents
22
33 import (
4- "strings"
54 "testing"
65
76 "github.com/bnema/zut/packages/provider"
87 )
98
10-func TestBudgetLimitDefaultsToSeventyFivePercent(t *testing.T) {
11- limit, ratio, err := BudgetLimit(500_000, 0, 0)
9+func TestContextBudgetLimitUsesFullModelContext(t *testing.T) {
10+ limit, err := ContextBudgetLimit(500_000)
1211 if err != nil {
1312 t.Fatal(err)
1413 }
15- if limit != 375_000 || ratio != 0.75 {
16- t.Fatalf("BudgetLimit = %d, %v; want 375000, 0.75", limit, ratio)
14+ if limit != 500_000 {
15+ t.Fatalf("ContextBudgetLimit = %d, want 500000", limit)
1716 }
18-
19- limit, ratio, err = BudgetLimit(500_000, 0.5, 0)
20- if err != nil || limit != 250_000 || ratio != 0.5 {
21- t.Fatalf("ratio override = %d, %v, %v", limit, ratio, err)
22- }
23-
24- limit, ratio, err = BudgetLimit(500_000, 0.5, 42_000)
25- if err != nil || limit != 42_000 || ratio != 0 {
26- t.Fatalf("token override = %d, %v, %v", limit, ratio, err)
17+ if _, err := ContextBudgetLimit(0); err == nil {
18+ t.Fatal("ContextBudgetLimit(0) succeeded")
2719 }
2820 }
2921
30-func TestEffectiveBudgetLimitDerivesLegacyDefault(t *testing.T) {
31- if got := EffectiveBudgetLimit(0, 500_000); got != 375_000 {
32- t.Fatalf("legacy budget limit = %d, want 375000", got)
22+func TestValidateResidentBudgetRequiresModelContext(t *testing.T) {
23+ if err := ValidateResidentBudget(ResidentChildSpec{BudgetLimit: 500_000, BudgetSource: "model_context"}); err != nil {
24+ t.Fatal(err)
3325 }
34- if got := EffectiveBudgetLimit(42_000, 500_000); got != 42_000 {
35- t.Fatalf("persisted budget limit = %d, want 42000", got)
26+ for _, spec := range []ResidentChildSpec{
27+ {},
28+ {BudgetLimit: 500_000},
29+ {BudgetLimit: 375_000, BudgetSource: "default_ratio"},
30+ } {
31+ if err := ValidateResidentBudget(spec); err == nil {
32+ t.Fatalf("ValidateResidentBudget(%#v) succeeded", spec)
33+ }
3634 }
3735 }
3836
@@ -43,34 +41,16 @@ func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) {
4341 }
4442 }
4543
46-func TestBudgetThresholdInstructionsAreProgressiveAndOneShot(t *testing.T) {
44+func TestRolloutBudgetOnlyExhaustsAtLimit(t *testing.T) {
4745 budget := NewRolloutBudget(1_000, provider.Usage{})
48- cases := []struct {
49- used int
50- state BudgetState
51- text string
52- }{
53- {500, BudgetNotice, "prioritize concrete evidence"},
54- {700, BudgetFocused, "Stop broad exploration"},
55- {850, BudgetVerifying, "Do not identify new candidates"},
56- {900, BudgetFinalizing, "Finalization is required now"},
57- }
58- for _, tc := range cases {
59- budget.Observe(provider.Usage{InputTokens: tc.used})
60- if got := budget.Snapshot().State; got != tc.state {
61- t.Fatalf("usage %d state = %q, want %q", tc.used, got, tc.state)
62- }
63- instruction, exceeded := budget.TurnContext()
64- if exceeded || !strings.Contains(instruction, tc.text) {
65- t.Fatalf("usage %d instruction = %q exceeded=%t", tc.used, instruction, exceeded)
66- }
67- if repeated, exceeded := budget.TurnContext(); repeated != "" || exceeded {
68- t.Fatalf("usage %d repeated instruction = %q exceeded=%t", tc.used, repeated, exceeded)
46+ for _, used := range []int{500, 700, 850, 900, 999} {
47+ budget.Observe(provider.Usage{InputTokens: used})
48+ if got := budget.Snapshot().State; got != BudgetNormal {
49+ t.Fatalf("usage %d state = %q, want %q", used, got, BudgetNormal)
6950 }
7051 }
71-
7252 budget.Observe(provider.Usage{InputTokens: 1_000})
73- if instruction, exceeded := budget.TurnContext(); instruction != "" || !exceeded {
74- t.Fatalf("exhausted instruction = %q exceeded=%t", instruction, exceeded)
53+ if got := budget.Snapshot().State; got != BudgetExceeded {
54+ t.Fatalf("exhausted state = %q, want %q", got, BudgetExceeded)
7555 }
7656 }
@@ -1,38 +1,36 @@
1 package subagents1 package subagents
2 2
3 import (3 import (
4- "strings"
5 "testing"4 "testing"
6 5
7 "github.com/bnema/zut/packages/provider"6 "github.com/bnema/zut/packages/provider"
8 )7 )
9 8
10-func TestBudgetLimitDefaultsToSeventyFivePercent(t *testing.T) {9+func TestContextBudgetLimitUsesFullModelContext(t *testing.T) {
11- limit, ratio, err := BudgetLimit(500_000, 0, 0)10+ limit, err := ContextBudgetLimit(500_000)
12 if err != nil {11 if err != nil {
13 t.Fatal(err)12 t.Fatal(err)
14 }13 }
15- if limit != 375_000 || ratio != 0.75 {14+ if limit != 500_000 {
16- t.Fatalf("BudgetLimit = %d, %v; want 375000, 0.75", limit, ratio)15+ t.Fatalf("ContextBudgetLimit = %d, want 500000", limit)
17 }16 }
18-17+ if _, err := ContextBudgetLimit(0); err == nil {
19- limit, ratio, err = BudgetLimit(500_000, 0.5, 0)18+ t.Fatal("ContextBudgetLimit(0) succeeded")
20- if err != nil || limit != 250_000 || ratio != 0.5 {
21- t.Fatalf("ratio override = %d, %v, %v", limit, ratio, err)
22- }
23-
24- limit, ratio, err = BudgetLimit(500_000, 0.5, 42_000)
25- if err != nil || limit != 42_000 || ratio != 0 {
26- t.Fatalf("token override = %d, %v, %v", limit, ratio, err)
27 }19 }
28 }20 }
29 21
30-func TestEffectiveBudgetLimitDerivesLegacyDefault(t *testing.T) {22+func TestValidateResidentBudgetRequiresModelContext(t *testing.T) {
31- if got := EffectiveBudgetLimit(0, 500_000); got != 375_000 {23+ if err := ValidateResidentBudget(ResidentChildSpec{BudgetLimit: 500_000, BudgetSource: "model_context"}); err != nil {
32- t.Fatalf("legacy budget limit = %d, want 375000", got)24+ t.Fatal(err)
33 }25 }
34- if got := EffectiveBudgetLimit(42_000, 500_000); got != 42_000 {26+ for _, spec := range []ResidentChildSpec{
35- t.Fatalf("persisted budget limit = %d, want 42000", got)27+ {},
28+ {BudgetLimit: 500_000},
29+ {BudgetLimit: 375_000, BudgetSource: "default_ratio"},
30+ } {
31+ if err := ValidateResidentBudget(spec); err == nil {
32+ t.Fatalf("ValidateResidentBudget(%#v) succeeded", spec)
33+ }
36 }34 }
37 }35 }
38 36
@@ -43,34 +41,16 @@ func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) {
43 }41 }
44 }42 }
45 43
46-func TestBudgetThresholdInstructionsAreProgressiveAndOneShot(t *testing.T) {44+func TestRolloutBudgetOnlyExhaustsAtLimit(t *testing.T) {
47 budget := NewRolloutBudget(1_000, provider.Usage{})45 budget := NewRolloutBudget(1_000, provider.Usage{})
48- cases := []struct {46+ for _, used := range []int{500, 700, 850, 900, 999} {
49- used int47+ budget.Observe(provider.Usage{InputTokens: used})
50- state BudgetState48+ if got := budget.Snapshot().State; got != BudgetNormal {
51- text string49+ t.Fatalf("usage %d state = %q, want %q", used, got, BudgetNormal)
52- }{
53- {500, BudgetNotice, "prioritize concrete evidence"},
54- {700, BudgetFocused, "Stop broad exploration"},
55- {850, BudgetVerifying, "Do not identify new candidates"},
56- {900, BudgetFinalizing, "Finalization is required now"},
57- }
58- for _, tc := range cases {
59- budget.Observe(provider.Usage{InputTokens: tc.used})
60- if got := budget.Snapshot().State; got != tc.state {
61- t.Fatalf("usage %d state = %q, want %q", tc.used, got, tc.state)
62- }
63- instruction, exceeded := budget.TurnContext()
64- if exceeded || !strings.Contains(instruction, tc.text) {
65- t.Fatalf("usage %d instruction = %q exceeded=%t", tc.used, instruction, exceeded)
66- }
67- if repeated, exceeded := budget.TurnContext(); repeated != "" || exceeded {
68- t.Fatalf("usage %d repeated instruction = %q exceeded=%t", tc.used, repeated, exceeded)
69 }50 }
70 }51 }
71-
72 budget.Observe(provider.Usage{InputTokens: 1_000})52 budget.Observe(provider.Usage{InputTokens: 1_000})
73- if instruction, exceeded := budget.TurnContext(); instruction != "" || !exceeded {53+ if got := budget.Snapshot().State; got != BudgetExceeded {
74- t.Fatalf("exhausted instruction = %q exceeded=%t", instruction, exceeded)54+ t.Fatalf("exhausted state = %q, want %q", got, BudgetExceeded)
75 }55 }
76 }56 }
M packages/agent/subagents/model.go +4 -6
@@ -47,12 +47,10 @@ const (
4747
4848 // SubagentPolicy contains the resident manager's limits and capability ceiling.
4949 type SubagentPolicy struct {
50- MaxConcurrent int
51- QueueTimeout time.Duration
52- AllowedTools []string
53- AllowedRoots []string
54- BudgetRatio float64
55- BudgetRatioConfigured bool
50+ MaxConcurrent int
51+ QueueTimeout time.Duration
52+ AllowedTools []string
53+ AllowedRoots []string
5654 }
5755
5856 func (p SubagentPolicy) allowedTool(name string) bool {
@@ -47,12 +47,10 @@ const (
47 47
48 // SubagentPolicy contains the resident manager's limits and capability ceiling.48 // SubagentPolicy contains the resident manager's limits and capability ceiling.
49 type SubagentPolicy struct {49 type SubagentPolicy struct {
50- MaxConcurrent int50+ MaxConcurrent int
51- QueueTimeout time.Duration51+ QueueTimeout time.Duration
52- AllowedTools []string52+ AllowedTools []string
53- AllowedRoots []string53+ AllowedRoots []string
54- BudgetRatio float64
55- BudgetRatioConfigured bool
56 }54 }
57 55
58 func (p SubagentPolicy) allowedTool(name string) bool {56 func (p SubagentPolicy) allowedTool(name string) bool {
M packages/agent/subagents/profiles.go +0 -29
@@ -10,7 +10,6 @@ package subagents
1010
1111 import (
1212 "fmt"
13- "math"
1413 "os"
1514 "path/filepath"
1615 "sort"
@@ -36,11 +35,6 @@ type Profile struct {
3635 // Nil means the profile did not specify whether fast mode is enabled.
3736 FastMode *bool
3837
39- // BudgetRatio and BudgetTokens override the host's inherited rollout
40- // budget. At most one may be declared.
41- BudgetRatio *float64
42- BudgetTokens *int64
43-
4438 // SystemPromptMode is "append" (the default) or "replace".
4539 SystemPromptMode string
4640
@@ -193,12 +187,6 @@ func SystemPromptAddendum(profiles []*Profile) string {
193187 if profile.FastMode != nil {
194188 metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode))
195189 }
196- if profile.BudgetRatio != nil {
197- metadata = append(metadata, "budgetRatio="+strconv.FormatFloat(*profile.BudgetRatio, 'f', -1, 64))
198- }
199- if profile.BudgetTokens != nil {
200- metadata = append(metadata, "budgetTokens="+strconv.FormatInt(*profile.BudgetTokens, 10))
201- }
202190 if len(metadata) > 0 {
203191 fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description)
204192 } else {
@@ -320,23 +308,6 @@ func load(path, source string) (*Profile, error) {
320308 }
321309 profile.FastMode = &value
322310 }
323- if raw, ok := values["budgetratio"]; ok {
324- value, err := strconv.ParseFloat(strings.TrimSpace(unquote(raw)), 64)
325- if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value > 1 {
326- return nil, fmt.Errorf("budgetRatio must be greater than 0 and at most 1")
327- }
328- profile.BudgetRatio = &value
329- }
330- if raw, ok := values["budgettokens"]; ok {
331- value, err := strconv.ParseInt(strings.TrimSpace(unquote(raw)), 10, 64)
332- if err != nil || value <= 0 {
333- return nil, fmt.Errorf("budgetTokens must be a positive integer")
334- }
335- profile.BudgetTokens = &value
336- }
337- if profile.BudgetRatio != nil && profile.BudgetTokens != nil {
338- return nil, fmt.Errorf("budgetRatio and budgetTokens are mutually exclusive")
339- }
340311 return profile, nil
341312 }
342313
@@ -10,7 +10,6 @@ package subagents
10 10
11 import (11 import (
12 "fmt"12 "fmt"
13- "math"
14 "os"13 "os"
15 "path/filepath"14 "path/filepath"
16 "sort"15 "sort"
@@ -36,11 +35,6 @@ type Profile struct {
36 // Nil means the profile did not specify whether fast mode is enabled.35 // Nil means the profile did not specify whether fast mode is enabled.
37 FastMode *bool36 FastMode *bool
38 37
39- // BudgetRatio and BudgetTokens override the host's inherited rollout
40- // budget. At most one may be declared.
41- BudgetRatio *float64
42- BudgetTokens *int64
43-
44 // SystemPromptMode is "append" (the default) or "replace".38 // SystemPromptMode is "append" (the default) or "replace".
45 SystemPromptMode string39 SystemPromptMode string
46 40
@@ -193,12 +187,6 @@ func SystemPromptAddendum(profiles []*Profile) string {
193 if profile.FastMode != nil {187 if profile.FastMode != nil {
194 metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode))188 metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode))
195 }189 }
196- if profile.BudgetRatio != nil {
197- metadata = append(metadata, "budgetRatio="+strconv.FormatFloat(*profile.BudgetRatio, 'f', -1, 64))
198- }
199- if profile.BudgetTokens != nil {
200- metadata = append(metadata, "budgetTokens="+strconv.FormatInt(*profile.BudgetTokens, 10))
201- }
202 if len(metadata) > 0 {190 if len(metadata) > 0 {
203 fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description)191 fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description)
204 } else {192 } else {
@@ -320,23 +308,6 @@ func load(path, source string) (*Profile, error) {
320 }308 }
321 profile.FastMode = &value309 profile.FastMode = &value
322 }310 }
323- if raw, ok := values["budgetratio"]; ok {
324- value, err := strconv.ParseFloat(strings.TrimSpace(unquote(raw)), 64)
325- if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value > 1 {
326- return nil, fmt.Errorf("budgetRatio must be greater than 0 and at most 1")
327- }
328- profile.BudgetRatio = &value
329- }
330- if raw, ok := values["budgettokens"]; ok {
331- value, err := strconv.ParseInt(strings.TrimSpace(unquote(raw)), 10, 64)
332- if err != nil || value <= 0 {
333- return nil, fmt.Errorf("budgetTokens must be a positive integer")
334- }
335- profile.BudgetTokens = &value
336- }
337- if profile.BudgetRatio != nil && profile.BudgetTokens != nil {
338- return nil, fmt.Errorf("budgetRatio and budgetTokens are mutually exclusive")
339- }
340 return profile, nil311 return profile, nil
341 }312 }
342 313
M packages/agent/subagents/profiles_test.go +4 -11
@@ -164,9 +164,6 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
164164 {name: "invalid-project-context", field: "inheritProjectContext: sometimes"},
165165 {name: "invalid-skills", field: "inheritSkills: sometimes"},
166166 {name: "invalid-fast-mode", field: "fastMode: sometimes"},
167- {name: "invalid-budget-ratio", field: "budgetRatio: 1.2"},
168- {name: "invalid-budget-tokens", field: "budgetTokens: 0"},
169- {name: "conflicting-budget", field: "budgetRatio: 0.5\nbudgetTokens: 1000"},
170167 }
171168 for _, tc := range cases {
172169 t.Run(tc.name, func(t *testing.T) {
@@ -179,15 +176,11 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
179176 }
180177 }
181178
182-func TestLoadProfileBudgetOverride(t *testing.T) {
179+func TestLoadIgnoresRemovedBudgetMetadata(t *testing.T) {
183180 path := filepath.Join(t.TempDir(), "profile.md")
184- writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\n---\nInstructions.\n")
185- profile, err := load(path, "test")
186- if err != nil {
187- t.Fatal(err)
188- }
189- if profile.BudgetRatio == nil || *profile.BudgetRatio != 0.6 || profile.BudgetTokens != nil {
190- t.Fatalf("profile budget = ratio %v tokens %v", profile.BudgetRatio, profile.BudgetTokens)
181+ writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\nbudgetTokens: 20000\n---\nInstructions.\n")
182+ if _, err := load(path, "test"); err != nil {
183+ t.Fatalf("load removed budget metadata: %v", err)
191184 }
192185 }
193186
@@ -164,9 +164,6 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
164 {name: "invalid-project-context", field: "inheritProjectContext: sometimes"},164 {name: "invalid-project-context", field: "inheritProjectContext: sometimes"},
165 {name: "invalid-skills", field: "inheritSkills: sometimes"},165 {name: "invalid-skills", field: "inheritSkills: sometimes"},
166 {name: "invalid-fast-mode", field: "fastMode: sometimes"},166 {name: "invalid-fast-mode", field: "fastMode: sometimes"},
167- {name: "invalid-budget-ratio", field: "budgetRatio: 1.2"},
168- {name: "invalid-budget-tokens", field: "budgetTokens: 0"},
169- {name: "conflicting-budget", field: "budgetRatio: 0.5\nbudgetTokens: 1000"},
170 }167 }
171 for _, tc := range cases {168 for _, tc := range cases {
172 t.Run(tc.name, func(t *testing.T) {169 t.Run(tc.name, func(t *testing.T) {
@@ -179,15 +176,11 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
179 }176 }
180 }177 }
181 178
182-func TestLoadProfileBudgetOverride(t *testing.T) {179+func TestLoadIgnoresRemovedBudgetMetadata(t *testing.T) {
183 path := filepath.Join(t.TempDir(), "profile.md")180 path := filepath.Join(t.TempDir(), "profile.md")
184- writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\n---\nInstructions.\n")181+ writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\nbudgetTokens: 20000\n---\nInstructions.\n")
185- profile, err := load(path, "test")182+ if _, err := load(path, "test"); err != nil {
186- if err != nil {183+ t.Fatalf("load removed budget metadata: %v", err)
187- t.Fatal(err)
188- }
189- if profile.BudgetRatio == nil || *profile.BudgetRatio != 0.6 || profile.BudgetTokens != nil {
190- t.Fatalf("profile budget = ratio %v tokens %v", profile.BudgetRatio, profile.BudgetTokens)
191 }184 }
192 }185 }
193 186
M packages/agent/subagents/resident_journal.go +17 -4
@@ -18,6 +18,8 @@ import (
1818 "github.com/bnema/zut/packages/provider"
1919 )
2020
21+var ErrIncompatibleResidentBudget = errors.New("resident journal: incompatible rollout budget; start a new subagent")
22+
2123 const (
2224 residentMetadataName = "metadata.json"
2325 residentTranscriptName = "transcript.jsonl"
@@ -34,7 +36,7 @@ const (
3436 residentRecordToolCall = "tool.call"
3537 residentRecordToolResult = "tool.result"
3638 residentRecordUsage = "usage"
37- residentJournalVersion = 1
39+ residentJournalVersion = 2
3840 residentInterruptedText = "tool interrupted by resident host restart"
3941 residentMaxRecordBytes = 2 << 20
4042 residentResultSummaryBytes = 256 << 10
@@ -384,6 +386,14 @@ func (j *ResidentJournal) Dir() string {
384386 return j.dir
385387 }
386388
389+// ValidateResidentBudget ensures a new child has a model-context budget.
390+func ValidateResidentBudget(spec ResidentChildSpec) error {
391+ if spec.BudgetLimit <= 0 || spec.BudgetSource != "model_context" {
392+ return ErrIncompatibleResidentBudget
393+ }
394+ return nil
395+}
396+
387397 // Accept establishes the authoritative spawn commit point. The transcript is
388398 // synced before metadata is projected so a crash cannot fabricate acceptance.
389399 func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error {
@@ -730,15 +740,18 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
730740 if err != nil {
731741 return ResidentMetadata{}, err
732742 }
743+ if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil {
744+ return ResidentMetadata{}, errors.New("resident journal: missing accepted child record")
745+ }
746+ if records[0].Version != residentJournalVersion {
747+ return ResidentMetadata{}, ErrIncompatibleResidentBudget
748+ }
733749 if repaired, ok := repairLegacyFalseRecovery(records); ok {
734750 if err := journal.rewriteTranscript(repaired); err != nil {
735751 return ResidentMetadata{}, err
736752 }
737753 records = repaired
738754 }
739- if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil {
740- return ResidentMetadata{}, errors.New("resident journal: missing accepted child record")
741- }
742755 spec := *records[0].Spec
743756 if spec.ID == "" || spec.SessionID == "" {
744757 return ResidentMetadata{}, errors.New("resident journal: invalid accepted child record")
@@ -18,6 +18,8 @@ import (
18 "github.com/bnema/zut/packages/provider"18 "github.com/bnema/zut/packages/provider"
19 )19 )
20 20
21+var ErrIncompatibleResidentBudget = errors.New("resident journal: incompatible rollout budget; start a new subagent")
22+
21 const (23 const (
22 residentMetadataName = "metadata.json"24 residentMetadataName = "metadata.json"
23 residentTranscriptName = "transcript.jsonl"25 residentTranscriptName = "transcript.jsonl"
@@ -34,7 +36,7 @@ const (
34 residentRecordToolCall = "tool.call"36 residentRecordToolCall = "tool.call"
35 residentRecordToolResult = "tool.result"37 residentRecordToolResult = "tool.result"
36 residentRecordUsage = "usage"38 residentRecordUsage = "usage"
37- residentJournalVersion = 139+ residentJournalVersion = 2
38 residentInterruptedText = "tool interrupted by resident host restart"40 residentInterruptedText = "tool interrupted by resident host restart"
39 residentMaxRecordBytes = 2 << 2041 residentMaxRecordBytes = 2 << 20
40 residentResultSummaryBytes = 256 << 1042 residentResultSummaryBytes = 256 << 10
@@ -384,6 +386,14 @@ func (j *ResidentJournal) Dir() string {
384 return j.dir386 return j.dir
385 }387 }
386 388
389+// ValidateResidentBudget ensures a new child has a model-context budget.
390+func ValidateResidentBudget(spec ResidentChildSpec) error {
391+ if spec.BudgetLimit <= 0 || spec.BudgetSource != "model_context" {
392+ return ErrIncompatibleResidentBudget
393+ }
394+ return nil
395+}
396+
387 // Accept establishes the authoritative spawn commit point. The transcript is397 // Accept establishes the authoritative spawn commit point. The transcript is
388 // synced before metadata is projected so a crash cannot fabricate acceptance.398 // synced before metadata is projected so a crash cannot fabricate acceptance.
389 func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error {399 func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error {
@@ -730,15 +740,18 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
730 if err != nil {740 if err != nil {
731 return ResidentMetadata{}, err741 return ResidentMetadata{}, err
732 }742 }
743+ if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil {
744+ return ResidentMetadata{}, errors.New("resident journal: missing accepted child record")
745+ }
746+ if records[0].Version != residentJournalVersion {
747+ return ResidentMetadata{}, ErrIncompatibleResidentBudget
748+ }
733 if repaired, ok := repairLegacyFalseRecovery(records); ok {749 if repaired, ok := repairLegacyFalseRecovery(records); ok {
734 if err := journal.rewriteTranscript(repaired); err != nil {750 if err := journal.rewriteTranscript(repaired); err != nil {
735 return ResidentMetadata{}, err751 return ResidentMetadata{}, err
736 }752 }
737 records = repaired753 records = repaired
738 }754 }
739- if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil {
740- return ResidentMetadata{}, errors.New("resident journal: missing accepted child record")
741- }
742 spec := *records[0].Spec755 spec := *records[0].Spec
743 if spec.ID == "" || spec.SessionID == "" {756 if spec.ID == "" || spec.SessionID == "" {
744 return ResidentMetadata{}, errors.New("resident journal: invalid accepted child record")757 return ResidentMetadata{}, errors.New("resident journal: invalid accepted child record")
M packages/agent/subagents/resident_manager.go +4 -8
@@ -230,7 +230,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
230230 return ResidentSnapshot{}
231231 }
232232 live := child.Live()
233- budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, live.ContextMax)
233+ budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource)
234234 return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile,
235235 Provider: child.spec.Provider, Model: child.spec.Model,
236236 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
@@ -240,12 +240,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
240240 Budget: budget, BudgetSource: budgetSource}
241241 }
242242
243-func residentBudgetSnapshot(usage provider.Usage, limit int64, source string, contextWindow int) (BudgetSnapshot, string) {
244- effective := EffectiveBudgetLimit(limit, contextWindow)
245- if effective > 0 && source == "" {
246- source = "default_ratio"
247- }
248- return BudgetSnapshotFor(usage, effective), source
243+func residentBudgetSnapshot(usage provider.Usage, limit int64, source string) (BudgetSnapshot, string) {
244+ return BudgetSnapshotFor(usage, limit), source
249245 }
250246
251247 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {
@@ -880,7 +876,7 @@ func (m *ResidentManager) Reconcile() []error {
880876 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))
881877 continue
882878 }
883- budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.ContextMax)
879+ budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource)
884880 m.mu.Lock()
885881 if _, live := m.children[childID]; !live {
886882 m.recovered[childID] = ResidentSnapshot{
@@ -230,7 +230,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
230 return ResidentSnapshot{}230 return ResidentSnapshot{}
231 }231 }
232 live := child.Live()232 live := child.Live()
233- budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, live.ContextMax)233+ budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource)
234 return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile,234 return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile,
235 Provider: child.spec.Provider, Model: child.spec.Model,235 Provider: child.spec.Provider, Model: child.spec.Model,
236 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,236 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
@@ -240,12 +240,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
240 Budget: budget, BudgetSource: budgetSource}240 Budget: budget, BudgetSource: budgetSource}
241 }241 }
242 242
243-func residentBudgetSnapshot(usage provider.Usage, limit int64, source string, contextWindow int) (BudgetSnapshot, string) {243+func residentBudgetSnapshot(usage provider.Usage, limit int64, source string) (BudgetSnapshot, string) {
244- effective := EffectiveBudgetLimit(limit, contextWindow)244+ return BudgetSnapshotFor(usage, limit), source
245- if effective > 0 && source == "" {
246- source = "default_ratio"
247- }
248- return BudgetSnapshotFor(usage, effective), source
249 }245 }
250 246
251 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {247 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {
@@ -880,7 +876,7 @@ func (m *ResidentManager) Reconcile() []error {
880 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))876 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))
881 continue877 continue
882 }878 }
883- budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.ContextMax)879+ budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource)
884 m.mu.Lock()880 m.mu.Lock()
885 if _, live := m.children[childID]; !live {881 if _, live := m.children[childID]; !live {
886 m.recovered[childID] = ResidentSnapshot{882 m.recovered[childID] = ResidentSnapshot{
M packages/agent/subagents/resident_manager_test.go +43 -0
@@ -584,6 +584,49 @@ func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing
584584 }
585585 }
586586
587+func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) {
588+ root := t.TempDir()
589+ journal, err := OpenResidentJournal(root, "legacy-budget")
590+ if err != nil {
591+ t.Fatal(err)
592+ }
593+ spec := ResidentChildSpec{ID: "legacy-budget", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
594+ if err := journal.Accept(spec, "review"); err != nil {
595+ t.Fatal(err)
596+ }
597+ if err := journal.Close(); err != nil {
598+ t.Fatal(err)
599+ }
600+ transcript := filepath.Join(root, spec.ID, residentTranscriptName)
601+ data, err := os.ReadFile(transcript)
602+ if err != nil {
603+ t.Fatal(err)
604+ }
605+ legacy := strings.Replace(string(data), `"version":2`, `"version":1`, 1)
606+ if legacy == string(data) {
607+ t.Fatal("accepted record has no journal version")
608+ }
609+ if err := os.WriteFile(transcript, []byte(legacy), 0o600); err != nil {
610+ t.Fatal(err)
611+ }
612+ manager := NewResidentManager(root, nil)
613+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
614+ errs := manager.Reconcile()
615+ if len(errs) != 1 || !errors.Is(errs[0], ErrIncompatibleResidentBudget) {
616+ t.Fatalf("Reconcile errors = %v", errs)
617+ }
618+ if snapshots := manager.Snapshot(); len(snapshots) != 0 {
619+ t.Fatalf("snapshots = %#v", snapshots)
620+ }
621+ unchanged, err := os.ReadFile(transcript)
622+ if err != nil {
623+ t.Fatal(err)
624+ }
625+ if string(unchanged) != legacy {
626+ t.Fatal("Reconcile modified incompatible journal")
627+ }
628+}
629+
587630 func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) {
588631 root := t.TempDir()
589632 journal, err := OpenResidentJournal(root, "recovered")
@@ -584,6 +584,49 @@ func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing
584 }584 }
585 }585 }
586 586
587+func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) {
588+ root := t.TempDir()
589+ journal, err := OpenResidentJournal(root, "legacy-budget")
590+ if err != nil {
591+ t.Fatal(err)
592+ }
593+ spec := ResidentChildSpec{ID: "legacy-budget", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
594+ if err := journal.Accept(spec, "review"); err != nil {
595+ t.Fatal(err)
596+ }
597+ if err := journal.Close(); err != nil {
598+ t.Fatal(err)
599+ }
600+ transcript := filepath.Join(root, spec.ID, residentTranscriptName)
601+ data, err := os.ReadFile(transcript)
602+ if err != nil {
603+ t.Fatal(err)
604+ }
605+ legacy := strings.Replace(string(data), `"version":2`, `"version":1`, 1)
606+ if legacy == string(data) {
607+ t.Fatal("accepted record has no journal version")
608+ }
609+ if err := os.WriteFile(transcript, []byte(legacy), 0o600); err != nil {
610+ t.Fatal(err)
611+ }
612+ manager := NewResidentManager(root, nil)
613+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
614+ errs := manager.Reconcile()
615+ if len(errs) != 1 || !errors.Is(errs[0], ErrIncompatibleResidentBudget) {
616+ t.Fatalf("Reconcile errors = %v", errs)
617+ }
618+ if snapshots := manager.Snapshot(); len(snapshots) != 0 {
619+ t.Fatalf("snapshots = %#v", snapshots)
620+ }
621+ unchanged, err := os.ReadFile(transcript)
622+ if err != nil {
623+ t.Fatal(err)
624+ }
625+ if string(unchanged) != legacy {
626+ t.Fatal("Reconcile modified incompatible journal")
627+ }
628+}
629+
587 func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) {630 func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) {
588 root := t.TempDir()631 root := t.TempDir()
589 journal, err := OpenResidentJournal(root, "recovered")632 journal, err := OpenResidentJournal(root, "recovered")
M packages/agent/tools/resident_tools_test.go +7 -9
@@ -187,9 +187,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
187187 `{"task":"x","wait":0}`,
188188 `{"task":"x","wait":301}`,
189189 `{"task":"x","isolation":"outside"}`,
190- `{"task":"x","budget_ratio":0}`,
191- `{"task":"x","budget_tokens":-1}`,
192- `{"task":"x","budget_ratio":0.5,"budget_tokens":1000}`,
190+ `{"task":"x","budget_ratio":0.5}`,
191+ `{"task":"x","budget_tokens":1000}`,
193192 } {
194193 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
195194 if err != nil && !strings.Contains(err.Error(), "invalid args") {
@@ -210,7 +209,6 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
210209 })
211210 t.Cleanup(func() { _ = manager.Close(context.Background()) })
212211 profileFast := false
213- profileBudget := 0.6
214212 var got ResidentSpawnRequest
215213 spawn := &SubagentSpawnTool{
216214 ResidentManager: manager,
@@ -219,18 +217,18 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
219217 DefaultProvider: func() string { return "host-provider" },
220218 DefaultReasoning: func() string { return "medium" },
221219 ResolveSubagent: func(name string) (*subagents.Profile, error) {
222- return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast, BudgetRatio: &profileBudget}, nil
220+ return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil
223221 },
224222 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
225223 got = request
226224 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
227225 },
228226 }
229- result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true,"budget_tokens":42000}`), nil)
227+ result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil)
230228 if err != nil || result.IsError {
231229 t.Fatalf("Execute = (%#v, %v)", result, err)
232230 }
233- if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode || got.BudgetTokens == nil || *got.BudgetTokens != 42_000 {
231+ if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode {
234232 t.Fatalf("factory request = %#v", got)
235233 }
236234 }
@@ -264,9 +262,9 @@ func toolResultText(t *testing.T, result core.ToolResult) string {
264262
265263 func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {
266264 entry := publicResidentStatus(subagents.ResidentSnapshot{
267- ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetFocused}, BudgetSource: "default_ratio",
265+ ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetNormal}, BudgetSource: "model_context",
268266 })
269- if entry.Budget == nil || entry.Budget.State != subagents.BudgetFocused || entry.BudgetSource != "default_ratio" {
267+ if entry.Budget == nil || entry.Budget.State != subagents.BudgetNormal || entry.BudgetSource != "model_context" {
270268 t.Fatalf("status budget = %#v", entry)
271269 }
272270 }
@@ -187,9 +187,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
187 `{"task":"x","wait":0}`,187 `{"task":"x","wait":0}`,
188 `{"task":"x","wait":301}`,188 `{"task":"x","wait":301}`,
189 `{"task":"x","isolation":"outside"}`,189 `{"task":"x","isolation":"outside"}`,
190- `{"task":"x","budget_ratio":0}`,190+ `{"task":"x","budget_ratio":0.5}`,
191- `{"task":"x","budget_tokens":-1}`,191+ `{"task":"x","budget_tokens":1000}`,
192- `{"task":"x","budget_ratio":0.5,"budget_tokens":1000}`,
193 } {192 } {
194 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)193 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
195 if err != nil && !strings.Contains(err.Error(), "invalid args") {194 if err != nil && !strings.Contains(err.Error(), "invalid args") {
@@ -210,7 +209,6 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
210 })209 })
211 t.Cleanup(func() { _ = manager.Close(context.Background()) })210 t.Cleanup(func() { _ = manager.Close(context.Background()) })
212 profileFast := false211 profileFast := false
213- profileBudget := 0.6
214 var got ResidentSpawnRequest212 var got ResidentSpawnRequest
215 spawn := &SubagentSpawnTool{213 spawn := &SubagentSpawnTool{
216 ResidentManager: manager,214 ResidentManager: manager,
@@ -219,18 +217,18 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
219 DefaultProvider: func() string { return "host-provider" },217 DefaultProvider: func() string { return "host-provider" },
220 DefaultReasoning: func() string { return "medium" },218 DefaultReasoning: func() string { return "medium" },
221 ResolveSubagent: func(name string) (*subagents.Profile, error) {219 ResolveSubagent: func(name string) (*subagents.Profile, error) {
222- return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast, BudgetRatio: &profileBudget}, nil220+ return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil
223 },221 },
224 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {222 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
225 got = request223 got = request
226 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil224 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
227 },225 },
228 }226 }
229- result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true,"budget_tokens":42000}`), nil)227+ result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil)
230 if err != nil || result.IsError {228 if err != nil || result.IsError {
231 t.Fatalf("Execute = (%#v, %v)", result, err)229 t.Fatalf("Execute = (%#v, %v)", result, err)
232 }230 }
233- if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode || got.BudgetTokens == nil || *got.BudgetTokens != 42_000 {231+ if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode {
234 t.Fatalf("factory request = %#v", got)232 t.Fatalf("factory request = %#v", got)
235 }233 }
236 }234 }
@@ -264,9 +262,9 @@ func toolResultText(t *testing.T, result core.ToolResult) string {
264 262
265 func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {263 func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {
266 entry := publicResidentStatus(subagents.ResidentSnapshot{264 entry := publicResidentStatus(subagents.ResidentSnapshot{
267- ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetFocused}, BudgetSource: "default_ratio",265+ ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetNormal}, BudgetSource: "model_context",
268 })266 })
269- if entry.Budget == nil || entry.Budget.State != subagents.BudgetFocused || entry.BudgetSource != "default_ratio" {267+ if entry.Budget == nil || entry.Budget.State != subagents.BudgetNormal || entry.BudgetSource != "model_context" {
270 t.Fatalf("status budget = %#v", entry)268 t.Fatalf("status budget = %#v", entry)
271 }269 }
272 }270 }
M packages/agent/tools/subagent_spawn.go +10 -34
@@ -65,22 +65,18 @@ type ResidentSpawnRequest struct {
6565 FastMode *bool
6666 Required bool
6767 WorkspaceMode subagents.WorkspaceMode
68- BudgetRatio *float64
69- BudgetTokens *int64
7068 }
7169
7270 type subagentSpawnArgs struct {
73- Task string `json:"task"`
74- Agent string `json:"agent,omitempty"`
75- Model string `json:"model,omitempty"`
76- Provider string `json:"provider,omitempty"`
77- Reasoning string `json:"reasoning,omitempty"`
78- FastMode *bool `json:"fast_mode,omitempty"`
79- Required bool `json:"required,omitempty"`
80- Wait *int `json:"wait,omitempty"`
81- Isolation string `json:"isolation,omitempty"`
82- BudgetRatio *float64 `json:"budget_ratio,omitempty"`
83- BudgetTokens *int64 `json:"budget_tokens,omitempty"`
71+ Task string `json:"task"`
72+ Agent string `json:"agent,omitempty"`
73+ Model string `json:"model,omitempty"`
74+ Provider string `json:"provider,omitempty"`
75+ Reasoning string `json:"reasoning,omitempty"`
76+ FastMode *bool `json:"fast_mode,omitempty"`
77+ Required bool `json:"required,omitempty"`
78+ Wait *int `json:"wait,omitempty"`
79+ Isolation string `json:"isolation,omitempty"`
8480 }
8581
8682 const subagentSpawnSchemaTemplate = `{
@@ -125,17 +121,6 @@ const subagentSpawnSchemaTemplate = `{
125121 "type": "string",
126122 "enum": ["shared", "worktree"],
127123 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
128- },
129- "budget_ratio": {
130- "type": "number",
131- "exclusiveMinimum": 0,
132- "maximum": 1,
133- "description": "Optional cumulative rollout budget as a fraction of the parent model context window. Overrides the selected profile and host default. Mutually exclusive with budget_tokens."
134- },
135- "budget_tokens": {
136- "type": "integer",
137- "minimum": 1,
138- "description": "Optional absolute cumulative weighted-token budget. Overrides the selected profile and host default. Mutually exclusive with budget_ratio."
139124 }
140125 },
141126 "required": ["task"]
@@ -168,15 +153,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
168153 if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
169154 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
170155 }
171- if a.BudgetRatio != nil && (*a.BudgetRatio <= 0 || *a.BudgetRatio > 1) {
172- return protocolToolError(prefix + ": budget_ratio must be greater than 0 and at most 1")
173- }
174- if a.BudgetTokens != nil && *a.BudgetTokens <= 0 {
175- return protocolToolError(prefix + ": budget_tokens must be positive")
176- }
177- if a.BudgetRatio != nil && a.BudgetTokens != nil {
178- return protocolToolError(prefix + ": budget_ratio and budget_tokens are mutually exclusive")
179- }
180156
181157 workspaceMode := subagents.WorkspaceShared
182158 if value := strings.TrimSpace(a.Isolation); value != "" {
@@ -251,7 +227,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
251227 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
252228 Task: task, Profile: profile, Model: model, Provider: providerID,
253229 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
254- WorkspaceMode: workspaceMode, BudgetRatio: a.BudgetRatio, BudgetTokens: a.BudgetTokens,
230+ WorkspaceMode: workspaceMode,
255231 })
256232 if err != nil {
257233 return protocolToolError(prefix + ": " + err.Error())
@@ -65,22 +65,18 @@ type ResidentSpawnRequest struct {
65 FastMode *bool65 FastMode *bool
66 Required bool66 Required bool
67 WorkspaceMode subagents.WorkspaceMode67 WorkspaceMode subagents.WorkspaceMode
68- BudgetRatio *float64
69- BudgetTokens *int64
70 }68 }
71 69
72 type subagentSpawnArgs struct {70 type subagentSpawnArgs struct {
73- Task string `json:"task"`71+ Task string `json:"task"`
74- Agent string `json:"agent,omitempty"`72+ Agent string `json:"agent,omitempty"`
75- Model string `json:"model,omitempty"`73+ Model string `json:"model,omitempty"`
76- Provider string `json:"provider,omitempty"`74+ Provider string `json:"provider,omitempty"`
77- Reasoning string `json:"reasoning,omitempty"`75+ Reasoning string `json:"reasoning,omitempty"`
78- FastMode *bool `json:"fast_mode,omitempty"`76+ FastMode *bool `json:"fast_mode,omitempty"`
79- Required bool `json:"required,omitempty"`77+ Required bool `json:"required,omitempty"`
80- Wait *int `json:"wait,omitempty"`78+ Wait *int `json:"wait,omitempty"`
81- Isolation string `json:"isolation,omitempty"`79+ Isolation string `json:"isolation,omitempty"`
82- BudgetRatio *float64 `json:"budget_ratio,omitempty"`
83- BudgetTokens *int64 `json:"budget_tokens,omitempty"`
84 }80 }
85 81
86 const subagentSpawnSchemaTemplate = `{82 const subagentSpawnSchemaTemplate = `{
@@ -125,17 +121,6 @@ const subagentSpawnSchemaTemplate = `{
125 "type": "string",121 "type": "string",
126 "enum": ["shared", "worktree"],122 "enum": ["shared", "worktree"],
127 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."123 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
128- },
129- "budget_ratio": {
130- "type": "number",
131- "exclusiveMinimum": 0,
132- "maximum": 1,
133- "description": "Optional cumulative rollout budget as a fraction of the parent model context window. Overrides the selected profile and host default. Mutually exclusive with budget_tokens."
134- },
135- "budget_tokens": {
136- "type": "integer",
137- "minimum": 1,
138- "description": "Optional absolute cumulative weighted-token budget. Overrides the selected profile and host default. Mutually exclusive with budget_ratio."
139 }124 }
140 },125 },
141 "required": ["task"]126 "required": ["task"]
@@ -168,15 +153,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
168 if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {153 if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
169 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))154 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
170 }155 }
171- if a.BudgetRatio != nil && (*a.BudgetRatio <= 0 || *a.BudgetRatio > 1) {
172- return protocolToolError(prefix + ": budget_ratio must be greater than 0 and at most 1")
173- }
174- if a.BudgetTokens != nil && *a.BudgetTokens <= 0 {
175- return protocolToolError(prefix + ": budget_tokens must be positive")
176- }
177- if a.BudgetRatio != nil && a.BudgetTokens != nil {
178- return protocolToolError(prefix + ": budget_ratio and budget_tokens are mutually exclusive")
179- }
180 156
181 workspaceMode := subagents.WorkspaceShared157 workspaceMode := subagents.WorkspaceShared
182 if value := strings.TrimSpace(a.Isolation); value != "" {158 if value := strings.TrimSpace(a.Isolation); value != "" {
@@ -251,7 +227,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
251 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{227 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
252 Task: task, Profile: profile, Model: model, Provider: providerID,228 Task: task, Profile: profile, Model: model, Provider: providerID,
253 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,229 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
254- WorkspaceMode: workspaceMode, BudgetRatio: a.BudgetRatio, BudgetTokens: a.BudgetTokens,230+ WorkspaceMode: workspaceMode,
255 })231 })
256 if err != nil {232 if err != nil {
257 return protocolToolError(prefix + ": " + err.Error())233 return protocolToolError(prefix + ": " + err.Error())

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close