fix(subagents): use child model context budget (#169)
* fix(subagents): use child model context budget * fix(subagents): preserve legacy budget source * fix(subagents): reject incompatible budget journals
- commit
- b482fc2f34f0e198b59ae7a1357f1ae6a3b874b4
- author
- bnema · · 2026-09-03 15:56 UTC
- committer
- GitHub · · 2026-09-03 15:56 UTC
- parent
- cce7649
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
19 files · +199 -476
M README.md +1 -1
| @@ -424,7 +424,7 @@ Resident subagents run alongside the main session as independent in-process agen | |||
| 424 | 424 | ||
| 425 | 425 | The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up. | |
| 426 | 426 | ||
| 427 | - | Each child receives a cumulative rollout budget equal to 75% of the active parent model's context window. `subagents.budget_ratio`, profile frontmatter (`budgetRatio` or `budgetTokens`), and spawn arguments (`budget_ratio` or `budget_tokens`) can override it. The child and dashboard show progressive budget states; at 90% zut disables tools and reserves the remainder for finalization, and at 100% it makes the current response terminal. Budget-exhausted children reject follow-ups. | |
| 427 | + | Each child receives a cumulative rollout budget equal to its resolved model's context window. The dashboard shows its usage, and zut allows normal work until the hard limit is reached. Budget-exhausted children reject follow-ups. | |
| 428 | 428 | ||
| 429 | 429 | Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`. | |
| 430 | 430 | ||
| @@ -424,7 +424,7 @@ Resident subagents run alongside the main session as independent in-process agen | |||||
| 424 | 424 | ||||
| 425 | The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up. | 425 | The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up. | ||
| 426 | 426 | ||||
| 427 | - | Each child receives a cumulative rollout budget equal to 75% of the active parent model's context window. `subagents.budget_ratio`, profile frontmatter (`budgetRatio` or `budgetTokens`), and spawn arguments (`budget_ratio` or `budget_tokens`) can override it. The child and dashboard show progressive budget states; at 90% zut disables tools and reserves the remainder for finalization, and at 100% it makes the current response terminal. Budget-exhausted children reject follow-ups. | 427 | + | Each child receives a cumulative rollout budget equal to its resolved model's context window. The dashboard shows its usage, and zut allows normal work until the hard limit is reached. Budget-exhausted children reject follow-ups. |
| 428 | 428 | ||||
| 429 | Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`. | 429 | Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`. | ||
| 430 | 430 | ||||
M docs/subagents.md +17 -22
| @@ -21,7 +21,6 @@ description: Read-only code reviewer | |||
| 21 | 21 | tools: [read, grep] | |
| 22 | 22 | model: openai-codex/gpt-5.6-luna | |
| 23 | 23 | thinking: high | |
| 24 | - | budgetRatio: 0.6 | |
| 25 | 24 | systemPromptMode: replace | |
| 26 | 25 | inheritProjectContext: false | |
| 27 | 26 | inheritSkills: false | |
| @@ -36,11 +35,9 @@ catalogue, `tools: []` grants no tools, and an explicit list replaces the | |||
| 36 | 35 | default. Names unavailable to the child (including host-only extensions) and | |
| 37 | 36 | tools denied by `subagents.allowed_tools` are omitted from an explicit list; | |
| 38 | 37 | they never grant the child additional access. `model`, `provider`, `thinking`, | |
| 39 | - | the prompt mode, project/skill inheritance, `fastMode`, and optional | |
| 40 | - | `budgetRatio` or `budgetTokens` override are resolved before the child is | |
| 41 | - | accepted. Spawn-level model, provider, reasoning, fast-mode, or budget values | |
| 42 | - | take precedence where supplied. `budgetRatio` and `budgetTokens` are mutually | |
| 43 | - | exclusive. | |
| 38 | + | the prompt mode, project/skill inheritance, and `fastMode` are resolved before | |
| 39 | + | the child is accepted. Spawn-level model, provider, reasoning, and fast-mode | |
| 40 | + | values take precedence where supplied. | |
| 44 | 41 | ||
| 45 | 42 | Children are always fresh conversations: they receive the delegated task and | |
| 46 | 43 | resolved host/profile context, never the parent transcript or hidden reasoning. | |
| @@ -89,23 +86,21 @@ one turn for each child, and defaults to six concurrent turns. Set | |||
| 89 | 86 | unlimited by default; a positive `subagents.queue_timeout` cancels an accepted | |
| 90 | 87 | prompt that has not received a slot and records its terminal failure durably. | |
| 91 | 88 | `subagents.allowed_tools` is an allowlist for child-visible tools and | |
| 92 | - | `subagents.allowed_roots` limits eligible child workspaces. Each child also | |
| 93 | - | inherits a cumulative weighted-token budget equal to 75% of the active parent | |
| 94 | - | model's context window. Set `subagents.budget_ratio` to another value greater | |
| 95 | - | than zero and at most one, or override one spawn/profile with `budget_ratio` or | |
| 96 | - | `budget_tokens`. Removed legacy settings, including `tui_subagent_position`, | |
| 97 | - | are ignored. | |
| 89 | + | `subagents.allowed_roots` limits eligible child workspaces. Each child receives | |
| 90 | + | a cumulative weighted-token budget equal to its resolved model's context window. | |
| 91 | + | Removed legacy settings, including `tui_subagent_position` and | |
| 92 | + | `subagents.budget_ratio`, are ignored. Resident journals created before this | |
| 93 | + | budget policy are incompatible: zut leaves them untouched, reports a recovery | |
| 94 | + | error, and requires a new child rather than resuming them. | |
| 98 | 95 | ||
| 99 | 96 | Budget accounting charges uncached input, cache writes, and output fully; cache | |
| 100 | 97 | reads count at 25%. It is separate from active context-window usage and survives | |
| 101 | - | follow-up turns and compaction. The child and TUI receive progressive states at | |
| 102 | - | 50% (`notice`), 70% (`focused`), 85% (`verifying`), and 90% (`finalizing`). At | |
| 103 | - | 90%, zut disables further tool use and reserves the remaining budget for the | |
| 104 | - | final response. Every request's output cap is also clamped to the remaining | |
| 105 | - | budget. Providers report exact input usage only after a response, so the | |
| 106 | - | terminal response can make displayed usage exceed 100%; no later request is | |
| 107 | - | sent. An exhausted child cannot accept follow-up turns; spawn a replacement | |
| 108 | - | with an explicitly larger override when more work is required. | |
| 98 | + | follow-up turns and compaction. The TUI shows the child's current budget usage, | |
| 99 | + | but does not alter its instructions, tools, or output cap before exhaustion. | |
| 100 | + | Providers report exact input usage only after a response, so the terminal | |
| 101 | + | response can make displayed usage exceed 100%; no later request is sent. An | |
| 102 | + | exhausted child cannot accept follow-up turns; spawn a replacement when more | |
| 103 | + | work is required. | |
| 109 | 104 | ||
| 110 | 105 | `required: true` makes a delegated result an obligation of the parent turn. | |
| 111 | 106 | Failed, cancelled, and interrupted required work remains unresolved until an | |
| @@ -139,8 +134,8 @@ required capability. | |||
| 139 | 134 | The model-facing tools retain their logical names: | |
| 140 | 135 | ||
| 141 | 136 | - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`, | |
| 142 | - | `reasoning`, `fast_mode`, `required`, `wait`, `isolation` (`shared` or | |
| 143 | - | `worktree`), and mutually exclusive `budget_ratio` or `budget_tokens`. | |
| 137 | + | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or | |
| 138 | + | `worktree`). | |
| 144 | 139 | `wait` is an explicit whole-second value from 1 through 300; when omitted, | |
| 145 | 140 | spawning returns immediately. A timed-out wait leaves the | |
| 146 | 141 | accepted child active, whether it is queued or running. Do not retry that | |
| @@ -21,7 +21,6 @@ description: Read-only code reviewer | |||||
| 21 | tools: [read, grep] | 21 | tools: [read, grep] | ||
| 22 | model: openai-codex/gpt-5.6-luna | 22 | model: openai-codex/gpt-5.6-luna | ||
| 23 | thinking: high | 23 | thinking: high | ||
| 24 | - | budgetRatio: 0.6 | |||
| 25 | systemPromptMode: replace | 24 | systemPromptMode: replace | ||
| 26 | inheritProjectContext: false | 25 | inheritProjectContext: false | ||
| 27 | inheritSkills: false | 26 | inheritSkills: false | ||
| @@ -36,11 +35,9 @@ catalogue, `tools: []` grants no tools, and an explicit list replaces the | |||||
| 36 | default. Names unavailable to the child (including host-only extensions) and | 35 | default. Names unavailable to the child (including host-only extensions) and | ||
| 37 | tools denied by `subagents.allowed_tools` are omitted from an explicit list; | 36 | tools denied by `subagents.allowed_tools` are omitted from an explicit list; | ||
| 38 | they never grant the child additional access. `model`, `provider`, `thinking`, | 37 | they never grant the child additional access. `model`, `provider`, `thinking`, | ||
| 39 | - | the prompt mode, project/skill inheritance, `fastMode`, and optional | 38 | + | the prompt mode, project/skill inheritance, and `fastMode` are resolved before |
| 40 | - | `budgetRatio` or `budgetTokens` override are resolved before the child is | 39 | + | the child is accepted. Spawn-level model, provider, reasoning, and fast-mode |
| 41 | - | accepted. Spawn-level model, provider, reasoning, fast-mode, or budget values | 40 | + | values take precedence where supplied. |
| 42 | - | take precedence where supplied. `budgetRatio` and `budgetTokens` are mutually | |||
| 43 | - | exclusive. | |||
| 44 | 41 | ||||
| 45 | Children are always fresh conversations: they receive the delegated task and | 42 | Children are always fresh conversations: they receive the delegated task and | ||
| 46 | resolved host/profile context, never the parent transcript or hidden reasoning. | 43 | resolved host/profile context, never the parent transcript or hidden reasoning. | ||
| @@ -89,23 +86,21 @@ one turn for each child, and defaults to six concurrent turns. Set | |||||
| 89 | unlimited by default; a positive `subagents.queue_timeout` cancels an accepted | 86 | unlimited by default; a positive `subagents.queue_timeout` cancels an accepted | ||
| 90 | prompt that has not received a slot and records its terminal failure durably. | 87 | prompt that has not received a slot and records its terminal failure durably. | ||
| 91 | `subagents.allowed_tools` is an allowlist for child-visible tools and | 88 | `subagents.allowed_tools` is an allowlist for child-visible tools and | ||
| 92 | - | `subagents.allowed_roots` limits eligible child workspaces. Each child also | 89 | + | `subagents.allowed_roots` limits eligible child workspaces. Each child receives |
| 93 | - | inherits a cumulative weighted-token budget equal to 75% of the active parent | 90 | + | a cumulative weighted-token budget equal to its resolved model's context window. |
| 94 | - | model's context window. Set `subagents.budget_ratio` to another value greater | 91 | + | Removed legacy settings, including `tui_subagent_position` and |
| 95 | - | than zero and at most one, or override one spawn/profile with `budget_ratio` or | 92 | + | `subagents.budget_ratio`, are ignored. Resident journals created before this |
| 96 | - | `budget_tokens`. Removed legacy settings, including `tui_subagent_position`, | 93 | + | budget policy are incompatible: zut leaves them untouched, reports a recovery |
| 97 | - | are ignored. | 94 | + | error, and requires a new child rather than resuming them. |
| 98 | 95 | ||||
| 99 | Budget accounting charges uncached input, cache writes, and output fully; cache | 96 | Budget accounting charges uncached input, cache writes, and output fully; cache | ||
| 100 | reads count at 25%. It is separate from active context-window usage and survives | 97 | reads count at 25%. It is separate from active context-window usage and survives | ||
| 101 | - | follow-up turns and compaction. The child and TUI receive progressive states at | 98 | + | follow-up turns and compaction. The TUI shows the child's current budget usage, |
| 102 | - | 50% (`notice`), 70% (`focused`), 85% (`verifying`), and 90% (`finalizing`). At | 99 | + | but does not alter its instructions, tools, or output cap before exhaustion. |
| 103 | - | 90%, zut disables further tool use and reserves the remaining budget for the | 100 | + | Providers report exact input usage only after a response, so the terminal |
| 104 | - | final response. Every request's output cap is also clamped to the remaining | 101 | + | response can make displayed usage exceed 100%; no later request is sent. An |
| 105 | - | budget. Providers report exact input usage only after a response, so the | 102 | + | exhausted child cannot accept follow-up turns; spawn a replacement when more |
| 106 | - | terminal response can make displayed usage exceed 100%; no later request is | 103 | + | work is required. |
| 107 | - | sent. An exhausted child cannot accept follow-up turns; spawn a replacement | |||
| 108 | - | with an explicitly larger override when more work is required. | |||
| 109 | 104 | ||||
| 110 | `required: true` makes a delegated result an obligation of the parent turn. | 105 | `required: true` makes a delegated result an obligation of the parent turn. | ||
| 111 | Failed, cancelled, and interrupted required work remains unresolved until an | 106 | Failed, cancelled, and interrupted required work remains unresolved until an | ||
| @@ -139,8 +134,8 @@ required capability. | |||||
| 139 | The model-facing tools retain their logical names: | 134 | The model-facing tools retain their logical names: | ||
| 140 | 135 | ||||
| 141 | - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`, | 136 | - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`, | ||
| 142 | - | `reasoning`, `fast_mode`, `required`, `wait`, `isolation` (`shared` or | 137 | + | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or |
| 143 | - | `worktree`), and mutually exclusive `budget_ratio` or `budget_tokens`. | 138 | + | `worktree`). |
| 144 | `wait` is an explicit whole-second value from 1 through 300; when omitted, | 139 | `wait` is an explicit whole-second value from 1 through 300; when omitted, | ||
| 145 | spawning returns immediately. A timed-out wait leaves the | 140 | spawning returns immediately. A timed-out wait leaves the | ||
| 146 | accepted child active, whether it is queued or running. Do not retry that | 141 | accepted child active, whether it is queued or running. Do not retry that | ||
M packages/agent/config.go +2 -8
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | - | "math" | |
| 10 | 9 | "os" | |
| 11 | 10 | "path/filepath" | |
| 12 | 11 | "runtime" | |
| @@ -25,15 +24,13 @@ type QuickModelShortcut struct { | |||
| 25 | 24 | ||
| 26 | 25 | // SubagentsConfig contains the resident manager policy. max_concurrent limits | |
| 27 | 26 | // simultaneous turns, queue_timeout bounds admission waits, allowed_tools | |
| 28 | - | // limits child capabilities, allowed_roots limits eligible workspaces, and | |
| 29 | - | // budget_ratio derives each child's cumulative rollout budget from the active | |
| 30 | - | // parent model's context window. Obsolete keys are ignored. | |
| 27 | + | // limits child capabilities and allowed_roots limits eligible workspaces. | |
| 28 | + | // Obsolete keys are ignored. | |
| 31 | 29 | type SubagentsConfig struct { | |
| 32 | 30 | MaxConcurrent int `json:"max_concurrent,omitempty"` | |
| 33 | 31 | QueueTimeout string `json:"queue_timeout,omitempty"` | |
| 34 | 32 | AllowedTools []string `json:"allowed_tools,omitempty"` | |
| 35 | 33 | AllowedRoots []string `json:"allowed_roots,omitempty"` | |
| 36 | - | BudgetRatio *float64 `json:"budget_ratio,omitempty"` | |
| 37 | 34 | } | |
| 38 | 35 | ||
| 39 | 36 | // GoalsConfig controls optional resource limits for autonomous goals. A nil | |
| @@ -325,9 +322,6 @@ func LoadConfig() (Config, error) { | |||
| 325 | 322 | } | |
| 326 | 323 | ||
| 327 | 324 | func validateSubagentConfig(cfg SubagentsConfig) error { | |
| 328 | - | if cfg.BudgetRatio != nil && (math.IsNaN(*cfg.BudgetRatio) || math.IsInf(*cfg.BudgetRatio, 0) || *cfg.BudgetRatio <= 0 || *cfg.BudgetRatio > 1) { | |
| 329 | - | return fmt.Errorf("parse config: subagents.budget_ratio must be greater than 0 and at most 1") | |
| 330 | - | } | |
| 331 | 325 | values := []struct { | |
| 332 | 326 | name string | |
| 333 | 327 | value string | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | - | "math" | |||
| 10 | "os" | 9 | "os" | ||
| 11 | "path/filepath" | 10 | "path/filepath" | ||
| 12 | "runtime" | 11 | "runtime" | ||
| @@ -25,15 +24,13 @@ type QuickModelShortcut struct { | |||||
| 25 | 24 | ||||
| 26 | // SubagentsConfig contains the resident manager policy. max_concurrent limits | 25 | // SubagentsConfig contains the resident manager policy. max_concurrent limits | ||
| 27 | // simultaneous turns, queue_timeout bounds admission waits, allowed_tools | 26 | // simultaneous turns, queue_timeout bounds admission waits, allowed_tools | ||
| 28 | - | // limits child capabilities, allowed_roots limits eligible workspaces, and | 27 | + | // limits child capabilities and allowed_roots limits eligible workspaces. |
| 29 | - | // budget_ratio derives each child's cumulative rollout budget from the active | 28 | + | // Obsolete keys are ignored. |
| 30 | - | // parent model's context window. Obsolete keys are ignored. | |||
| 31 | type SubagentsConfig struct { | 29 | type SubagentsConfig struct { | ||
| 32 | MaxConcurrent int `json:"max_concurrent,omitempty"` | 30 | MaxConcurrent int `json:"max_concurrent,omitempty"` | ||
| 33 | QueueTimeout string `json:"queue_timeout,omitempty"` | 31 | QueueTimeout string `json:"queue_timeout,omitempty"` | ||
| 34 | AllowedTools []string `json:"allowed_tools,omitempty"` | 32 | AllowedTools []string `json:"allowed_tools,omitempty"` | ||
| 35 | AllowedRoots []string `json:"allowed_roots,omitempty"` | 33 | AllowedRoots []string `json:"allowed_roots,omitempty"` | ||
| 36 | - | BudgetRatio *float64 `json:"budget_ratio,omitempty"` | |||
| 37 | } | 34 | } | ||
| 38 | 35 | ||||
| 39 | // GoalsConfig controls optional resource limits for autonomous goals. A nil | 36 | // GoalsConfig controls optional resource limits for autonomous goals. A nil | ||
| @@ -325,9 +322,6 @@ func LoadConfig() (Config, error) { | |||||
| 325 | } | 322 | } | ||
| 326 | 323 | ||||
| 327 | func validateSubagentConfig(cfg SubagentsConfig) error { | 324 | func validateSubagentConfig(cfg SubagentsConfig) error { | ||
| 328 | - | if cfg.BudgetRatio != nil && (math.IsNaN(*cfg.BudgetRatio) || math.IsInf(*cfg.BudgetRatio, 0) || *cfg.BudgetRatio <= 0 || *cfg.BudgetRatio > 1) { | |||
| 329 | - | return fmt.Errorf("parse config: subagents.budget_ratio must be greater than 0 and at most 1") | |||
| 330 | - | } | |||
| 331 | values := []struct { | 325 | values := []struct { | ||
| 332 | name string | 326 | name string | ||
| 333 | value string | 327 | value string | ||
M packages/agent/config_subagents_test.go +9 -22
| @@ -2,27 +2,14 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import "testing" | |
| 4 | 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 | - | } | |
| 5 | + | func TestSubagentPolicyFromConfigRetainsSchedulingAndToolSettings(t *testing.T) { | |
| 6 | + | policy := subagentPolicyFromConfig(SubagentsConfig{ | |
| 7 | + | MaxConcurrent: 3, | |
| 8 | + | QueueTimeout: "2s", | |
| 9 | + | AllowedTools: []string{"read"}, | |
| 10 | + | AllowedRoots: []string{"/workspace"}, | |
| 11 | + | }) | |
| 12 | + | if policy.MaxConcurrent != 3 || policy.QueueTimeout.String() != "2s" || len(policy.AllowedTools) != 1 || len(policy.AllowedRoots) != 1 { | |
| 13 | + | t.Fatalf("policy = %#v", policy) | |
| 27 | 14 | } | |
| 28 | 15 | } | |
| @@ -2,27 +2,14 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | - | func TestSubagentPolicyDefaultsBudgetRatio(t *testing.T) { | 5 | + | func TestSubagentPolicyFromConfigRetainsSchedulingAndToolSettings(t *testing.T) { |
| 6 | - | policy := subagentPolicyFromConfig(SubagentsConfig{}) | 6 | + | policy := subagentPolicyFromConfig(SubagentsConfig{ |
| 7 | - | if policy.BudgetRatio != 0.75 || policy.BudgetRatioConfigured { | 7 | + | MaxConcurrent: 3, |
| 8 | - | t.Fatalf("default budget policy = %#v", policy) | 8 | + | QueueTimeout: "2s", |
| 9 | - | } | 9 | + | AllowedTools: []string{"read"}, |
| 10 | - | ratio := 0.75 | 10 | + | AllowedRoots: []string{"/workspace"}, |
| 11 | - | policy = subagentPolicyFromConfig(SubagentsConfig{BudgetRatio: &ratio}) | 11 | + | }) |
| 12 | - | if policy.BudgetRatio != 0.75 || !policy.BudgetRatioConfigured { | 12 | + | if policy.MaxConcurrent != 3 || policy.QueueTimeout.String() != "2s" || len(policy.AllowedTools) != 1 || len(policy.AllowedRoots) != 1 { |
| 13 | - | t.Fatalf("configured budget policy = %#v", policy) | 13 | + | t.Fatalf("policy = %#v", policy) |
| 14 | - | } | |||
| 15 | - | } | |||
| 16 | - | ||||
| 17 | - | func TestValidateSubagentBudgetRatio(t *testing.T) { | |||
| 18 | - | valid := 0.6 | |||
| 19 | - | if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &valid}); err != nil { | |||
| 20 | - | t.Fatal(err) | |||
| 21 | - | } | |||
| 22 | - | for _, value := range []float64{0, -0.1, 1.1} { | |||
| 23 | - | value := value | |||
| 24 | - | if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &value}); err == nil { | |||
| 25 | - | t.Fatalf("budget ratio %v accepted", value) | |||
| 26 | - | } | |||
| 27 | } | 14 | } | ||
| 28 | } | 15 | } | ||
M packages/agent/modes/resident_subagent_indicator_test.go +2 -2
| @@ -235,9 +235,9 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t | |||
| 235 | 235 | ||
| 236 | 236 | func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) { | |
| 237 | 237 | metadata := residentUsageMetadata(subagents.ResidentSnapshot{ | |
| 238 | - | Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 375_000, Percent: 90, State: subagents.BudgetFinalizing}, | |
| 238 | + | Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 500_000, Percent: 67, State: subagents.BudgetNormal}, | |
| 239 | 239 | }) | |
| 240 | - | if metadata != "budget:finalizing 90%/375k" { | |
| 240 | + | if metadata != "budget:normal 67%/500k" { | |
| 241 | 241 | t.Fatalf("budget metadata = %q", metadata) | |
| 242 | 242 | } | |
| 243 | 243 | } | |
| @@ -235,9 +235,9 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t | |||||
| 235 | 235 | ||||
| 236 | func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) { | 236 | func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) { | ||
| 237 | metadata := residentUsageMetadata(subagents.ResidentSnapshot{ | 237 | metadata := residentUsageMetadata(subagents.ResidentSnapshot{ | ||
| 238 | - | Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 375_000, Percent: 90, State: subagents.BudgetFinalizing}, | 238 | + | Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 500_000, Percent: 67, State: subagents.BudgetNormal}, |
| 239 | }) | 239 | }) | ||
| 240 | - | if metadata != "budget:finalizing 90%/375k" { | 240 | + | if metadata != "budget:normal 67%/500k" { |
| 241 | t.Fatalf("budget metadata = %q", metadata) | 241 | t.Fatalf("budget metadata = %q", metadata) | ||
| 242 | } | 242 | } | ||
| 243 | } | 243 | } | ||
M packages/agent/subagent_factory.go +5 -36
| @@ -2,7 +2,6 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/json" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "strings" | |
| @@ -11,7 +10,6 @@ import ( | |||
| 11 | 10 | "github.com/bnema/zut/packages/agent/subagents" | |
| 12 | 11 | "github.com/bnema/zut/packages/agent/tools" | |
| 13 | 12 | "github.com/bnema/zut/packages/core" | |
| 14 | - | "github.com/bnema/zut/packages/provider" | |
| 15 | 13 | ) | |
| 16 | 14 | ||
| 17 | 15 | // newResidentChildRunner is the host-owned construction boundary for one | |
| @@ -39,10 +37,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 39 | 37 | system = strings.TrimSpace(system) + "\n\n" + extra | |
| 40 | 38 | } | |
| 41 | 39 | } | |
| 42 | - | budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow) | |
| 43 | - | if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" { | |
| 44 | - | system = strings.TrimSpace(system) + "\n\n" + guidance | |
| 40 | + | if err := subagents.ValidateResidentBudget(spec); err != nil { | |
| 41 | + | return nil, fmt.Errorf("resident child %q: %w", spec.ID, err) | |
| 45 | 42 | } | |
| 43 | + | budgetLimit := spec.BudgetLimit | |
| 46 | 44 | agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry) | |
| 47 | 45 | agent.MaxSteps = resolved.MaxSteps | |
| 48 | 46 | agent.ContextWindow = resolved.ContextWindow | |
| @@ -120,40 +118,11 @@ func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) | |||
| 120 | 118 | return | |
| 121 | 119 | } | |
| 122 | 120 | agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) { | |
| 123 | - | instruction, exceeded := budget.TurnContext() | |
| 124 | - | if exceeded { | |
| 121 | + | if budget.Snapshot().State == subagents.BudgetExceeded { | |
| 125 | 122 | return false, subagents.ErrBudgetExceeded.Error(), "" | |
| 126 | 123 | } | |
| 127 | - | snapshot := budget.Snapshot() | |
| 128 | - | if snapshot.Limit > 0 { | |
| 129 | - | remaining := residentBudgetOutputLimit(snapshot.Limit - snapshot.Used) | |
| 130 | - | if agent.MaxTokens <= 0 || agent.MaxTokens > remaining { | |
| 131 | - | agent.MaxTokens = remaining | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | if snapshot.State == subagents.BudgetFinalizing { | |
| 135 | - | agent.SetTools(core.Registry{}) | |
| 136 | - | } | |
| 137 | - | return true, "", instruction | |
| 138 | - | } | |
| 139 | - | agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 140 | - | state := budget.Snapshot().State | |
| 141 | - | if state == subagents.BudgetFinalizing || state == subagents.BudgetExceeded { | |
| 142 | - | return false, "subagent rollout budget is reserved for the final response; tools are disabled", nil | |
| 143 | - | } | |
| 144 | - | return true, "", nil | |
| 145 | - | } | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | func residentBudgetOutputLimit(remaining int64) int { | |
| 149 | - | if remaining <= 1 { | |
| 150 | - | return 1 | |
| 151 | - | } | |
| 152 | - | maxInt := int64(^uint(0) >> 1) | |
| 153 | - | if remaining > maxInt { | |
| 154 | - | return int(maxInt) | |
| 124 | + | return true, "", "" | |
| 155 | 125 | } | |
| 156 | - | return int(remaining) | |
| 157 | 126 | } | |
| 158 | 127 | ||
| 159 | 128 | // residentChildArgs applies the complete durable child specification without | |
| @@ -2,7 +2,6 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "encoding/json" | |||
| 6 | "errors" | 5 | "errors" | ||
| 7 | "fmt" | 6 | "fmt" | ||
| 8 | "strings" | 7 | "strings" | ||
| @@ -11,7 +10,6 @@ import ( | |||||
| 11 | "github.com/bnema/zut/packages/agent/subagents" | 10 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 12 | "github.com/bnema/zut/packages/agent/tools" | 11 | "github.com/bnema/zut/packages/agent/tools" | ||
| 13 | "github.com/bnema/zut/packages/core" | 12 | "github.com/bnema/zut/packages/core" | ||
| 14 | - | "github.com/bnema/zut/packages/provider" | |||
| 15 | ) | 13 | ) | ||
| 16 | 14 | ||||
| 17 | // newResidentChildRunner is the host-owned construction boundary for one | 15 | // newResidentChildRunner is the host-owned construction boundary for one | ||
| @@ -39,10 +37,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 39 | system = strings.TrimSpace(system) + "\n\n" + extra | 37 | system = strings.TrimSpace(system) + "\n\n" + extra | ||
| 40 | } | 38 | } | ||
| 41 | } | 39 | } | ||
| 42 | - | budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow) | 40 | + | if err := subagents.ValidateResidentBudget(spec); err != nil { |
| 43 | - | if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" { | 41 | + | return nil, fmt.Errorf("resident child %q: %w", spec.ID, err) |
| 44 | - | system = strings.TrimSpace(system) + "\n\n" + guidance | |||
| 45 | } | 42 | } | ||
| 43 | + | budgetLimit := spec.BudgetLimit | |||
| 46 | agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry) | 44 | agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry) | ||
| 47 | agent.MaxSteps = resolved.MaxSteps | 45 | agent.MaxSteps = resolved.MaxSteps | ||
| 48 | agent.ContextWindow = resolved.ContextWindow | 46 | agent.ContextWindow = resolved.ContextWindow | ||
| @@ -120,40 +118,11 @@ func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) | |||||
| 120 | return | 118 | return | ||
| 121 | } | 119 | } | ||
| 122 | agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) { | 120 | agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) { | ||
| 123 | - | instruction, exceeded := budget.TurnContext() | 121 | + | if budget.Snapshot().State == subagents.BudgetExceeded { |
| 124 | - | if exceeded { | |||
| 125 | return false, subagents.ErrBudgetExceeded.Error(), "" | 122 | return false, subagents.ErrBudgetExceeded.Error(), "" | ||
| 126 | } | 123 | } | ||
| 127 | - | snapshot := budget.Snapshot() | 124 | + | return true, "", "" |
| 128 | - | if snapshot.Limit > 0 { | |||
| 129 | - | remaining := residentBudgetOutputLimit(snapshot.Limit - snapshot.Used) | |||
| 130 | - | if agent.MaxTokens <= 0 || agent.MaxTokens > remaining { | |||
| 131 | - | agent.MaxTokens = remaining | |||
| 132 | - | } | |||
| 133 | - | } | |||
| 134 | - | if snapshot.State == subagents.BudgetFinalizing { | |||
| 135 | - | agent.SetTools(core.Registry{}) | |||
| 136 | - | } | |||
| 137 | - | return true, "", instruction | |||
| 138 | - | } | |||
| 139 | - | agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 140 | - | state := budget.Snapshot().State | |||
| 141 | - | if state == subagents.BudgetFinalizing || state == subagents.BudgetExceeded { | |||
| 142 | - | return false, "subagent rollout budget is reserved for the final response; tools are disabled", nil | |||
| 143 | - | } | |||
| 144 | - | return true, "", nil | |||
| 145 | - | } | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | func residentBudgetOutputLimit(remaining int64) int { | |||
| 149 | - | if remaining <= 1 { | |||
| 150 | - | return 1 | |||
| 151 | - | } | |||
| 152 | - | maxInt := int64(^uint(0) >> 1) | |||
| 153 | - | if remaining > maxInt { | |||
| 154 | - | return int(maxInt) | |||
| 155 | } | 125 | } | ||
| 156 | - | return int(remaining) | |||
| 157 | } | 126 | } | ||
| 158 | 127 | ||||
| 159 | // residentChildArgs applies the complete durable child specification without | 128 | // residentChildArgs applies the complete durable child specification without | ||
M packages/agent/subagent_factory_test.go +15 -21
| @@ -37,24 +37,18 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T | |||
| 37 | 37 | } | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | - | func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) { | |
| 40 | + | func TestConfigureResidentBudgetOnlyStopsAtExhaustion(t *testing.T) { | |
| 41 | 41 | agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil}) | |
| 42 | 42 | budget := subagents.NewRolloutBudget(1_000, provider.Usage{}) | |
| 43 | 43 | configureResidentBudget(agent, budget) | |
| 44 | 44 | ||
| 45 | 45 | budget.Observe(provider.Usage{InputTokens: 900}) | |
| 46 | 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) | |
| 47 | + | if !allowed || reason != "" || contextText != "" { | |
| 48 | + | t.Fatalf("near-limit context = allowed=%t reason=%q context=%q", allowed, reason, contextText) | |
| 49 | 49 | } | |
| 50 | - | if len(agent.ToolsSnapshot()) != 0 { | |
| 51 | - | t.Fatalf("finalization tools = %#v, want none", agent.ToolsSnapshot()) | |
| 52 | - | } | |
| 53 | - | if agent.MaxTokens != 100 { | |
| 54 | - | t.Fatalf("finalization max tokens = %d, want 100", agent.MaxTokens) | |
| 55 | - | } | |
| 56 | - | if allowed, reason, _ := agent.BeforeToolExecute(provider.ToolCallBlock{Name: "read"}); allowed || !strings.Contains(reason, "tools are disabled") { | |
| 57 | - | t.Fatalf("finalization tool guard = allowed=%t reason=%q", allowed, reason) | |
| 50 | + | if len(agent.ToolsSnapshot()) != 1 || agent.MaxTokens != 0 { | |
| 51 | + | t.Fatalf("near-limit agent = tools=%#v max_tokens=%d", agent.ToolsSnapshot(), agent.MaxTokens) | |
| 58 | 52 | } | |
| 59 | 53 | ||
| 60 | 54 | budget.Observe(provider.Usage{InputTokens: 1_000}) | |
| @@ -64,7 +58,7 @@ func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) | |||
| 64 | 58 | } | |
| 65 | 59 | } | |
| 66 | 60 | ||
| 67 | - | func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) { | |
| 61 | + | func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) { | |
| 68 | 62 | var requests atomic.Int32 | |
| 69 | 63 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 70 | 64 | requestNumber := requests.Add(1) | |
| @@ -74,8 +68,8 @@ func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) { | |||
| 74 | 68 | } | |
| 75 | 69 | w.Header().Set("content-type", "text/event-stream") | |
| 76 | 70 | if requestNumber == 1 { | |
| 77 | - | if got := requestMaxTokens(t, body); got != 1_000 { | |
| 78 | - | t.Errorf("initial max_tokens = %d, want 1000", got) | |
| 71 | + | if got := requestMaxTokens(t, body); got != 16_384 { | |
| 72 | + | t.Errorf("initial max_tokens = %d, want 16384", got) | |
| 79 | 73 | } | |
| 80 | 74 | writeOpenAIChunk(t, w, map[string]any{ | |
| 81 | 75 | "choices": []any{map[string]any{ | |
| @@ -90,11 +84,11 @@ func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) { | |||
| 90 | 84 | }) | |
| 91 | 85 | return | |
| 92 | 86 | } | |
| 93 | - | if tools, ok := body["tools"].([]any); ok && len(tools) != 0 { | |
| 94 | - | t.Errorf("finalization request tools = %#v, want none", tools) | |
| 87 | + | if tools, ok := body["tools"].([]any); !ok || len(tools) == 0 { | |
| 88 | + | t.Errorf("near-limit request tools = %#v, want registered tools", body["tools"]) | |
| 95 | 89 | } | |
| 96 | - | if got := requestMaxTokens(t, body); got != 100 { | |
| 97 | - | t.Errorf("finalization max_tokens = %d, want 100", got) | |
| 90 | + | if got := requestMaxTokens(t, body); got <= 100 { | |
| 91 | + | t.Errorf("near-limit max_tokens = %d, want model output limit", got) | |
| 98 | 92 | } | |
| 99 | 93 | writeOpenAIChunk(t, w, map[string]any{ | |
| 100 | 94 | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}}, | |
| @@ -123,8 +117,8 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) { | |||
| 123 | 117 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 124 | 118 | t.Errorf("decode request: %v", err) | |
| 125 | 119 | } | |
| 126 | - | if got := requestMaxTokens(t, body); got != 1_000 { | |
| 127 | - | t.Errorf("initial max_tokens = %d, want 1000", got) | |
| 120 | + | if got := requestMaxTokens(t, body); got != 16_384 { | |
| 121 | + | t.Errorf("initial max_tokens = %d, want 16384", got) | |
| 128 | 122 | } | |
| 129 | 123 | w.Header().Set("content-type", "text/event-stream") | |
| 130 | 124 | writeOpenAIChunk(t, w, map[string]any{ | |
| @@ -156,7 +150,7 @@ func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (s | |||
| 156 | 150 | t.Helper() | |
| 157 | 151 | spec := subagents.ResidentChildSpec{ | |
| 158 | 152 | ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o", | |
| 159 | - | Tools: []string{"read"}, BudgetLimit: limit, | |
| 153 | + | Tools: []string{"read"}, BudgetLimit: limit, BudgetSource: "model_context", | |
| 160 | 154 | } | |
| 161 | 155 | journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID) | |
| 162 | 156 | if err != nil { | |
| @@ -37,24 +37,18 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T | |||||
| 37 | } | 37 | } | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | - | func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) { | 40 | + | func TestConfigureResidentBudgetOnlyStopsAtExhaustion(t *testing.T) { |
| 41 | agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil}) | 41 | agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil}) | ||
| 42 | budget := subagents.NewRolloutBudget(1_000, provider.Usage{}) | 42 | budget := subagents.NewRolloutBudget(1_000, provider.Usage{}) | ||
| 43 | configureResidentBudget(agent, budget) | 43 | configureResidentBudget(agent, budget) | ||
| 44 | 44 | ||||
| 45 | budget.Observe(provider.Usage{InputTokens: 900}) | 45 | budget.Observe(provider.Usage{InputTokens: 900}) | ||
| 46 | allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1) | 46 | allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1) | ||
| 47 | - | if !allowed || reason != "" || !strings.Contains(contextText, "Finalization is required") { | 47 | + | if !allowed || reason != "" || contextText != "" { |
| 48 | - | t.Fatalf("finalization context = allowed=%t reason=%q context=%q", allowed, reason, contextText) | 48 | + | t.Fatalf("near-limit context = allowed=%t reason=%q context=%q", allowed, reason, contextText) |
| 49 | } | 49 | } | ||
| 50 | - | if len(agent.ToolsSnapshot()) != 0 { | 50 | + | if len(agent.ToolsSnapshot()) != 1 || agent.MaxTokens != 0 { |
| 51 | - | t.Fatalf("finalization tools = %#v, want none", agent.ToolsSnapshot()) | 51 | + | t.Fatalf("near-limit agent = tools=%#v max_tokens=%d", agent.ToolsSnapshot(), agent.MaxTokens) |
| 52 | - | } | |||
| 53 | - | if agent.MaxTokens != 100 { | |||
| 54 | - | t.Fatalf("finalization max tokens = %d, want 100", agent.MaxTokens) | |||
| 55 | - | } | |||
| 56 | - | if allowed, reason, _ := agent.BeforeToolExecute(provider.ToolCallBlock{Name: "read"}); allowed || !strings.Contains(reason, "tools are disabled") { | |||
| 57 | - | t.Fatalf("finalization tool guard = allowed=%t reason=%q", allowed, reason) | |||
| 58 | } | 52 | } | ||
| 59 | 53 | ||||
| 60 | budget.Observe(provider.Usage{InputTokens: 1_000}) | 54 | budget.Observe(provider.Usage{InputTokens: 1_000}) | ||
| @@ -64,7 +58,7 @@ func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) | |||||
| 64 | } | 58 | } | ||
| 65 | } | 59 | } | ||
| 66 | 60 | ||||
| 67 | - | func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) { | 61 | + | func TestResidentChildRunnerKeepsToolsAtNinetyPercent(t *testing.T) { |
| 68 | var requests atomic.Int32 | 62 | var requests atomic.Int32 | ||
| 69 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 63 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 70 | requestNumber := requests.Add(1) | 64 | requestNumber := requests.Add(1) | ||
| @@ -74,8 +68,8 @@ func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) { | |||||
| 74 | } | 68 | } | ||
| 75 | w.Header().Set("content-type", "text/event-stream") | 69 | w.Header().Set("content-type", "text/event-stream") | ||
| 76 | if requestNumber == 1 { | 70 | if requestNumber == 1 { | ||
| 77 | - | if got := requestMaxTokens(t, body); got != 1_000 { | 71 | + | if got := requestMaxTokens(t, body); got != 16_384 { |
| 78 | - | t.Errorf("initial max_tokens = %d, want 1000", got) | 72 | + | t.Errorf("initial max_tokens = %d, want 16384", got) |
| 79 | } | 73 | } | ||
| 80 | writeOpenAIChunk(t, w, map[string]any{ | 74 | writeOpenAIChunk(t, w, map[string]any{ | ||
| 81 | "choices": []any{map[string]any{ | 75 | "choices": []any{map[string]any{ | ||
| @@ -90,11 +84,11 @@ func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) { | |||||
| 90 | }) | 84 | }) | ||
| 91 | return | 85 | return | ||
| 92 | } | 86 | } | ||
| 93 | - | if tools, ok := body["tools"].([]any); ok && len(tools) != 0 { | 87 | + | if tools, ok := body["tools"].([]any); !ok || len(tools) == 0 { |
| 94 | - | t.Errorf("finalization request tools = %#v, want none", tools) | 88 | + | t.Errorf("near-limit request tools = %#v, want registered tools", body["tools"]) |
| 95 | } | 89 | } | ||
| 96 | - | if got := requestMaxTokens(t, body); got != 100 { | 90 | + | if got := requestMaxTokens(t, body); got <= 100 { |
| 97 | - | t.Errorf("finalization max_tokens = %d, want 100", got) | 91 | + | t.Errorf("near-limit max_tokens = %d, want model output limit", got) |
| 98 | } | 92 | } | ||
| 99 | writeOpenAIChunk(t, w, map[string]any{ | 93 | writeOpenAIChunk(t, w, map[string]any{ | ||
| 100 | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}}, | 94 | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}}, | ||
| @@ -123,8 +117,8 @@ func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) { | |||||
| 123 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | 117 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | ||
| 124 | t.Errorf("decode request: %v", err) | 118 | t.Errorf("decode request: %v", err) | ||
| 125 | } | 119 | } | ||
| 126 | - | if got := requestMaxTokens(t, body); got != 1_000 { | 120 | + | if got := requestMaxTokens(t, body); got != 16_384 { |
| 127 | - | t.Errorf("initial max_tokens = %d, want 1000", got) | 121 | + | t.Errorf("initial max_tokens = %d, want 16384", got) |
| 128 | } | 122 | } | ||
| 129 | w.Header().Set("content-type", "text/event-stream") | 123 | w.Header().Set("content-type", "text/event-stream") | ||
| 130 | writeOpenAIChunk(t, w, map[string]any{ | 124 | writeOpenAIChunk(t, w, map[string]any{ | ||
| @@ -156,7 +150,7 @@ func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (s | |||||
| 156 | t.Helper() | 150 | t.Helper() | ||
| 157 | spec := subagents.ResidentChildSpec{ | 151 | spec := subagents.ResidentChildSpec{ | ||
| 158 | ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o", | 152 | ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o", | ||
| 159 | - | Tools: []string{"read"}, BudgetLimit: limit, | 153 | + | Tools: []string{"read"}, BudgetLimit: limit, BudgetSource: "model_context", |
| 160 | } | 154 | } | ||
| 161 | journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID) | 155 | journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID) | ||
| 162 | if err != nil { | 156 | if err != nil { | ||
M packages/agent/subagent_runtime.go +15 -39
| @@ -3,6 +3,7 @@ package agent | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "path/filepath" | |
| 7 | 8 | "sort" | |
| 8 | 9 | "strings" | |
| @@ -307,7 +308,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 307 | 308 | providerID, model, reasoning := activeProvider, rt.model, rt.reasoning | |
| 308 | 309 | baseURL, insecureTLS := rt.baseURL, rt.insecureTLS | |
| 309 | 310 | workspace, parentSession := rt.repoRoot, rt.activeSession | |
| 310 | - | fastMode, policy, parentContextWindow := rt.fastMode, rt.policy, rt.contextWindow | |
| 311 | + | fastMode, policy := rt.fastMode, rt.policy | |
| 311 | 312 | rt.settingsMu.RUnlock() | |
| 312 | 313 | if strings.TrimSpace(request.Provider) != "" { | |
| 313 | 314 | providerID = strings.TrimSpace(request.Provider) | |
| @@ -364,16 +365,11 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 364 | 365 | if workspaceMode == "" { | |
| 365 | 366 | workspaceMode = subagents.WorkspaceShared | |
| 366 | 367 | } | |
| 367 | - | budgetLimit, resolvedBudgetRatio, budgetSource, err := resolveResidentBudget(parentContextWindow, policy.BudgetRatio, policy.BudgetRatioConfigured, request.Profile, request.BudgetRatio, request.BudgetTokens) | |
| 368 | - | if err != nil { | |
| 369 | - | return subagents.ResidentChildSpec{}, err | |
| 370 | - | } | |
| 371 | 368 | spec := subagents.ResidentChildSpec{ | |
| 372 | 369 | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | |
| 373 | 370 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | |
| 374 | 371 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | |
| 375 | 372 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | |
| 376 | - | BudgetLimit: budgetLimit, BudgetRatio: resolvedBudgetRatio, BudgetSource: budgetSource, | |
| 377 | 373 | } | |
| 378 | 374 | if request.Profile != nil { | |
| 379 | 375 | spec.Profile = request.Profile.Name | |
| @@ -382,31 +378,17 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 382 | 378 | spec.InheritProjectContext = request.Profile.InheritProjectContext | |
| 383 | 379 | spec.InheritSkills = request.Profile.InheritSkills | |
| 384 | 380 | } | |
| 385 | - | return spec, nil | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | func resolveResidentBudget(parentContextWindow int, policyRatio float64, policyConfigured bool, profile *subagents.Profile, spawnRatio *float64, spawnTokens *int64) (int64, float64, string, error) { | |
| 389 | - | ratio, source := policyRatio, "default_ratio" | |
| 390 | - | if policyConfigured { | |
| 391 | - | source = "config_ratio" | |
| 392 | - | } | |
| 393 | - | var tokens int64 | |
| 394 | - | if profile != nil { | |
| 395 | - | if profile.BudgetTokens != nil { | |
| 396 | - | tokens, source = *profile.BudgetTokens, "profile_tokens" | |
| 397 | - | } else if profile.BudgetRatio != nil { | |
| 398 | - | ratio, source = *profile.BudgetRatio, "profile_ratio" | |
| 399 | - | } | |
| 381 | + | resolved, err := Resolve(residentChildArgs(rt.args, rt.credentialProvider, spec), false) | |
| 382 | + | if err != nil { | |
| 383 | + | return subagents.ResidentChildSpec{}, fmt.Errorf("resolve resident child model: %w", err) | |
| 400 | 384 | } | |
| 401 | - | if spawnTokens != nil { | |
| 402 | - | tokens, source = *spawnTokens, "spawn_tokens" | |
| 403 | - | ratio = 0 | |
| 404 | - | } else if spawnRatio != nil { | |
| 405 | - | ratio, source = *spawnRatio, "spawn_ratio" | |
| 406 | - | tokens = 0 | |
| 385 | + | budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow) | |
| 386 | + | if err != nil { | |
| 387 | + | return subagents.ResidentChildSpec{}, err | |
| 407 | 388 | } | |
| 408 | - | limit, resolvedRatio, err := subagents.BudgetLimit(parentContextWindow, ratio, tokens) | |
| 409 | - | return limit, resolvedRatio, source, err | |
| 389 | + | spec.BudgetLimit = budgetLimit | |
| 390 | + | spec.BudgetSource = "model_context" | |
| 391 | + | return spec, nil | |
| 410 | 392 | } | |
| 411 | 393 | ||
| 412 | 394 | func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string { | |
| @@ -568,16 +550,10 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |||
| 568 | 550 | } | |
| 569 | 551 | return parsed | |
| 570 | 552 | } | |
| 571 | - | budgetRatio := subagents.DefaultBudgetRatio | |
| 572 | - | if cfg.BudgetRatio != nil { | |
| 573 | - | budgetRatio = *cfg.BudgetRatio | |
| 574 | - | } | |
| 575 | 553 | return subagents.SubagentPolicy{ | |
| 576 | - | MaxConcurrent: cfg.MaxConcurrent, | |
| 577 | - | QueueTimeout: parseDuration(cfg.QueueTimeout), | |
| 578 | - | AllowedTools: append([]string(nil), cfg.AllowedTools...), | |
| 579 | - | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | |
| 580 | - | BudgetRatio: budgetRatio, | |
| 581 | - | BudgetRatioConfigured: cfg.BudgetRatio != nil, | |
| 554 | + | MaxConcurrent: cfg.MaxConcurrent, | |
| 555 | + | QueueTimeout: parseDuration(cfg.QueueTimeout), | |
| 556 | + | AllowedTools: append([]string(nil), cfg.AllowedTools...), | |
| 557 | + | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | |
| 582 | 558 | } | |
| 583 | 559 | } | |
| @@ -3,6 +3,7 @@ package agent | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "fmt" | |||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 7 | "sort" | 8 | "sort" | ||
| 8 | "strings" | 9 | "strings" | ||
| @@ -307,7 +308,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 307 | providerID, model, reasoning := activeProvider, rt.model, rt.reasoning | 308 | providerID, model, reasoning := activeProvider, rt.model, rt.reasoning | ||
| 308 | baseURL, insecureTLS := rt.baseURL, rt.insecureTLS | 309 | baseURL, insecureTLS := rt.baseURL, rt.insecureTLS | ||
| 309 | workspace, parentSession := rt.repoRoot, rt.activeSession | 310 | workspace, parentSession := rt.repoRoot, rt.activeSession | ||
| 310 | - | fastMode, policy, parentContextWindow := rt.fastMode, rt.policy, rt.contextWindow | 311 | + | fastMode, policy := rt.fastMode, rt.policy |
| 311 | rt.settingsMu.RUnlock() | 312 | rt.settingsMu.RUnlock() | ||
| 312 | if strings.TrimSpace(request.Provider) != "" { | 313 | if strings.TrimSpace(request.Provider) != "" { | ||
| 313 | providerID = strings.TrimSpace(request.Provider) | 314 | providerID = strings.TrimSpace(request.Provider) | ||
| @@ -364,16 +365,11 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 364 | if workspaceMode == "" { | 365 | if workspaceMode == "" { | ||
| 365 | workspaceMode = subagents.WorkspaceShared | 366 | workspaceMode = subagents.WorkspaceShared | ||
| 366 | } | 367 | } | ||
| 367 | - | budgetLimit, resolvedBudgetRatio, budgetSource, err := resolveResidentBudget(parentContextWindow, policy.BudgetRatio, policy.BudgetRatioConfigured, request.Profile, request.BudgetRatio, request.BudgetTokens) | |||
| 368 | - | if err != nil { | |||
| 369 | - | return subagents.ResidentChildSpec{}, err | |||
| 370 | - | } | |||
| 371 | spec := subagents.ResidentChildSpec{ | 368 | spec := subagents.ResidentChildSpec{ | ||
| 372 | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | 369 | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | ||
| 373 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | 370 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | ||
| 374 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | 371 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | ||
| 375 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | 372 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | ||
| 376 | - | BudgetLimit: budgetLimit, BudgetRatio: resolvedBudgetRatio, BudgetSource: budgetSource, | |||
| 377 | } | 373 | } | ||
| 378 | if request.Profile != nil { | 374 | if request.Profile != nil { | ||
| 379 | spec.Profile = request.Profile.Name | 375 | spec.Profile = request.Profile.Name | ||
| @@ -382,31 +378,17 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 382 | spec.InheritProjectContext = request.Profile.InheritProjectContext | 378 | spec.InheritProjectContext = request.Profile.InheritProjectContext | ||
| 383 | spec.InheritSkills = request.Profile.InheritSkills | 379 | spec.InheritSkills = request.Profile.InheritSkills | ||
| 384 | } | 380 | } | ||
| 385 | - | return spec, nil | 381 | + | resolved, err := Resolve(residentChildArgs(rt.args, rt.credentialProvider, spec), false) |
| 386 | - | } | 382 | + | if err != nil { |
| 387 | - | 383 | + | return subagents.ResidentChildSpec{}, fmt.Errorf("resolve resident child model: %w", err) | |
| 388 | - | func resolveResidentBudget(parentContextWindow int, policyRatio float64, policyConfigured bool, profile *subagents.Profile, spawnRatio *float64, spawnTokens *int64) (int64, float64, string, error) { | |||
| 389 | - | ratio, source := policyRatio, "default_ratio" | |||
| 390 | - | if policyConfigured { | |||
| 391 | - | source = "config_ratio" | |||
| 392 | - | } | |||
| 393 | - | var tokens int64 | |||
| 394 | - | if profile != nil { | |||
| 395 | - | if profile.BudgetTokens != nil { | |||
| 396 | - | tokens, source = *profile.BudgetTokens, "profile_tokens" | |||
| 397 | - | } else if profile.BudgetRatio != nil { | |||
| 398 | - | ratio, source = *profile.BudgetRatio, "profile_ratio" | |||
| 399 | - | } | |||
| 400 | } | 384 | } | ||
| 401 | - | if spawnTokens != nil { | 385 | + | budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow) |
| 402 | - | tokens, source = *spawnTokens, "spawn_tokens" | 386 | + | if err != nil { |
| 403 | - | ratio = 0 | 387 | + | return subagents.ResidentChildSpec{}, err |
| 404 | - | } else if spawnRatio != nil { | |||
| 405 | - | ratio, source = *spawnRatio, "spawn_ratio" | |||
| 406 | - | tokens = 0 | |||
| 407 | } | 388 | } | ||
| 408 | - | limit, resolvedRatio, err := subagents.BudgetLimit(parentContextWindow, ratio, tokens) | 389 | + | spec.BudgetLimit = budgetLimit |
| 409 | - | return limit, resolvedRatio, source, err | 390 | + | spec.BudgetSource = "model_context" |
| 391 | + | return spec, nil | |||
| 410 | } | 392 | } | ||
| 411 | 393 | ||||
| 412 | func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string { | 394 | func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string { | ||
| @@ -568,16 +550,10 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |||||
| 568 | } | 550 | } | ||
| 569 | return parsed | 551 | return parsed | ||
| 570 | } | 552 | } | ||
| 571 | - | budgetRatio := subagents.DefaultBudgetRatio | |||
| 572 | - | if cfg.BudgetRatio != nil { | |||
| 573 | - | budgetRatio = *cfg.BudgetRatio | |||
| 574 | - | } | |||
| 575 | return subagents.SubagentPolicy{ | 553 | return subagents.SubagentPolicy{ | ||
| 576 | - | MaxConcurrent: cfg.MaxConcurrent, | 554 | + | MaxConcurrent: cfg.MaxConcurrent, |
| 577 | - | QueueTimeout: parseDuration(cfg.QueueTimeout), | 555 | + | QueueTimeout: parseDuration(cfg.QueueTimeout), |
| 578 | - | AllowedTools: append([]string(nil), cfg.AllowedTools...), | 556 | + | AllowedTools: append([]string(nil), cfg.AllowedTools...), |
| 579 | - | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | 557 | + | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), |
| 580 | - | BudgetRatio: budgetRatio, | |||
| 581 | - | BudgetRatioConfigured: cfg.BudgetRatio != nil, | |||
| 582 | } | 558 | } | ||
| 583 | } | 559 | } | ||
M packages/agent/subagent_runtime_test.go +4 -57
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/zut/packages/agent/subagents" | |
| 8 | 7 | "github.com/bnema/zut/packages/agent/tools" | |
| 9 | 8 | "github.com/bnema/zut/packages/core" | |
| 10 | 9 | ) | |
| @@ -68,12 +67,10 @@ func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T | |||
| 68 | 67 | } | |
| 69 | 68 | } | |
| 70 | 69 | ||
| 71 | - | func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) { | |
| 72 | - | ratio := 0.6 | |
| 70 | + | func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) { | |
| 73 | 71 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 74 | 72 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | |
| 75 | - | Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 500_000, | |
| 76 | - | Policy: subagents.SubagentPolicy{BudgetRatio: ratio, BudgetRatioConfigured: true}, | |
| 73 | + | Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000, | |
| 77 | 74 | }) | |
| 78 | 75 | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |
| 79 | 76 | ||
| @@ -81,58 +78,8 @@ func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) { | |||
| 81 | 78 | if err != nil { | |
| 82 | 79 | t.Fatal(err) | |
| 83 | 80 | } | |
| 84 | - | if spec.BudgetLimit != 300_000 || spec.BudgetRatio != ratio || spec.BudgetSource != "config_ratio" { | |
| 85 | - | t.Fatalf("inherited budget = %#v", spec) | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | tokens := int64(42_000) | |
| 89 | - | spec, err = runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |
| 90 | - | Task: "deep review", BudgetTokens: &tokens, | |
| 91 | - | }, core.Registry{"read": nil}) | |
| 92 | - | if err != nil { | |
| 93 | - | t.Fatal(err) | |
| 94 | - | } | |
| 95 | - | if spec.BudgetLimit != tokens || spec.BudgetRatio != 0 || spec.BudgetSource != "spawn_tokens" { | |
| 96 | - | t.Fatalf("absolute budget = %#v", spec) | |
| 97 | - | } | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func TestResolveResidentBudgetPrecedence(t *testing.T) { | |
| 101 | - | configRatio, profileRatio, spawnRatio := 0.6, 0.5, 0.4 | |
| 102 | - | profileTokens, spawnTokens := int64(200_000), int64(150_000) | |
| 103 | - | for _, tc := range []struct { | |
| 104 | - | name string | |
| 105 | - | profile *subagents.Profile | |
| 106 | - | spawnRatio *float64 | |
| 107 | - | spawnTokens *int64 | |
| 108 | - | wantLimit int64 | |
| 109 | - | wantRatio float64 | |
| 110 | - | wantSource string | |
| 111 | - | }{ | |
| 112 | - | {name: "default", wantLimit: 375_000, wantRatio: 0.75, wantSource: "default_ratio"}, | |
| 113 | - | {name: "config ratio", wantLimit: 300_000, wantRatio: configRatio, wantSource: "config_ratio"}, | |
| 114 | - | {name: "profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, wantLimit: 250_000, wantRatio: profileRatio, wantSource: "profile_ratio"}, | |
| 115 | - | {name: "profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, wantLimit: profileTokens, wantSource: "profile_tokens"}, | |
| 116 | - | {name: "spawn ratio over profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, spawnRatio: &spawnRatio, wantLimit: 200_000, wantRatio: spawnRatio, wantSource: "spawn_ratio"}, | |
| 117 | - | {name: "spawn tokens over profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, spawnTokens: &spawnTokens, wantLimit: spawnTokens, wantSource: "spawn_tokens"}, | |
| 118 | - | } { | |
| 119 | - | t.Run(tc.name, func(t *testing.T) { | |
| 120 | - | policyRatio := configRatio | |
| 121 | - | if tc.name == "default" { | |
| 122 | - | policyRatio = 0 | |
| 123 | - | } | |
| 124 | - | limit, ratio, source, err := resolveResidentBudget(500_000, policyRatio, tc.name != "default", tc.profile, tc.spawnRatio, tc.spawnTokens) | |
| 125 | - | if err != nil { | |
| 126 | - | t.Fatal(err) | |
| 127 | - | } | |
| 128 | - | if limit != tc.wantLimit || ratio != tc.wantRatio || source != tc.wantSource { | |
| 129 | - | t.Fatalf("resolved budget = (%d, %v, %q), want (%d, %v, %q)", limit, ratio, source, tc.wantLimit, tc.wantRatio, tc.wantSource) | |
| 130 | - | } | |
| 131 | - | }) | |
| 132 | - | } | |
| 133 | - | limit, ratio, source, err := resolveResidentBudget(500_000, 0.75, true, nil, nil, nil) | |
| 134 | - | if err != nil || limit != 375_000 || ratio != 0.75 || source != "config_ratio" { | |
| 135 | - | t.Fatalf("explicit default ratio = (%d, %v, %q, %v)", limit, ratio, source, err) | |
| 81 | + | if spec.BudgetLimit != 500_000 || spec.BudgetSource != "model_context" { | |
| 82 | + | t.Fatalf("child model budget = %#v", spec) | |
| 136 | 83 | } | |
| 137 | 84 | } | |
| 138 | 85 | ||
| @@ -4,7 +4,6 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/zut/packages/agent/subagents" | |||
| 8 | "github.com/bnema/zut/packages/agent/tools" | 7 | "github.com/bnema/zut/packages/agent/tools" | ||
| 9 | "github.com/bnema/zut/packages/core" | 8 | "github.com/bnema/zut/packages/core" | ||
| 10 | ) | 9 | ) | ||
| @@ -68,12 +67,10 @@ func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T | |||||
| 68 | } | 67 | } | ||
| 69 | } | 68 | } | ||
| 70 | 69 | ||||
| 71 | - | func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) { | 70 | + | func TestResidentChildSpecUsesSelectedModelContextForBudget(t *testing.T) { |
| 72 | - | ratio := 0.6 | |||
| 73 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | 71 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | ||
| 74 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | 72 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | ||
| 75 | - | Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 500_000, | 73 | + | Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000, |
| 76 | - | Policy: subagents.SubagentPolicy{BudgetRatio: ratio, BudgetRatioConfigured: true}, | |||
| 77 | }) | 74 | }) | ||
| 78 | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | 75 | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | ||
| 79 | 76 | ||||
| @@ -81,58 +78,8 @@ func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) { | |||||
| 81 | if err != nil { | 78 | if err != nil { | ||
| 82 | t.Fatal(err) | 79 | t.Fatal(err) | ||
| 83 | } | 80 | } | ||
| 84 | - | if spec.BudgetLimit != 300_000 || spec.BudgetRatio != ratio || spec.BudgetSource != "config_ratio" { | 81 | + | if spec.BudgetLimit != 500_000 || spec.BudgetSource != "model_context" { |
| 85 | - | t.Fatalf("inherited budget = %#v", spec) | 82 | + | t.Fatalf("child model budget = %#v", spec) |
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | tokens := int64(42_000) | |||
| 89 | - | spec, err = runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |||
| 90 | - | Task: "deep review", BudgetTokens: &tokens, | |||
| 91 | - | }, core.Registry{"read": nil}) | |||
| 92 | - | if err != nil { | |||
| 93 | - | t.Fatal(err) | |||
| 94 | - | } | |||
| 95 | - | if spec.BudgetLimit != tokens || spec.BudgetRatio != 0 || spec.BudgetSource != "spawn_tokens" { | |||
| 96 | - | t.Fatalf("absolute budget = %#v", spec) | |||
| 97 | - | } | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | func TestResolveResidentBudgetPrecedence(t *testing.T) { | |||
| 101 | - | configRatio, profileRatio, spawnRatio := 0.6, 0.5, 0.4 | |||
| 102 | - | profileTokens, spawnTokens := int64(200_000), int64(150_000) | |||
| 103 | - | for _, tc := range []struct { | |||
| 104 | - | name string | |||
| 105 | - | profile *subagents.Profile | |||
| 106 | - | spawnRatio *float64 | |||
| 107 | - | spawnTokens *int64 | |||
| 108 | - | wantLimit int64 | |||
| 109 | - | wantRatio float64 | |||
| 110 | - | wantSource string | |||
| 111 | - | }{ | |||
| 112 | - | {name: "default", wantLimit: 375_000, wantRatio: 0.75, wantSource: "default_ratio"}, | |||
| 113 | - | {name: "config ratio", wantLimit: 300_000, wantRatio: configRatio, wantSource: "config_ratio"}, | |||
| 114 | - | {name: "profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, wantLimit: 250_000, wantRatio: profileRatio, wantSource: "profile_ratio"}, | |||
| 115 | - | {name: "profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, wantLimit: profileTokens, wantSource: "profile_tokens"}, | |||
| 116 | - | {name: "spawn ratio over profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, spawnRatio: &spawnRatio, wantLimit: 200_000, wantRatio: spawnRatio, wantSource: "spawn_ratio"}, | |||
| 117 | - | {name: "spawn tokens over profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, spawnTokens: &spawnTokens, wantLimit: spawnTokens, wantSource: "spawn_tokens"}, | |||
| 118 | - | } { | |||
| 119 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 120 | - | policyRatio := configRatio | |||
| 121 | - | if tc.name == "default" { | |||
| 122 | - | policyRatio = 0 | |||
| 123 | - | } | |||
| 124 | - | limit, ratio, source, err := resolveResidentBudget(500_000, policyRatio, tc.name != "default", tc.profile, tc.spawnRatio, tc.spawnTokens) | |||
| 125 | - | if err != nil { | |||
| 126 | - | t.Fatal(err) | |||
| 127 | - | } | |||
| 128 | - | if limit != tc.wantLimit || ratio != tc.wantRatio || source != tc.wantSource { | |||
| 129 | - | t.Fatalf("resolved budget = (%d, %v, %q), want (%d, %v, %q)", limit, ratio, source, tc.wantLimit, tc.wantRatio, tc.wantSource) | |||
| 130 | - | } | |||
| 131 | - | }) | |||
| 132 | - | } | |||
| 133 | - | limit, ratio, source, err := resolveResidentBudget(500_000, 0.75, true, nil, nil, nil) | |||
| 134 | - | if err != nil || limit != 375_000 || ratio != 0.75 || source != "config_ratio" { | |||
| 135 | - | t.Fatalf("explicit default ratio = (%d, %v, %q, %v)", limit, ratio, source, err) | |||
| 136 | } | 83 | } | ||
| 137 | } | 84 | } | ||
| 138 | 85 | ||||
M packages/agent/subagents/budget.go +16 -123
| @@ -2,28 +2,21 @@ package subagents | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | - | "fmt" | |
| 6 | 5 | "math" | |
| 7 | 6 | "sync" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/zut/packages/provider" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | - | const ( | |
| 13 | - | DefaultBudgetRatio = 0.75 | |
| 14 | - | cachedInputBudgetWeight = 0.25 | |
| 15 | - | ) | |
| 11 | + | const cachedInputBudgetWeight = 0.25 | |
| 16 | 12 | ||
| 17 | - | // BudgetState is the progressive constraint applied to a resident child. | |
| 13 | + | // BudgetState is the host-visible state of a resident child's cumulative | |
| 14 | + | // rollout budget. | |
| 18 | 15 | type BudgetState string | |
| 19 | 16 | ||
| 20 | 17 | const ( | |
| 21 | - | BudgetNormal BudgetState = "normal" | |
| 22 | - | BudgetNotice BudgetState = "notice" | |
| 23 | - | BudgetFocused BudgetState = "focused" | |
| 24 | - | BudgetVerifying BudgetState = "verifying" | |
| 25 | - | BudgetFinalizing BudgetState = "finalizing" | |
| 26 | - | BudgetExceeded BudgetState = "exceeded" | |
| 18 | + | BudgetNormal BudgetState = "normal" | |
| 19 | + | BudgetExceeded BudgetState = "exceeded" | |
| 27 | 20 | ) | |
| 28 | 21 | ||
| 29 | 22 | var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded") | |
| @@ -37,27 +30,13 @@ type BudgetSnapshot struct { | |||
| 37 | 30 | State BudgetState `json:"state"` | |
| 38 | 31 | } | |
| 39 | 32 | ||
| 40 | - | func BudgetLimit(contextWindow int, ratio float64, tokens int64) (int64, float64, error) { | |
| 41 | - | if tokens < 0 { | |
| 42 | - | return 0, 0, errors.New("budget_tokens must be positive") | |
| 43 | - | } | |
| 44 | - | if tokens > 0 { | |
| 45 | - | return tokens, 0, nil | |
| 46 | - | } | |
| 47 | - | if ratio == 0 { | |
| 48 | - | ratio = DefaultBudgetRatio | |
| 49 | - | } | |
| 50 | - | if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio <= 0 || ratio > 1 { | |
| 51 | - | return 0, 0, errors.New("budget_ratio must be greater than 0 and at most 1") | |
| 52 | - | } | |
| 33 | + | // ContextBudgetLimit derives the deterministic rollout limit for a child from | |
| 34 | + | // its resolved model metadata. | |
| 35 | + | func ContextBudgetLimit(contextWindow int) (int64, error) { | |
| 53 | 36 | if contextWindow <= 0 { | |
| 54 | - | return 0, ratio, errors.New("cannot derive a subagent budget without a parent context window") | |
| 55 | - | } | |
| 56 | - | limit := int64(math.Floor(float64(contextWindow) * ratio)) | |
| 57 | - | if limit <= 0 { | |
| 58 | - | return 0, ratio, errors.New("derived subagent budget is empty") | |
| 37 | + | return 0, errors.New("cannot derive a subagent budget without a model context window") | |
| 59 | 38 | } | |
| 60 | - | return limit, ratio, nil | |
| 39 | + | return int64(contextWindow), nil | |
| 61 | 40 | } | |
| 62 | 41 | ||
| 63 | 42 | // WeightedBudgetUsage charges uncached and cache-write input fully, cache-read | |
| @@ -71,26 +50,6 @@ func WeightedBudgetUsage(usage provider.Usage) int64 { | |||
| 71 | 50 | return int64(math.Ceil(weighted)) | |
| 72 | 51 | } | |
| 73 | 52 | ||
| 74 | - | // EffectiveBudgetLimit preserves budgets accepted by current versions and | |
| 75 | - | // derives the default for resident specs written before budgets were stored. | |
| 76 | - | func EffectiveBudgetLimit(limit int64, contextWindow int) int64 { | |
| 77 | - | if limit > 0 { | |
| 78 | - | return limit | |
| 79 | - | } | |
| 80 | - | derived, _, err := BudgetLimit(contextWindow, DefaultBudgetRatio, 0) | |
| 81 | - | if err != nil { | |
| 82 | - | return 0 | |
| 83 | - | } | |
| 84 | - | return derived | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | func BudgetSystemPrompt(limit int64) string { | |
| 88 | - | if limit <= 0 { | |
| 89 | - | return "" | |
| 90 | - | } | |
| 91 | - | return fmt.Sprintf("Your cumulative rollout budget is %d weighted tokens. At 50%% prioritize concrete evidence; at 70%% stop broad exploration; at 85%% verify existing candidates only; at 90%% stop using tools and return the best verified final result. The host enforces the finalization reserve and terminates work at 100%%.", limit) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | 53 | func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot { | |
| 95 | 54 | if limit <= 0 { | |
| 96 | 55 | return BudgetSnapshot{} | |
| @@ -104,30 +63,17 @@ func budgetState(used, limit int64) BudgetState { | |||
| 104 | 63 | if limit <= 0 { | |
| 105 | 64 | return "" | |
| 106 | 65 | } | |
| 107 | - | ratio := float64(used) / float64(limit) | |
| 108 | - | switch { | |
| 109 | - | case ratio >= 1: | |
| 66 | + | if used >= limit { | |
| 110 | 67 | return BudgetExceeded | |
| 111 | - | case ratio >= 0.90: | |
| 112 | - | return BudgetFinalizing | |
| 113 | - | case ratio >= 0.85: | |
| 114 | - | return BudgetVerifying | |
| 115 | - | case ratio >= 0.70: | |
| 116 | - | return BudgetFocused | |
| 117 | - | case ratio >= 0.50: | |
| 118 | - | return BudgetNotice | |
| 119 | - | default: | |
| 120 | - | return BudgetNormal | |
| 121 | 68 | } | |
| 69 | + | return BudgetNormal | |
| 122 | 70 | } | |
| 123 | 71 | ||
| 124 | - | // RolloutBudget accounts one resident child's cumulative provider usage and | |
| 125 | - | // produces progressive model instructions at threshold transitions. | |
| 72 | + | // RolloutBudget accounts one resident child's cumulative provider usage. | |
| 126 | 73 | type RolloutBudget struct { | |
| 127 | - | mu sync.Mutex | |
| 128 | - | limit int64 | |
| 129 | - | snapshot BudgetSnapshot | |
| 130 | - | delivered BudgetState | |
| 74 | + | mu sync.Mutex | |
| 75 | + | limit int64 | |
| 76 | + | snapshot BudgetSnapshot | |
| 131 | 77 | } | |
| 132 | 78 | ||
| 133 | 79 | func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget { | |
| @@ -156,56 +102,3 @@ func (b *RolloutBudget) Snapshot() BudgetSnapshot { | |||
| 156 | 102 | b.mu.Unlock() | |
| 157 | 103 | return snapshot | |
| 158 | 104 | } | |
| 159 | - | ||
| 160 | - | // TurnContext returns a one-shot model-visible transition alert. Core stores | |
| 161 | - | // the resulting developer context in the transcript, including across compaction. | |
| 162 | - | func (b *RolloutBudget) TurnContext() (string, bool) { | |
| 163 | - | if b == nil { | |
| 164 | - | return "", false | |
| 165 | - | } | |
| 166 | - | b.mu.Lock() | |
| 167 | - | defer b.mu.Unlock() | |
| 168 | - | state := b.snapshot.State | |
| 169 | - | if state == BudgetExceeded { | |
| 170 | - | return "", true | |
| 171 | - | } | |
| 172 | - | if budgetStateRank(state) <= budgetStateRank(b.delivered) { | |
| 173 | - | return "", false | |
| 174 | - | } | |
| 175 | - | b.delivered = state | |
| 176 | - | return budgetInstruction(b.snapshot), false | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | func budgetStateRank(state BudgetState) int { | |
| 180 | - | switch state { | |
| 181 | - | case BudgetNotice: | |
| 182 | - | return 1 | |
| 183 | - | case BudgetFocused: | |
| 184 | - | return 2 | |
| 185 | - | case BudgetVerifying: | |
| 186 | - | return 3 | |
| 187 | - | case BudgetFinalizing: | |
| 188 | - | return 4 | |
| 189 | - | case BudgetExceeded: | |
| 190 | - | return 5 | |
| 191 | - | default: | |
| 192 | - | return 0 | |
| 193 | - | } | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | func budgetInstruction(snapshot BudgetSnapshot) string { | |
| 197 | - | remaining := max(snapshot.Limit-snapshot.Used, 0) | |
| 198 | - | prefix := fmt.Sprintf("[subagent budget: %d%% used, %d weighted tokens remaining] ", snapshot.Percent, remaining) | |
| 199 | - | switch snapshot.State { | |
| 200 | - | case BudgetNotice: | |
| 201 | - | return prefix + "Keep the remaining investigation bounded and prioritize concrete evidence." | |
| 202 | - | case BudgetFocused: | |
| 203 | - | return prefix + "Stop broad exploration. Investigate only concrete unresolved hypotheses." | |
| 204 | - | case BudgetVerifying: | |
| 205 | - | return prefix + "Do not identify new candidates. Verify or reject current candidates and prepare the final result." | |
| 206 | - | case BudgetFinalizing: | |
| 207 | - | return prefix + "Finalization is required now. Return the best verified result without using tools or starting new investigations." | |
| 208 | - | default: | |
| 209 | - | return "" | |
| 210 | - | } | |
| 211 | - | } | |
| @@ -2,28 +2,21 @@ package subagents | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | - | "fmt" | |||
| 6 | "math" | 5 | "math" | ||
| 7 | "sync" | 6 | "sync" | ||
| 8 | 7 | ||||
| 9 | "github.com/bnema/zut/packages/provider" | 8 | "github.com/bnema/zut/packages/provider" | ||
| 10 | ) | 9 | ) | ||
| 11 | 10 | ||||
| 12 | - | const ( | 11 | + | const cachedInputBudgetWeight = 0.25 |
| 13 | - | DefaultBudgetRatio = 0.75 | |||
| 14 | - | cachedInputBudgetWeight = 0.25 | |||
| 15 | - | ) | |||
| 16 | 12 | ||||
| 17 | - | // BudgetState is the progressive constraint applied to a resident child. | 13 | + | // BudgetState is the host-visible state of a resident child's cumulative |
| 14 | + | // rollout budget. | |||
| 18 | type BudgetState string | 15 | type BudgetState string | ||
| 19 | 16 | ||||
| 20 | const ( | 17 | const ( | ||
| 21 | - | BudgetNormal BudgetState = "normal" | 18 | + | BudgetNormal BudgetState = "normal" |
| 22 | - | BudgetNotice BudgetState = "notice" | 19 | + | BudgetExceeded BudgetState = "exceeded" |
| 23 | - | BudgetFocused BudgetState = "focused" | |||
| 24 | - | BudgetVerifying BudgetState = "verifying" | |||
| 25 | - | BudgetFinalizing BudgetState = "finalizing" | |||
| 26 | - | BudgetExceeded BudgetState = "exceeded" | |||
| 27 | ) | 20 | ) | ||
| 28 | 21 | ||||
| 29 | var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded") | 22 | var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded") | ||
| @@ -37,27 +30,13 @@ type BudgetSnapshot struct { | |||||
| 37 | State BudgetState `json:"state"` | 30 | State BudgetState `json:"state"` | ||
| 38 | } | 31 | } | ||
| 39 | 32 | ||||
| 40 | - | func BudgetLimit(contextWindow int, ratio float64, tokens int64) (int64, float64, error) { | 33 | + | // ContextBudgetLimit derives the deterministic rollout limit for a child from |
| 41 | - | if tokens < 0 { | 34 | + | // its resolved model metadata. |
| 42 | - | return 0, 0, errors.New("budget_tokens must be positive") | 35 | + | func ContextBudgetLimit(contextWindow int) (int64, error) { |
| 43 | - | } | |||
| 44 | - | if tokens > 0 { | |||
| 45 | - | return tokens, 0, nil | |||
| 46 | - | } | |||
| 47 | - | if ratio == 0 { | |||
| 48 | - | ratio = DefaultBudgetRatio | |||
| 49 | - | } | |||
| 50 | - | if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio <= 0 || ratio > 1 { | |||
| 51 | - | return 0, 0, errors.New("budget_ratio must be greater than 0 and at most 1") | |||
| 52 | - | } | |||
| 53 | if contextWindow <= 0 { | 36 | if contextWindow <= 0 { | ||
| 54 | - | return 0, ratio, errors.New("cannot derive a subagent budget without a parent context window") | 37 | + | return 0, errors.New("cannot derive a subagent budget without a model context window") |
| 55 | - | } | |||
| 56 | - | limit := int64(math.Floor(float64(contextWindow) * ratio)) | |||
| 57 | - | if limit <= 0 { | |||
| 58 | - | return 0, ratio, errors.New("derived subagent budget is empty") | |||
| 59 | } | 38 | } | ||
| 60 | - | return limit, ratio, nil | 39 | + | return int64(contextWindow), nil |
| 61 | } | 40 | } | ||
| 62 | 41 | ||||
| 63 | // WeightedBudgetUsage charges uncached and cache-write input fully, cache-read | 42 | // WeightedBudgetUsage charges uncached and cache-write input fully, cache-read | ||
| @@ -71,26 +50,6 @@ func WeightedBudgetUsage(usage provider.Usage) int64 { | |||||
| 71 | return int64(math.Ceil(weighted)) | 50 | return int64(math.Ceil(weighted)) | ||
| 72 | } | 51 | } | ||
| 73 | 52 | ||||
| 74 | - | // EffectiveBudgetLimit preserves budgets accepted by current versions and | |||
| 75 | - | // derives the default for resident specs written before budgets were stored. | |||
| 76 | - | func EffectiveBudgetLimit(limit int64, contextWindow int) int64 { | |||
| 77 | - | if limit > 0 { | |||
| 78 | - | return limit | |||
| 79 | - | } | |||
| 80 | - | derived, _, err := BudgetLimit(contextWindow, DefaultBudgetRatio, 0) | |||
| 81 | - | if err != nil { | |||
| 82 | - | return 0 | |||
| 83 | - | } | |||
| 84 | - | return derived | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | func BudgetSystemPrompt(limit int64) string { | |||
| 88 | - | if limit <= 0 { | |||
| 89 | - | return "" | |||
| 90 | - | } | |||
| 91 | - | return fmt.Sprintf("Your cumulative rollout budget is %d weighted tokens. At 50%% prioritize concrete evidence; at 70%% stop broad exploration; at 85%% verify existing candidates only; at 90%% stop using tools and return the best verified final result. The host enforces the finalization reserve and terminates work at 100%%.", limit) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot { | 53 | func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot { | ||
| 95 | if limit <= 0 { | 54 | if limit <= 0 { | ||
| 96 | return BudgetSnapshot{} | 55 | return BudgetSnapshot{} | ||
| @@ -104,30 +63,17 @@ func budgetState(used, limit int64) BudgetState { | |||||
| 104 | if limit <= 0 { | 63 | if limit <= 0 { | ||
| 105 | return "" | 64 | return "" | ||
| 106 | } | 65 | } | ||
| 107 | - | ratio := float64(used) / float64(limit) | 66 | + | if used >= limit { |
| 108 | - | switch { | |||
| 109 | - | case ratio >= 1: | |||
| 110 | return BudgetExceeded | 67 | return BudgetExceeded | ||
| 111 | - | case ratio >= 0.90: | |||
| 112 | - | return BudgetFinalizing | |||
| 113 | - | case ratio >= 0.85: | |||
| 114 | - | return BudgetVerifying | |||
| 115 | - | case ratio >= 0.70: | |||
| 116 | - | return BudgetFocused | |||
| 117 | - | case ratio >= 0.50: | |||
| 118 | - | return BudgetNotice | |||
| 119 | - | default: | |||
| 120 | - | return BudgetNormal | |||
| 121 | } | 68 | } | ||
| 69 | + | return BudgetNormal | |||
| 122 | } | 70 | } | ||
| 123 | 71 | ||||
| 124 | - | // RolloutBudget accounts one resident child's cumulative provider usage and | 72 | + | // RolloutBudget accounts one resident child's cumulative provider usage. |
| 125 | - | // produces progressive model instructions at threshold transitions. | |||
| 126 | type RolloutBudget struct { | 73 | type RolloutBudget struct { | ||
| 127 | - | mu sync.Mutex | 74 | + | mu sync.Mutex |
| 128 | - | limit int64 | 75 | + | limit int64 |
| 129 | - | snapshot BudgetSnapshot | 76 | + | snapshot BudgetSnapshot |
| 130 | - | delivered BudgetState | |||
| 131 | } | 77 | } | ||
| 132 | 78 | ||||
| 133 | func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget { | 79 | func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget { | ||
| @@ -156,56 +102,3 @@ func (b *RolloutBudget) Snapshot() BudgetSnapshot { | |||||
| 156 | b.mu.Unlock() | 102 | b.mu.Unlock() | ||
| 157 | return snapshot | 103 | return snapshot | ||
| 158 | } | 104 | } | ||
| 159 | - | ||||
| 160 | - | // TurnContext returns a one-shot model-visible transition alert. Core stores | |||
| 161 | - | // the resulting developer context in the transcript, including across compaction. | |||
| 162 | - | func (b *RolloutBudget) TurnContext() (string, bool) { | |||
| 163 | - | if b == nil { | |||
| 164 | - | return "", false | |||
| 165 | - | } | |||
| 166 | - | b.mu.Lock() | |||
| 167 | - | defer b.mu.Unlock() | |||
| 168 | - | state := b.snapshot.State | |||
| 169 | - | if state == BudgetExceeded { | |||
| 170 | - | return "", true | |||
| 171 | - | } | |||
| 172 | - | if budgetStateRank(state) <= budgetStateRank(b.delivered) { | |||
| 173 | - | return "", false | |||
| 174 | - | } | |||
| 175 | - | b.delivered = state | |||
| 176 | - | return budgetInstruction(b.snapshot), false | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | func budgetStateRank(state BudgetState) int { | |||
| 180 | - | switch state { | |||
| 181 | - | case BudgetNotice: | |||
| 182 | - | return 1 | |||
| 183 | - | case BudgetFocused: | |||
| 184 | - | return 2 | |||
| 185 | - | case BudgetVerifying: | |||
| 186 | - | return 3 | |||
| 187 | - | case BudgetFinalizing: | |||
| 188 | - | return 4 | |||
| 189 | - | case BudgetExceeded: | |||
| 190 | - | return 5 | |||
| 191 | - | default: | |||
| 192 | - | return 0 | |||
| 193 | - | } | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | func budgetInstruction(snapshot BudgetSnapshot) string { | |||
| 197 | - | remaining := max(snapshot.Limit-snapshot.Used, 0) | |||
| 198 | - | prefix := fmt.Sprintf("[subagent budget: %d%% used, %d weighted tokens remaining] ", snapshot.Percent, remaining) | |||
| 199 | - | switch snapshot.State { | |||
| 200 | - | case BudgetNotice: | |||
| 201 | - | return prefix + "Keep the remaining investigation bounded and prioritize concrete evidence." | |||
| 202 | - | case BudgetFocused: | |||
| 203 | - | return prefix + "Stop broad exploration. Investigate only concrete unresolved hypotheses." | |||
| 204 | - | case BudgetVerifying: | |||
| 205 | - | return prefix + "Do not identify new candidates. Verify or reject current candidates and prepare the final result." | |||
| 206 | - | case BudgetFinalizing: | |||
| 207 | - | return prefix + "Finalization is required now. Return the best verified result without using tools or starting new investigations." | |||
| 208 | - | default: | |||
| 209 | - | return "" | |||
| 210 | - | } | |||
| 211 | - | } | |||
M packages/agent/subagents/budget_test.go +24 -44
| @@ -1,38 +1,36 @@ | |||
| 1 | 1 | package subagents | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "strings" | |
| 5 | 4 | "testing" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/zut/packages/provider" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| 10 | - | func TestBudgetLimitDefaultsToSeventyFivePercent(t *testing.T) { | |
| 11 | - | limit, ratio, err := BudgetLimit(500_000, 0, 0) | |
| 9 | + | func TestContextBudgetLimitUsesFullModelContext(t *testing.T) { | |
| 10 | + | limit, err := ContextBudgetLimit(500_000) | |
| 12 | 11 | if err != nil { | |
| 13 | 12 | t.Fatal(err) | |
| 14 | 13 | } | |
| 15 | - | if limit != 375_000 || ratio != 0.75 { | |
| 16 | - | t.Fatalf("BudgetLimit = %d, %v; want 375000, 0.75", limit, ratio) | |
| 14 | + | if limit != 500_000 { | |
| 15 | + | t.Fatalf("ContextBudgetLimit = %d, want 500000", limit) | |
| 17 | 16 | } | |
| 18 | - | ||
| 19 | - | limit, ratio, err = BudgetLimit(500_000, 0.5, 0) | |
| 20 | - | if err != nil || limit != 250_000 || ratio != 0.5 { | |
| 21 | - | t.Fatalf("ratio override = %d, %v, %v", limit, ratio, err) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | limit, ratio, err = BudgetLimit(500_000, 0.5, 42_000) | |
| 25 | - | if err != nil || limit != 42_000 || ratio != 0 { | |
| 26 | - | t.Fatalf("token override = %d, %v, %v", limit, ratio, err) | |
| 17 | + | if _, err := ContextBudgetLimit(0); err == nil { | |
| 18 | + | t.Fatal("ContextBudgetLimit(0) succeeded") | |
| 27 | 19 | } | |
| 28 | 20 | } | |
| 29 | 21 | ||
| 30 | - | func TestEffectiveBudgetLimitDerivesLegacyDefault(t *testing.T) { | |
| 31 | - | if got := EffectiveBudgetLimit(0, 500_000); got != 375_000 { | |
| 32 | - | t.Fatalf("legacy budget limit = %d, want 375000", got) | |
| 22 | + | func TestValidateResidentBudgetRequiresModelContext(t *testing.T) { | |
| 23 | + | if err := ValidateResidentBudget(ResidentChildSpec{BudgetLimit: 500_000, BudgetSource: "model_context"}); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 33 | 25 | } | |
| 34 | - | if got := EffectiveBudgetLimit(42_000, 500_000); got != 42_000 { | |
| 35 | - | t.Fatalf("persisted budget limit = %d, want 42000", got) | |
| 26 | + | for _, spec := range []ResidentChildSpec{ | |
| 27 | + | {}, | |
| 28 | + | {BudgetLimit: 500_000}, | |
| 29 | + | {BudgetLimit: 375_000, BudgetSource: "default_ratio"}, | |
| 30 | + | } { | |
| 31 | + | if err := ValidateResidentBudget(spec); err == nil { | |
| 32 | + | t.Fatalf("ValidateResidentBudget(%#v) succeeded", spec) | |
| 33 | + | } | |
| 36 | 34 | } | |
| 37 | 35 | } | |
| 38 | 36 | ||
| @@ -43,34 +41,16 @@ func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) { | |||
| 43 | 41 | } | |
| 44 | 42 | } | |
| 45 | 43 | ||
| 46 | - | func TestBudgetThresholdInstructionsAreProgressiveAndOneShot(t *testing.T) { | |
| 44 | + | func TestRolloutBudgetOnlyExhaustsAtLimit(t *testing.T) { | |
| 47 | 45 | budget := NewRolloutBudget(1_000, provider.Usage{}) | |
| 48 | - | cases := []struct { | |
| 49 | - | used int | |
| 50 | - | state BudgetState | |
| 51 | - | text string | |
| 52 | - | }{ | |
| 53 | - | {500, BudgetNotice, "prioritize concrete evidence"}, | |
| 54 | - | {700, BudgetFocused, "Stop broad exploration"}, | |
| 55 | - | {850, BudgetVerifying, "Do not identify new candidates"}, | |
| 56 | - | {900, BudgetFinalizing, "Finalization is required now"}, | |
| 57 | - | } | |
| 58 | - | for _, tc := range cases { | |
| 59 | - | budget.Observe(provider.Usage{InputTokens: tc.used}) | |
| 60 | - | if got := budget.Snapshot().State; got != tc.state { | |
| 61 | - | t.Fatalf("usage %d state = %q, want %q", tc.used, got, tc.state) | |
| 62 | - | } | |
| 63 | - | instruction, exceeded := budget.TurnContext() | |
| 64 | - | if exceeded || !strings.Contains(instruction, tc.text) { | |
| 65 | - | t.Fatalf("usage %d instruction = %q exceeded=%t", tc.used, instruction, exceeded) | |
| 66 | - | } | |
| 67 | - | if repeated, exceeded := budget.TurnContext(); repeated != "" || exceeded { | |
| 68 | - | t.Fatalf("usage %d repeated instruction = %q exceeded=%t", tc.used, repeated, exceeded) | |
| 46 | + | for _, used := range []int{500, 700, 850, 900, 999} { | |
| 47 | + | budget.Observe(provider.Usage{InputTokens: used}) | |
| 48 | + | if got := budget.Snapshot().State; got != BudgetNormal { | |
| 49 | + | t.Fatalf("usage %d state = %q, want %q", used, got, BudgetNormal) | |
| 69 | 50 | } | |
| 70 | 51 | } | |
| 71 | - | ||
| 72 | 52 | budget.Observe(provider.Usage{InputTokens: 1_000}) | |
| 73 | - | if instruction, exceeded := budget.TurnContext(); instruction != "" || !exceeded { | |
| 74 | - | t.Fatalf("exhausted instruction = %q exceeded=%t", instruction, exceeded) | |
| 53 | + | if got := budget.Snapshot().State; got != BudgetExceeded { | |
| 54 | + | t.Fatalf("exhausted state = %q, want %q", got, BudgetExceeded) | |
| 75 | 55 | } | |
| 76 | 56 | } | |
| @@ -1,38 +1,36 @@ | |||||
| 1 | package subagents | 1 | package subagents | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "strings" | |||
| 5 | "testing" | 4 | "testing" | ||
| 6 | 5 | ||||
| 7 | "github.com/bnema/zut/packages/provider" | 6 | "github.com/bnema/zut/packages/provider" | ||
| 8 | ) | 7 | ) | ||
| 9 | 8 | ||||
| 10 | - | func TestBudgetLimitDefaultsToSeventyFivePercent(t *testing.T) { | 9 | + | func TestContextBudgetLimitUsesFullModelContext(t *testing.T) { |
| 11 | - | limit, ratio, err := BudgetLimit(500_000, 0, 0) | 10 | + | limit, err := ContextBudgetLimit(500_000) |
| 12 | if err != nil { | 11 | if err != nil { | ||
| 13 | t.Fatal(err) | 12 | t.Fatal(err) | ||
| 14 | } | 13 | } | ||
| 15 | - | if limit != 375_000 || ratio != 0.75 { | 14 | + | if limit != 500_000 { |
| 16 | - | t.Fatalf("BudgetLimit = %d, %v; want 375000, 0.75", limit, ratio) | 15 | + | t.Fatalf("ContextBudgetLimit = %d, want 500000", limit) |
| 17 | } | 16 | } | ||
| 18 | - | 17 | + | if _, err := ContextBudgetLimit(0); err == nil { | |
| 19 | - | limit, ratio, err = BudgetLimit(500_000, 0.5, 0) | 18 | + | t.Fatal("ContextBudgetLimit(0) succeeded") |
| 20 | - | if err != nil || limit != 250_000 || ratio != 0.5 { | |||
| 21 | - | t.Fatalf("ratio override = %d, %v, %v", limit, ratio, err) | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | limit, ratio, err = BudgetLimit(500_000, 0.5, 42_000) | |||
| 25 | - | if err != nil || limit != 42_000 || ratio != 0 { | |||
| 26 | - | t.Fatalf("token override = %d, %v, %v", limit, ratio, err) | |||
| 27 | } | 19 | } | ||
| 28 | } | 20 | } | ||
| 29 | 21 | ||||
| 30 | - | func TestEffectiveBudgetLimitDerivesLegacyDefault(t *testing.T) { | 22 | + | func TestValidateResidentBudgetRequiresModelContext(t *testing.T) { |
| 31 | - | if got := EffectiveBudgetLimit(0, 500_000); got != 375_000 { | 23 | + | if err := ValidateResidentBudget(ResidentChildSpec{BudgetLimit: 500_000, BudgetSource: "model_context"}); err != nil { |
| 32 | - | t.Fatalf("legacy budget limit = %d, want 375000", got) | 24 | + | t.Fatal(err) |
| 33 | } | 25 | } | ||
| 34 | - | if got := EffectiveBudgetLimit(42_000, 500_000); got != 42_000 { | 26 | + | for _, spec := range []ResidentChildSpec{ |
| 35 | - | t.Fatalf("persisted budget limit = %d, want 42000", got) | 27 | + | {}, |
| 28 | + | {BudgetLimit: 500_000}, | |||
| 29 | + | {BudgetLimit: 375_000, BudgetSource: "default_ratio"}, | |||
| 30 | + | } { | |||
| 31 | + | if err := ValidateResidentBudget(spec); err == nil { | |||
| 32 | + | t.Fatalf("ValidateResidentBudget(%#v) succeeded", spec) | |||
| 33 | + | } | |||
| 36 | } | 34 | } | ||
| 37 | } | 35 | } | ||
| 38 | 36 | ||||
| @@ -43,34 +41,16 @@ func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) { | |||||
| 43 | } | 41 | } | ||
| 44 | } | 42 | } | ||
| 45 | 43 | ||||
| 46 | - | func TestBudgetThresholdInstructionsAreProgressiveAndOneShot(t *testing.T) { | 44 | + | func TestRolloutBudgetOnlyExhaustsAtLimit(t *testing.T) { |
| 47 | budget := NewRolloutBudget(1_000, provider.Usage{}) | 45 | budget := NewRolloutBudget(1_000, provider.Usage{}) | ||
| 48 | - | cases := []struct { | 46 | + | for _, used := range []int{500, 700, 850, 900, 999} { |
| 49 | - | used int | 47 | + | budget.Observe(provider.Usage{InputTokens: used}) |
| 50 | - | state BudgetState | 48 | + | if got := budget.Snapshot().State; got != BudgetNormal { |
| 51 | - | text string | 49 | + | t.Fatalf("usage %d state = %q, want %q", used, got, BudgetNormal) |
| 52 | - | }{ | |||
| 53 | - | {500, BudgetNotice, "prioritize concrete evidence"}, | |||
| 54 | - | {700, BudgetFocused, "Stop broad exploration"}, | |||
| 55 | - | {850, BudgetVerifying, "Do not identify new candidates"}, | |||
| 56 | - | {900, BudgetFinalizing, "Finalization is required now"}, | |||
| 57 | - | } | |||
| 58 | - | for _, tc := range cases { | |||
| 59 | - | budget.Observe(provider.Usage{InputTokens: tc.used}) | |||
| 60 | - | if got := budget.Snapshot().State; got != tc.state { | |||
| 61 | - | t.Fatalf("usage %d state = %q, want %q", tc.used, got, tc.state) | |||
| 62 | - | } | |||
| 63 | - | instruction, exceeded := budget.TurnContext() | |||
| 64 | - | if exceeded || !strings.Contains(instruction, tc.text) { | |||
| 65 | - | t.Fatalf("usage %d instruction = %q exceeded=%t", tc.used, instruction, exceeded) | |||
| 66 | - | } | |||
| 67 | - | if repeated, exceeded := budget.TurnContext(); repeated != "" || exceeded { | |||
| 68 | - | t.Fatalf("usage %d repeated instruction = %q exceeded=%t", tc.used, repeated, exceeded) | |||
| 69 | } | 50 | } | ||
| 70 | } | 51 | } | ||
| 71 | - | ||||
| 72 | budget.Observe(provider.Usage{InputTokens: 1_000}) | 52 | budget.Observe(provider.Usage{InputTokens: 1_000}) | ||
| 73 | - | if instruction, exceeded := budget.TurnContext(); instruction != "" || !exceeded { | 53 | + | if got := budget.Snapshot().State; got != BudgetExceeded { |
| 74 | - | t.Fatalf("exhausted instruction = %q exceeded=%t", instruction, exceeded) | 54 | + | t.Fatalf("exhausted state = %q, want %q", got, BudgetExceeded) |
| 75 | } | 55 | } | ||
| 76 | } | 56 | } | ||
M packages/agent/subagents/model.go +4 -6
| @@ -47,12 +47,10 @@ const ( | |||
| 47 | 47 | ||
| 48 | 48 | // SubagentPolicy contains the resident manager's limits and capability ceiling. | |
| 49 | 49 | type SubagentPolicy struct { | |
| 50 | - | MaxConcurrent int | |
| 51 | - | QueueTimeout time.Duration | |
| 52 | - | AllowedTools []string | |
| 53 | - | AllowedRoots []string | |
| 54 | - | BudgetRatio float64 | |
| 55 | - | BudgetRatioConfigured bool | |
| 50 | + | MaxConcurrent int | |
| 51 | + | QueueTimeout time.Duration | |
| 52 | + | AllowedTools []string | |
| 53 | + | AllowedRoots []string | |
| 56 | 54 | } | |
| 57 | 55 | ||
| 58 | 56 | func (p SubagentPolicy) allowedTool(name string) bool { | |
| @@ -47,12 +47,10 @@ const ( | |||||
| 47 | 47 | ||||
| 48 | // SubagentPolicy contains the resident manager's limits and capability ceiling. | 48 | // SubagentPolicy contains the resident manager's limits and capability ceiling. | ||
| 49 | type SubagentPolicy struct { | 49 | type SubagentPolicy struct { | ||
| 50 | - | MaxConcurrent int | 50 | + | MaxConcurrent int |
| 51 | - | QueueTimeout time.Duration | 51 | + | QueueTimeout time.Duration |
| 52 | - | AllowedTools []string | 52 | + | AllowedTools []string |
| 53 | - | AllowedRoots []string | 53 | + | AllowedRoots []string |
| 54 | - | BudgetRatio float64 | |||
| 55 | - | BudgetRatioConfigured bool | |||
| 56 | } | 54 | } | ||
| 57 | 55 | ||||
| 58 | func (p SubagentPolicy) allowedTool(name string) bool { | 56 | func (p SubagentPolicy) allowedTool(name string) bool { | ||
M packages/agent/subagents/profiles.go +0 -29
| @@ -10,7 +10,6 @@ package subagents | |||
| 10 | 10 | ||
| 11 | 11 | import ( | |
| 12 | 12 | "fmt" | |
| 13 | - | "math" | |
| 14 | 13 | "os" | |
| 15 | 14 | "path/filepath" | |
| 16 | 15 | "sort" | |
| @@ -36,11 +35,6 @@ type Profile struct { | |||
| 36 | 35 | // Nil means the profile did not specify whether fast mode is enabled. | |
| 37 | 36 | FastMode *bool | |
| 38 | 37 | ||
| 39 | - | // BudgetRatio and BudgetTokens override the host's inherited rollout | |
| 40 | - | // budget. At most one may be declared. | |
| 41 | - | BudgetRatio *float64 | |
| 42 | - | BudgetTokens *int64 | |
| 43 | - | ||
| 44 | 38 | // SystemPromptMode is "append" (the default) or "replace". | |
| 45 | 39 | SystemPromptMode string | |
| 46 | 40 | ||
| @@ -193,12 +187,6 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||
| 193 | 187 | if profile.FastMode != nil { | |
| 194 | 188 | metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode)) | |
| 195 | 189 | } | |
| 196 | - | if profile.BudgetRatio != nil { | |
| 197 | - | metadata = append(metadata, "budgetRatio="+strconv.FormatFloat(*profile.BudgetRatio, 'f', -1, 64)) | |
| 198 | - | } | |
| 199 | - | if profile.BudgetTokens != nil { | |
| 200 | - | metadata = append(metadata, "budgetTokens="+strconv.FormatInt(*profile.BudgetTokens, 10)) | |
| 201 | - | } | |
| 202 | 190 | if len(metadata) > 0 { | |
| 203 | 191 | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | |
| 204 | 192 | } else { | |
| @@ -320,23 +308,6 @@ func load(path, source string) (*Profile, error) { | |||
| 320 | 308 | } | |
| 321 | 309 | profile.FastMode = &value | |
| 322 | 310 | } | |
| 323 | - | if raw, ok := values["budgetratio"]; ok { | |
| 324 | - | value, err := strconv.ParseFloat(strings.TrimSpace(unquote(raw)), 64) | |
| 325 | - | if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value > 1 { | |
| 326 | - | return nil, fmt.Errorf("budgetRatio must be greater than 0 and at most 1") | |
| 327 | - | } | |
| 328 | - | profile.BudgetRatio = &value | |
| 329 | - | } | |
| 330 | - | if raw, ok := values["budgettokens"]; ok { | |
| 331 | - | value, err := strconv.ParseInt(strings.TrimSpace(unquote(raw)), 10, 64) | |
| 332 | - | if err != nil || value <= 0 { | |
| 333 | - | return nil, fmt.Errorf("budgetTokens must be a positive integer") | |
| 334 | - | } | |
| 335 | - | profile.BudgetTokens = &value | |
| 336 | - | } | |
| 337 | - | if profile.BudgetRatio != nil && profile.BudgetTokens != nil { | |
| 338 | - | return nil, fmt.Errorf("budgetRatio and budgetTokens are mutually exclusive") | |
| 339 | - | } | |
| 340 | 311 | return profile, nil | |
| 341 | 312 | } | |
| 342 | 313 | ||
| @@ -10,7 +10,6 @@ package subagents | |||||
| 10 | 10 | ||||
| 11 | import ( | 11 | import ( | ||
| 12 | "fmt" | 12 | "fmt" | ||
| 13 | - | "math" | |||
| 14 | "os" | 13 | "os" | ||
| 15 | "path/filepath" | 14 | "path/filepath" | ||
| 16 | "sort" | 15 | "sort" | ||
| @@ -36,11 +35,6 @@ type Profile struct { | |||||
| 36 | // Nil means the profile did not specify whether fast mode is enabled. | 35 | // Nil means the profile did not specify whether fast mode is enabled. | ||
| 37 | FastMode *bool | 36 | FastMode *bool | ||
| 38 | 37 | ||||
| 39 | - | // BudgetRatio and BudgetTokens override the host's inherited rollout | |||
| 40 | - | // budget. At most one may be declared. | |||
| 41 | - | BudgetRatio *float64 | |||
| 42 | - | BudgetTokens *int64 | |||
| 43 | - | ||||
| 44 | // SystemPromptMode is "append" (the default) or "replace". | 38 | // SystemPromptMode is "append" (the default) or "replace". | ||
| 45 | SystemPromptMode string | 39 | SystemPromptMode string | ||
| 46 | 40 | ||||
| @@ -193,12 +187,6 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 193 | if profile.FastMode != nil { | 187 | if profile.FastMode != nil { | ||
| 194 | metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode)) | 188 | metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode)) | ||
| 195 | } | 189 | } | ||
| 196 | - | if profile.BudgetRatio != nil { | |||
| 197 | - | metadata = append(metadata, "budgetRatio="+strconv.FormatFloat(*profile.BudgetRatio, 'f', -1, 64)) | |||
| 198 | - | } | |||
| 199 | - | if profile.BudgetTokens != nil { | |||
| 200 | - | metadata = append(metadata, "budgetTokens="+strconv.FormatInt(*profile.BudgetTokens, 10)) | |||
| 201 | - | } | |||
| 202 | if len(metadata) > 0 { | 190 | if len(metadata) > 0 { | ||
| 203 | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | 191 | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | ||
| 204 | } else { | 192 | } else { | ||
| @@ -320,23 +308,6 @@ func load(path, source string) (*Profile, error) { | |||||
| 320 | } | 308 | } | ||
| 321 | profile.FastMode = &value | 309 | profile.FastMode = &value | ||
| 322 | } | 310 | } | ||
| 323 | - | if raw, ok := values["budgetratio"]; ok { | |||
| 324 | - | value, err := strconv.ParseFloat(strings.TrimSpace(unquote(raw)), 64) | |||
| 325 | - | if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value > 1 { | |||
| 326 | - | return nil, fmt.Errorf("budgetRatio must be greater than 0 and at most 1") | |||
| 327 | - | } | |||
| 328 | - | profile.BudgetRatio = &value | |||
| 329 | - | } | |||
| 330 | - | if raw, ok := values["budgettokens"]; ok { | |||
| 331 | - | value, err := strconv.ParseInt(strings.TrimSpace(unquote(raw)), 10, 64) | |||
| 332 | - | if err != nil || value <= 0 { | |||
| 333 | - | return nil, fmt.Errorf("budgetTokens must be a positive integer") | |||
| 334 | - | } | |||
| 335 | - | profile.BudgetTokens = &value | |||
| 336 | - | } | |||
| 337 | - | if profile.BudgetRatio != nil && profile.BudgetTokens != nil { | |||
| 338 | - | return nil, fmt.Errorf("budgetRatio and budgetTokens are mutually exclusive") | |||
| 339 | - | } | |||
| 340 | return profile, nil | 311 | return profile, nil | ||
| 341 | } | 312 | } | ||
| 342 | 313 | ||||
M packages/agent/subagents/profiles_test.go +4 -11
| @@ -164,9 +164,6 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||
| 164 | 164 | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | |
| 165 | 165 | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | |
| 166 | 166 | {name: "invalid-fast-mode", field: "fastMode: sometimes"}, | |
| 167 | - | {name: "invalid-budget-ratio", field: "budgetRatio: 1.2"}, | |
| 168 | - | {name: "invalid-budget-tokens", field: "budgetTokens: 0"}, | |
| 169 | - | {name: "conflicting-budget", field: "budgetRatio: 0.5\nbudgetTokens: 1000"}, | |
| 170 | 167 | } | |
| 171 | 168 | for _, tc := range cases { | |
| 172 | 169 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -179,15 +176,11 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||
| 179 | 176 | } | |
| 180 | 177 | } | |
| 181 | 178 | ||
| 182 | - | func TestLoadProfileBudgetOverride(t *testing.T) { | |
| 179 | + | func TestLoadIgnoresRemovedBudgetMetadata(t *testing.T) { | |
| 183 | 180 | path := filepath.Join(t.TempDir(), "profile.md") | |
| 184 | - | writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\n---\nInstructions.\n") | |
| 185 | - | profile, err := load(path, "test") | |
| 186 | - | if err != nil { | |
| 187 | - | t.Fatal(err) | |
| 188 | - | } | |
| 189 | - | if profile.BudgetRatio == nil || *profile.BudgetRatio != 0.6 || profile.BudgetTokens != nil { | |
| 190 | - | t.Fatalf("profile budget = ratio %v tokens %v", profile.BudgetRatio, profile.BudgetTokens) | |
| 181 | + | writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\nbudgetTokens: 20000\n---\nInstructions.\n") | |
| 182 | + | if _, err := load(path, "test"); err != nil { | |
| 183 | + | t.Fatalf("load removed budget metadata: %v", err) | |
| 191 | 184 | } | |
| 192 | 185 | } | |
| 193 | 186 | ||
| @@ -164,9 +164,6 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||||
| 164 | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | 164 | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | ||
| 165 | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | 165 | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | ||
| 166 | {name: "invalid-fast-mode", field: "fastMode: sometimes"}, | 166 | {name: "invalid-fast-mode", field: "fastMode: sometimes"}, | ||
| 167 | - | {name: "invalid-budget-ratio", field: "budgetRatio: 1.2"}, | |||
| 168 | - | {name: "invalid-budget-tokens", field: "budgetTokens: 0"}, | |||
| 169 | - | {name: "conflicting-budget", field: "budgetRatio: 0.5\nbudgetTokens: 1000"}, | |||
| 170 | } | 167 | } | ||
| 171 | for _, tc := range cases { | 168 | for _, tc := range cases { | ||
| 172 | t.Run(tc.name, func(t *testing.T) { | 169 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -179,15 +176,11 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||||
| 179 | } | 176 | } | ||
| 180 | } | 177 | } | ||
| 181 | 178 | ||||
| 182 | - | func TestLoadProfileBudgetOverride(t *testing.T) { | 179 | + | func TestLoadIgnoresRemovedBudgetMetadata(t *testing.T) { |
| 183 | path := filepath.Join(t.TempDir(), "profile.md") | 180 | path := filepath.Join(t.TempDir(), "profile.md") | ||
| 184 | - | writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\n---\nInstructions.\n") | 181 | + | writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\nbudgetTokens: 20000\n---\nInstructions.\n") |
| 185 | - | profile, err := load(path, "test") | 182 | + | if _, err := load(path, "test"); err != nil { |
| 186 | - | if err != nil { | 183 | + | t.Fatalf("load removed budget metadata: %v", err) |
| 187 | - | t.Fatal(err) | |||
| 188 | - | } | |||
| 189 | - | if profile.BudgetRatio == nil || *profile.BudgetRatio != 0.6 || profile.BudgetTokens != nil { | |||
| 190 | - | t.Fatalf("profile budget = ratio %v tokens %v", profile.BudgetRatio, profile.BudgetTokens) | |||
| 191 | } | 184 | } | ||
| 192 | } | 185 | } | ||
| 193 | 186 | ||||
M packages/agent/subagents/resident_journal.go +17 -4
| @@ -18,6 +18,8 @@ import ( | |||
| 18 | 18 | "github.com/bnema/zut/packages/provider" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | + | var ErrIncompatibleResidentBudget = errors.New("resident journal: incompatible rollout budget; start a new subagent") | |
| 22 | + | ||
| 21 | 23 | const ( | |
| 22 | 24 | residentMetadataName = "metadata.json" | |
| 23 | 25 | residentTranscriptName = "transcript.jsonl" | |
| @@ -34,7 +36,7 @@ const ( | |||
| 34 | 36 | residentRecordToolCall = "tool.call" | |
| 35 | 37 | residentRecordToolResult = "tool.result" | |
| 36 | 38 | residentRecordUsage = "usage" | |
| 37 | - | residentJournalVersion = 1 | |
| 39 | + | residentJournalVersion = 2 | |
| 38 | 40 | residentInterruptedText = "tool interrupted by resident host restart" | |
| 39 | 41 | residentMaxRecordBytes = 2 << 20 | |
| 40 | 42 | residentResultSummaryBytes = 256 << 10 | |
| @@ -384,6 +386,14 @@ func (j *ResidentJournal) Dir() string { | |||
| 384 | 386 | return j.dir | |
| 385 | 387 | } | |
| 386 | 388 | ||
| 389 | + | // ValidateResidentBudget ensures a new child has a model-context budget. | |
| 390 | + | func ValidateResidentBudget(spec ResidentChildSpec) error { | |
| 391 | + | if spec.BudgetLimit <= 0 || spec.BudgetSource != "model_context" { | |
| 392 | + | return ErrIncompatibleResidentBudget | |
| 393 | + | } | |
| 394 | + | return nil | |
| 395 | + | } | |
| 396 | + | ||
| 387 | 397 | // Accept establishes the authoritative spawn commit point. The transcript is | |
| 388 | 398 | // synced before metadata is projected so a crash cannot fabricate acceptance. | |
| 389 | 399 | func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error { | |
| @@ -730,15 +740,18 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 730 | 740 | if err != nil { | |
| 731 | 741 | return ResidentMetadata{}, err | |
| 732 | 742 | } | |
| 743 | + | if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil { | |
| 744 | + | return ResidentMetadata{}, errors.New("resident journal: missing accepted child record") | |
| 745 | + | } | |
| 746 | + | if records[0].Version != residentJournalVersion { | |
| 747 | + | return ResidentMetadata{}, ErrIncompatibleResidentBudget | |
| 748 | + | } | |
| 733 | 749 | if repaired, ok := repairLegacyFalseRecovery(records); ok { | |
| 734 | 750 | if err := journal.rewriteTranscript(repaired); err != nil { | |
| 735 | 751 | return ResidentMetadata{}, err | |
| 736 | 752 | } | |
| 737 | 753 | records = repaired | |
| 738 | 754 | } | |
| 739 | - | if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil { | |
| 740 | - | return ResidentMetadata{}, errors.New("resident journal: missing accepted child record") | |
| 741 | - | } | |
| 742 | 755 | spec := *records[0].Spec | |
| 743 | 756 | if spec.ID == "" || spec.SessionID == "" { | |
| 744 | 757 | return ResidentMetadata{}, errors.New("resident journal: invalid accepted child record") | |
| @@ -18,6 +18,8 @@ import ( | |||||
| 18 | "github.com/bnema/zut/packages/provider" | 18 | "github.com/bnema/zut/packages/provider" | ||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | + | var ErrIncompatibleResidentBudget = errors.New("resident journal: incompatible rollout budget; start a new subagent") | |||
| 22 | + | ||||
| 21 | const ( | 23 | const ( | ||
| 22 | residentMetadataName = "metadata.json" | 24 | residentMetadataName = "metadata.json" | ||
| 23 | residentTranscriptName = "transcript.jsonl" | 25 | residentTranscriptName = "transcript.jsonl" | ||
| @@ -34,7 +36,7 @@ const ( | |||||
| 34 | residentRecordToolCall = "tool.call" | 36 | residentRecordToolCall = "tool.call" | ||
| 35 | residentRecordToolResult = "tool.result" | 37 | residentRecordToolResult = "tool.result" | ||
| 36 | residentRecordUsage = "usage" | 38 | residentRecordUsage = "usage" | ||
| 37 | - | residentJournalVersion = 1 | 39 | + | residentJournalVersion = 2 |
| 38 | residentInterruptedText = "tool interrupted by resident host restart" | 40 | residentInterruptedText = "tool interrupted by resident host restart" | ||
| 39 | residentMaxRecordBytes = 2 << 20 | 41 | residentMaxRecordBytes = 2 << 20 | ||
| 40 | residentResultSummaryBytes = 256 << 10 | 42 | residentResultSummaryBytes = 256 << 10 | ||
| @@ -384,6 +386,14 @@ func (j *ResidentJournal) Dir() string { | |||||
| 384 | return j.dir | 386 | return j.dir | ||
| 385 | } | 387 | } | ||
| 386 | 388 | ||||
| 389 | + | // ValidateResidentBudget ensures a new child has a model-context budget. | |||
| 390 | + | func ValidateResidentBudget(spec ResidentChildSpec) error { | |||
| 391 | + | if spec.BudgetLimit <= 0 || spec.BudgetSource != "model_context" { | |||
| 392 | + | return ErrIncompatibleResidentBudget | |||
| 393 | + | } | |||
| 394 | + | return nil | |||
| 395 | + | } | |||
| 396 | + | ||||
| 387 | // Accept establishes the authoritative spawn commit point. The transcript is | 397 | // Accept establishes the authoritative spawn commit point. The transcript is | ||
| 388 | // synced before metadata is projected so a crash cannot fabricate acceptance. | 398 | // synced before metadata is projected so a crash cannot fabricate acceptance. | ||
| 389 | func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error { | 399 | func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error { | ||
| @@ -730,15 +740,18 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 730 | if err != nil { | 740 | if err != nil { | ||
| 731 | return ResidentMetadata{}, err | 741 | return ResidentMetadata{}, err | ||
| 732 | } | 742 | } | ||
| 743 | + | if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil { | |||
| 744 | + | return ResidentMetadata{}, errors.New("resident journal: missing accepted child record") | |||
| 745 | + | } | |||
| 746 | + | if records[0].Version != residentJournalVersion { | |||
| 747 | + | return ResidentMetadata{}, ErrIncompatibleResidentBudget | |||
| 748 | + | } | |||
| 733 | if repaired, ok := repairLegacyFalseRecovery(records); ok { | 749 | if repaired, ok := repairLegacyFalseRecovery(records); ok { | ||
| 734 | if err := journal.rewriteTranscript(repaired); err != nil { | 750 | if err := journal.rewriteTranscript(repaired); err != nil { | ||
| 735 | return ResidentMetadata{}, err | 751 | return ResidentMetadata{}, err | ||
| 736 | } | 752 | } | ||
| 737 | records = repaired | 753 | records = repaired | ||
| 738 | } | 754 | } | ||
| 739 | - | if len(records) == 0 || records[0].Type != residentRecordAccepted || records[0].Spec == nil { | |||
| 740 | - | return ResidentMetadata{}, errors.New("resident journal: missing accepted child record") | |||
| 741 | - | } | |||
| 742 | spec := *records[0].Spec | 755 | spec := *records[0].Spec | ||
| 743 | if spec.ID == "" || spec.SessionID == "" { | 756 | if spec.ID == "" || spec.SessionID == "" { | ||
| 744 | return ResidentMetadata{}, errors.New("resident journal: invalid accepted child record") | 757 | return ResidentMetadata{}, errors.New("resident journal: invalid accepted child record") | ||
M packages/agent/subagents/resident_manager.go +4 -8
| @@ -230,7 +230,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||
| 230 | 230 | return ResidentSnapshot{} | |
| 231 | 231 | } | |
| 232 | 232 | live := child.Live() | |
| 233 | - | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, live.ContextMax) | |
| 233 | + | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource) | |
| 234 | 234 | return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile, | |
| 235 | 235 | Provider: child.spec.Provider, Model: child.spec.Model, | |
| 236 | 236 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | |
| @@ -240,12 +240,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||
| 240 | 240 | Budget: budget, BudgetSource: budgetSource} | |
| 241 | 241 | } | |
| 242 | 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 | |
| 243 | + | func residentBudgetSnapshot(usage provider.Usage, limit int64, source string) (BudgetSnapshot, string) { | |
| 244 | + | return BudgetSnapshotFor(usage, limit), source | |
| 249 | 245 | } | |
| 250 | 246 | ||
| 251 | 247 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | |
| @@ -880,7 +876,7 @@ func (m *ResidentManager) Reconcile() []error { | |||
| 880 | 876 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | |
| 881 | 877 | continue | |
| 882 | 878 | } | |
| 883 | - | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.ContextMax) | |
| 879 | + | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource) | |
| 884 | 880 | m.mu.Lock() | |
| 885 | 881 | if _, live := m.children[childID]; !live { | |
| 886 | 882 | m.recovered[childID] = ResidentSnapshot{ | |
| @@ -230,7 +230,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||||
| 230 | return ResidentSnapshot{} | 230 | return ResidentSnapshot{} | ||
| 231 | } | 231 | } | ||
| 232 | live := child.Live() | 232 | live := child.Live() | ||
| 233 | - | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, live.ContextMax) | 233 | + | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource) |
| 234 | return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile, | 234 | return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile, | ||
| 235 | Provider: child.spec.Provider, Model: child.spec.Model, | 235 | Provider: child.spec.Provider, Model: child.spec.Model, | ||
| 236 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | 236 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | ||
| @@ -240,12 +240,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||||
| 240 | Budget: budget, BudgetSource: budgetSource} | 240 | Budget: budget, BudgetSource: budgetSource} | ||
| 241 | } | 241 | } | ||
| 242 | 242 | ||||
| 243 | - | func residentBudgetSnapshot(usage provider.Usage, limit int64, source string, contextWindow int) (BudgetSnapshot, string) { | 243 | + | func residentBudgetSnapshot(usage provider.Usage, limit int64, source string) (BudgetSnapshot, string) { |
| 244 | - | effective := EffectiveBudgetLimit(limit, contextWindow) | 244 | + | return BudgetSnapshotFor(usage, limit), source |
| 245 | - | if effective > 0 && source == "" { | |||
| 246 | - | source = "default_ratio" | |||
| 247 | - | } | |||
| 248 | - | return BudgetSnapshotFor(usage, effective), source | |||
| 249 | } | 245 | } | ||
| 250 | 246 | ||||
| 251 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | 247 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | ||
| @@ -880,7 +876,7 @@ func (m *ResidentManager) Reconcile() []error { | |||||
| 880 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | 876 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | ||
| 881 | continue | 877 | continue | ||
| 882 | } | 878 | } | ||
| 883 | - | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.ContextMax) | 879 | + | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource) |
| 884 | m.mu.Lock() | 880 | m.mu.Lock() | ||
| 885 | if _, live := m.children[childID]; !live { | 881 | if _, live := m.children[childID]; !live { | ||
| 886 | m.recovered[childID] = ResidentSnapshot{ | 882 | m.recovered[childID] = ResidentSnapshot{ | ||
M packages/agent/subagents/resident_manager_test.go +43 -0
| @@ -584,6 +584,49 @@ func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing | |||
| 584 | 584 | } | |
| 585 | 585 | } | |
| 586 | 586 | ||
| 587 | + | func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) { | |
| 588 | + | root := t.TempDir() | |
| 589 | + | journal, err := OpenResidentJournal(root, "legacy-budget") | |
| 590 | + | if err != nil { | |
| 591 | + | t.Fatal(err) | |
| 592 | + | } | |
| 593 | + | spec := ResidentChildSpec{ID: "legacy-budget", SessionID: "child-session", Provider: "openai", Model: "gpt-5"} | |
| 594 | + | if err := journal.Accept(spec, "review"); err != nil { | |
| 595 | + | t.Fatal(err) | |
| 596 | + | } | |
| 597 | + | if err := journal.Close(); err != nil { | |
| 598 | + | t.Fatal(err) | |
| 599 | + | } | |
| 600 | + | transcript := filepath.Join(root, spec.ID, residentTranscriptName) | |
| 601 | + | data, err := os.ReadFile(transcript) | |
| 602 | + | if err != nil { | |
| 603 | + | t.Fatal(err) | |
| 604 | + | } | |
| 605 | + | legacy := strings.Replace(string(data), `"version":2`, `"version":1`, 1) | |
| 606 | + | if legacy == string(data) { | |
| 607 | + | t.Fatal("accepted record has no journal version") | |
| 608 | + | } | |
| 609 | + | if err := os.WriteFile(transcript, []byte(legacy), 0o600); err != nil { | |
| 610 | + | t.Fatal(err) | |
| 611 | + | } | |
| 612 | + | manager := NewResidentManager(root, nil) | |
| 613 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 614 | + | errs := manager.Reconcile() | |
| 615 | + | if len(errs) != 1 || !errors.Is(errs[0], ErrIncompatibleResidentBudget) { | |
| 616 | + | t.Fatalf("Reconcile errors = %v", errs) | |
| 617 | + | } | |
| 618 | + | if snapshots := manager.Snapshot(); len(snapshots) != 0 { | |
| 619 | + | t.Fatalf("snapshots = %#v", snapshots) | |
| 620 | + | } | |
| 621 | + | unchanged, err := os.ReadFile(transcript) | |
| 622 | + | if err != nil { | |
| 623 | + | t.Fatal(err) | |
| 624 | + | } | |
| 625 | + | if string(unchanged) != legacy { | |
| 626 | + | t.Fatal("Reconcile modified incompatible journal") | |
| 627 | + | } | |
| 628 | + | } | |
| 629 | + | ||
| 587 | 630 | func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) { | |
| 588 | 631 | root := t.TempDir() | |
| 589 | 632 | journal, err := OpenResidentJournal(root, "recovered") | |
| @@ -584,6 +584,49 @@ func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing | |||||
| 584 | } | 584 | } | ||
| 585 | } | 585 | } | ||
| 586 | 586 | ||||
| 587 | + | func TestResidentManagerReconcileRejectsIncompatibleBudgetJournal(t *testing.T) { | |||
| 588 | + | root := t.TempDir() | |||
| 589 | + | journal, err := OpenResidentJournal(root, "legacy-budget") | |||
| 590 | + | if err != nil { | |||
| 591 | + | t.Fatal(err) | |||
| 592 | + | } | |||
| 593 | + | spec := ResidentChildSpec{ID: "legacy-budget", SessionID: "child-session", Provider: "openai", Model: "gpt-5"} | |||
| 594 | + | if err := journal.Accept(spec, "review"); err != nil { | |||
| 595 | + | t.Fatal(err) | |||
| 596 | + | } | |||
| 597 | + | if err := journal.Close(); err != nil { | |||
| 598 | + | t.Fatal(err) | |||
| 599 | + | } | |||
| 600 | + | transcript := filepath.Join(root, spec.ID, residentTranscriptName) | |||
| 601 | + | data, err := os.ReadFile(transcript) | |||
| 602 | + | if err != nil { | |||
| 603 | + | t.Fatal(err) | |||
| 604 | + | } | |||
| 605 | + | legacy := strings.Replace(string(data), `"version":2`, `"version":1`, 1) | |||
| 606 | + | if legacy == string(data) { | |||
| 607 | + | t.Fatal("accepted record has no journal version") | |||
| 608 | + | } | |||
| 609 | + | if err := os.WriteFile(transcript, []byte(legacy), 0o600); err != nil { | |||
| 610 | + | t.Fatal(err) | |||
| 611 | + | } | |||
| 612 | + | manager := NewResidentManager(root, nil) | |||
| 613 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 614 | + | errs := manager.Reconcile() | |||
| 615 | + | if len(errs) != 1 || !errors.Is(errs[0], ErrIncompatibleResidentBudget) { | |||
| 616 | + | t.Fatalf("Reconcile errors = %v", errs) | |||
| 617 | + | } | |||
| 618 | + | if snapshots := manager.Snapshot(); len(snapshots) != 0 { | |||
| 619 | + | t.Fatalf("snapshots = %#v", snapshots) | |||
| 620 | + | } | |||
| 621 | + | unchanged, err := os.ReadFile(transcript) | |||
| 622 | + | if err != nil { | |||
| 623 | + | t.Fatal(err) | |||
| 624 | + | } | |||
| 625 | + | if string(unchanged) != legacy { | |||
| 626 | + | t.Fatal("Reconcile modified incompatible journal") | |||
| 627 | + | } | |||
| 628 | + | } | |||
| 629 | + | ||||
| 587 | func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) { | 630 | func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutReplay(t *testing.T) { | ||
| 588 | root := t.TempDir() | 631 | root := t.TempDir() | ||
| 589 | journal, err := OpenResidentJournal(root, "recovered") | 632 | journal, err := OpenResidentJournal(root, "recovered") | ||
M packages/agent/tools/resident_tools_test.go +7 -9
| @@ -187,9 +187,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |||
| 187 | 187 | `{"task":"x","wait":0}`, | |
| 188 | 188 | `{"task":"x","wait":301}`, | |
| 189 | 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 | + | `{"task":"x","budget_ratio":0.5}`, | |
| 191 | + | `{"task":"x","budget_tokens":1000}`, | |
| 193 | 192 | } { | |
| 194 | 193 | result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil) | |
| 195 | 194 | if err != nil && !strings.Contains(err.Error(), "invalid args") { | |
| @@ -210,7 +209,6 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | |||
| 210 | 209 | }) | |
| 211 | 210 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 212 | 211 | profileFast := false | |
| 213 | - | profileBudget := 0.6 | |
| 214 | 212 | var got ResidentSpawnRequest | |
| 215 | 213 | spawn := &SubagentSpawnTool{ | |
| 216 | 214 | ResidentManager: manager, | |
| @@ -219,18 +217,18 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | |||
| 219 | 217 | DefaultProvider: func() string { return "host-provider" }, | |
| 220 | 218 | DefaultReasoning: func() string { return "medium" }, | |
| 221 | 219 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 222 | - | return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast, BudgetRatio: &profileBudget}, nil | |
| 220 | + | return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil | |
| 223 | 221 | }, | |
| 224 | 222 | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | |
| 225 | 223 | got = request | |
| 226 | 224 | return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil | |
| 227 | 225 | }, | |
| 228 | 226 | } | |
| 229 | - | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true,"budget_tokens":42000}`), nil) | |
| 227 | + | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil) | |
| 230 | 228 | if err != nil || result.IsError { | |
| 231 | 229 | t.Fatalf("Execute = (%#v, %v)", result, err) | |
| 232 | 230 | } | |
| 233 | - | if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode || got.BudgetTokens == nil || *got.BudgetTokens != 42_000 { | |
| 231 | + | if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode { | |
| 234 | 232 | t.Fatalf("factory request = %#v", got) | |
| 235 | 233 | } | |
| 236 | 234 | } | |
| @@ -264,9 +262,9 @@ func toolResultText(t *testing.T, result core.ToolResult) string { | |||
| 264 | 262 | ||
| 265 | 263 | func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) { | |
| 266 | 264 | entry := publicResidentStatus(subagents.ResidentSnapshot{ | |
| 267 | - | ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetFocused}, BudgetSource: "default_ratio", | |
| 265 | + | ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetNormal}, BudgetSource: "model_context", | |
| 268 | 266 | }) | |
| 269 | - | if entry.Budget == nil || entry.Budget.State != subagents.BudgetFocused || entry.BudgetSource != "default_ratio" { | |
| 267 | + | if entry.Budget == nil || entry.Budget.State != subagents.BudgetNormal || entry.BudgetSource != "model_context" { | |
| 270 | 268 | t.Fatalf("status budget = %#v", entry) | |
| 271 | 269 | } | |
| 272 | 270 | } | |
| @@ -187,9 +187,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |||||
| 187 | `{"task":"x","wait":0}`, | 187 | `{"task":"x","wait":0}`, | ||
| 188 | `{"task":"x","wait":301}`, | 188 | `{"task":"x","wait":301}`, | ||
| 189 | `{"task":"x","isolation":"outside"}`, | 189 | `{"task":"x","isolation":"outside"}`, | ||
| 190 | - | `{"task":"x","budget_ratio":0}`, | 190 | + | `{"task":"x","budget_ratio":0.5}`, |
| 191 | - | `{"task":"x","budget_tokens":-1}`, | 191 | + | `{"task":"x","budget_tokens":1000}`, |
| 192 | - | `{"task":"x","budget_ratio":0.5,"budget_tokens":1000}`, | |||
| 193 | } { | 192 | } { | ||
| 194 | result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil) | 193 | result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil) | ||
| 195 | if err != nil && !strings.Contains(err.Error(), "invalid args") { | 194 | if err != nil && !strings.Contains(err.Error(), "invalid args") { | ||
| @@ -210,7 +209,6 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | |||||
| 210 | }) | 209 | }) | ||
| 211 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 210 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 212 | profileFast := false | 211 | profileFast := false | ||
| 213 | - | profileBudget := 0.6 | |||
| 214 | var got ResidentSpawnRequest | 212 | var got ResidentSpawnRequest | ||
| 215 | spawn := &SubagentSpawnTool{ | 213 | spawn := &SubagentSpawnTool{ | ||
| 216 | ResidentManager: manager, | 214 | ResidentManager: manager, | ||
| @@ -219,18 +217,18 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | |||||
| 219 | DefaultProvider: func() string { return "host-provider" }, | 217 | DefaultProvider: func() string { return "host-provider" }, | ||
| 220 | DefaultReasoning: func() string { return "medium" }, | 218 | DefaultReasoning: func() string { return "medium" }, | ||
| 221 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | 219 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | ||
| 222 | - | return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast, BudgetRatio: &profileBudget}, nil | 220 | + | return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil |
| 223 | }, | 221 | }, | ||
| 224 | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | 222 | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | ||
| 225 | got = request | 223 | got = request | ||
| 226 | return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil | 224 | return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil | ||
| 227 | }, | 225 | }, | ||
| 228 | } | 226 | } | ||
| 229 | - | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true,"budget_tokens":42000}`), nil) | 227 | + | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil) |
| 230 | if err != nil || result.IsError { | 228 | if err != nil || result.IsError { | ||
| 231 | t.Fatalf("Execute = (%#v, %v)", result, err) | 229 | t.Fatalf("Execute = (%#v, %v)", result, err) | ||
| 232 | } | 230 | } | ||
| 233 | - | if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode || got.BudgetTokens == nil || *got.BudgetTokens != 42_000 { | 231 | + | if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode { |
| 234 | t.Fatalf("factory request = %#v", got) | 232 | t.Fatalf("factory request = %#v", got) | ||
| 235 | } | 233 | } | ||
| 236 | } | 234 | } | ||
| @@ -264,9 +262,9 @@ func toolResultText(t *testing.T, result core.ToolResult) string { | |||||
| 264 | 262 | ||||
| 265 | func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) { | 263 | func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) { | ||
| 266 | entry := publicResidentStatus(subagents.ResidentSnapshot{ | 264 | entry := publicResidentStatus(subagents.ResidentSnapshot{ | ||
| 267 | - | ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetFocused}, BudgetSource: "default_ratio", | 265 | + | ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetNormal}, BudgetSource: "model_context", |
| 268 | }) | 266 | }) | ||
| 269 | - | if entry.Budget == nil || entry.Budget.State != subagents.BudgetFocused || entry.BudgetSource != "default_ratio" { | 267 | + | if entry.Budget == nil || entry.Budget.State != subagents.BudgetNormal || entry.BudgetSource != "model_context" { |
| 270 | t.Fatalf("status budget = %#v", entry) | 268 | t.Fatalf("status budget = %#v", entry) | ||
| 271 | } | 269 | } | ||
| 272 | } | 270 | } | ||
M packages/agent/tools/subagent_spawn.go +10 -34
| @@ -65,22 +65,18 @@ type ResidentSpawnRequest struct { | |||
| 65 | 65 | FastMode *bool | |
| 66 | 66 | Required bool | |
| 67 | 67 | WorkspaceMode subagents.WorkspaceMode | |
| 68 | - | BudgetRatio *float64 | |
| 69 | - | BudgetTokens *int64 | |
| 70 | 68 | } | |
| 71 | 69 | ||
| 72 | 70 | type subagentSpawnArgs struct { | |
| 73 | - | Task string `json:"task"` | |
| 74 | - | Agent string `json:"agent,omitempty"` | |
| 75 | - | Model string `json:"model,omitempty"` | |
| 76 | - | Provider string `json:"provider,omitempty"` | |
| 77 | - | Reasoning string `json:"reasoning,omitempty"` | |
| 78 | - | FastMode *bool `json:"fast_mode,omitempty"` | |
| 79 | - | Required bool `json:"required,omitempty"` | |
| 80 | - | Wait *int `json:"wait,omitempty"` | |
| 81 | - | Isolation string `json:"isolation,omitempty"` | |
| 82 | - | BudgetRatio *float64 `json:"budget_ratio,omitempty"` | |
| 83 | - | BudgetTokens *int64 `json:"budget_tokens,omitempty"` | |
| 71 | + | Task string `json:"task"` | |
| 72 | + | Agent string `json:"agent,omitempty"` | |
| 73 | + | Model string `json:"model,omitempty"` | |
| 74 | + | Provider string `json:"provider,omitempty"` | |
| 75 | + | Reasoning string `json:"reasoning,omitempty"` | |
| 76 | + | FastMode *bool `json:"fast_mode,omitempty"` | |
| 77 | + | Required bool `json:"required,omitempty"` | |
| 78 | + | Wait *int `json:"wait,omitempty"` | |
| 79 | + | Isolation string `json:"isolation,omitempty"` | |
| 84 | 80 | } | |
| 85 | 81 | ||
| 86 | 82 | const subagentSpawnSchemaTemplate = `{ | |
| @@ -125,17 +121,6 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 125 | 121 | "type": "string", | |
| 126 | 122 | "enum": ["shared", "worktree"], | |
| 127 | 123 | "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it." | |
| 128 | - | }, | |
| 129 | - | "budget_ratio": { | |
| 130 | - | "type": "number", | |
| 131 | - | "exclusiveMinimum": 0, | |
| 132 | - | "maximum": 1, | |
| 133 | - | "description": "Optional cumulative rollout budget as a fraction of the parent model context window. Overrides the selected profile and host default. Mutually exclusive with budget_tokens." | |
| 134 | - | }, | |
| 135 | - | "budget_tokens": { | |
| 136 | - | "type": "integer", | |
| 137 | - | "minimum": 1, | |
| 138 | - | "description": "Optional absolute cumulative weighted-token budget. Overrides the selected profile and host default. Mutually exclusive with budget_ratio." | |
| 139 | 124 | } | |
| 140 | 125 | }, | |
| 141 | 126 | "required": ["task"] | |
| @@ -168,15 +153,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 168 | 153 | if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) { | |
| 169 | 154 | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | |
| 170 | 155 | } | |
| 171 | - | if a.BudgetRatio != nil && (*a.BudgetRatio <= 0 || *a.BudgetRatio > 1) { | |
| 172 | - | return protocolToolError(prefix + ": budget_ratio must be greater than 0 and at most 1") | |
| 173 | - | } | |
| 174 | - | if a.BudgetTokens != nil && *a.BudgetTokens <= 0 { | |
| 175 | - | return protocolToolError(prefix + ": budget_tokens must be positive") | |
| 176 | - | } | |
| 177 | - | if a.BudgetRatio != nil && a.BudgetTokens != nil { | |
| 178 | - | return protocolToolError(prefix + ": budget_ratio and budget_tokens are mutually exclusive") | |
| 179 | - | } | |
| 180 | 156 | ||
| 181 | 157 | workspaceMode := subagents.WorkspaceShared | |
| 182 | 158 | if value := strings.TrimSpace(a.Isolation); value != "" { | |
| @@ -251,7 +227,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 251 | 227 | spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{ | |
| 252 | 228 | Task: task, Profile: profile, Model: model, Provider: providerID, | |
| 253 | 229 | Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required, | |
| 254 | - | WorkspaceMode: workspaceMode, BudgetRatio: a.BudgetRatio, BudgetTokens: a.BudgetTokens, | |
| 230 | + | WorkspaceMode: workspaceMode, | |
| 255 | 231 | }) | |
| 256 | 232 | if err != nil { | |
| 257 | 233 | return protocolToolError(prefix + ": " + err.Error()) | |
| @@ -65,22 +65,18 @@ type ResidentSpawnRequest struct { | |||||
| 65 | FastMode *bool | 65 | FastMode *bool | ||
| 66 | Required bool | 66 | Required bool | ||
| 67 | WorkspaceMode subagents.WorkspaceMode | 67 | WorkspaceMode subagents.WorkspaceMode | ||
| 68 | - | BudgetRatio *float64 | |||
| 69 | - | BudgetTokens *int64 | |||
| 70 | } | 68 | } | ||
| 71 | 69 | ||||
| 72 | type subagentSpawnArgs struct { | 70 | type subagentSpawnArgs struct { | ||
| 73 | - | Task string `json:"task"` | 71 | + | Task string `json:"task"` |
| 74 | - | Agent string `json:"agent,omitempty"` | 72 | + | Agent string `json:"agent,omitempty"` |
| 75 | - | Model string `json:"model,omitempty"` | 73 | + | Model string `json:"model,omitempty"` |
| 76 | - | Provider string `json:"provider,omitempty"` | 74 | + | Provider string `json:"provider,omitempty"` |
| 77 | - | Reasoning string `json:"reasoning,omitempty"` | 75 | + | Reasoning string `json:"reasoning,omitempty"` |
| 78 | - | FastMode *bool `json:"fast_mode,omitempty"` | 76 | + | FastMode *bool `json:"fast_mode,omitempty"` |
| 79 | - | Required bool `json:"required,omitempty"` | 77 | + | Required bool `json:"required,omitempty"` |
| 80 | - | Wait *int `json:"wait,omitempty"` | 78 | + | Wait *int `json:"wait,omitempty"` |
| 81 | - | Isolation string `json:"isolation,omitempty"` | 79 | + | Isolation string `json:"isolation,omitempty"` |
| 82 | - | BudgetRatio *float64 `json:"budget_ratio,omitempty"` | |||
| 83 | - | BudgetTokens *int64 `json:"budget_tokens,omitempty"` | |||
| 84 | } | 80 | } | ||
| 85 | 81 | ||||
| 86 | const subagentSpawnSchemaTemplate = `{ | 82 | const subagentSpawnSchemaTemplate = `{ | ||
| @@ -125,17 +121,6 @@ const subagentSpawnSchemaTemplate = `{ | |||||
| 125 | "type": "string", | 121 | "type": "string", | ||
| 126 | "enum": ["shared", "worktree"], | 122 | "enum": ["shared", "worktree"], | ||
| 127 | "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it." | 123 | "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it." | ||
| 128 | - | }, | |||
| 129 | - | "budget_ratio": { | |||
| 130 | - | "type": "number", | |||
| 131 | - | "exclusiveMinimum": 0, | |||
| 132 | - | "maximum": 1, | |||
| 133 | - | "description": "Optional cumulative rollout budget as a fraction of the parent model context window. Overrides the selected profile and host default. Mutually exclusive with budget_tokens." | |||
| 134 | - | }, | |||
| 135 | - | "budget_tokens": { | |||
| 136 | - | "type": "integer", | |||
| 137 | - | "minimum": 1, | |||
| 138 | - | "description": "Optional absolute cumulative weighted-token budget. Overrides the selected profile and host default. Mutually exclusive with budget_ratio." | |||
| 139 | } | 124 | } | ||
| 140 | }, | 125 | }, | ||
| 141 | "required": ["task"] | 126 | "required": ["task"] | ||
| @@ -168,15 +153,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 168 | if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) { | 153 | if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) { | ||
| 169 | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | 154 | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | ||
| 170 | } | 155 | } | ||
| 171 | - | if a.BudgetRatio != nil && (*a.BudgetRatio <= 0 || *a.BudgetRatio > 1) { | |||
| 172 | - | return protocolToolError(prefix + ": budget_ratio must be greater than 0 and at most 1") | |||
| 173 | - | } | |||
| 174 | - | if a.BudgetTokens != nil && *a.BudgetTokens <= 0 { | |||
| 175 | - | return protocolToolError(prefix + ": budget_tokens must be positive") | |||
| 176 | - | } | |||
| 177 | - | if a.BudgetRatio != nil && a.BudgetTokens != nil { | |||
| 178 | - | return protocolToolError(prefix + ": budget_ratio and budget_tokens are mutually exclusive") | |||
| 179 | - | } | |||
| 180 | 156 | ||||
| 181 | workspaceMode := subagents.WorkspaceShared | 157 | workspaceMode := subagents.WorkspaceShared | ||
| 182 | if value := strings.TrimSpace(a.Isolation); value != "" { | 158 | if value := strings.TrimSpace(a.Isolation); value != "" { | ||
| @@ -251,7 +227,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 251 | spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{ | 227 | spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{ | ||
| 252 | Task: task, Profile: profile, Model: model, Provider: providerID, | 228 | Task: task, Profile: profile, Model: model, Provider: providerID, | ||
| 253 | Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required, | 229 | Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required, | ||
| 254 | - | WorkspaceMode: workspaceMode, BudgetRatio: a.BudgetRatio, BudgetTokens: a.BudgetTokens, | 230 | + | WorkspaceMode: workspaceMode, |
| 255 | }) | 231 | }) | ||
| 256 | if err != nil { | 232 | if err != nil { | ||
| 257 | return protocolToolError(prefix + ": " + err.Error()) | 233 | return protocolToolError(prefix + ": " + err.Error()) | ||