detached 560e11b

feat(subagents): enforce inherited rollout budgets (#162)

* feat(subagents): enforce inherited rollout budgets

* fix(tui): avoid thousand-k token labels
commit
560e11bd2b71e628a959b57800a0cfbdb60afe3f
author
bnema · · 2026-08-30 07:56 UTC
committer
GitHub · · 2026-08-30 07:56 UTC
parent
7e66a81
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
23 files · +960 -58
M README.md +2 -0
@@ -424,6 +424,8 @@ 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.
428+
427429 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`.
428430
429431 **Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident tools remain available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll `subagent_status` or journal files solely to wait for completion.
@@ -424,6 +424,8 @@ 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.
428+
427 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`.
428 430
429 **Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident tools remain available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll `subagent_status` or journal files solely to wait for completion.431 **Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident tools remain available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll `subagent_status` or journal files solely to wait for completion.
M docs/subagents.md +28 -8
@@ -22,6 +22,7 @@ description: Read-only code reviewer
2222 tools: [read, grep]
2323 model: openai-codex/gpt-5.6-luna
2424 thinking: high
25+budgetRatio: 0.6
2526 systemPromptMode: replace
2627 inheritProjectContext: false
2728 inheritSkills: false
@@ -36,9 +37,11 @@ catalogue, `tools: []` grants no tools, and an explicit list replaces the
3637 default. Names unavailable to the child (including host-only extensions) and
3738 tools denied by `subagents.allowed_tools` are omitted from an explicit list;
3839 they never grant the child additional access. `model`, `provider`, `thinking`,
39-the prompt mode, project/skill inheritance, and `fastMode` are resolved before
40-the child is accepted. A spawn-level model, provider, reasoning, or fast-mode
41-override takes precedence where supplied.
40+the prompt mode, project/skill inheritance, `fastMode`, and optional
41+`budgetRatio` or `budgetTokens` override are resolved before the child is
42+accepted. Spawn-level model, provider, reasoning, fast-mode, or budget values
43+take precedence where supplied. `budgetRatio` and `budgetTokens` are mutually
44+exclusive.
4245
4346 Children are always fresh conversations: they receive the delegated task and
4447 resolved host/profile context, never the parent transcript or hidden reasoning.
@@ -87,8 +90,23 @@ one turn for each child, and defaults to six concurrent turns. Set
8790 unlimited by default; a positive `subagents.queue_timeout` cancels an accepted
8891 prompt that has not received a slot and records its terminal failure durably.
8992 `subagents.allowed_tools` is an allowlist for child-visible tools and
90-`subagents.allowed_roots` limits eligible child workspaces. Removed legacy
91-settings, including `tui_subagent_position`, are ignored.
93+`subagents.allowed_roots` limits eligible child workspaces. Each child also
94+inherits a cumulative weighted-token budget equal to 75% of the active parent
95+model's context window. Set `subagents.budget_ratio` to another value greater
96+than zero and at most one, or override one spawn/profile with `budget_ratio` or
97+`budget_tokens`. Removed legacy settings, including `tui_subagent_position`,
98+are ignored.
99+
100+Budget accounting charges uncached input, cache writes, and output fully; cache
101+reads count at 25%. It is separate from active context-window usage and survives
102+follow-up turns and compaction. The child and TUI receive progressive states at
103+50% (`notice`), 70% (`focused`), 85% (`verifying`), and 90% (`finalizing`). At
104+90%, zut disables further tool use and reserves the remaining budget for the
105+final response. Every request's output cap is also clamped to the remaining
106+budget. Providers report exact input usage only after a response, so the
107+terminal response can make displayed usage exceed 100%; no later request is
108+sent. An exhausted child cannot accept follow-up turns; spawn a replacement
109+with an explicitly larger override when more work is required.
92110
93111 `required: true` makes a delegated result an obligation of the parent turn.
94112 Failed, cancelled, and interrupted required work remains unresolved until an
@@ -122,9 +140,10 @@ required capability.
122140 The model-facing tools retain their logical names:
123141
124142 - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,
125- `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
126- `worktree`). `wait` is an explicit whole-second value from 1 through 300;
127- when omitted, spawning returns immediately. A timed-out wait leaves the
143+ `reasoning`, `fast_mode`, `required`, `wait`, `isolation` (`shared` or
144+ `worktree`), and mutually exclusive `budget_ratio` or `budget_tokens`.
145+ `wait` is an explicit whole-second value from 1 through 300; when omitted,
146+ spawning returns immediately. A timed-out wait leaves the
128147 accepted child active, whether it is queued or running. Do not retry that
129148 task until it reaches a terminal failure, cancellation, or interruption. It
130149 returns a logical `subagent://<id>` reference.
@@ -132,6 +151,7 @@ The model-facing tools retain their logical names:
132151 set immediately.
133152 - `subagent_stop` stops one live child.
134153 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.
154+ Budget-exhausted children are terminal and reject follow-ups.
135155
136156 Child execution started by `subagent_spawn` is asynchronous unless it receives
137157 an explicit `wait` value. For an unwaited spawn, completion arrives through the
@@ -22,6 +22,7 @@ description: Read-only code reviewer
22 tools: [read, grep]22 tools: [read, grep]
23 model: openai-codex/gpt-5.6-luna23 model: openai-codex/gpt-5.6-luna
24 thinking: high24 thinking: high
25+budgetRatio: 0.6
25 systemPromptMode: replace26 systemPromptMode: replace
26 inheritProjectContext: false27 inheritProjectContext: false
27 inheritSkills: false28 inheritSkills: false
@@ -36,9 +37,11 @@ catalogue, `tools: []` grants no tools, and an explicit list replaces the
36 default. Names unavailable to the child (including host-only extensions) and37 default. Names unavailable to the child (including host-only extensions) and
37 tools denied by `subagents.allowed_tools` are omitted from an explicit list;38 tools denied by `subagents.allowed_tools` are omitted from an explicit list;
38 they never grant the child additional access. `model`, `provider`, `thinking`,39 they never grant the child additional access. `model`, `provider`, `thinking`,
39-the prompt mode, project/skill inheritance, and `fastMode` are resolved before40+the prompt mode, project/skill inheritance, `fastMode`, and optional
40-the child is accepted. A spawn-level model, provider, reasoning, or fast-mode41+`budgetRatio` or `budgetTokens` override are resolved before the child is
41-override takes precedence where supplied.42+accepted. Spawn-level model, provider, reasoning, fast-mode, or budget values
43+take precedence where supplied. `budgetRatio` and `budgetTokens` are mutually
44+exclusive.
42 45
43 Children are always fresh conversations: they receive the delegated task and46 Children are always fresh conversations: they receive the delegated task and
44 resolved host/profile context, never the parent transcript or hidden reasoning.47 resolved host/profile context, never the parent transcript or hidden reasoning.
@@ -87,8 +90,23 @@ one turn for each child, and defaults to six concurrent turns. Set
87 unlimited by default; a positive `subagents.queue_timeout` cancels an accepted90 unlimited by default; a positive `subagents.queue_timeout` cancels an accepted
88 prompt that has not received a slot and records its terminal failure durably.91 prompt that has not received a slot and records its terminal failure durably.
89 `subagents.allowed_tools` is an allowlist for child-visible tools and92 `subagents.allowed_tools` is an allowlist for child-visible tools and
90-`subagents.allowed_roots` limits eligible child workspaces. Removed legacy93+`subagents.allowed_roots` limits eligible child workspaces. Each child also
91-settings, including `tui_subagent_position`, are ignored.94+inherits a cumulative weighted-token budget equal to 75% of the active parent
95+model's context window. Set `subagents.budget_ratio` to another value greater
96+than zero and at most one, or override one spawn/profile with `budget_ratio` or
97+`budget_tokens`. Removed legacy settings, including `tui_subagent_position`,
98+are ignored.
99+
100+Budget accounting charges uncached input, cache writes, and output fully; cache
101+reads count at 25%. It is separate from active context-window usage and survives
102+follow-up turns and compaction. The child and TUI receive progressive states at
103+50% (`notice`), 70% (`focused`), 85% (`verifying`), and 90% (`finalizing`). At
104+90%, zut disables further tool use and reserves the remaining budget for the
105+final response. Every request's output cap is also clamped to the remaining
106+budget. Providers report exact input usage only after a response, so the
107+terminal response can make displayed usage exceed 100%; no later request is
108+sent. An exhausted child cannot accept follow-up turns; spawn a replacement
109+with an explicitly larger override when more work is required.
92 110
93 `required: true` makes a delegated result an obligation of the parent turn.111 `required: true` makes a delegated result an obligation of the parent turn.
94 Failed, cancelled, and interrupted required work remains unresolved until an112 Failed, cancelled, and interrupted required work remains unresolved until an
@@ -122,9 +140,10 @@ required capability.
122 The model-facing tools retain their logical names:140 The model-facing tools retain their logical names:
123 141
124 - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,142 - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,
125- `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or143+ `reasoning`, `fast_mode`, `required`, `wait`, `isolation` (`shared` or
126- `worktree`). `wait` is an explicit whole-second value from 1 through 300;144+ `worktree`), and mutually exclusive `budget_ratio` or `budget_tokens`.
127- when omitted, spawning returns immediately. A timed-out wait leaves the145+ `wait` is an explicit whole-second value from 1 through 300; when omitted,
146+ spawning returns immediately. A timed-out wait leaves the
128 accepted child active, whether it is queued or running. Do not retry that147 accepted child active, whether it is queued or running. Do not retry that
129 task until it reaches a terminal failure, cancellation, or interruption. It148 task until it reaches a terminal failure, cancellation, or interruption. It
130 returns a logical `subagent://<id>` reference.149 returns a logical `subagent://<id>` reference.
@@ -132,6 +151,7 @@ The model-facing tools retain their logical names:
132 set immediately.151 set immediately.
133 - `subagent_stop` stops one live child.152 - `subagent_stop` stops one live child.
134 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.153 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.
154+ Budget-exhausted children are terminal and reject follow-ups.
135 155
136 Child execution started by `subagent_spawn` is asynchronous unless it receives156 Child execution started by `subagent_spawn` is asynchronous unless it receives
137 an explicit `wait` value. For an unwaited spawn, completion arrives through the157 an explicit `wait` value. For an unwaited spawn, completion arrives through the
M packages/agent/cli.go +8 -0
@@ -644,6 +644,7 @@ func applyInitialSessionResumeWithRuntime(ctx context.Context, args Args, base R
644644 if runtime != nil {
645645 runtime.SetProvider(resolved.Provider)
646646 runtime.SetModel(resolved.Model)
647+ runtime.SetContextWindow(resolved.ContextWindow)
647648 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
648649 runtime.SetFastMode(resolved.FastMode)
649650 runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
@@ -1581,6 +1582,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
15811582 BaseURL: r.BaseURL,
15821583 InsecureTLS: r.InsecureTLS,
15831584 FastMode: r.FastMode,
1585+ ContextWindow: r.ContextWindow,
15841586 Policy: subagentPolicyFromConfig(initialCfg.Subagents),
15851587 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
15861588 WebSearchGuard: webSearchGuard,
@@ -1723,6 +1725,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17231725 }
17241726 resolved.UseSandbox(sharedSandbox)
17251727 runtime.SetProvider(resolved.Provider)
1728+ runtime.SetModel(resolved.Model)
1729+ runtime.SetContextWindow(resolved.ContextWindow)
17261730 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
17271731 runtime.SetFastMode(resolved.FastMode)
17281732 resolved.MergeExtensionTools(extToolAdapter)
@@ -1746,6 +1750,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17461750 }
17471751 resolved.UseSandbox(sharedSandbox)
17481752 runtime.SetProvider(resolved.Provider)
1753+ runtime.SetModel(resolved.Model)
1754+ runtime.SetContextWindow(resolved.ContextWindow)
17491755 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
17501756 runtime.SetFastMode(resolved.FastMode)
17511757 resolved.MergeExtensionTools(extToolAdapter)
@@ -1777,6 +1783,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17771783 }
17781784 resolved.UseSandbox(sharedSandbox)
17791785 runtime.SetProvider(resolved.Provider)
1786+ runtime.SetModel(resolved.Model)
1787+ runtime.SetContextWindow(resolved.ContextWindow)
17801788 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
17811789 runtime.SetFastMode(resolved.FastMode)
17821790 resolved.MergeExtensionTools(extToolAdapter)
@@ -644,6 +644,7 @@ func applyInitialSessionResumeWithRuntime(ctx context.Context, args Args, base R
644 if runtime != nil {644 if runtime != nil {
645 runtime.SetProvider(resolved.Provider)645 runtime.SetProvider(resolved.Provider)
646 runtime.SetModel(resolved.Model)646 runtime.SetModel(resolved.Model)
647+ runtime.SetContextWindow(resolved.ContextWindow)
647 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)648 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
648 runtime.SetFastMode(resolved.FastMode)649 runtime.SetFastMode(resolved.FastMode)
649 runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)650 runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
@@ -1581,6 +1582,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1581 BaseURL: r.BaseURL,1582 BaseURL: r.BaseURL,
1582 InsecureTLS: r.InsecureTLS,1583 InsecureTLS: r.InsecureTLS,
1583 FastMode: r.FastMode,1584 FastMode: r.FastMode,
1585+ ContextWindow: r.ContextWindow,
1584 Policy: subagentPolicyFromConfig(initialCfg.Subagents),1586 Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1585 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),1587 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1586 WebSearchGuard: webSearchGuard,1588 WebSearchGuard: webSearchGuard,
@@ -1723,6 +1725,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1723 }1725 }
1724 resolved.UseSandbox(sharedSandbox)1726 resolved.UseSandbox(sharedSandbox)
1725 runtime.SetProvider(resolved.Provider)1727 runtime.SetProvider(resolved.Provider)
1728+ runtime.SetModel(resolved.Model)
1729+ runtime.SetContextWindow(resolved.ContextWindow)
1726 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1730 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1727 runtime.SetFastMode(resolved.FastMode)1731 runtime.SetFastMode(resolved.FastMode)
1728 resolved.MergeExtensionTools(extToolAdapter)1732 resolved.MergeExtensionTools(extToolAdapter)
@@ -1746,6 +1750,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1746 }1750 }
1747 resolved.UseSandbox(sharedSandbox)1751 resolved.UseSandbox(sharedSandbox)
1748 runtime.SetProvider(resolved.Provider)1752 runtime.SetProvider(resolved.Provider)
1753+ runtime.SetModel(resolved.Model)
1754+ runtime.SetContextWindow(resolved.ContextWindow)
1749 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1755 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1750 runtime.SetFastMode(resolved.FastMode)1756 runtime.SetFastMode(resolved.FastMode)
1751 resolved.MergeExtensionTools(extToolAdapter)1757 resolved.MergeExtensionTools(extToolAdapter)
@@ -1777,6 +1783,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1777 }1783 }
1778 resolved.UseSandbox(sharedSandbox)1784 resolved.UseSandbox(sharedSandbox)
1779 runtime.SetProvider(resolved.Provider)1785 runtime.SetProvider(resolved.Provider)
1786+ runtime.SetModel(resolved.Model)
1787+ runtime.SetContextWindow(resolved.ContextWindow)
1780 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)1788 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1781 runtime.SetFastMode(resolved.FastMode)1789 runtime.SetFastMode(resolved.FastMode)
1782 resolved.MergeExtensionTools(extToolAdapter)1790 resolved.MergeExtensionTools(extToolAdapter)
M packages/agent/config.go +10 -4
@@ -6,6 +6,7 @@ import (
66 "encoding/json"
77 "errors"
88 "fmt"
9+ "math"
910 "os"
1011 "path/filepath"
1112 "runtime"
@@ -24,13 +25,15 @@ type QuickModelShortcut struct {
2425
2526 // SubagentsConfig contains the resident manager policy. max_concurrent limits
2627 // simultaneous turns, queue_timeout bounds admission waits, allowed_tools
27-// limits child capabilities, and allowed_roots limits eligible workspaces.
28-// Obsolete keys such as tui_subagent_position are ignored.
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.
2931 type SubagentsConfig struct {
3032 MaxConcurrent int `json:"max_concurrent,omitempty"`
3133 QueueTimeout string `json:"queue_timeout,omitempty"`
3234 AllowedTools []string `json:"allowed_tools,omitempty"`
3335 AllowedRoots []string `json:"allowed_roots,omitempty"`
36+ BudgetRatio *float64 `json:"budget_ratio,omitempty"`
3437 }
3538
3639 // GoalsConfig controls optional resource limits for autonomous goals. A nil
@@ -315,13 +318,16 @@ func LoadConfig() (Config, error) {
315318 if err := json.Unmarshal(b, &c); err != nil {
316319 return c, fmt.Errorf("parse config: %w", err)
317320 }
318- if err := validateSubagentDurations(c.Subagents); err != nil {
321+ if err := validateSubagentConfig(c.Subagents); err != nil {
319322 return c, err
320323 }
321324 return c, nil
322325 }
323326
324-func validateSubagentDurations(cfg SubagentsConfig) error {
327+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+ }
325331 values := []struct {
326332 name string
327333 value string
@@ -6,6 +6,7 @@ import (
6 "encoding/json"6 "encoding/json"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "math"
9 "os"10 "os"
10 "path/filepath"11 "path/filepath"
11 "runtime"12 "runtime"
@@ -24,13 +25,15 @@ type QuickModelShortcut struct {
24 25
25 // SubagentsConfig contains the resident manager policy. max_concurrent limits26 // SubagentsConfig contains the resident manager policy. max_concurrent limits
26 // simultaneous turns, queue_timeout bounds admission waits, allowed_tools27 // simultaneous turns, queue_timeout bounds admission waits, allowed_tools
27-// limits child capabilities, and allowed_roots limits eligible workspaces.28+// limits child capabilities, allowed_roots limits eligible workspaces, and
28-// Obsolete keys such as tui_subagent_position are ignored.29+// budget_ratio derives each child's cumulative rollout budget from the active
30+// parent model's context window. Obsolete keys are ignored.
29 type SubagentsConfig struct {31 type SubagentsConfig struct {
30 MaxConcurrent int `json:"max_concurrent,omitempty"`32 MaxConcurrent int `json:"max_concurrent,omitempty"`
31 QueueTimeout string `json:"queue_timeout,omitempty"`33 QueueTimeout string `json:"queue_timeout,omitempty"`
32 AllowedTools []string `json:"allowed_tools,omitempty"`34 AllowedTools []string `json:"allowed_tools,omitempty"`
33 AllowedRoots []string `json:"allowed_roots,omitempty"`35 AllowedRoots []string `json:"allowed_roots,omitempty"`
36+ BudgetRatio *float64 `json:"budget_ratio,omitempty"`
34 }37 }
35 38
36 // GoalsConfig controls optional resource limits for autonomous goals. A nil39 // GoalsConfig controls optional resource limits for autonomous goals. A nil
@@ -315,13 +318,16 @@ func LoadConfig() (Config, error) {
315 if err := json.Unmarshal(b, &c); err != nil {318 if err := json.Unmarshal(b, &c); err != nil {
316 return c, fmt.Errorf("parse config: %w", err)319 return c, fmt.Errorf("parse config: %w", err)
317 }320 }
318- if err := validateSubagentDurations(c.Subagents); err != nil {321+ if err := validateSubagentConfig(c.Subagents); err != nil {
319 return c, err322 return c, err
320 }323 }
321 return c, nil324 return c, nil
322 }325 }
323 326
324-func validateSubagentDurations(cfg SubagentsConfig) error {327+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+ }
325 values := []struct {331 values := []struct {
326 name string332 name string
327 value string333 value string
A packages/agent/config_subagents_test.go +28 -0
@@ -0,0 +1,28 @@
1+package agent
2+
3+import "testing"
4+
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+ }
27+ }
28+}
@@ -0,0 +1,28 @@
1+package agent
2+
3+import "testing"
4+
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+ }
27+ }
28+}
M packages/agent/modes/resident_subagent_indicator.go +14 -0
@@ -101,9 +101,23 @@ func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
101101 if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
102102 parts = append(parts, context)
103103 }
104+ if snapshot.Budget.Limit > 0 {
105+ parts = append(parts, fmt.Sprintf("budget:%s %d%%/%s", snapshot.Budget.State, snapshot.Budget.Percent, compactResidentTokens(snapshot.Budget.Limit)))
106+ }
104107 return strings.Join(parts, " ")
105108 }
106109
110+func compactResidentTokens(tokens int64) string {
111+ switch {
112+ case tokens >= 999_500:
113+ return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
114+ case tokens >= 1_000:
115+ return fmt.Sprintf("%.0fk", float64(tokens)/1_000)
116+ default:
117+ return fmt.Sprintf("%d", tokens)
118+ }
119+}
120+
107121 func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
108122 if !snapshot.TurnStartedAt.IsZero() {
109123 return snapshot.TurnStartedAt
@@ -101,9 +101,23 @@ func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
101 if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {101 if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
102 parts = append(parts, context)102 parts = append(parts, context)
103 }103 }
104+ if snapshot.Budget.Limit > 0 {
105+ parts = append(parts, fmt.Sprintf("budget:%s %d%%/%s", snapshot.Budget.State, snapshot.Budget.Percent, compactResidentTokens(snapshot.Budget.Limit)))
106+ }
104 return strings.Join(parts, " ")107 return strings.Join(parts, " ")
105 }108 }
106 109
110+func compactResidentTokens(tokens int64) string {
111+ switch {
112+ case tokens >= 999_500:
113+ return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
114+ case tokens >= 1_000:
115+ return fmt.Sprintf("%.0fk", float64(tokens)/1_000)
116+ default:
117+ return fmt.Sprintf("%d", tokens)
118+ }
119+}
120+
107 func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {121 func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
108 if !snapshot.TurnStartedAt.IsZero() {122 if !snapshot.TurnStartedAt.IsZero() {
109 return snapshot.TurnStartedAt123 return snapshot.TurnStartedAt
M packages/agent/modes/resident_subagent_indicator_test.go +25 -0
@@ -233,6 +233,31 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t
233233 }
234234 }
235235
236+func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) {
237+ metadata := residentUsageMetadata(subagents.ResidentSnapshot{
238+ Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 375_000, Percent: 90, State: subagents.BudgetFinalizing},
239+ })
240+ if metadata != "budget:finalizing 90%/375k" {
241+ t.Fatalf("budget metadata = %q", metadata)
242+ }
243+}
244+
245+func TestCompactResidentTokensAvoidsThousandKBoundary(t *testing.T) {
246+ for _, tc := range []struct {
247+ tokens int64
248+ want string
249+ }{
250+ {tokens: 999_499, want: "999k"},
251+ {tokens: 999_500, want: "1.0M"},
252+ {tokens: 999_999, want: "1.0M"},
253+ {tokens: 1_000_000, want: "1.0M"},
254+ } {
255+ if got := compactResidentTokens(tc.tokens); got != tc.want {
256+ t.Errorf("compactResidentTokens(%d) = %q, want %q", tc.tokens, got, tc.want)
257+ }
258+ }
259+}
260+
236261 func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
237262 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
238263 const width = 34
@@ -233,6 +233,31 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t
233 }233 }
234 }234 }
235 235
236+func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) {
237+ metadata := residentUsageMetadata(subagents.ResidentSnapshot{
238+ Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 375_000, Percent: 90, State: subagents.BudgetFinalizing},
239+ })
240+ if metadata != "budget:finalizing 90%/375k" {
241+ t.Fatalf("budget metadata = %q", metadata)
242+ }
243+}
244+
245+func TestCompactResidentTokensAvoidsThousandKBoundary(t *testing.T) {
246+ for _, tc := range []struct {
247+ tokens int64
248+ want string
249+ }{
250+ {tokens: 999_499, want: "999k"},
251+ {tokens: 999_500, want: "1.0M"},
252+ {tokens: 999_999, want: "1.0M"},
253+ {tokens: 1_000_000, want: "1.0M"},
254+ } {
255+ if got := compactResidentTokens(tc.tokens); got != tc.want {
256+ t.Errorf("compactResidentTokens(%d) = %q, want %q", tc.tokens, got, tc.want)
257+ }
258+ }
259+}
260+
236 func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {261 func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
237 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)262 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
238 const width = 34263 const width = 34
M packages/agent/orchestration_modes.go +3 -0
@@ -78,6 +78,8 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
7878 return runtime.PrepareRegistry(reg)
7979 }
8080 runtime.SetProvider(r.Provider)
81+ runtime.SetModel(r.Model)
82+ runtime.SetContextWindow(r.ContextWindow)
8183 runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS)
8284 runtime.SetFastMode(r.FastMode)
8385 runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy)
@@ -203,6 +205,7 @@ func newOrchestratedRuntime(_ context.Context, args Args, r Resolved, cfg Config
203205 BaseURL: r.BaseURL,
204206 InsecureTLS: r.InsecureTLS,
205207 FastMode: r.FastMode,
208+ ContextWindow: r.ContextWindow,
206209 Policy: subagentPolicyFromConfig(cfg.Subagents),
207210 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
208211 ResidentCompletion: func(completion subagents.ResidentCompletion) {
@@ -78,6 +78,8 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
78 return runtime.PrepareRegistry(reg)78 return runtime.PrepareRegistry(reg)
79 }79 }
80 runtime.SetProvider(r.Provider)80 runtime.SetProvider(r.Provider)
81+ runtime.SetModel(r.Model)
82+ runtime.SetContextWindow(r.ContextWindow)
81 runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS)83 runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS)
82 runtime.SetFastMode(r.FastMode)84 runtime.SetFastMode(r.FastMode)
83 runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy)85 runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy)
@@ -203,6 +205,7 @@ func newOrchestratedRuntime(_ context.Context, args Args, r Resolved, cfg Config
203 BaseURL: r.BaseURL,205 BaseURL: r.BaseURL,
204 InsecureTLS: r.InsecureTLS,206 InsecureTLS: r.InsecureTLS,
205 FastMode: r.FastMode,207 FastMode: r.FastMode,
208+ ContextWindow: r.ContextWindow,
206 Policy: subagentPolicyFromConfig(cfg.Subagents),209 Policy: subagentPolicyFromConfig(cfg.Subagents),
207 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),210 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
208 ResidentCompletion: func(completion subagents.ResidentCompletion) {211 ResidentCompletion: func(completion subagents.ResidentCompletion) {
M packages/agent/subagent_factory.go +62 -1
@@ -2,6 +2,8 @@ package agent
22
33 import (
44 "context"
5+ "encoding/json"
6+ "errors"
57 "fmt"
68 "strings"
79 "sync"
@@ -9,6 +11,7 @@ import (
911 "github.com/bnema/zut/packages/agent/subagents"
1012 "github.com/bnema/zut/packages/agent/tools"
1113 "github.com/bnema/zut/packages/core"
14+ "github.com/bnema/zut/packages/provider"
1215 )
1316
1417 // newResidentChildRunner is the host-owned construction boundary for one
@@ -36,6 +39,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
3639 system = strings.TrimSpace(system) + "\n\n" + extra
3740 }
3841 }
42+ budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow)
43+ if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" {
44+ system = strings.TrimSpace(system) + "\n\n" + guidance
45+ }
3946 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
4047 agent.MaxSteps = resolved.MaxSteps
4148 agent.ContextWindow = resolved.ContextWindow
@@ -43,10 +50,13 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
4350 agent.Reasoning = resolved.Reasoning
4451 agent.Temperature = resolved.Temperature
4552 agent.FastMode = resolved.FastMode
53+ var baseline subagents.ResidentUsageSnapshot
4654 if journal != nil {
47- baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
55+ baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
4856 agent.SeedCost(baseline.Usage)
4957 }
58+ budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage)
59+ configureResidentBudget(agent, budget)
5060 rootCacheID := strings.TrimSpace(spec.RootCacheID)
5161 if rootCacheID == "" {
5262 // Journals written before root cache identity was persisted can still
@@ -74,9 +84,13 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
7484 }
7585 turnCtx, cancel := context.WithCancel(ctx)
7686 defer cancel()
87+ startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
7788 var journalErr error
7889 var journalMu sync.Mutex
7990 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
91+ if usage, ok := event.(core.EvUsage); ok {
92+ budget.Observe(usage.Cumulative)
93+ }
8094 if err := journal.RecordAgentEvent(event); err != nil {
8195 journalMu.Lock()
8296 if journalErr == nil {
@@ -91,10 +105,57 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
91105 if journalErr != nil {
92106 return fmt.Errorf("persist resident child transcript: %w", journalErr)
93107 }
108+ if budget.Snapshot().State == subagents.BudgetExceeded {
109+ if !startedExhausted && err == nil {
110+ return nil
111+ }
112+ return errors.Join(err, subagents.ErrBudgetExceeded)
113+ }
94114 return err
95115 }, nil
96116 }
97117
118+func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) {
119+ if agent == nil || budget == nil {
120+ return
121+ }
122+ agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
123+ instruction, exceeded := budget.TurnContext()
124+ if exceeded {
125+ return false, subagents.ErrBudgetExceeded.Error(), ""
126+ }
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)
155+ }
156+ return int(remaining)
157+}
158+
98159 // residentChildArgs applies the complete durable child specification without
99160 // rediscovering a mutable profile file. That preserves the profile inheritance
100161 // decision accepted at spawn time across explicit resume and restart.
@@ -2,6 +2,8 @@ package agent
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
6+ "errors"
5 "fmt"7 "fmt"
6 "strings"8 "strings"
7 "sync"9 "sync"
@@ -9,6 +11,7 @@ import (
9 "github.com/bnema/zut/packages/agent/subagents"11 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/agent/tools"12 "github.com/bnema/zut/packages/agent/tools"
11 "github.com/bnema/zut/packages/core"13 "github.com/bnema/zut/packages/core"
14+ "github.com/bnema/zut/packages/provider"
12 )15 )
13 16
14 // newResidentChildRunner is the host-owned construction boundary for one17 // newResidentChildRunner is the host-owned construction boundary for one
@@ -36,6 +39,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
36 system = strings.TrimSpace(system) + "\n\n" + extra39 system = strings.TrimSpace(system) + "\n\n" + extra
37 }40 }
38 }41 }
42+ budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow)
43+ if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" {
44+ system = strings.TrimSpace(system) + "\n\n" + guidance
45+ }
39 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)46 agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry)
40 agent.MaxSteps = resolved.MaxSteps47 agent.MaxSteps = resolved.MaxSteps
41 agent.ContextWindow = resolved.ContextWindow48 agent.ContextWindow = resolved.ContextWindow
@@ -43,10 +50,13 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
43 agent.Reasoning = resolved.Reasoning50 agent.Reasoning = resolved.Reasoning
44 agent.Temperature = resolved.Temperature51 agent.Temperature = resolved.Temperature
45 agent.FastMode = resolved.FastMode52 agent.FastMode = resolved.FastMode
53+ var baseline subagents.ResidentUsageSnapshot
46 if journal != nil {54 if journal != nil {
47- baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")55+ baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
48 agent.SeedCost(baseline.Usage)56 agent.SeedCost(baseline.Usage)
49 }57 }
58+ budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage)
59+ configureResidentBudget(agent, budget)
50 rootCacheID := strings.TrimSpace(spec.RootCacheID)60 rootCacheID := strings.TrimSpace(spec.RootCacheID)
51 if rootCacheID == "" {61 if rootCacheID == "" {
52 // Journals written before root cache identity was persisted can still62 // Journals written before root cache identity was persisted can still
@@ -74,9 +84,13 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
74 }84 }
75 turnCtx, cancel := context.WithCancel(ctx)85 turnCtx, cancel := context.WithCancel(ctx)
76 defer cancel()86 defer cancel()
87+ startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded
77 var journalErr error88 var journalErr error
78 var journalMu sync.Mutex89 var journalMu sync.Mutex
79 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {90 err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) {
91+ if usage, ok := event.(core.EvUsage); ok {
92+ budget.Observe(usage.Cumulative)
93+ }
80 if err := journal.RecordAgentEvent(event); err != nil {94 if err := journal.RecordAgentEvent(event); err != nil {
81 journalMu.Lock()95 journalMu.Lock()
82 if journalErr == nil {96 if journalErr == nil {
@@ -91,10 +105,57 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
91 if journalErr != nil {105 if journalErr != nil {
92 return fmt.Errorf("persist resident child transcript: %w", journalErr)106 return fmt.Errorf("persist resident child transcript: %w", journalErr)
93 }107 }
108+ if budget.Snapshot().State == subagents.BudgetExceeded {
109+ if !startedExhausted && err == nil {
110+ return nil
111+ }
112+ return errors.Join(err, subagents.ErrBudgetExceeded)
113+ }
94 return err114 return err
95 }, nil115 }, nil
96 }116 }
97 117
118+func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) {
119+ if agent == nil || budget == nil {
120+ return
121+ }
122+ agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) {
123+ instruction, exceeded := budget.TurnContext()
124+ if exceeded {
125+ return false, subagents.ErrBudgetExceeded.Error(), ""
126+ }
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)
155+ }
156+ return int(remaining)
157+}
158+
98 // residentChildArgs applies the complete durable child specification without159 // residentChildArgs applies the complete durable child specification without
99 // rediscovering a mutable profile file. That preserves the profile inheritance160 // rediscovering a mutable profile file. That preserves the profile inheritance
100 // decision accepted at spawn time across explicit resume and restart.161 // decision accepted at spawn time across explicit resume and restart.
M packages/agent/subagent_factory_test.go +186 -0
@@ -1,11 +1,18 @@
11 package agent
22
33 import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
49 "strings"
10+ "sync/atomic"
511 "testing"
612
713 "github.com/bnema/zut/packages/agent/subagents"
814 "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/provider"
916 )
1017
1118 func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {
@@ -30,6 +37,185 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
3037 }
3138 }
3239
40+func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) {
41+ agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
42+ budget := subagents.NewRolloutBudget(1_000, provider.Usage{})
43+ configureResidentBudget(agent, budget)
44+
45+ budget.Observe(provider.Usage{InputTokens: 900})
46+ 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)
49+ }
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)
58+ }
59+
60+ budget.Observe(provider.Usage{InputTokens: 1_000})
61+ allowed, reason, _ = agent.BeforeTurnContext(t.Context(), 2)
62+ if allowed || reason != subagents.ErrBudgetExceeded.Error() {
63+ t.Fatalf("exhausted context = allowed=%t reason=%q", allowed, reason)
64+ }
65+}
66+
67+func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) {
68+ var requests atomic.Int32
69+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70+ requestNumber := requests.Add(1)
71+ var body map[string]any
72+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
73+ t.Errorf("decode request: %v", err)
74+ }
75+ w.Header().Set("content-type", "text/event-stream")
76+ if requestNumber == 1 {
77+ if got := requestMaxTokens(t, body); got != 1_000 {
78+ t.Errorf("initial max_tokens = %d, want 1000", got)
79+ }
80+ writeOpenAIChunk(t, w, map[string]any{
81+ "choices": []any{map[string]any{
82+ "index": 0,
83+ "delta": map[string]any{"tool_calls": []any{map[string]any{
84+ "index": 0, "id": "call-1", "type": "function",
85+ "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`},
86+ }}},
87+ "finish_reason": "tool_calls",
88+ }},
89+ "usage": map[string]int{"prompt_tokens": 900, "completion_tokens": 0},
90+ })
91+ return
92+ }
93+ if tools, ok := body["tools"].([]any); ok && len(tools) != 0 {
94+ t.Errorf("finalization request tools = %#v, want none", tools)
95+ }
96+ if got := requestMaxTokens(t, body); got != 100 {
97+ t.Errorf("finalization max_tokens = %d, want 100", got)
98+ }
99+ writeOpenAIChunk(t, w, map[string]any{
100+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}},
101+ "usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 50},
102+ })
103+ }))
104+ defer server.Close()
105+
106+ runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000)
107+ if err := runner(t.Context(), "review"); err != nil {
108+ t.Fatal(err)
109+ }
110+ if got := requests.Load(); got != 2 {
111+ t.Fatalf("provider requests = %d, want 2", got)
112+ }
113+ if summary := latestResidentAssistantText(t, journal); summary != "final result" {
114+ t.Fatalf("latest summary = %q", summary)
115+ }
116+}
117+
118+func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
119+ var requests atomic.Int32
120+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121+ requests.Add(1)
122+ var body map[string]any
123+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
124+ t.Errorf("decode request: %v", err)
125+ }
126+ if got := requestMaxTokens(t, body); got != 1_000 {
127+ t.Errorf("initial max_tokens = %d, want 1000", got)
128+ }
129+ w.Header().Set("content-type", "text/event-stream")
130+ writeOpenAIChunk(t, w, map[string]any{
131+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "best available result"}, "finish_reason": "stop"}},
132+ "usage": map[string]int{"prompt_tokens": 1_200, "completion_tokens": 0},
133+ })
134+ }))
135+ defer server.Close()
136+
137+ runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000)
138+ if err := runner(t.Context(), "review"); err != nil {
139+ t.Fatalf("runner error = %v, want terminal result", err)
140+ }
141+ if got := requests.Load(); got != 1 {
142+ t.Fatalf("provider requests = %d, want 1", got)
143+ }
144+ if summary := latestResidentAssistantText(t, journal); summary != "best available result" {
145+ t.Fatalf("latest summary = %q", summary)
146+ }
147+ if err := runner(t.Context(), "continue"); !errors.Is(err, subagents.ErrBudgetExceeded) {
148+ t.Fatalf("exhausted follow-up error = %v", err)
149+ }
150+ if got := requests.Load(); got != 1 {
151+ t.Fatalf("exhausted follow-up made %d provider requests, want 1 total", got)
152+ }
153+}
154+
155+func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {
156+ t.Helper()
157+ spec := subagents.ResidentChildSpec{
158+ ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o",
159+ Tools: []string{"read"}, BudgetLimit: limit,
160+ }
161+ journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
162+ if err != nil {
163+ t.Fatal(err)
164+ }
165+ t.Cleanup(func() { _ = journal.Close() })
166+ if err := journal.Accept(spec, "review"); err != nil {
167+ t.Fatal(err)
168+ }
169+ runner, err := newResidentChildRunner(Args{
170+ Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL,
171+ CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
172+ }, spec, journal)
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ return runner, journal
177+}
178+
179+func requestMaxTokens(t *testing.T, body map[string]any) int {
180+ t.Helper()
181+ value, ok := body["max_tokens"].(float64)
182+ if !ok {
183+ t.Errorf("request max_tokens = %#v", body["max_tokens"])
184+ return 0
185+ }
186+ return int(value)
187+}
188+
189+func latestResidentAssistantText(t *testing.T, journal *subagents.ResidentJournal) string {
190+ t.Helper()
191+ messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ for index := len(messages) - 1; index >= 0; index-- {
196+ if messages[index].Role != provider.RoleAssistant {
197+ continue
198+ }
199+ var text strings.Builder
200+ for _, content := range messages[index].Content {
201+ if block, ok := content.(provider.TextBlock); ok {
202+ text.WriteString(block.Text)
203+ }
204+ }
205+ return text.String()
206+ }
207+ return ""
208+}
209+
210+func writeOpenAIChunk(t *testing.T, w http.ResponseWriter, chunk map[string]any) {
211+ t.Helper()
212+ data, err := json.Marshal(chunk)
213+ if err != nil {
214+ t.Fatal(err)
215+ }
216+ _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
217+}
218+
33219 func TestResidentChildArgsPreserveDurableProfileInheritance(t *testing.T) {
34220 no := false
35221 next := residentChildArgs(Args{Orchestrate: true, NoSkill: false, NoContextFiles: false, BaseURL: "https://stale.example/v1", InsecureTLS: false}, "openai", subagents.ResidentChildSpec{
@@ -1,11 +1,18 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
4 "strings"9 "strings"
10+ "sync/atomic"
5 "testing"11 "testing"
6 12
7 "github.com/bnema/zut/packages/agent/subagents"13 "github.com/bnema/zut/packages/agent/subagents"
8 "github.com/bnema/zut/packages/core"14 "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/provider"
9 )16 )
10 17
11 func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {18 func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {
@@ -30,6 +37,185 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
30 }37 }
31 }38 }
32 39
40+func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) {
41+ agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
42+ budget := subagents.NewRolloutBudget(1_000, provider.Usage{})
43+ configureResidentBudget(agent, budget)
44+
45+ budget.Observe(provider.Usage{InputTokens: 900})
46+ 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)
49+ }
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)
58+ }
59+
60+ budget.Observe(provider.Usage{InputTokens: 1_000})
61+ allowed, reason, _ = agent.BeforeTurnContext(t.Context(), 2)
62+ if allowed || reason != subagents.ErrBudgetExceeded.Error() {
63+ t.Fatalf("exhausted context = allowed=%t reason=%q", allowed, reason)
64+ }
65+}
66+
67+func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) {
68+ var requests atomic.Int32
69+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70+ requestNumber := requests.Add(1)
71+ var body map[string]any
72+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
73+ t.Errorf("decode request: %v", err)
74+ }
75+ w.Header().Set("content-type", "text/event-stream")
76+ if requestNumber == 1 {
77+ if got := requestMaxTokens(t, body); got != 1_000 {
78+ t.Errorf("initial max_tokens = %d, want 1000", got)
79+ }
80+ writeOpenAIChunk(t, w, map[string]any{
81+ "choices": []any{map[string]any{
82+ "index": 0,
83+ "delta": map[string]any{"tool_calls": []any{map[string]any{
84+ "index": 0, "id": "call-1", "type": "function",
85+ "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`},
86+ }}},
87+ "finish_reason": "tool_calls",
88+ }},
89+ "usage": map[string]int{"prompt_tokens": 900, "completion_tokens": 0},
90+ })
91+ return
92+ }
93+ if tools, ok := body["tools"].([]any); ok && len(tools) != 0 {
94+ t.Errorf("finalization request tools = %#v, want none", tools)
95+ }
96+ if got := requestMaxTokens(t, body); got != 100 {
97+ t.Errorf("finalization max_tokens = %d, want 100", got)
98+ }
99+ writeOpenAIChunk(t, w, map[string]any{
100+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}},
101+ "usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 50},
102+ })
103+ }))
104+ defer server.Close()
105+
106+ runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000)
107+ if err := runner(t.Context(), "review"); err != nil {
108+ t.Fatal(err)
109+ }
110+ if got := requests.Load(); got != 2 {
111+ t.Fatalf("provider requests = %d, want 2", got)
112+ }
113+ if summary := latestResidentAssistantText(t, journal); summary != "final result" {
114+ t.Fatalf("latest summary = %q", summary)
115+ }
116+}
117+
118+func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) {
119+ var requests atomic.Int32
120+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121+ requests.Add(1)
122+ var body map[string]any
123+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
124+ t.Errorf("decode request: %v", err)
125+ }
126+ if got := requestMaxTokens(t, body); got != 1_000 {
127+ t.Errorf("initial max_tokens = %d, want 1000", got)
128+ }
129+ w.Header().Set("content-type", "text/event-stream")
130+ writeOpenAIChunk(t, w, map[string]any{
131+ "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "best available result"}, "finish_reason": "stop"}},
132+ "usage": map[string]int{"prompt_tokens": 1_200, "completion_tokens": 0},
133+ })
134+ }))
135+ defer server.Close()
136+
137+ runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000)
138+ if err := runner(t.Context(), "review"); err != nil {
139+ t.Fatalf("runner error = %v, want terminal result", err)
140+ }
141+ if got := requests.Load(); got != 1 {
142+ t.Fatalf("provider requests = %d, want 1", got)
143+ }
144+ if summary := latestResidentAssistantText(t, journal); summary != "best available result" {
145+ t.Fatalf("latest summary = %q", summary)
146+ }
147+ if err := runner(t.Context(), "continue"); !errors.Is(err, subagents.ErrBudgetExceeded) {
148+ t.Fatalf("exhausted follow-up error = %v", err)
149+ }
150+ if got := requests.Load(); got != 1 {
151+ t.Fatalf("exhausted follow-up made %d provider requests, want 1 total", got)
152+ }
153+}
154+
155+func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {
156+ t.Helper()
157+ spec := subagents.ResidentChildSpec{
158+ ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o",
159+ Tools: []string{"read"}, BudgetLimit: limit,
160+ }
161+ journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
162+ if err != nil {
163+ t.Fatal(err)
164+ }
165+ t.Cleanup(func() { _ = journal.Close() })
166+ if err := journal.Accept(spec, "review"); err != nil {
167+ t.Fatal(err)
168+ }
169+ runner, err := newResidentChildRunner(Args{
170+ Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL,
171+ CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
172+ }, spec, journal)
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ return runner, journal
177+}
178+
179+func requestMaxTokens(t *testing.T, body map[string]any) int {
180+ t.Helper()
181+ value, ok := body["max_tokens"].(float64)
182+ if !ok {
183+ t.Errorf("request max_tokens = %#v", body["max_tokens"])
184+ return 0
185+ }
186+ return int(value)
187+}
188+
189+func latestResidentAssistantText(t *testing.T, journal *subagents.ResidentJournal) string {
190+ t.Helper()
191+ messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ for index := len(messages) - 1; index >= 0; index-- {
196+ if messages[index].Role != provider.RoleAssistant {
197+ continue
198+ }
199+ var text strings.Builder
200+ for _, content := range messages[index].Content {
201+ if block, ok := content.(provider.TextBlock); ok {
202+ text.WriteString(block.Text)
203+ }
204+ }
205+ return text.String()
206+ }
207+ return ""
208+}
209+
210+func writeOpenAIChunk(t *testing.T, w http.ResponseWriter, chunk map[string]any) {
211+ t.Helper()
212+ data, err := json.Marshal(chunk)
213+ if err != nil {
214+ t.Fatal(err)
215+ }
216+ _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
217+}
218+
33 func TestResidentChildArgsPreserveDurableProfileInheritance(t *testing.T) {219 func TestResidentChildArgsPreserveDurableProfileInheritance(t *testing.T) {
34 no := false220 no := false
35 next := residentChildArgs(Args{Orchestrate: true, NoSkill: false, NoContextFiles: false, BaseURL: "https://stale.example/v1", InsecureTLS: false}, "openai", subagents.ResidentChildSpec{221 next := residentChildArgs(Args{Orchestrate: true, NoSkill: false, NoContextFiles: false, BaseURL: "https://stale.example/v1", InsecureTLS: false}, "openai", subagents.ResidentChildSpec{
M packages/agent/subagent_runtime.go +61 -11
@@ -34,6 +34,7 @@ type subagentRuntime struct {
3434 insecureTLS bool
3535 fastMode bool
3636 repoRoot string
37+ contextWindow int
3738
3839 activeProvider func() string
3940 activeModel func() string
@@ -57,6 +58,7 @@ type subagentRuntimeConfiguration struct {
5758 insecureTLS bool
5859 fastMode bool
5960 repoRoot string
61+ contextWindow int
6062 webSearchPolicy subagents.WebSearchPolicy
6163 }
6264
@@ -68,12 +70,13 @@ type subagentRuntimeConfig struct {
6870 Root string
6971 RepoRoot string
7072
71- Provider string
72- Model string
73- Reasoning string
74- BaseURL string
75- InsecureTLS bool
76- FastMode bool
73+ Provider string
74+ Model string
75+ Reasoning string
76+ BaseURL string
77+ InsecureTLS bool
78+ FastMode bool
79+ ContextWindow int
7780
7881 Policy subagents.SubagentPolicy
7982 WebSearchPolicy subagents.WebSearchPolicy
@@ -97,6 +100,7 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
97100 insecureTLS: cfg.InsecureTLS,
98101 fastMode: cfg.FastMode,
99102 repoRoot: cfg.RepoRoot,
103+ contextWindow: cfg.ContextWindow,
100104 activeProvider: cfg.ActiveProvider,
101105 activeModel: cfg.ActiveModel,
102106 activeReasoning: cfg.ActiveReasoning,
@@ -179,6 +183,15 @@ func (rt *subagentRuntime) SetModel(model string) {
179183 rt.settingsMu.Unlock()
180184 }
181185
186+func (rt *subagentRuntime) SetContextWindow(contextWindow int) {
187+ if rt == nil {
188+ return
189+ }
190+ rt.settingsMu.Lock()
191+ rt.contextWindow = contextWindow
192+ rt.settingsMu.Unlock()
193+}
194+
182195 func (rt *subagentRuntime) SetReasoning(reasoning string) {
183196 if rt == nil {
184197 return
@@ -250,6 +263,7 @@ func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration
250263 insecureTLS: rt.insecureTLS,
251264 fastMode: rt.fastMode,
252265 repoRoot: rt.repoRoot,
266+ contextWindow: rt.contextWindow,
253267 webSearchPolicy: rt.webSearchPolicy,
254268 }
255269 }
@@ -265,6 +279,7 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur
265279 rt.insecureTLS = snapshot.insecureTLS
266280 rt.fastMode = snapshot.fastMode
267281 rt.repoRoot = snapshot.repoRoot
282+ rt.contextWindow = snapshot.contextWindow
268283 rt.webSearchPolicy = snapshot.webSearchPolicy
269284 rt.settingsMu.Unlock()
270285 }
@@ -292,7 +307,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
292307 providerID, model, reasoning := activeProvider, rt.model, rt.reasoning
293308 baseURL, insecureTLS := rt.baseURL, rt.insecureTLS
294309 workspace, parentSession := rt.repoRoot, rt.activeSession
295- fastMode, policy := rt.fastMode, rt.policy
310+ fastMode, policy, parentContextWindow := rt.fastMode, rt.policy, rt.contextWindow
296311 rt.settingsMu.RUnlock()
297312 if strings.TrimSpace(request.Provider) != "" {
298313 providerID = strings.TrimSpace(request.Provider)
@@ -349,11 +364,16 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
349364 if workspaceMode == "" {
350365 workspaceMode = subagents.WorkspaceShared
351366 }
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+ }
352371 spec := subagents.ResidentChildSpec{
353372 ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,
354373 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
355374 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
356375 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
376+ BudgetLimit: budgetLimit, BudgetRatio: resolvedBudgetRatio, BudgetSource: budgetSource,
357377 }
358378 if request.Profile != nil {
359379 spec.Profile = request.Profile.Name
@@ -365,6 +385,30 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
365385 return spec, nil
366386 }
367387
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+ }
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
407+ }
408+ limit, resolvedRatio, err := subagents.BudgetLimit(parentContextWindow, ratio, tokens)
409+ return limit, resolvedRatio, source, err
410+}
411+
368412 func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {
369413 hasWebTool := false
370414 for _, name := range selected {
@@ -524,10 +568,16 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
524568 }
525569 return parsed
526570 }
571+ budgetRatio := subagents.DefaultBudgetRatio
572+ if cfg.BudgetRatio != nil {
573+ budgetRatio = *cfg.BudgetRatio
574+ }
527575 return subagents.SubagentPolicy{
528- MaxConcurrent: cfg.MaxConcurrent,
529- QueueTimeout: parseDuration(cfg.QueueTimeout),
530- AllowedTools: append([]string(nil), cfg.AllowedTools...),
531- AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
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,
532582 }
533583 }
@@ -34,6 +34,7 @@ type subagentRuntime struct {
34 insecureTLS bool34 insecureTLS bool
35 fastMode bool35 fastMode bool
36 repoRoot string36 repoRoot string
37+ contextWindow int
37 38
38 activeProvider func() string39 activeProvider func() string
39 activeModel func() string40 activeModel func() string
@@ -57,6 +58,7 @@ type subagentRuntimeConfiguration struct {
57 insecureTLS bool58 insecureTLS bool
58 fastMode bool59 fastMode bool
59 repoRoot string60 repoRoot string
61+ contextWindow int
60 webSearchPolicy subagents.WebSearchPolicy62 webSearchPolicy subagents.WebSearchPolicy
61 }63 }
62 64
@@ -68,12 +70,13 @@ type subagentRuntimeConfig struct {
68 Root string70 Root string
69 RepoRoot string71 RepoRoot string
70 72
71- Provider string73+ Provider string
72- Model string74+ Model string
73- Reasoning string75+ Reasoning string
74- BaseURL string76+ BaseURL string
75- InsecureTLS bool77+ InsecureTLS bool
76- FastMode bool78+ FastMode bool
79+ ContextWindow int
77 80
78 Policy subagents.SubagentPolicy81 Policy subagents.SubagentPolicy
79 WebSearchPolicy subagents.WebSearchPolicy82 WebSearchPolicy subagents.WebSearchPolicy
@@ -97,6 +100,7 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
97 insecureTLS: cfg.InsecureTLS,100 insecureTLS: cfg.InsecureTLS,
98 fastMode: cfg.FastMode,101 fastMode: cfg.FastMode,
99 repoRoot: cfg.RepoRoot,102 repoRoot: cfg.RepoRoot,
103+ contextWindow: cfg.ContextWindow,
100 activeProvider: cfg.ActiveProvider,104 activeProvider: cfg.ActiveProvider,
101 activeModel: cfg.ActiveModel,105 activeModel: cfg.ActiveModel,
102 activeReasoning: cfg.ActiveReasoning,106 activeReasoning: cfg.ActiveReasoning,
@@ -179,6 +183,15 @@ func (rt *subagentRuntime) SetModel(model string) {
179 rt.settingsMu.Unlock()183 rt.settingsMu.Unlock()
180 }184 }
181 185
186+func (rt *subagentRuntime) SetContextWindow(contextWindow int) {
187+ if rt == nil {
188+ return
189+ }
190+ rt.settingsMu.Lock()
191+ rt.contextWindow = contextWindow
192+ rt.settingsMu.Unlock()
193+}
194+
182 func (rt *subagentRuntime) SetReasoning(reasoning string) {195 func (rt *subagentRuntime) SetReasoning(reasoning string) {
183 if rt == nil {196 if rt == nil {
184 return197 return
@@ -250,6 +263,7 @@ func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration
250 insecureTLS: rt.insecureTLS,263 insecureTLS: rt.insecureTLS,
251 fastMode: rt.fastMode,264 fastMode: rt.fastMode,
252 repoRoot: rt.repoRoot,265 repoRoot: rt.repoRoot,
266+ contextWindow: rt.contextWindow,
253 webSearchPolicy: rt.webSearchPolicy,267 webSearchPolicy: rt.webSearchPolicy,
254 }268 }
255 }269 }
@@ -265,6 +279,7 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur
265 rt.insecureTLS = snapshot.insecureTLS279 rt.insecureTLS = snapshot.insecureTLS
266 rt.fastMode = snapshot.fastMode280 rt.fastMode = snapshot.fastMode
267 rt.repoRoot = snapshot.repoRoot281 rt.repoRoot = snapshot.repoRoot
282+ rt.contextWindow = snapshot.contextWindow
268 rt.webSearchPolicy = snapshot.webSearchPolicy283 rt.webSearchPolicy = snapshot.webSearchPolicy
269 rt.settingsMu.Unlock()284 rt.settingsMu.Unlock()
270 }285 }
@@ -292,7 +307,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
292 providerID, model, reasoning := activeProvider, rt.model, rt.reasoning307 providerID, model, reasoning := activeProvider, rt.model, rt.reasoning
293 baseURL, insecureTLS := rt.baseURL, rt.insecureTLS308 baseURL, insecureTLS := rt.baseURL, rt.insecureTLS
294 workspace, parentSession := rt.repoRoot, rt.activeSession309 workspace, parentSession := rt.repoRoot, rt.activeSession
295- fastMode, policy := rt.fastMode, rt.policy310+ fastMode, policy, parentContextWindow := rt.fastMode, rt.policy, rt.contextWindow
296 rt.settingsMu.RUnlock()311 rt.settingsMu.RUnlock()
297 if strings.TrimSpace(request.Provider) != "" {312 if strings.TrimSpace(request.Provider) != "" {
298 providerID = strings.TrimSpace(request.Provider)313 providerID = strings.TrimSpace(request.Provider)
@@ -349,11 +364,16 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
349 if workspaceMode == "" {364 if workspaceMode == "" {
350 workspaceMode = subagents.WorkspaceShared365 workspaceMode = subagents.WorkspaceShared
351 }366 }
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+ }
352 spec := subagents.ResidentChildSpec{371 spec := subagents.ResidentChildSpec{
353 ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,372 ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,
354 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),373 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
355 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,374 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
356 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,375 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
376+ BudgetLimit: budgetLimit, BudgetRatio: resolvedBudgetRatio, BudgetSource: budgetSource,
357 }377 }
358 if request.Profile != nil {378 if request.Profile != nil {
359 spec.Profile = request.Profile.Name379 spec.Profile = request.Profile.Name
@@ -365,6 +385,30 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
365 return spec, nil385 return spec, nil
366 }386 }
367 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+ }
400+ }
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
407+ }
408+ limit, resolvedRatio, err := subagents.BudgetLimit(parentContextWindow, ratio, tokens)
409+ return limit, resolvedRatio, source, err
410+}
411+
368 func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {412 func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {
369 hasWebTool := false413 hasWebTool := false
370 for _, name := range selected {414 for _, name := range selected {
@@ -524,10 +568,16 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
524 }568 }
525 return parsed569 return parsed
526 }570 }
571+ budgetRatio := subagents.DefaultBudgetRatio
572+ if cfg.BudgetRatio != nil {
573+ budgetRatio = *cfg.BudgetRatio
574+ }
527 return subagents.SubagentPolicy{575 return subagents.SubagentPolicy{
528- MaxConcurrent: cfg.MaxConcurrent,576+ MaxConcurrent: cfg.MaxConcurrent,
529- QueueTimeout: parseDuration(cfg.QueueTimeout),577+ QueueTimeout: parseDuration(cfg.QueueTimeout),
530- AllowedTools: append([]string(nil), cfg.AllowedTools...),578+ AllowedTools: append([]string(nil), cfg.AllowedTools...),
531- AllowedRoots: append([]string(nil), cfg.AllowedRoots...),579+ AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
580+ BudgetRatio: budgetRatio,
581+ BudgetRatioConfigured: cfg.BudgetRatio != nil,
532 }582 }
533 }583 }
M packages/agent/subagent_runtime_test.go +74 -4
@@ -4,6 +4,7 @@ import (
44 "context"
55 "testing"
66
7+ "github.com/bnema/zut/packages/agent/subagents"
78 "github.com/bnema/zut/packages/agent/tools"
89 "github.com/bnema/zut/packages/core"
910 )
@@ -53,23 +54,92 @@ func TestExpandWebCapabilityToolsPreservesPolicyAndNonWebSelections(t *testing.T
5354 func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {
5455 runtime := newSubagentRuntime(subagentRuntimeConfig{
5556 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
56- Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false,
57+ Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false, ContextWindow: 500_000,
5758 })
5859 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
5960 runtime.SetProviderSettings("https://current.example/v1", true)
61+ runtime.SetModel("gpt-current")
6062 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
6163 if err != nil {
6264 t.Fatal(err)
6365 }
64- if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS {
65- t.Fatalf("resident spec transport = %q insecure=%t", spec.BaseURL, spec.InsecureTLS)
66+ if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS || spec.Model != "gpt-current" {
67+ t.Fatalf("resident spec transport/model = %q insecure=%t model=%q", spec.BaseURL, spec.InsecureTLS, spec.Model)
68+ }
69+}
70+
71+func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) {
72+ ratio := 0.6
73+ runtime := newSubagentRuntime(subagentRuntimeConfig{
74+ 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},
77+ })
78+ t.Cleanup(func() { _ = runtime.Close(context.Background()) })
79+
80+ spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
81+ if err != nil {
82+ t.Fatal(err)
83+ }
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)
66136 }
67137 }
68138
69139 func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) {
70140 runtime := newSubagentRuntime(subagentRuntimeConfig{
71141 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
72- Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true,
142+ Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true, ContextWindow: 500_000,
73143 })
74144 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
75145 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/zut/packages/agent/subagents"
7 "github.com/bnema/zut/packages/agent/tools"8 "github.com/bnema/zut/packages/agent/tools"
8 "github.com/bnema/zut/packages/core"9 "github.com/bnema/zut/packages/core"
9 )10 )
@@ -53,23 +54,92 @@ func TestExpandWebCapabilityToolsPreservesPolicyAndNonWebSelections(t *testing.T
53 func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {54 func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {
54 runtime := newSubagentRuntime(subagentRuntimeConfig{55 runtime := newSubagentRuntime(subagentRuntimeConfig{
55 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),56 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
56- Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false,57+ Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false, ContextWindow: 500_000,
57 })58 })
58 t.Cleanup(func() { _ = runtime.Close(context.Background()) })59 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
59 runtime.SetProviderSettings("https://current.example/v1", true)60 runtime.SetProviderSettings("https://current.example/v1", true)
61+ runtime.SetModel("gpt-current")
60 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})62 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
61 if err != nil {63 if err != nil {
62 t.Fatal(err)64 t.Fatal(err)
63 }65 }
64- if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS {66+ if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS || spec.Model != "gpt-current" {
65- t.Fatalf("resident spec transport = %q insecure=%t", spec.BaseURL, spec.InsecureTLS)67+ t.Fatalf("resident spec transport/model = %q insecure=%t model=%q", spec.BaseURL, spec.InsecureTLS, spec.Model)
68+ }
69+}
70+
71+func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) {
72+ ratio := 0.6
73+ runtime := newSubagentRuntime(subagentRuntimeConfig{
74+ 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},
77+ })
78+ t.Cleanup(func() { _ = runtime.Close(context.Background()) })
79+
80+ spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
81+ if err != nil {
82+ t.Fatal(err)
83+ }
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)
66 }136 }
67 }137 }
68 138
69 func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) {139 func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) {
70 runtime := newSubagentRuntime(subagentRuntimeConfig{140 runtime := newSubagentRuntime(subagentRuntimeConfig{
71 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),141 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
72- Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true,142+ Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true, ContextWindow: 500_000,
73 })143 })
74 t.Cleanup(func() { _ = runtime.Close(context.Background()) })144 t.Cleanup(func() { _ = runtime.Close(context.Background()) })
75 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{145 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
A packages/agent/subagents/budget.go +211 -0
@@ -0,0 +1,211 @@
1+package subagents
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "math"
7+ "sync"
8+
9+ "github.com/bnema/zut/packages/provider"
10+)
11+
12+const (
13+ DefaultBudgetRatio = 0.75
14+ cachedInputBudgetWeight = 0.25
15+)
16+
17+// BudgetState is the progressive constraint applied to a resident child.
18+type BudgetState string
19+
20+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"
27+)
28+
29+var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded")
30+
31+// BudgetSnapshot is the bounded public projection of a child's cumulative
32+// rollout budget. Used is weighted usage, not the active context size.
33+type BudgetSnapshot struct {
34+ Used int64 `json:"used_tokens"`
35+ Limit int64 `json:"limit_tokens"`
36+ Percent int `json:"percent"`
37+ State BudgetState `json:"state"`
38+}
39+
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+ }
53+ 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")
59+ }
60+ return limit, ratio, nil
61+}
62+
63+// WeightedBudgetUsage charges uncached and cache-write input fully, cache-read
64+// input at a reduced weight, and output once (provider OutputTokens already
65+// includes reasoning tokens where the provider reports them).
66+func WeightedBudgetUsage(usage provider.Usage) int64 {
67+ weighted := float64(max(usage.InputTokens, 0)) +
68+ float64(max(usage.CacheWriteTokens, 0)) +
69+ float64(max(usage.OutputTokens, 0)) +
70+ float64(max(usage.CacheReadTokens, 0))*cachedInputBudgetWeight
71+ return int64(math.Ceil(weighted))
72+}
73+
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 {
95+ if limit <= 0 {
96+ return BudgetSnapshot{}
97+ }
98+ used := WeightedBudgetUsage(usage)
99+ percent := int(math.Floor(float64(used) * 100 / float64(limit)))
100+ return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)}
101+}
102+
103+func budgetState(used, limit int64) BudgetState {
104+ if limit <= 0 {
105+ return ""
106+ }
107+ ratio := float64(used) / float64(limit)
108+ switch {
109+ case ratio >= 1:
110+ 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+ }
122+}
123+
124+// RolloutBudget accounts one resident child's cumulative provider usage and
125+// produces progressive model instructions at threshold transitions.
126+type RolloutBudget struct {
127+ mu sync.Mutex
128+ limit int64
129+ snapshot BudgetSnapshot
130+ delivered BudgetState
131+}
132+
133+func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget {
134+ budget := &RolloutBudget{limit: limit}
135+ budget.snapshot = BudgetSnapshotFor(usage, limit)
136+ return budget
137+}
138+
139+func (b *RolloutBudget) Observe(usage provider.Usage) BudgetSnapshot {
140+ if b == nil {
141+ return BudgetSnapshot{}
142+ }
143+ b.mu.Lock()
144+ b.snapshot = BudgetSnapshotFor(usage, b.limit)
145+ snapshot := b.snapshot
146+ b.mu.Unlock()
147+ return snapshot
148+}
149+
150+func (b *RolloutBudget) Snapshot() BudgetSnapshot {
151+ if b == nil {
152+ return BudgetSnapshot{}
153+ }
154+ b.mu.Lock()
155+ snapshot := b.snapshot
156+ b.mu.Unlock()
157+ return snapshot
158+}
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+}
@@ -0,0 +1,211 @@
1+package subagents
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "math"
7+ "sync"
8+
9+ "github.com/bnema/zut/packages/provider"
10+)
11+
12+const (
13+ DefaultBudgetRatio = 0.75
14+ cachedInputBudgetWeight = 0.25
15+)
16+
17+// BudgetState is the progressive constraint applied to a resident child.
18+type BudgetState string
19+
20+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"
27+)
28+
29+var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded")
30+
31+// BudgetSnapshot is the bounded public projection of a child's cumulative
32+// rollout budget. Used is weighted usage, not the active context size.
33+type BudgetSnapshot struct {
34+ Used int64 `json:"used_tokens"`
35+ Limit int64 `json:"limit_tokens"`
36+ Percent int `json:"percent"`
37+ State BudgetState `json:"state"`
38+}
39+
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+ }
53+ 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")
59+ }
60+ return limit, ratio, nil
61+}
62+
63+// WeightedBudgetUsage charges uncached and cache-write input fully, cache-read
64+// input at a reduced weight, and output once (provider OutputTokens already
65+// includes reasoning tokens where the provider reports them).
66+func WeightedBudgetUsage(usage provider.Usage) int64 {
67+ weighted := float64(max(usage.InputTokens, 0)) +
68+ float64(max(usage.CacheWriteTokens, 0)) +
69+ float64(max(usage.OutputTokens, 0)) +
70+ float64(max(usage.CacheReadTokens, 0))*cachedInputBudgetWeight
71+ return int64(math.Ceil(weighted))
72+}
73+
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 {
95+ if limit <= 0 {
96+ return BudgetSnapshot{}
97+ }
98+ used := WeightedBudgetUsage(usage)
99+ percent := int(math.Floor(float64(used) * 100 / float64(limit)))
100+ return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)}
101+}
102+
103+func budgetState(used, limit int64) BudgetState {
104+ if limit <= 0 {
105+ return ""
106+ }
107+ ratio := float64(used) / float64(limit)
108+ switch {
109+ case ratio >= 1:
110+ 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+ }
122+}
123+
124+// RolloutBudget accounts one resident child's cumulative provider usage and
125+// produces progressive model instructions at threshold transitions.
126+type RolloutBudget struct {
127+ mu sync.Mutex
128+ limit int64
129+ snapshot BudgetSnapshot
130+ delivered BudgetState
131+}
132+
133+func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget {
134+ budget := &RolloutBudget{limit: limit}
135+ budget.snapshot = BudgetSnapshotFor(usage, limit)
136+ return budget
137+}
138+
139+func (b *RolloutBudget) Observe(usage provider.Usage) BudgetSnapshot {
140+ if b == nil {
141+ return BudgetSnapshot{}
142+ }
143+ b.mu.Lock()
144+ b.snapshot = BudgetSnapshotFor(usage, b.limit)
145+ snapshot := b.snapshot
146+ b.mu.Unlock()
147+ return snapshot
148+}
149+
150+func (b *RolloutBudget) Snapshot() BudgetSnapshot {
151+ if b == nil {
152+ return BudgetSnapshot{}
153+ }
154+ b.mu.Lock()
155+ snapshot := b.snapshot
156+ b.mu.Unlock()
157+ return snapshot
158+}
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+}
A packages/agent/subagents/budget_test.go +76 -0
@@ -0,0 +1,76 @@
1+package subagents
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestBudgetLimitDefaultsToSeventyFivePercent(t *testing.T) {
11+ limit, ratio, err := BudgetLimit(500_000, 0, 0)
12+ if err != nil {
13+ t.Fatal(err)
14+ }
15+ if limit != 375_000 || ratio != 0.75 {
16+ t.Fatalf("BudgetLimit = %d, %v; want 375000, 0.75", limit, ratio)
17+ }
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)
27+ }
28+}
29+
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)
33+ }
34+ if got := EffectiveBudgetLimit(42_000, 500_000); got != 42_000 {
35+ t.Fatalf("persisted budget limit = %d, want 42000", got)
36+ }
37+}
38+
39+func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) {
40+ usage := provider.Usage{InputTokens: 100, CacheReadTokens: 100, CacheWriteTokens: 20, OutputTokens: 30, ReasoningTokens: 10, ReasoningTokensKnown: true}
41+ if got := WeightedBudgetUsage(usage); got != 175 {
42+ t.Fatalf("WeightedBudgetUsage = %d, want 175", got)
43+ }
44+}
45+
46+func TestBudgetThresholdInstructionsAreProgressiveAndOneShot(t *testing.T) {
47+ 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)
69+ }
70+ }
71+
72+ 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)
75+ }
76+}
@@ -0,0 +1,76 @@
1+package subagents
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestBudgetLimitDefaultsToSeventyFivePercent(t *testing.T) {
11+ limit, ratio, err := BudgetLimit(500_000, 0, 0)
12+ if err != nil {
13+ t.Fatal(err)
14+ }
15+ if limit != 375_000 || ratio != 0.75 {
16+ t.Fatalf("BudgetLimit = %d, %v; want 375000, 0.75", limit, ratio)
17+ }
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)
27+ }
28+}
29+
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)
33+ }
34+ if got := EffectiveBudgetLimit(42_000, 500_000); got != 42_000 {
35+ t.Fatalf("persisted budget limit = %d, want 42000", got)
36+ }
37+}
38+
39+func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) {
40+ usage := provider.Usage{InputTokens: 100, CacheReadTokens: 100, CacheWriteTokens: 20, OutputTokens: 30, ReasoningTokens: 10, ReasoningTokensKnown: true}
41+ if got := WeightedBudgetUsage(usage); got != 175 {
42+ t.Fatalf("WeightedBudgetUsage = %d, want 175", got)
43+ }
44+}
45+
46+func TestBudgetThresholdInstructionsAreProgressiveAndOneShot(t *testing.T) {
47+ 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)
69+ }
70+ }
71+
72+ 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)
75+ }
76+}
M packages/agent/subagents/model.go +6 -4
@@ -47,10 +47,12 @@ 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
50+ MaxConcurrent int
51+ QueueTimeout time.Duration
52+ AllowedTools []string
53+ AllowedRoots []string
54+ BudgetRatio float64
55+ BudgetRatioConfigured bool
5456 }
5557
5658 func (p SubagentPolicy) allowedTool(name string) bool {
@@ -47,10 +47,12 @@ 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
54 }56 }
55 57
56 func (p SubagentPolicy) allowedTool(name string) bool {58 func (p SubagentPolicy) allowedTool(name string) bool {
M packages/agent/subagents/profiles.go +31 -2
@@ -11,6 +11,7 @@ package subagents
1111
1212 import (
1313 "fmt"
14+ "math"
1415 "os"
1516 "path/filepath"
1617 "sort"
@@ -36,6 +37,11 @@ type Profile struct {
3637 // Nil means the profile did not specify whether fast mode is enabled.
3738 FastMode *bool
3839
40+ // BudgetRatio and BudgetTokens override the host's inherited rollout
41+ // budget. At most one may be declared.
42+ BudgetRatio *float64
43+ BudgetTokens *int64
44+
3945 // SystemPromptMode is "append" (the default) or "replace".
4046 SystemPromptMode string
4147
@@ -162,7 +168,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
162168 }
163169 var sb strings.Builder
164170 sb.WriteString("[subagents_list]\n")
165- sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")
171+ sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, fast-mode preference, and budget override are applied to the child.\n")
166172 for _, profile := range profiles {
167173 if profile == nil {
168174 continue
@@ -172,7 +178,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
172178 if description == "" {
173179 description = "(no description)"
174180 }
175- metadata := make([]string, 0, 5)
181+ metadata := make([]string, 0, 7)
176182 if model := manifestValue(profile.Model); model != "" {
177183 metadata = append(metadata, "model="+model)
178184 }
@@ -188,6 +194,12 @@ func SystemPromptAddendum(profiles []*Profile) string {
188194 if profile.FastMode != nil {
189195 metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode))
190196 }
197+ if profile.BudgetRatio != nil {
198+ metadata = append(metadata, "budgetRatio="+strconv.FormatFloat(*profile.BudgetRatio, 'f', -1, 64))
199+ }
200+ if profile.BudgetTokens != nil {
201+ metadata = append(metadata, "budgetTokens="+strconv.FormatInt(*profile.BudgetTokens, 10))
202+ }
191203 if len(metadata) > 0 {
192204 fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description)
193205 } else {
@@ -312,6 +324,23 @@ func load(path, source string) (*Profile, error) {
312324 }
313325 profile.FastMode = &value
314326 }
327+ if raw, ok := values["budgetratio"]; ok {
328+ value, err := strconv.ParseFloat(strings.TrimSpace(unquote(raw)), 64)
329+ if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value > 1 {
330+ return nil, fmt.Errorf("budgetRatio must be greater than 0 and at most 1")
331+ }
332+ profile.BudgetRatio = &value
333+ }
334+ if raw, ok := values["budgettokens"]; ok {
335+ value, err := strconv.ParseInt(strings.TrimSpace(unquote(raw)), 10, 64)
336+ if err != nil || value <= 0 {
337+ return nil, fmt.Errorf("budgetTokens must be a positive integer")
338+ }
339+ profile.BudgetTokens = &value
340+ }
341+ if profile.BudgetRatio != nil && profile.BudgetTokens != nil {
342+ return nil, fmt.Errorf("budgetRatio and budgetTokens are mutually exclusive")
343+ }
315344 return profile, nil
316345 }
317346
@@ -11,6 +11,7 @@ package subagents
11 11
12 import (12 import (
13 "fmt"13 "fmt"
14+ "math"
14 "os"15 "os"
15 "path/filepath"16 "path/filepath"
16 "sort"17 "sort"
@@ -36,6 +37,11 @@ type Profile struct {
36 // Nil means the profile did not specify whether fast mode is enabled.37 // Nil means the profile did not specify whether fast mode is enabled.
37 FastMode *bool38 FastMode *bool
38 39
40+ // BudgetRatio and BudgetTokens override the host's inherited rollout
41+ // budget. At most one may be declared.
42+ BudgetRatio *float64
43+ BudgetTokens *int64
44+
39 // SystemPromptMode is "append" (the default) or "replace".45 // SystemPromptMode is "append" (the default) or "replace".
40 SystemPromptMode string46 SystemPromptMode string
41 47
@@ -162,7 +168,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
162 }168 }
163 var sb strings.Builder169 var sb strings.Builder
164 sb.WriteString("[subagents_list]\n")170 sb.WriteString("[subagents_list]\n")
165- sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")171+ sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, fast-mode preference, and budget override are applied to the child.\n")
166 for _, profile := range profiles {172 for _, profile := range profiles {
167 if profile == nil {173 if profile == nil {
168 continue174 continue
@@ -172,7 +178,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
172 if description == "" {178 if description == "" {
173 description = "(no description)"179 description = "(no description)"
174 }180 }
175- metadata := make([]string, 0, 5)181+ metadata := make([]string, 0, 7)
176 if model := manifestValue(profile.Model); model != "" {182 if model := manifestValue(profile.Model); model != "" {
177 metadata = append(metadata, "model="+model)183 metadata = append(metadata, "model="+model)
178 }184 }
@@ -188,6 +194,12 @@ func SystemPromptAddendum(profiles []*Profile) string {
188 if profile.FastMode != nil {194 if profile.FastMode != nil {
189 metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode))195 metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode))
190 }196 }
197+ if profile.BudgetRatio != nil {
198+ metadata = append(metadata, "budgetRatio="+strconv.FormatFloat(*profile.BudgetRatio, 'f', -1, 64))
199+ }
200+ if profile.BudgetTokens != nil {
201+ metadata = append(metadata, "budgetTokens="+strconv.FormatInt(*profile.BudgetTokens, 10))
202+ }
191 if len(metadata) > 0 {203 if len(metadata) > 0 {
192 fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description)204 fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description)
193 } else {205 } else {
@@ -312,6 +324,23 @@ func load(path, source string) (*Profile, error) {
312 }324 }
313 profile.FastMode = &value325 profile.FastMode = &value
314 }326 }
327+ if raw, ok := values["budgetratio"]; ok {
328+ value, err := strconv.ParseFloat(strings.TrimSpace(unquote(raw)), 64)
329+ if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value > 1 {
330+ return nil, fmt.Errorf("budgetRatio must be greater than 0 and at most 1")
331+ }
332+ profile.BudgetRatio = &value
333+ }
334+ if raw, ok := values["budgettokens"]; ok {
335+ value, err := strconv.ParseInt(strings.TrimSpace(unquote(raw)), 10, 64)
336+ if err != nil || value <= 0 {
337+ return nil, fmt.Errorf("budgetTokens must be a positive integer")
338+ }
339+ profile.BudgetTokens = &value
340+ }
341+ if profile.BudgetRatio != nil && profile.BudgetTokens != nil {
342+ return nil, fmt.Errorf("budgetRatio and budgetTokens are mutually exclusive")
343+ }
315 return profile, nil344 return profile, nil
316 }345 }
317 346
M packages/agent/subagents/profiles_test.go +15 -0
@@ -164,6 +164,9 @@ 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"},
167170 }
168171 for _, tc := range cases {
169172 t.Run(tc.name, func(t *testing.T) {
@@ -176,6 +179,18 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
176179 }
177180 }
178181
182+func TestLoadProfileBudgetOverride(t *testing.T) {
183+ 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)
191+ }
192+}
193+
179194 func TestProfileModelSelection(t *testing.T) {
180195 profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"}
181196 provider, model := profile.ModelSelection()
@@ -164,6 +164,9 @@ 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"},
167 }170 }
168 for _, tc := range cases {171 for _, tc := range cases {
169 t.Run(tc.name, func(t *testing.T) {172 t.Run(tc.name, func(t *testing.T) {
@@ -176,6 +179,18 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
176 }179 }
177 }180 }
178 181
182+func TestLoadProfileBudgetOverride(t *testing.T) {
183+ 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)
191+ }
192+}
193+
179 func TestProfileModelSelection(t *testing.T) {194 func TestProfileModelSelection(t *testing.T) {
180 profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"}195 profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"}
181 provider, model := profile.ModelSelection()196 provider, model := profile.ModelSelection()
M packages/agent/subagents/resident_journal.go +3 -0
@@ -91,6 +91,9 @@ type ResidentChildSpec struct {
9191 InheritSkills *bool `json:"inherit_skills,omitempty"`
9292 Permissions []string `json:"permissions,omitempty"`
9393 Required bool `json:"required,omitempty"`
94+ BudgetLimit int64 `json:"budget_limit,omitempty"`
95+ BudgetRatio float64 `json:"budget_ratio,omitempty"`
96+ BudgetSource string `json:"budget_source,omitempty"`
9497 }
9598
9699 type residentRecord struct {
@@ -91,6 +91,9 @@ type ResidentChildSpec struct {
91 InheritSkills *bool `json:"inherit_skills,omitempty"`91 InheritSkills *bool `json:"inherit_skills,omitempty"`
92 Permissions []string `json:"permissions,omitempty"`92 Permissions []string `json:"permissions,omitempty"`
93 Required bool `json:"required,omitempty"`93 Required bool `json:"required,omitempty"`
94+ BudgetLimit int64 `json:"budget_limit,omitempty"`
95+ BudgetRatio float64 `json:"budget_ratio,omitempty"`
96+ BudgetSource string `json:"budget_source,omitempty"`
94 }97 }
95 98
96 type residentRecord struct {99 type residentRecord struct {
M packages/agent/subagents/resident_manager.go +22 -1
@@ -221,6 +221,8 @@ type ResidentSnapshot struct {
221221 ContextUsed int
222222 ContextMax int
223223 Subscription bool
224+ Budget BudgetSnapshot
225+ BudgetSource string
224226 }
225227
226228 func residentSnapshot(child *ResidentChild) ResidentSnapshot {
@@ -228,12 +230,22 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
228230 return ResidentSnapshot{}
229231 }
230232 live := child.Live()
233+ budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, live.ContextMax)
231234 return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile,
232235 Provider: child.spec.Provider, Model: child.spec.Model,
233236 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
234237 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),
235238 ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,
236- Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription}
239+ Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription,
240+ Budget: budget, BudgetSource: budgetSource}
241+}
242+
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
237249 }
238250
239251 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {
@@ -462,6 +474,10 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
462474 if child == nil && snapshot.OwnedElsewhere {
463475 recovered = true
464476 }
477+ if child == nil && recovered && snapshot.Budget.State == BudgetExceeded {
478+ m.mu.Unlock()
479+ return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override")
480+ }
465481 if child == nil && recovered {
466482 if _, pending := m.pending[childID]; pending {
467483 m.mu.Unlock()
@@ -470,6 +486,9 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
470486 m.pending[childID] = struct{}{}
471487 }
472488 m.mu.Unlock()
489+ if child != nil && residentSnapshot(child).Budget.State == BudgetExceeded {
490+ return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override")
491+ }
473492 if child == nil && recovered {
474493 defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }()
475494 journal, err := OpenResidentJournal(m.root, childID)
@@ -861,12 +880,14 @@ func (m *ResidentManager) Reconcile() []error {
861880 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))
862881 continue
863882 }
883+ budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.ContextMax)
864884 m.mu.Lock()
865885 if _, live := m.children[childID]; !live {
866886 m.recovered[childID] = ResidentSnapshot{
867887 ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model,
868888 WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt,
869889 Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription,
890+ Budget: budget, BudgetSource: budgetSource,
870891 }
871892 m.recoveredSpec[childID] = spec
872893 }
@@ -221,6 +221,8 @@ type ResidentSnapshot struct {
221 ContextUsed int221 ContextUsed int
222 ContextMax int222 ContextMax int
223 Subscription bool223 Subscription bool
224+ Budget BudgetSnapshot
225+ BudgetSource string
224 }226 }
225 227
226 func residentSnapshot(child *ResidentChild) ResidentSnapshot {228 func residentSnapshot(child *ResidentChild) ResidentSnapshot {
@@ -228,12 +230,22 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
228 return ResidentSnapshot{}230 return ResidentSnapshot{}
229 }231 }
230 live := child.Live()232 live := child.Live()
233+ budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, live.ContextMax)
231 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,
232 Provider: child.spec.Provider, Model: child.spec.Model,235 Provider: child.spec.Provider, Model: child.spec.Model,
233 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,236 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
234 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),237 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),
235 ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,238 ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,
236- Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription}239+ Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription,
240+ Budget: budget, BudgetSource: budgetSource}
241+}
242+
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
237 }249 }
238 250
239 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {251 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {
@@ -462,6 +474,10 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
462 if child == nil && snapshot.OwnedElsewhere {474 if child == nil && snapshot.OwnedElsewhere {
463 recovered = true475 recovered = true
464 }476 }
477+ if child == nil && recovered && snapshot.Budget.State == BudgetExceeded {
478+ m.mu.Unlock()
479+ return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override")
480+ }
465 if child == nil && recovered {481 if child == nil && recovered {
466 if _, pending := m.pending[childID]; pending {482 if _, pending := m.pending[childID]; pending {
467 m.mu.Unlock()483 m.mu.Unlock()
@@ -470,6 +486,9 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
470 m.pending[childID] = struct{}{}486 m.pending[childID] = struct{}{}
471 }487 }
472 m.mu.Unlock()488 m.mu.Unlock()
489+ if child != nil && residentSnapshot(child).Budget.State == BudgetExceeded {
490+ return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override")
491+ }
473 if child == nil && recovered {492 if child == nil && recovered {
474 defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }()493 defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }()
475 journal, err := OpenResidentJournal(m.root, childID)494 journal, err := OpenResidentJournal(m.root, childID)
@@ -861,12 +880,14 @@ func (m *ResidentManager) Reconcile() []error {
861 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))880 errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr))
862 continue881 continue
863 }882 }
883+ budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.ContextMax)
864 m.mu.Lock()884 m.mu.Lock()
865 if _, live := m.children[childID]; !live {885 if _, live := m.children[childID]; !live {
866 m.recovered[childID] = ResidentSnapshot{886 m.recovered[childID] = ResidentSnapshot{
867 ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model,887 ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model,
868 WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt,888 WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt,
869 Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription,889 Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription,
890+ Budget: budget, BudgetSource: budgetSource,
870 }891 }
871 m.recoveredSpec[childID] = spec892 m.recoveredSpec[childID] = spec
872 }893 }
M packages/agent/subagents/resident_manager_test.go +26 -0
@@ -55,6 +55,32 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {
5555 }
5656 }
5757
58+func TestResidentManagerRejectsResumeAfterBudgetExhaustion(t *testing.T) {
59+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
60+ return func(context.Context, string) error {
61+ return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})
62+ }, nil
63+ })
64+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
65+ spec := ResidentChildSpec{ID: "budget-exhausted", SessionID: "budget-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5", BudgetLimit: 100}
66+ completion, cancelWait := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
67+ defer cancelWait()
68+ if _, err := manager.Spawn(context.Background(), spec, "review"); err != nil {
69+ t.Fatal(err)
70+ }
71+ result := <-completion
72+ if result.Err != nil {
73+ t.Fatalf("initial completion error = %v", result.Err)
74+ }
75+ snapshot, ok := manager.SnapshotFor(spec.ID)
76+ if !ok || snapshot.State != ResidentIdle || snapshot.Budget.State != BudgetExceeded {
77+ t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok)
78+ }
79+ if err := manager.Resume(context.Background(), spec.ID, "continue"); err == nil || !strings.Contains(err.Error(), "budget is exhausted") {
80+ t.Fatalf("resume error = %v", err)
81+ }
82+}
83+
5884 func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {
5985 completed := make(chan ResidentCompletion, 1)
6086 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
@@ -55,6 +55,32 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) {
55 }55 }
56 }56 }
57 57
58+func TestResidentManagerRejectsResumeAfterBudgetExhaustion(t *testing.T) {
59+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
60+ return func(context.Context, string) error {
61+ return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}})
62+ }, nil
63+ })
64+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
65+ spec := ResidentChildSpec{ID: "budget-exhausted", SessionID: "budget-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5", BudgetLimit: 100}
66+ completion, cancelWait := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
67+ defer cancelWait()
68+ if _, err := manager.Spawn(context.Background(), spec, "review"); err != nil {
69+ t.Fatal(err)
70+ }
71+ result := <-completion
72+ if result.Err != nil {
73+ t.Fatalf("initial completion error = %v", result.Err)
74+ }
75+ snapshot, ok := manager.SnapshotFor(spec.ID)
76+ if !ok || snapshot.State != ResidentIdle || snapshot.Budget.State != BudgetExceeded {
77+ t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok)
78+ }
79+ if err := manager.Resume(context.Background(), spec.ID, "continue"); err == nil || !strings.Contains(err.Error(), "budget is exhausted") {
80+ t.Fatalf("resume error = %v", err)
81+ }
82+}
83+
58 func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {84 func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {
59 completed := make(chan ResidentCompletion, 1)85 completed := make(chan ResidentCompletion, 1)
60 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {86 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
M packages/agent/tools/resident_tools_test.go +16 -3
@@ -187,6 +187,9 @@ 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}`,
190193 } {
191194 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
192195 if err != nil && !strings.Contains(err.Error(), "invalid args") {
@@ -207,6 +210,7 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
207210 })
208211 t.Cleanup(func() { _ = manager.Close(context.Background()) })
209212 profileFast := false
213+ profileBudget := 0.6
210214 var got ResidentSpawnRequest
211215 spawn := &SubagentSpawnTool{
212216 ResidentManager: manager,
@@ -215,18 +219,18 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
215219 DefaultProvider: func() string { return "host-provider" },
216220 DefaultReasoning: func() string { return "medium" },
217221 ResolveSubagent: func(name string) (*subagents.Profile, error) {
218- return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil
222+ return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast, BudgetRatio: &profileBudget}, nil
219223 },
220224 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
221225 got = request
222226 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
223227 },
224228 }
225- result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil)
229+ result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true,"budget_tokens":42000}`), nil)
226230 if err != nil || result.IsError {
227231 t.Fatalf("Execute = (%#v, %v)", result, err)
228232 }
229- if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode {
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 {
230234 t.Fatalf("factory request = %#v", got)
231235 }
232236 }
@@ -258,6 +262,15 @@ func toolResultText(t *testing.T, result core.ToolResult) string {
258262 return block.Text
259263 }
260264
265+func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {
266+ entry := publicResidentStatus(subagents.ResidentSnapshot{
267+ ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetFocused}, BudgetSource: "default_ratio",
268+ })
269+ if entry.Budget == nil || entry.Budget.State != subagents.BudgetFocused || entry.BudgetSource != "default_ratio" {
270+ t.Fatalf("status budget = %#v", entry)
271+ }
272+}
273+
261274 func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
262275 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
263276 if ok {
@@ -187,6 +187,9 @@ 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}`,
191+ `{"task":"x","budget_tokens":-1}`,
192+ `{"task":"x","budget_ratio":0.5,"budget_tokens":1000}`,
190 } {193 } {
191 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)194 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
192 if err != nil && !strings.Contains(err.Error(), "invalid args") {195 if err != nil && !strings.Contains(err.Error(), "invalid args") {
@@ -207,6 +210,7 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
207 })210 })
208 t.Cleanup(func() { _ = manager.Close(context.Background()) })211 t.Cleanup(func() { _ = manager.Close(context.Background()) })
209 profileFast := false212 profileFast := false
213+ profileBudget := 0.6
210 var got ResidentSpawnRequest214 var got ResidentSpawnRequest
211 spawn := &SubagentSpawnTool{215 spawn := &SubagentSpawnTool{
212 ResidentManager: manager,216 ResidentManager: manager,
@@ -215,18 +219,18 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
215 DefaultProvider: func() string { return "host-provider" },219 DefaultProvider: func() string { return "host-provider" },
216 DefaultReasoning: func() string { return "medium" },220 DefaultReasoning: func() string { return "medium" },
217 ResolveSubagent: func(name string) (*subagents.Profile, error) {221 ResolveSubagent: func(name string) (*subagents.Profile, error) {
218- return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil222+ return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast, BudgetRatio: &profileBudget}, nil
219 },223 },
220 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {224 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
221 got = request225 got = request
222 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil226 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
223 },227 },
224 }228 }
225- result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil)229+ result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true,"budget_tokens":42000}`), nil)
226 if err != nil || result.IsError {230 if err != nil || result.IsError {
227 t.Fatalf("Execute = (%#v, %v)", result, err)231 t.Fatalf("Execute = (%#v, %v)", result, err)
228 }232 }
229- if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode {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 {
230 t.Fatalf("factory request = %#v", got)234 t.Fatalf("factory request = %#v", got)
231 }235 }
232 }236 }
@@ -258,6 +262,15 @@ func toolResultText(t *testing.T, result core.ToolResult) string {
258 return block.Text262 return block.Text
259 }263 }
260 264
265+func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {
266+ entry := publicResidentStatus(subagents.ResidentSnapshot{
267+ ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetFocused}, BudgetSource: "default_ratio",
268+ })
269+ if entry.Budget == nil || entry.Budget.State != subagents.BudgetFocused || entry.BudgetSource != "default_ratio" {
270+ t.Fatalf("status budget = %#v", entry)
271+ }
272+}
273+
261 func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {274 func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
262 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")275 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
263 if ok {276 if ok {
M packages/agent/tools/subagent_spawn.go +37 -11
@@ -65,18 +65,22 @@ type ResidentSpawnRequest struct {
6565 FastMode *bool
6666 Required bool
6767 WorkspaceMode subagents.WorkspaceMode
68+ BudgetRatio *float64
69+ BudgetTokens *int64
6870 }
6971
7072 type subagentSpawnArgs struct {
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"`
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"`
8084 }
8185
8286 const subagentSpawnSchemaTemplate = `{
@@ -121,6 +125,17 @@ const subagentSpawnSchemaTemplate = `{
121125 "type": "string",
122126 "enum": ["shared", "worktree"],
123127 "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."
124139 }
125140 },
126141 "required": ["task"]
@@ -153,6 +168,15 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
153168 if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
154169 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
155170 }
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+ }
156180
157181 workspaceMode := subagents.WorkspaceShared
158182 if value := strings.TrimSpace(a.Isolation); value != "" {
@@ -181,7 +205,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
181205 if a.FastMode != nil {
182206 fastModeOverride = a.FastMode
183207 }
184-
185208 model := strings.TrimSpace(a.Model)
186209 providerID := strings.TrimSpace(a.Provider)
187210 if (model == "") != (providerID == "") {
@@ -228,7 +251,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
228251 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
229252 Task: task, Profile: profile, Model: model, Provider: providerID,
230253 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
231- WorkspaceMode: workspaceMode,
254+ WorkspaceMode: workspaceMode, BudgetRatio: a.BudgetRatio, BudgetTokens: a.BudgetTokens,
232255 })
233256 if err != nil {
234257 return protocolToolError(prefix + ": " + err.Error())
@@ -288,6 +311,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
288311 if spec.Reasoning != "" {
289312 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
290313 }
314+ if spec.BudgetLimit > 0 {
315+ fmt.Fprintf(&sb, "budget: %d weighted tokens (%s)\n", spec.BudgetLimit, spec.BudgetSource)
316+ }
291317 if spec.Required {
292318 fmt.Fprintf(&sb, "required: %s\n", state)
293319 }
@@ -65,18 +65,22 @@ 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
68 }70 }
69 71
70 type subagentSpawnArgs struct {72 type subagentSpawnArgs struct {
71- Task string `json:"task"`73+ Task string `json:"task"`
72- Agent string `json:"agent,omitempty"`74+ Agent string `json:"agent,omitempty"`
73- Model string `json:"model,omitempty"`75+ Model string `json:"model,omitempty"`
74- Provider string `json:"provider,omitempty"`76+ Provider string `json:"provider,omitempty"`
75- Reasoning string `json:"reasoning,omitempty"`77+ Reasoning string `json:"reasoning,omitempty"`
76- FastMode *bool `json:"fast_mode,omitempty"`78+ FastMode *bool `json:"fast_mode,omitempty"`
77- Required bool `json:"required,omitempty"`79+ Required bool `json:"required,omitempty"`
78- Wait *int `json:"wait,omitempty"`80+ Wait *int `json:"wait,omitempty"`
79- Isolation string `json:"isolation,omitempty"`81+ Isolation string `json:"isolation,omitempty"`
82+ BudgetRatio *float64 `json:"budget_ratio,omitempty"`
83+ BudgetTokens *int64 `json:"budget_tokens,omitempty"`
80 }84 }
81 85
82 const subagentSpawnSchemaTemplate = `{86 const subagentSpawnSchemaTemplate = `{
@@ -121,6 +125,17 @@ const subagentSpawnSchemaTemplate = `{
121 "type": "string",125 "type": "string",
122 "enum": ["shared", "worktree"],126 "enum": ["shared", "worktree"],
123 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."127 "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."
124 }139 }
125 },140 },
126 "required": ["task"]141 "required": ["task"]
@@ -153,6 +168,15 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
153 if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {168 if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
154 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))169 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
155 }170 }
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+ }
156 180
157 workspaceMode := subagents.WorkspaceShared181 workspaceMode := subagents.WorkspaceShared
158 if value := strings.TrimSpace(a.Isolation); value != "" {182 if value := strings.TrimSpace(a.Isolation); value != "" {
@@ -181,7 +205,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
181 if a.FastMode != nil {205 if a.FastMode != nil {
182 fastModeOverride = a.FastMode206 fastModeOverride = a.FastMode
183 }207 }
184-
185 model := strings.TrimSpace(a.Model)208 model := strings.TrimSpace(a.Model)
186 providerID := strings.TrimSpace(a.Provider)209 providerID := strings.TrimSpace(a.Provider)
187 if (model == "") != (providerID == "") {210 if (model == "") != (providerID == "") {
@@ -228,7 +251,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
228 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{251 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
229 Task: task, Profile: profile, Model: model, Provider: providerID,252 Task: task, Profile: profile, Model: model, Provider: providerID,
230 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,253 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
231- WorkspaceMode: workspaceMode,254+ WorkspaceMode: workspaceMode, BudgetRatio: a.BudgetRatio, BudgetTokens: a.BudgetTokens,
232 })255 })
233 if err != nil {256 if err != nil {
234 return protocolToolError(prefix + ": " + err.Error())257 return protocolToolError(prefix + ": " + err.Error())
@@ -288,6 +311,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
288 if spec.Reasoning != "" {311 if spec.Reasoning != "" {
289 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)312 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
290 }313 }
314+ if spec.BudgetLimit > 0 {
315+ fmt.Fprintf(&sb, "budget: %d weighted tokens (%s)\n", spec.BudgetLimit, spec.BudgetSource)
316+ }
291 if spec.Required {317 if spec.Required {
292 fmt.Fprintf(&sb, "required: %s\n", state)318 fmt.Fprintf(&sb, "required: %s\n", state)
293 }319 }
M packages/agent/tools/subagent_status.go +16 -9
@@ -32,14 +32,16 @@ type subagentStatusResponse struct {
3232 }
3333
3434 type subagentStatusEntry struct {
35- ID string `json:"agent_id"`
36- State subagents.ResidentState `json:"state"`
37- Profile string `json:"profile,omitempty"`
38- Provider string `json:"provider"`
39- Model string `json:"model"`
40- Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
41- Required bool `json:"required,omitempty"`
42- OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
35+ ID string `json:"agent_id"`
36+ State subagents.ResidentState `json:"state"`
37+ Profile string `json:"profile,omitempty"`
38+ Provider string `json:"provider"`
39+ Model string `json:"model"`
40+ Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
41+ Required bool `json:"required,omitempty"`
42+ OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
43+ Budget *subagents.BudgetSnapshot `json:"budget,omitempty"`
44+ BudgetSource string `json:"budget_source,omitempty"`
4345 }
4446
4547 const subagentStatusSchema = `{
@@ -117,7 +119,12 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin
117119 }
118120
119121 func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
120- return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere}
122+ entry := subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, BudgetSource: snapshot.BudgetSource}
123+ if snapshot.Budget.Limit > 0 {
124+ budget := snapshot.Budget
125+ entry.Budget = &budget
126+ }
127+ return entry
121128 }
122129
123130 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
@@ -32,14 +32,16 @@ type subagentStatusResponse struct {
32 }32 }
33 33
34 type subagentStatusEntry struct {34 type subagentStatusEntry struct {
35- ID string `json:"agent_id"`35+ ID string `json:"agent_id"`
36- State subagents.ResidentState `json:"state"`36+ State subagents.ResidentState `json:"state"`
37- Profile string `json:"profile,omitempty"`37+ Profile string `json:"profile,omitempty"`
38- Provider string `json:"provider"`38+ Provider string `json:"provider"`
39- Model string `json:"model"`39+ Model string `json:"model"`
40- Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`40+ Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"`
41- Required bool `json:"required,omitempty"`41+ Required bool `json:"required,omitempty"`
42- OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`42+ OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
43+ Budget *subagents.BudgetSnapshot `json:"budget,omitempty"`
44+ BudgetSource string `json:"budget_source,omitempty"`
43 }45 }
44 46
45 const subagentStatusSchema = `{47 const subagentStatusSchema = `{
@@ -117,7 +119,12 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin
117 }119 }
118 120
119 func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {121 func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry {
120- return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere}122+ entry := subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, BudgetSource: snapshot.BudgetSource}
123+ if snapshot.Budget.Limit > 0 {
124+ budget := snapshot.Budget
125+ entry.Budget = &budget
126+ }
127+ return entry
121 }128 }
122 129
123 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {130 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, 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