feat(subagents): enforce inherited rollout budgets (#162)
* feat(subagents): enforce inherited rollout budgets * fix(tui): avoid thousand-k token labels
- commit
- 560e11bd2b71e628a959b57800a0cfbdb60afe3f
- author
- bnema · · 2026-08-30 07:56 UTC
- committer
- GitHub · · 2026-08-30 07:56 UTC
- parent
- 7e66a81
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
23 files · +960 -58
M README.md +2 -0
| @@ -424,6 +424,8 @@ Resident subagents run alongside the main session as independent in-process agen | |||
| 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. | |
| 428 | + | ||
| 427 | 429 | Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`. | |
| 428 | 430 | ||
| 429 | 431 | **Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident tools remain available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll `subagent_status` or journal files solely to wait for completion. | |
| @@ -424,6 +424,8 @@ Resident subagents run alongside the main session as independent in-process agen | |||||
| 424 | 424 | ||||
| 425 | The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up. | 425 | The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up. | ||
| 426 | 426 | ||||
| 427 | + | Each child receives a cumulative rollout budget equal to 75% of the active parent model's context window. `subagents.budget_ratio`, profile frontmatter (`budgetRatio` or `budgetTokens`), and spawn arguments (`budget_ratio` or `budget_tokens`) can override it. The child and dashboard show progressive budget states; at 90% zut disables tools and reserves the remainder for finalization, and at 100% it makes the current response terminal. Budget-exhausted children reject follow-ups. | |||
| 428 | + | ||||
| 427 | Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`. | 429 | Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`. | ||
| 428 | 430 | ||||
| 429 | **Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident tools remain available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll `subagent_status` or journal files solely to wait for completion. | 431 | **Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident tools remain available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll `subagent_status` or journal files solely to wait for completion. | ||
M docs/subagents.md +28 -8
| @@ -22,6 +22,7 @@ description: Read-only code reviewer | |||
| 22 | 22 | tools: [read, grep] | |
| 23 | 23 | model: openai-codex/gpt-5.6-luna | |
| 24 | 24 | thinking: high | |
| 25 | + | budgetRatio: 0.6 | |
| 25 | 26 | systemPromptMode: replace | |
| 26 | 27 | inheritProjectContext: false | |
| 27 | 28 | inheritSkills: false | |
| @@ -36,9 +37,11 @@ catalogue, `tools: []` grants no tools, and an explicit list replaces the | |||
| 36 | 37 | default. Names unavailable to the child (including host-only extensions) and | |
| 37 | 38 | tools denied by `subagents.allowed_tools` are omitted from an explicit list; | |
| 38 | 39 | they never grant the child additional access. `model`, `provider`, `thinking`, | |
| 39 | - | the prompt mode, project/skill inheritance, and `fastMode` are resolved before | |
| 40 | - | the child is accepted. A spawn-level model, provider, reasoning, or fast-mode | |
| 41 | - | override takes precedence where supplied. | |
| 40 | + | the prompt mode, project/skill inheritance, `fastMode`, and optional | |
| 41 | + | `budgetRatio` or `budgetTokens` override are resolved before the child is | |
| 42 | + | accepted. Spawn-level model, provider, reasoning, fast-mode, or budget values | |
| 43 | + | take precedence where supplied. `budgetRatio` and `budgetTokens` are mutually | |
| 44 | + | exclusive. | |
| 42 | 45 | ||
| 43 | 46 | Children are always fresh conversations: they receive the delegated task and | |
| 44 | 47 | resolved host/profile context, never the parent transcript or hidden reasoning. | |
| @@ -87,8 +90,23 @@ one turn for each child, and defaults to six concurrent turns. Set | |||
| 87 | 90 | unlimited by default; a positive `subagents.queue_timeout` cancels an accepted | |
| 88 | 91 | prompt that has not received a slot and records its terminal failure durably. | |
| 89 | 92 | `subagents.allowed_tools` is an allowlist for child-visible tools and | |
| 90 | - | `subagents.allowed_roots` limits eligible child workspaces. Removed legacy | |
| 91 | - | settings, including `tui_subagent_position`, are ignored. | |
| 93 | + | `subagents.allowed_roots` limits eligible child workspaces. Each child also | |
| 94 | + | inherits a cumulative weighted-token budget equal to 75% of the active parent | |
| 95 | + | model's context window. Set `subagents.budget_ratio` to another value greater | |
| 96 | + | than zero and at most one, or override one spawn/profile with `budget_ratio` or | |
| 97 | + | `budget_tokens`. Removed legacy settings, including `tui_subagent_position`, | |
| 98 | + | are ignored. | |
| 99 | + | ||
| 100 | + | Budget accounting charges uncached input, cache writes, and output fully; cache | |
| 101 | + | reads count at 25%. It is separate from active context-window usage and survives | |
| 102 | + | follow-up turns and compaction. The child and TUI receive progressive states at | |
| 103 | + | 50% (`notice`), 70% (`focused`), 85% (`verifying`), and 90% (`finalizing`). At | |
| 104 | + | 90%, zut disables further tool use and reserves the remaining budget for the | |
| 105 | + | final response. Every request's output cap is also clamped to the remaining | |
| 106 | + | budget. Providers report exact input usage only after a response, so the | |
| 107 | + | terminal response can make displayed usage exceed 100%; no later request is | |
| 108 | + | sent. An exhausted child cannot accept follow-up turns; spawn a replacement | |
| 109 | + | with an explicitly larger override when more work is required. | |
| 92 | 110 | ||
| 93 | 111 | `required: true` makes a delegated result an obligation of the parent turn. | |
| 94 | 112 | Failed, cancelled, and interrupted required work remains unresolved until an | |
| @@ -122,9 +140,10 @@ required capability. | |||
| 122 | 140 | The model-facing tools retain their logical names: | |
| 123 | 141 | ||
| 124 | 142 | - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`, | |
| 125 | - | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or | |
| 126 | - | `worktree`). `wait` is an explicit whole-second value from 1 through 300; | |
| 127 | - | when omitted, spawning returns immediately. A timed-out wait leaves the | |
| 143 | + | `reasoning`, `fast_mode`, `required`, `wait`, `isolation` (`shared` or | |
| 144 | + | `worktree`), and mutually exclusive `budget_ratio` or `budget_tokens`. | |
| 145 | + | `wait` is an explicit whole-second value from 1 through 300; when omitted, | |
| 146 | + | spawning returns immediately. A timed-out wait leaves the | |
| 128 | 147 | accepted child active, whether it is queued or running. Do not retry that | |
| 129 | 148 | task until it reaches a terminal failure, cancellation, or interruption. It | |
| 130 | 149 | returns a logical `subagent://<id>` reference. | |
| @@ -132,6 +151,7 @@ The model-facing tools retain their logical names: | |||
| 132 | 151 | set immediately. | |
| 133 | 152 | - `subagent_stop` stops one live child. | |
| 134 | 153 | - `subagent_resume` accepts an explicit follow-up prompt for an existing child. | |
| 154 | + | Budget-exhausted children are terminal and reject follow-ups. | |
| 135 | 155 | ||
| 136 | 156 | Child execution started by `subagent_spawn` is asynchronous unless it receives | |
| 137 | 157 | an explicit `wait` value. For an unwaited spawn, completion arrives through the | |
| @@ -22,6 +22,7 @@ description: Read-only code reviewer | |||||
| 22 | tools: [read, grep] | 22 | tools: [read, grep] | ||
| 23 | model: openai-codex/gpt-5.6-luna | 23 | model: openai-codex/gpt-5.6-luna | ||
| 24 | thinking: high | 24 | thinking: high | ||
| 25 | + | budgetRatio: 0.6 | |||
| 25 | systemPromptMode: replace | 26 | systemPromptMode: replace | ||
| 26 | inheritProjectContext: false | 27 | inheritProjectContext: false | ||
| 27 | inheritSkills: false | 28 | inheritSkills: false | ||
| @@ -36,9 +37,11 @@ catalogue, `tools: []` grants no tools, and an explicit list replaces the | |||||
| 36 | default. Names unavailable to the child (including host-only extensions) and | 37 | default. Names unavailable to the child (including host-only extensions) and | ||
| 37 | tools denied by `subagents.allowed_tools` are omitted from an explicit list; | 38 | tools denied by `subagents.allowed_tools` are omitted from an explicit list; | ||
| 38 | they never grant the child additional access. `model`, `provider`, `thinking`, | 39 | they never grant the child additional access. `model`, `provider`, `thinking`, | ||
| 39 | - | the prompt mode, project/skill inheritance, and `fastMode` are resolved before | 40 | + | the prompt mode, project/skill inheritance, `fastMode`, and optional |
| 40 | - | the child is accepted. A spawn-level model, provider, reasoning, or fast-mode | 41 | + | `budgetRatio` or `budgetTokens` override are resolved before the child is |
| 41 | - | override takes precedence where supplied. | 42 | + | accepted. Spawn-level model, provider, reasoning, fast-mode, or budget values |
| 43 | + | take precedence where supplied. `budgetRatio` and `budgetTokens` are mutually | |||
| 44 | + | exclusive. | |||
| 42 | 45 | ||||
| 43 | Children are always fresh conversations: they receive the delegated task and | 46 | Children are always fresh conversations: they receive the delegated task and | ||
| 44 | resolved host/profile context, never the parent transcript or hidden reasoning. | 47 | resolved host/profile context, never the parent transcript or hidden reasoning. | ||
| @@ -87,8 +90,23 @@ one turn for each child, and defaults to six concurrent turns. Set | |||||
| 87 | unlimited by default; a positive `subagents.queue_timeout` cancels an accepted | 90 | unlimited by default; a positive `subagents.queue_timeout` cancels an accepted | ||
| 88 | prompt that has not received a slot and records its terminal failure durably. | 91 | prompt that has not received a slot and records its terminal failure durably. | ||
| 89 | `subagents.allowed_tools` is an allowlist for child-visible tools and | 92 | `subagents.allowed_tools` is an allowlist for child-visible tools and | ||
| 90 | - | `subagents.allowed_roots` limits eligible child workspaces. Removed legacy | 93 | + | `subagents.allowed_roots` limits eligible child workspaces. Each child also |
| 91 | - | settings, including `tui_subagent_position`, are ignored. | 94 | + | inherits a cumulative weighted-token budget equal to 75% of the active parent |
| 95 | + | model's context window. Set `subagents.budget_ratio` to another value greater | |||
| 96 | + | than zero and at most one, or override one spawn/profile with `budget_ratio` or | |||
| 97 | + | `budget_tokens`. Removed legacy settings, including `tui_subagent_position`, | |||
| 98 | + | are ignored. | |||
| 99 | + | ||||
| 100 | + | Budget accounting charges uncached input, cache writes, and output fully; cache | |||
| 101 | + | reads count at 25%. It is separate from active context-window usage and survives | |||
| 102 | + | follow-up turns and compaction. The child and TUI receive progressive states at | |||
| 103 | + | 50% (`notice`), 70% (`focused`), 85% (`verifying`), and 90% (`finalizing`). At | |||
| 104 | + | 90%, zut disables further tool use and reserves the remaining budget for the | |||
| 105 | + | final response. Every request's output cap is also clamped to the remaining | |||
| 106 | + | budget. Providers report exact input usage only after a response, so the | |||
| 107 | + | terminal response can make displayed usage exceed 100%; no later request is | |||
| 108 | + | sent. An exhausted child cannot accept follow-up turns; spawn a replacement | |||
| 109 | + | with an explicitly larger override when more work is required. | |||
| 92 | 110 | ||||
| 93 | `required: true` makes a delegated result an obligation of the parent turn. | 111 | `required: true` makes a delegated result an obligation of the parent turn. | ||
| 94 | Failed, cancelled, and interrupted required work remains unresolved until an | 112 | Failed, cancelled, and interrupted required work remains unresolved until an | ||
| @@ -122,9 +140,10 @@ required capability. | |||||
| 122 | The model-facing tools retain their logical names: | 140 | The model-facing tools retain their logical names: | ||
| 123 | 141 | ||||
| 124 | - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`, | 142 | - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`, | ||
| 125 | - | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or | 143 | + | `reasoning`, `fast_mode`, `required`, `wait`, `isolation` (`shared` or |
| 126 | - | `worktree`). `wait` is an explicit whole-second value from 1 through 300; | 144 | + | `worktree`), and mutually exclusive `budget_ratio` or `budget_tokens`. |
| 127 | - | when omitted, spawning returns immediately. A timed-out wait leaves the | 145 | + | `wait` is an explicit whole-second value from 1 through 300; when omitted, |
| 146 | + | spawning returns immediately. A timed-out wait leaves the | |||
| 128 | accepted child active, whether it is queued or running. Do not retry that | 147 | accepted child active, whether it is queued or running. Do not retry that | ||
| 129 | task until it reaches a terminal failure, cancellation, or interruption. It | 148 | task until it reaches a terminal failure, cancellation, or interruption. It | ||
| 130 | returns a logical `subagent://<id>` reference. | 149 | returns a logical `subagent://<id>` reference. | ||
| @@ -132,6 +151,7 @@ The model-facing tools retain their logical names: | |||||
| 132 | set immediately. | 151 | set immediately. | ||
| 133 | - `subagent_stop` stops one live child. | 152 | - `subagent_stop` stops one live child. | ||
| 134 | - `subagent_resume` accepts an explicit follow-up prompt for an existing child. | 153 | - `subagent_resume` accepts an explicit follow-up prompt for an existing child. | ||
| 154 | + | Budget-exhausted children are terminal and reject follow-ups. | |||
| 135 | 155 | ||||
| 136 | Child execution started by `subagent_spawn` is asynchronous unless it receives | 156 | Child execution started by `subagent_spawn` is asynchronous unless it receives | ||
| 137 | an explicit `wait` value. For an unwaited spawn, completion arrives through the | 157 | an explicit `wait` value. For an unwaited spawn, completion arrives through the | ||
M packages/agent/cli.go +8 -0
| @@ -644,6 +644,7 @@ func applyInitialSessionResumeWithRuntime(ctx context.Context, args Args, base R | |||
| 644 | 644 | if runtime != nil { | |
| 645 | 645 | runtime.SetProvider(resolved.Provider) | |
| 646 | 646 | runtime.SetModel(resolved.Model) | |
| 647 | + | runtime.SetContextWindow(resolved.ContextWindow) | |
| 647 | 648 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 648 | 649 | runtime.SetFastMode(resolved.FastMode) | |
| 649 | 650 | runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | |
| @@ -1581,6 +1582,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1581 | 1582 | BaseURL: r.BaseURL, | |
| 1582 | 1583 | InsecureTLS: r.InsecureTLS, | |
| 1583 | 1584 | FastMode: r.FastMode, | |
| 1585 | + | ContextWindow: r.ContextWindow, | |
| 1584 | 1586 | Policy: subagentPolicyFromConfig(initialCfg.Subagents), | |
| 1585 | 1587 | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | |
| 1586 | 1588 | WebSearchGuard: webSearchGuard, | |
| @@ -1723,6 +1725,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1723 | 1725 | } | |
| 1724 | 1726 | resolved.UseSandbox(sharedSandbox) | |
| 1725 | 1727 | runtime.SetProvider(resolved.Provider) | |
| 1728 | + | runtime.SetModel(resolved.Model) | |
| 1729 | + | runtime.SetContextWindow(resolved.ContextWindow) | |
| 1726 | 1730 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1727 | 1731 | runtime.SetFastMode(resolved.FastMode) | |
| 1728 | 1732 | resolved.MergeExtensionTools(extToolAdapter) | |
| @@ -1746,6 +1750,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1746 | 1750 | } | |
| 1747 | 1751 | resolved.UseSandbox(sharedSandbox) | |
| 1748 | 1752 | runtime.SetProvider(resolved.Provider) | |
| 1753 | + | runtime.SetModel(resolved.Model) | |
| 1754 | + | runtime.SetContextWindow(resolved.ContextWindow) | |
| 1749 | 1755 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1750 | 1756 | runtime.SetFastMode(resolved.FastMode) | |
| 1751 | 1757 | resolved.MergeExtensionTools(extToolAdapter) | |
| @@ -1777,6 +1783,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1777 | 1783 | } | |
| 1778 | 1784 | resolved.UseSandbox(sharedSandbox) | |
| 1779 | 1785 | runtime.SetProvider(resolved.Provider) | |
| 1786 | + | runtime.SetModel(resolved.Model) | |
| 1787 | + | runtime.SetContextWindow(resolved.ContextWindow) | |
| 1780 | 1788 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | |
| 1781 | 1789 | runtime.SetFastMode(resolved.FastMode) | |
| 1782 | 1790 | resolved.MergeExtensionTools(extToolAdapter) | |
| @@ -644,6 +644,7 @@ func applyInitialSessionResumeWithRuntime(ctx context.Context, args Args, base R | |||||
| 644 | if runtime != nil { | 644 | if runtime != nil { | ||
| 645 | runtime.SetProvider(resolved.Provider) | 645 | runtime.SetProvider(resolved.Provider) | ||
| 646 | runtime.SetModel(resolved.Model) | 646 | runtime.SetModel(resolved.Model) | ||
| 647 | + | runtime.SetContextWindow(resolved.ContextWindow) | |||
| 647 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | 648 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | ||
| 648 | runtime.SetFastMode(resolved.FastMode) | 649 | runtime.SetFastMode(resolved.FastMode) | ||
| 649 | runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | 650 | runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy) | ||
| @@ -1581,6 +1582,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1581 | BaseURL: r.BaseURL, | 1582 | BaseURL: r.BaseURL, | ||
| 1582 | InsecureTLS: r.InsecureTLS, | 1583 | InsecureTLS: r.InsecureTLS, | ||
| 1583 | FastMode: r.FastMode, | 1584 | FastMode: r.FastMode, | ||
| 1585 | + | ContextWindow: r.ContextWindow, | |||
| 1584 | Policy: subagentPolicyFromConfig(initialCfg.Subagents), | 1586 | Policy: subagentPolicyFromConfig(initialCfg.Subagents), | ||
| 1585 | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | 1587 | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | ||
| 1586 | WebSearchGuard: webSearchGuard, | 1588 | WebSearchGuard: webSearchGuard, | ||
| @@ -1723,6 +1725,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1723 | } | 1725 | } | ||
| 1724 | resolved.UseSandbox(sharedSandbox) | 1726 | resolved.UseSandbox(sharedSandbox) | ||
| 1725 | runtime.SetProvider(resolved.Provider) | 1727 | runtime.SetProvider(resolved.Provider) | ||
| 1728 | + | runtime.SetModel(resolved.Model) | |||
| 1729 | + | runtime.SetContextWindow(resolved.ContextWindow) | |||
| 1726 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | 1730 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | ||
| 1727 | runtime.SetFastMode(resolved.FastMode) | 1731 | runtime.SetFastMode(resolved.FastMode) | ||
| 1728 | resolved.MergeExtensionTools(extToolAdapter) | 1732 | resolved.MergeExtensionTools(extToolAdapter) | ||
| @@ -1746,6 +1750,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1746 | } | 1750 | } | ||
| 1747 | resolved.UseSandbox(sharedSandbox) | 1751 | resolved.UseSandbox(sharedSandbox) | ||
| 1748 | runtime.SetProvider(resolved.Provider) | 1752 | runtime.SetProvider(resolved.Provider) | ||
| 1753 | + | runtime.SetModel(resolved.Model) | |||
| 1754 | + | runtime.SetContextWindow(resolved.ContextWindow) | |||
| 1749 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | 1755 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | ||
| 1750 | runtime.SetFastMode(resolved.FastMode) | 1756 | runtime.SetFastMode(resolved.FastMode) | ||
| 1751 | resolved.MergeExtensionTools(extToolAdapter) | 1757 | resolved.MergeExtensionTools(extToolAdapter) | ||
| @@ -1777,6 +1783,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1777 | } | 1783 | } | ||
| 1778 | resolved.UseSandbox(sharedSandbox) | 1784 | resolved.UseSandbox(sharedSandbox) | ||
| 1779 | runtime.SetProvider(resolved.Provider) | 1785 | runtime.SetProvider(resolved.Provider) | ||
| 1786 | + | runtime.SetModel(resolved.Model) | |||
| 1787 | + | runtime.SetContextWindow(resolved.ContextWindow) | |||
| 1780 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | 1788 | runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS) | ||
| 1781 | runtime.SetFastMode(resolved.FastMode) | 1789 | runtime.SetFastMode(resolved.FastMode) | ||
| 1782 | resolved.MergeExtensionTools(extToolAdapter) | 1790 | resolved.MergeExtensionTools(extToolAdapter) | ||
M packages/agent/config.go +10 -4
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | + | "math" | |
| 9 | 10 | "os" | |
| 10 | 11 | "path/filepath" | |
| 11 | 12 | "runtime" | |
| @@ -24,13 +25,15 @@ type QuickModelShortcut struct { | |||
| 24 | 25 | ||
| 25 | 26 | // SubagentsConfig contains the resident manager policy. max_concurrent limits | |
| 26 | 27 | // simultaneous turns, queue_timeout bounds admission waits, allowed_tools | |
| 27 | - | // limits child capabilities, and allowed_roots limits eligible workspaces. | |
| 28 | - | // Obsolete keys such as tui_subagent_position are ignored. | |
| 28 | + | // limits child capabilities, allowed_roots limits eligible workspaces, and | |
| 29 | + | // budget_ratio derives each child's cumulative rollout budget from the active | |
| 30 | + | // parent model's context window. Obsolete keys are ignored. | |
| 29 | 31 | type SubagentsConfig struct { | |
| 30 | 32 | MaxConcurrent int `json:"max_concurrent,omitempty"` | |
| 31 | 33 | QueueTimeout string `json:"queue_timeout,omitempty"` | |
| 32 | 34 | AllowedTools []string `json:"allowed_tools,omitempty"` | |
| 33 | 35 | AllowedRoots []string `json:"allowed_roots,omitempty"` | |
| 36 | + | BudgetRatio *float64 `json:"budget_ratio,omitempty"` | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 36 | 39 | // GoalsConfig controls optional resource limits for autonomous goals. A nil | |
| @@ -315,13 +318,16 @@ func LoadConfig() (Config, error) { | |||
| 315 | 318 | if err := json.Unmarshal(b, &c); err != nil { | |
| 316 | 319 | return c, fmt.Errorf("parse config: %w", err) | |
| 317 | 320 | } | |
| 318 | - | if err := validateSubagentDurations(c.Subagents); err != nil { | |
| 321 | + | if err := validateSubagentConfig(c.Subagents); err != nil { | |
| 319 | 322 | return c, err | |
| 320 | 323 | } | |
| 321 | 324 | return c, nil | |
| 322 | 325 | } | |
| 323 | 326 | ||
| 324 | - | func validateSubagentDurations(cfg SubagentsConfig) error { | |
| 327 | + | func validateSubagentConfig(cfg SubagentsConfig) error { | |
| 328 | + | if cfg.BudgetRatio != nil && (math.IsNaN(*cfg.BudgetRatio) || math.IsInf(*cfg.BudgetRatio, 0) || *cfg.BudgetRatio <= 0 || *cfg.BudgetRatio > 1) { | |
| 329 | + | return fmt.Errorf("parse config: subagents.budget_ratio must be greater than 0 and at most 1") | |
| 330 | + | } | |
| 325 | 331 | values := []struct { | |
| 326 | 332 | name string | |
| 327 | 333 | value string | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | + | "math" | |||
| 9 | "os" | 10 | "os" | ||
| 10 | "path/filepath" | 11 | "path/filepath" | ||
| 11 | "runtime" | 12 | "runtime" | ||
| @@ -24,13 +25,15 @@ type QuickModelShortcut struct { | |||||
| 24 | 25 | ||||
| 25 | // SubagentsConfig contains the resident manager policy. max_concurrent limits | 26 | // SubagentsConfig contains the resident manager policy. max_concurrent limits | ||
| 26 | // simultaneous turns, queue_timeout bounds admission waits, allowed_tools | 27 | // simultaneous turns, queue_timeout bounds admission waits, allowed_tools | ||
| 27 | - | // limits child capabilities, and allowed_roots limits eligible workspaces. | 28 | + | // limits child capabilities, allowed_roots limits eligible workspaces, and |
| 28 | - | // Obsolete keys such as tui_subagent_position are ignored. | 29 | + | // budget_ratio derives each child's cumulative rollout budget from the active |
| 30 | + | // parent model's context window. Obsolete keys are ignored. | |||
| 29 | type SubagentsConfig struct { | 31 | type SubagentsConfig struct { | ||
| 30 | MaxConcurrent int `json:"max_concurrent,omitempty"` | 32 | MaxConcurrent int `json:"max_concurrent,omitempty"` | ||
| 31 | QueueTimeout string `json:"queue_timeout,omitempty"` | 33 | QueueTimeout string `json:"queue_timeout,omitempty"` | ||
| 32 | AllowedTools []string `json:"allowed_tools,omitempty"` | 34 | AllowedTools []string `json:"allowed_tools,omitempty"` | ||
| 33 | AllowedRoots []string `json:"allowed_roots,omitempty"` | 35 | AllowedRoots []string `json:"allowed_roots,omitempty"` | ||
| 36 | + | BudgetRatio *float64 `json:"budget_ratio,omitempty"` | |||
| 34 | } | 37 | } | ||
| 35 | 38 | ||||
| 36 | // GoalsConfig controls optional resource limits for autonomous goals. A nil | 39 | // GoalsConfig controls optional resource limits for autonomous goals. A nil | ||
| @@ -315,13 +318,16 @@ func LoadConfig() (Config, error) { | |||||
| 315 | if err := json.Unmarshal(b, &c); err != nil { | 318 | if err := json.Unmarshal(b, &c); err != nil { | ||
| 316 | return c, fmt.Errorf("parse config: %w", err) | 319 | return c, fmt.Errorf("parse config: %w", err) | ||
| 317 | } | 320 | } | ||
| 318 | - | if err := validateSubagentDurations(c.Subagents); err != nil { | 321 | + | if err := validateSubagentConfig(c.Subagents); err != nil { |
| 319 | return c, err | 322 | return c, err | ||
| 320 | } | 323 | } | ||
| 321 | return c, nil | 324 | return c, nil | ||
| 322 | } | 325 | } | ||
| 323 | 326 | ||||
| 324 | - | func validateSubagentDurations(cfg SubagentsConfig) error { | 327 | + | func validateSubagentConfig(cfg SubagentsConfig) error { |
| 328 | + | if cfg.BudgetRatio != nil && (math.IsNaN(*cfg.BudgetRatio) || math.IsInf(*cfg.BudgetRatio, 0) || *cfg.BudgetRatio <= 0 || *cfg.BudgetRatio > 1) { | |||
| 329 | + | return fmt.Errorf("parse config: subagents.budget_ratio must be greater than 0 and at most 1") | |||
| 330 | + | } | |||
| 325 | values := []struct { | 331 | values := []struct { | ||
| 326 | name string | 332 | name string | ||
| 327 | value string | 333 | value string | ||
A packages/agent/config_subagents_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSubagentPolicyDefaultsBudgetRatio(t *testing.T) { | |
| 6 | + | policy := subagentPolicyFromConfig(SubagentsConfig{}) | |
| 7 | + | if policy.BudgetRatio != 0.75 || policy.BudgetRatioConfigured { | |
| 8 | + | t.Fatalf("default budget policy = %#v", policy) | |
| 9 | + | } | |
| 10 | + | ratio := 0.75 | |
| 11 | + | policy = subagentPolicyFromConfig(SubagentsConfig{BudgetRatio: &ratio}) | |
| 12 | + | if policy.BudgetRatio != 0.75 || !policy.BudgetRatioConfigured { | |
| 13 | + | t.Fatalf("configured budget policy = %#v", policy) | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestValidateSubagentBudgetRatio(t *testing.T) { | |
| 18 | + | valid := 0.6 | |
| 19 | + | if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &valid}); err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | for _, value := range []float64{0, -0.1, 1.1} { | |
| 23 | + | value := value | |
| 24 | + | if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &value}); err == nil { | |
| 25 | + | t.Fatalf("budget ratio %v accepted", value) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSubagentPolicyDefaultsBudgetRatio(t *testing.T) { | |||
| 6 | + | policy := subagentPolicyFromConfig(SubagentsConfig{}) | |||
| 7 | + | if policy.BudgetRatio != 0.75 || policy.BudgetRatioConfigured { | |||
| 8 | + | t.Fatalf("default budget policy = %#v", policy) | |||
| 9 | + | } | |||
| 10 | + | ratio := 0.75 | |||
| 11 | + | policy = subagentPolicyFromConfig(SubagentsConfig{BudgetRatio: &ratio}) | |||
| 12 | + | if policy.BudgetRatio != 0.75 || !policy.BudgetRatioConfigured { | |||
| 13 | + | t.Fatalf("configured budget policy = %#v", policy) | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestValidateSubagentBudgetRatio(t *testing.T) { | |||
| 18 | + | valid := 0.6 | |||
| 19 | + | if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &valid}); err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | for _, value := range []float64{0, -0.1, 1.1} { | |||
| 23 | + | value := value | |||
| 24 | + | if err := validateSubagentConfig(SubagentsConfig{BudgetRatio: &value}); err == nil { | |||
| 25 | + | t.Fatalf("budget ratio %v accepted", value) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | } | |||
M packages/agent/modes/resident_subagent_indicator.go +14 -0
| @@ -101,9 +101,23 @@ func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string { | |||
| 101 | 101 | if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" { | |
| 102 | 102 | parts = append(parts, context) | |
| 103 | 103 | } | |
| 104 | + | if snapshot.Budget.Limit > 0 { | |
| 105 | + | parts = append(parts, fmt.Sprintf("budget:%s %d%%/%s", snapshot.Budget.State, snapshot.Budget.Percent, compactResidentTokens(snapshot.Budget.Limit))) | |
| 106 | + | } | |
| 104 | 107 | return strings.Join(parts, " ") | |
| 105 | 108 | } | |
| 106 | 109 | ||
| 110 | + | func compactResidentTokens(tokens int64) string { | |
| 111 | + | switch { | |
| 112 | + | case tokens >= 999_500: | |
| 113 | + | return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) | |
| 114 | + | case tokens >= 1_000: | |
| 115 | + | return fmt.Sprintf("%.0fk", float64(tokens)/1_000) | |
| 116 | + | default: | |
| 117 | + | return fmt.Sprintf("%d", tokens) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 107 | 121 | func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time { | |
| 108 | 122 | if !snapshot.TurnStartedAt.IsZero() { | |
| 109 | 123 | return snapshot.TurnStartedAt | |
| @@ -101,9 +101,23 @@ func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string { | |||||
| 101 | if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" { | 101 | if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" { | ||
| 102 | parts = append(parts, context) | 102 | parts = append(parts, context) | ||
| 103 | } | 103 | } | ||
| 104 | + | if snapshot.Budget.Limit > 0 { | |||
| 105 | + | parts = append(parts, fmt.Sprintf("budget:%s %d%%/%s", snapshot.Budget.State, snapshot.Budget.Percent, compactResidentTokens(snapshot.Budget.Limit))) | |||
| 106 | + | } | |||
| 104 | return strings.Join(parts, " ") | 107 | return strings.Join(parts, " ") | ||
| 105 | } | 108 | } | ||
| 106 | 109 | ||||
| 110 | + | func compactResidentTokens(tokens int64) string { | |||
| 111 | + | switch { | |||
| 112 | + | case tokens >= 999_500: | |||
| 113 | + | return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) | |||
| 114 | + | case tokens >= 1_000: | |||
| 115 | + | return fmt.Sprintf("%.0fk", float64(tokens)/1_000) | |||
| 116 | + | default: | |||
| 117 | + | return fmt.Sprintf("%d", tokens) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 107 | func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time { | 121 | func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time { | ||
| 108 | if !snapshot.TurnStartedAt.IsZero() { | 122 | if !snapshot.TurnStartedAt.IsZero() { | ||
| 109 | return snapshot.TurnStartedAt | 123 | return snapshot.TurnStartedAt | ||
M packages/agent/modes/resident_subagent_indicator_test.go +25 -0
| @@ -233,6 +233,31 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t | |||
| 233 | 233 | } | |
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | + | func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) { | |
| 237 | + | metadata := residentUsageMetadata(subagents.ResidentSnapshot{ | |
| 238 | + | Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 375_000, Percent: 90, State: subagents.BudgetFinalizing}, | |
| 239 | + | }) | |
| 240 | + | if metadata != "budget:finalizing 90%/375k" { | |
| 241 | + | t.Fatalf("budget metadata = %q", metadata) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func TestCompactResidentTokensAvoidsThousandKBoundary(t *testing.T) { | |
| 246 | + | for _, tc := range []struct { | |
| 247 | + | tokens int64 | |
| 248 | + | want string | |
| 249 | + | }{ | |
| 250 | + | {tokens: 999_499, want: "999k"}, | |
| 251 | + | {tokens: 999_500, want: "1.0M"}, | |
| 252 | + | {tokens: 999_999, want: "1.0M"}, | |
| 253 | + | {tokens: 1_000_000, want: "1.0M"}, | |
| 254 | + | } { | |
| 255 | + | if got := compactResidentTokens(tc.tokens); got != tc.want { | |
| 256 | + | t.Errorf("compactResidentTokens(%d) = %q, want %q", tc.tokens, got, tc.want) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 236 | 261 | func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) { | |
| 237 | 262 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 238 | 263 | const width = 34 | |
| @@ -233,6 +233,31 @@ func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t | |||||
| 233 | } | 233 | } | ||
| 234 | } | 234 | } | ||
| 235 | 235 | ||||
| 236 | + | func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) { | |||
| 237 | + | metadata := residentUsageMetadata(subagents.ResidentSnapshot{ | |||
| 238 | + | Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 375_000, Percent: 90, State: subagents.BudgetFinalizing}, | |||
| 239 | + | }) | |||
| 240 | + | if metadata != "budget:finalizing 90%/375k" { | |||
| 241 | + | t.Fatalf("budget metadata = %q", metadata) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func TestCompactResidentTokensAvoidsThousandKBoundary(t *testing.T) { | |||
| 246 | + | for _, tc := range []struct { | |||
| 247 | + | tokens int64 | |||
| 248 | + | want string | |||
| 249 | + | }{ | |||
| 250 | + | {tokens: 999_499, want: "999k"}, | |||
| 251 | + | {tokens: 999_500, want: "1.0M"}, | |||
| 252 | + | {tokens: 999_999, want: "1.0M"}, | |||
| 253 | + | {tokens: 1_000_000, want: "1.0M"}, | |||
| 254 | + | } { | |||
| 255 | + | if got := compactResidentTokens(tc.tokens); got != tc.want { | |||
| 256 | + | t.Errorf("compactResidentTokens(%d) = %q, want %q", tc.tokens, got, tc.want) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 236 | func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) { | 261 | func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) { | ||
| 237 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | 262 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | ||
| 238 | const width = 34 | 263 | const width = 34 | ||
M packages/agent/orchestration_modes.go +3 -0
| @@ -78,6 +78,8 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h | |||
| 78 | 78 | return runtime.PrepareRegistry(reg) | |
| 79 | 79 | } | |
| 80 | 80 | runtime.SetProvider(r.Provider) | |
| 81 | + | runtime.SetModel(r.Model) | |
| 82 | + | runtime.SetContextWindow(r.ContextWindow) | |
| 81 | 83 | runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS) | |
| 82 | 84 | runtime.SetFastMode(r.FastMode) | |
| 83 | 85 | runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy) | |
| @@ -203,6 +205,7 @@ func newOrchestratedRuntime(_ context.Context, args Args, r Resolved, cfg Config | |||
| 203 | 205 | BaseURL: r.BaseURL, | |
| 204 | 206 | InsecureTLS: r.InsecureTLS, | |
| 205 | 207 | FastMode: r.FastMode, | |
| 208 | + | ContextWindow: r.ContextWindow, | |
| 206 | 209 | Policy: subagentPolicyFromConfig(cfg.Subagents), | |
| 207 | 210 | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | |
| 208 | 211 | ResidentCompletion: func(completion subagents.ResidentCompletion) { | |
| @@ -78,6 +78,8 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h | |||||
| 78 | return runtime.PrepareRegistry(reg) | 78 | return runtime.PrepareRegistry(reg) | ||
| 79 | } | 79 | } | ||
| 80 | runtime.SetProvider(r.Provider) | 80 | runtime.SetProvider(r.Provider) | ||
| 81 | + | runtime.SetModel(r.Model) | |||
| 82 | + | runtime.SetContextWindow(r.ContextWindow) | |||
| 81 | runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS) | 83 | runtime.SetProviderSettings(r.BaseURL, r.InsecureTLS) | ||
| 82 | runtime.SetFastMode(r.FastMode) | 84 | runtime.SetFastMode(r.FastMode) | ||
| 83 | runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy) | 85 | runtime.PrepareResolvedRegistry(r.ToolRegistry, r.WebSearchPolicy) | ||
| @@ -203,6 +205,7 @@ func newOrchestratedRuntime(_ context.Context, args Args, r Resolved, cfg Config | |||||
| 203 | BaseURL: r.BaseURL, | 205 | BaseURL: r.BaseURL, | ||
| 204 | InsecureTLS: r.InsecureTLS, | 206 | InsecureTLS: r.InsecureTLS, | ||
| 205 | FastMode: r.FastMode, | 207 | FastMode: r.FastMode, | ||
| 208 | + | ContextWindow: r.ContextWindow, | |||
| 206 | Policy: subagentPolicyFromConfig(cfg.Subagents), | 209 | Policy: subagentPolicyFromConfig(cfg.Subagents), | ||
| 207 | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | 210 | WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry), | ||
| 208 | ResidentCompletion: func(completion subagents.ResidentCompletion) { | 211 | ResidentCompletion: func(completion subagents.ResidentCompletion) { | ||
M packages/agent/subagent_factory.go +62 -1
| @@ -2,6 +2,8 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 5 | 7 | "fmt" | |
| 6 | 8 | "strings" | |
| 7 | 9 | "sync" | |
| @@ -9,6 +11,7 @@ import ( | |||
| 9 | 11 | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | 12 | "github.com/bnema/zut/packages/agent/tools" | |
| 11 | 13 | "github.com/bnema/zut/packages/core" | |
| 14 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | 15 | ) | |
| 13 | 16 | ||
| 14 | 17 | // newResidentChildRunner is the host-owned construction boundary for one | |
| @@ -36,6 +39,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 36 | 39 | system = strings.TrimSpace(system) + "\n\n" + extra | |
| 37 | 40 | } | |
| 38 | 41 | } | |
| 42 | + | budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow) | |
| 43 | + | if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" { | |
| 44 | + | system = strings.TrimSpace(system) + "\n\n" + guidance | |
| 45 | + | } | |
| 39 | 46 | agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry) | |
| 40 | 47 | agent.MaxSteps = resolved.MaxSteps | |
| 41 | 48 | agent.ContextWindow = resolved.ContextWindow | |
| @@ -43,10 +50,13 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 43 | 50 | agent.Reasoning = resolved.Reasoning | |
| 44 | 51 | agent.Temperature = resolved.Temperature | |
| 45 | 52 | agent.FastMode = resolved.FastMode | |
| 53 | + | var baseline subagents.ResidentUsageSnapshot | |
| 46 | 54 | if journal != nil { | |
| 47 | - | baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | |
| 55 | + | baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | |
| 48 | 56 | agent.SeedCost(baseline.Usage) | |
| 49 | 57 | } | |
| 58 | + | budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage) | |
| 59 | + | configureResidentBudget(agent, budget) | |
| 50 | 60 | rootCacheID := strings.TrimSpace(spec.RootCacheID) | |
| 51 | 61 | if rootCacheID == "" { | |
| 52 | 62 | // Journals written before root cache identity was persisted can still | |
| @@ -74,9 +84,13 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 74 | 84 | } | |
| 75 | 85 | turnCtx, cancel := context.WithCancel(ctx) | |
| 76 | 86 | defer cancel() | |
| 87 | + | startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded | |
| 77 | 88 | var journalErr error | |
| 78 | 89 | var journalMu sync.Mutex | |
| 79 | 90 | err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) { | |
| 91 | + | if usage, ok := event.(core.EvUsage); ok { | |
| 92 | + | budget.Observe(usage.Cumulative) | |
| 93 | + | } | |
| 80 | 94 | if err := journal.RecordAgentEvent(event); err != nil { | |
| 81 | 95 | journalMu.Lock() | |
| 82 | 96 | if journalErr == nil { | |
| @@ -91,10 +105,57 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 91 | 105 | if journalErr != nil { | |
| 92 | 106 | return fmt.Errorf("persist resident child transcript: %w", journalErr) | |
| 93 | 107 | } | |
| 108 | + | if budget.Snapshot().State == subagents.BudgetExceeded { | |
| 109 | + | if !startedExhausted && err == nil { | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | return errors.Join(err, subagents.ErrBudgetExceeded) | |
| 113 | + | } | |
| 94 | 114 | return err | |
| 95 | 115 | }, nil | |
| 96 | 116 | } | |
| 97 | 117 | ||
| 118 | + | func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) { | |
| 119 | + | if agent == nil || budget == nil { | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) { | |
| 123 | + | instruction, exceeded := budget.TurnContext() | |
| 124 | + | if exceeded { | |
| 125 | + | return false, subagents.ErrBudgetExceeded.Error(), "" | |
| 126 | + | } | |
| 127 | + | snapshot := budget.Snapshot() | |
| 128 | + | if snapshot.Limit > 0 { | |
| 129 | + | remaining := residentBudgetOutputLimit(snapshot.Limit - snapshot.Used) | |
| 130 | + | if agent.MaxTokens <= 0 || agent.MaxTokens > remaining { | |
| 131 | + | agent.MaxTokens = remaining | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | if snapshot.State == subagents.BudgetFinalizing { | |
| 135 | + | agent.SetTools(core.Registry{}) | |
| 136 | + | } | |
| 137 | + | return true, "", instruction | |
| 138 | + | } | |
| 139 | + | agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 140 | + | state := budget.Snapshot().State | |
| 141 | + | if state == subagents.BudgetFinalizing || state == subagents.BudgetExceeded { | |
| 142 | + | return false, "subagent rollout budget is reserved for the final response; tools are disabled", nil | |
| 143 | + | } | |
| 144 | + | return true, "", nil | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func residentBudgetOutputLimit(remaining int64) int { | |
| 149 | + | if remaining <= 1 { | |
| 150 | + | return 1 | |
| 151 | + | } | |
| 152 | + | maxInt := int64(^uint(0) >> 1) | |
| 153 | + | if remaining > maxInt { | |
| 154 | + | return int(maxInt) | |
| 155 | + | } | |
| 156 | + | return int(remaining) | |
| 157 | + | } | |
| 158 | + | ||
| 98 | 159 | // residentChildArgs applies the complete durable child specification without | |
| 99 | 160 | // rediscovering a mutable profile file. That preserves the profile inheritance | |
| 100 | 161 | // decision accepted at spawn time across explicit resume and restart. | |
| @@ -2,6 +2,8 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 5 | "fmt" | 7 | "fmt" | ||
| 6 | "strings" | 8 | "strings" | ||
| 7 | "sync" | 9 | "sync" | ||
| @@ -9,6 +11,7 @@ import ( | |||||
| 9 | "github.com/bnema/zut/packages/agent/subagents" | 11 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 10 | "github.com/bnema/zut/packages/agent/tools" | 12 | "github.com/bnema/zut/packages/agent/tools" | ||
| 11 | "github.com/bnema/zut/packages/core" | 13 | "github.com/bnema/zut/packages/core" | ||
| 14 | + | "github.com/bnema/zut/packages/provider" | |||
| 12 | ) | 15 | ) | ||
| 13 | 16 | ||||
| 14 | // newResidentChildRunner is the host-owned construction boundary for one | 17 | // newResidentChildRunner is the host-owned construction boundary for one | ||
| @@ -36,6 +39,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 36 | system = strings.TrimSpace(system) + "\n\n" + extra | 39 | system = strings.TrimSpace(system) + "\n\n" + extra | ||
| 37 | } | 40 | } | ||
| 38 | } | 41 | } | ||
| 42 | + | budgetLimit := subagents.EffectiveBudgetLimit(spec.BudgetLimit, resolved.ContextWindow) | |||
| 43 | + | if guidance := subagents.BudgetSystemPrompt(budgetLimit); guidance != "" { | |||
| 44 | + | system = strings.TrimSpace(system) + "\n\n" + guidance | |||
| 45 | + | } | |||
| 39 | agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry) | 46 | agent := core.NewAgent(resolved.NewClient(), resolved.Model, system, registry) | ||
| 40 | agent.MaxSteps = resolved.MaxSteps | 47 | agent.MaxSteps = resolved.MaxSteps | ||
| 41 | agent.ContextWindow = resolved.ContextWindow | 48 | agent.ContextWindow = resolved.ContextWindow | ||
| @@ -43,10 +50,13 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 43 | agent.Reasoning = resolved.Reasoning | 50 | agent.Reasoning = resolved.Reasoning | ||
| 44 | agent.Temperature = resolved.Temperature | 51 | agent.Temperature = resolved.Temperature | ||
| 45 | agent.FastMode = resolved.FastMode | 52 | agent.FastMode = resolved.FastMode | ||
| 53 | + | var baseline subagents.ResidentUsageSnapshot | |||
| 46 | if journal != nil { | 54 | if journal != nil { | ||
| 47 | - | baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | 55 | + | baseline = journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") |
| 48 | agent.SeedCost(baseline.Usage) | 56 | agent.SeedCost(baseline.Usage) | ||
| 49 | } | 57 | } | ||
| 58 | + | budget := subagents.NewRolloutBudget(budgetLimit, baseline.Usage) | |||
| 59 | + | configureResidentBudget(agent, budget) | |||
| 50 | rootCacheID := strings.TrimSpace(spec.RootCacheID) | 60 | rootCacheID := strings.TrimSpace(spec.RootCacheID) | ||
| 51 | if rootCacheID == "" { | 61 | if rootCacheID == "" { | ||
| 52 | // Journals written before root cache identity was persisted can still | 62 | // Journals written before root cache identity was persisted can still | ||
| @@ -74,9 +84,13 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 74 | } | 84 | } | ||
| 75 | turnCtx, cancel := context.WithCancel(ctx) | 85 | turnCtx, cancel := context.WithCancel(ctx) | ||
| 76 | defer cancel() | 86 | defer cancel() | ||
| 87 | + | startedExhausted := budget.Snapshot().State == subagents.BudgetExceeded | |||
| 77 | var journalErr error | 88 | var journalErr error | ||
| 78 | var journalMu sync.Mutex | 89 | var journalMu sync.Mutex | ||
| 79 | err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) { | 90 | err := agent.Prompt(turnCtx, prompt, nil, func(event core.AgentEvent) { | ||
| 91 | + | if usage, ok := event.(core.EvUsage); ok { | |||
| 92 | + | budget.Observe(usage.Cumulative) | |||
| 93 | + | } | |||
| 80 | if err := journal.RecordAgentEvent(event); err != nil { | 94 | if err := journal.RecordAgentEvent(event); err != nil { | ||
| 81 | journalMu.Lock() | 95 | journalMu.Lock() | ||
| 82 | if journalErr == nil { | 96 | if journalErr == nil { | ||
| @@ -91,10 +105,57 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 91 | if journalErr != nil { | 105 | if journalErr != nil { | ||
| 92 | return fmt.Errorf("persist resident child transcript: %w", journalErr) | 106 | return fmt.Errorf("persist resident child transcript: %w", journalErr) | ||
| 93 | } | 107 | } | ||
| 108 | + | if budget.Snapshot().State == subagents.BudgetExceeded { | |||
| 109 | + | if !startedExhausted && err == nil { | |||
| 110 | + | return nil | |||
| 111 | + | } | |||
| 112 | + | return errors.Join(err, subagents.ErrBudgetExceeded) | |||
| 113 | + | } | |||
| 94 | return err | 114 | return err | ||
| 95 | }, nil | 115 | }, nil | ||
| 96 | } | 116 | } | ||
| 97 | 117 | ||||
| 118 | + | func configureResidentBudget(agent *core.Agent, budget *subagents.RolloutBudget) { | |||
| 119 | + | if agent == nil || budget == nil { | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | agent.BeforeTurnContext = func(_ context.Context, _ int) (bool, string, string) { | |||
| 123 | + | instruction, exceeded := budget.TurnContext() | |||
| 124 | + | if exceeded { | |||
| 125 | + | return false, subagents.ErrBudgetExceeded.Error(), "" | |||
| 126 | + | } | |||
| 127 | + | snapshot := budget.Snapshot() | |||
| 128 | + | if snapshot.Limit > 0 { | |||
| 129 | + | remaining := residentBudgetOutputLimit(snapshot.Limit - snapshot.Used) | |||
| 130 | + | if agent.MaxTokens <= 0 || agent.MaxTokens > remaining { | |||
| 131 | + | agent.MaxTokens = remaining | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | if snapshot.State == subagents.BudgetFinalizing { | |||
| 135 | + | agent.SetTools(core.Registry{}) | |||
| 136 | + | } | |||
| 137 | + | return true, "", instruction | |||
| 138 | + | } | |||
| 139 | + | agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 140 | + | state := budget.Snapshot().State | |||
| 141 | + | if state == subagents.BudgetFinalizing || state == subagents.BudgetExceeded { | |||
| 142 | + | return false, "subagent rollout budget is reserved for the final response; tools are disabled", nil | |||
| 143 | + | } | |||
| 144 | + | return true, "", nil | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func residentBudgetOutputLimit(remaining int64) int { | |||
| 149 | + | if remaining <= 1 { | |||
| 150 | + | return 1 | |||
| 151 | + | } | |||
| 152 | + | maxInt := int64(^uint(0) >> 1) | |||
| 153 | + | if remaining > maxInt { | |||
| 154 | + | return int(maxInt) | |||
| 155 | + | } | |||
| 156 | + | return int(remaining) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 98 | // residentChildArgs applies the complete durable child specification without | 159 | // residentChildArgs applies the complete durable child specification without | ||
| 99 | // rediscovering a mutable profile file. That preserves the profile inheritance | 160 | // rediscovering a mutable profile file. That preserves the profile inheritance | ||
| 100 | // decision accepted at spawn time across explicit resume and restart. | 161 | // decision accepted at spawn time across explicit resume and restart. | ||
M packages/agent/subagent_factory_test.go +186 -0
| @@ -1,11 +1,18 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 4 | 9 | "strings" | |
| 10 | + | "sync/atomic" | |
| 5 | 11 | "testing" | |
| 6 | 12 | ||
| 7 | 13 | "github.com/bnema/zut/packages/agent/subagents" | |
| 8 | 14 | "github.com/bnema/zut/packages/core" | |
| 15 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | 16 | ) | |
| 10 | 17 | ||
| 11 | 18 | func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) { | |
| @@ -30,6 +37,185 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T | |||
| 30 | 37 | } | |
| 31 | 38 | } | |
| 32 | 39 | ||
| 40 | + | func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) { | |
| 41 | + | agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil}) | |
| 42 | + | budget := subagents.NewRolloutBudget(1_000, provider.Usage{}) | |
| 43 | + | configureResidentBudget(agent, budget) | |
| 44 | + | ||
| 45 | + | budget.Observe(provider.Usage{InputTokens: 900}) | |
| 46 | + | allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1) | |
| 47 | + | if !allowed || reason != "" || !strings.Contains(contextText, "Finalization is required") { | |
| 48 | + | t.Fatalf("finalization context = allowed=%t reason=%q context=%q", allowed, reason, contextText) | |
| 49 | + | } | |
| 50 | + | if len(agent.ToolsSnapshot()) != 0 { | |
| 51 | + | t.Fatalf("finalization tools = %#v, want none", agent.ToolsSnapshot()) | |
| 52 | + | } | |
| 53 | + | if agent.MaxTokens != 100 { | |
| 54 | + | t.Fatalf("finalization max tokens = %d, want 100", agent.MaxTokens) | |
| 55 | + | } | |
| 56 | + | if allowed, reason, _ := agent.BeforeToolExecute(provider.ToolCallBlock{Name: "read"}); allowed || !strings.Contains(reason, "tools are disabled") { | |
| 57 | + | t.Fatalf("finalization tool guard = allowed=%t reason=%q", allowed, reason) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | budget.Observe(provider.Usage{InputTokens: 1_000}) | |
| 61 | + | allowed, reason, _ = agent.BeforeTurnContext(t.Context(), 2) | |
| 62 | + | if allowed || reason != subagents.ErrBudgetExceeded.Error() { | |
| 63 | + | t.Fatalf("exhausted context = allowed=%t reason=%q", allowed, reason) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) { | |
| 68 | + | var requests atomic.Int32 | |
| 69 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 70 | + | requestNumber := requests.Add(1) | |
| 71 | + | var body map[string]any | |
| 72 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 73 | + | t.Errorf("decode request: %v", err) | |
| 74 | + | } | |
| 75 | + | w.Header().Set("content-type", "text/event-stream") | |
| 76 | + | if requestNumber == 1 { | |
| 77 | + | if got := requestMaxTokens(t, body); got != 1_000 { | |
| 78 | + | t.Errorf("initial max_tokens = %d, want 1000", got) | |
| 79 | + | } | |
| 80 | + | writeOpenAIChunk(t, w, map[string]any{ | |
| 81 | + | "choices": []any{map[string]any{ | |
| 82 | + | "index": 0, | |
| 83 | + | "delta": map[string]any{"tool_calls": []any{map[string]any{ | |
| 84 | + | "index": 0, "id": "call-1", "type": "function", | |
| 85 | + | "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`}, | |
| 86 | + | }}}, | |
| 87 | + | "finish_reason": "tool_calls", | |
| 88 | + | }}, | |
| 89 | + | "usage": map[string]int{"prompt_tokens": 900, "completion_tokens": 0}, | |
| 90 | + | }) | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | if tools, ok := body["tools"].([]any); ok && len(tools) != 0 { | |
| 94 | + | t.Errorf("finalization request tools = %#v, want none", tools) | |
| 95 | + | } | |
| 96 | + | if got := requestMaxTokens(t, body); got != 100 { | |
| 97 | + | t.Errorf("finalization max_tokens = %d, want 100", got) | |
| 98 | + | } | |
| 99 | + | writeOpenAIChunk(t, w, map[string]any{ | |
| 100 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}}, | |
| 101 | + | "usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 50}, | |
| 102 | + | }) | |
| 103 | + | })) | |
| 104 | + | defer server.Close() | |
| 105 | + | ||
| 106 | + | runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000) | |
| 107 | + | if err := runner(t.Context(), "review"); err != nil { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | if got := requests.Load(); got != 2 { | |
| 111 | + | t.Fatalf("provider requests = %d, want 2", got) | |
| 112 | + | } | |
| 113 | + | if summary := latestResidentAssistantText(t, journal); summary != "final result" { | |
| 114 | + | t.Fatalf("latest summary = %q", summary) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) { | |
| 119 | + | var requests atomic.Int32 | |
| 120 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 121 | + | requests.Add(1) | |
| 122 | + | var body map[string]any | |
| 123 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 124 | + | t.Errorf("decode request: %v", err) | |
| 125 | + | } | |
| 126 | + | if got := requestMaxTokens(t, body); got != 1_000 { | |
| 127 | + | t.Errorf("initial max_tokens = %d, want 1000", got) | |
| 128 | + | } | |
| 129 | + | w.Header().Set("content-type", "text/event-stream") | |
| 130 | + | writeOpenAIChunk(t, w, map[string]any{ | |
| 131 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "best available result"}, "finish_reason": "stop"}}, | |
| 132 | + | "usage": map[string]int{"prompt_tokens": 1_200, "completion_tokens": 0}, | |
| 133 | + | }) | |
| 134 | + | })) | |
| 135 | + | defer server.Close() | |
| 136 | + | ||
| 137 | + | runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000) | |
| 138 | + | if err := runner(t.Context(), "review"); err != nil { | |
| 139 | + | t.Fatalf("runner error = %v, want terminal result", err) | |
| 140 | + | } | |
| 141 | + | if got := requests.Load(); got != 1 { | |
| 142 | + | t.Fatalf("provider requests = %d, want 1", got) | |
| 143 | + | } | |
| 144 | + | if summary := latestResidentAssistantText(t, journal); summary != "best available result" { | |
| 145 | + | t.Fatalf("latest summary = %q", summary) | |
| 146 | + | } | |
| 147 | + | if err := runner(t.Context(), "continue"); !errors.Is(err, subagents.ErrBudgetExceeded) { | |
| 148 | + | t.Fatalf("exhausted follow-up error = %v", err) | |
| 149 | + | } | |
| 150 | + | if got := requests.Load(); got != 1 { | |
| 151 | + | t.Fatalf("exhausted follow-up made %d provider requests, want 1 total", got) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) { | |
| 156 | + | t.Helper() | |
| 157 | + | spec := subagents.ResidentChildSpec{ | |
| 158 | + | ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o", | |
| 159 | + | Tools: []string{"read"}, BudgetLimit: limit, | |
| 160 | + | } | |
| 161 | + | journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID) | |
| 162 | + | if err != nil { | |
| 163 | + | t.Fatal(err) | |
| 164 | + | } | |
| 165 | + | t.Cleanup(func() { _ = journal.Close() }) | |
| 166 | + | if err := journal.Accept(spec, "review"); err != nil { | |
| 167 | + | t.Fatal(err) | |
| 168 | + | } | |
| 169 | + | runner, err := newResidentChildRunner(Args{ | |
| 170 | + | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL, | |
| 171 | + | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, | |
| 172 | + | }, spec, journal) | |
| 173 | + | if err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | return runner, journal | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func requestMaxTokens(t *testing.T, body map[string]any) int { | |
| 180 | + | t.Helper() | |
| 181 | + | value, ok := body["max_tokens"].(float64) | |
| 182 | + | if !ok { | |
| 183 | + | t.Errorf("request max_tokens = %#v", body["max_tokens"]) | |
| 184 | + | return 0 | |
| 185 | + | } | |
| 186 | + | return int(value) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func latestResidentAssistantText(t *testing.T, journal *subagents.ResidentJournal) string { | |
| 190 | + | t.Helper() | |
| 191 | + | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | |
| 192 | + | if err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | for index := len(messages) - 1; index >= 0; index-- { | |
| 196 | + | if messages[index].Role != provider.RoleAssistant { | |
| 197 | + | continue | |
| 198 | + | } | |
| 199 | + | var text strings.Builder | |
| 200 | + | for _, content := range messages[index].Content { | |
| 201 | + | if block, ok := content.(provider.TextBlock); ok { | |
| 202 | + | text.WriteString(block.Text) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | return text.String() | |
| 206 | + | } | |
| 207 | + | return "" | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func writeOpenAIChunk(t *testing.T, w http.ResponseWriter, chunk map[string]any) { | |
| 211 | + | t.Helper() | |
| 212 | + | data, err := json.Marshal(chunk) | |
| 213 | + | if err != nil { | |
| 214 | + | t.Fatal(err) | |
| 215 | + | } | |
| 216 | + | _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data) | |
| 217 | + | } | |
| 218 | + | ||
| 33 | 219 | func TestResidentChildArgsPreserveDurableProfileInheritance(t *testing.T) { | |
| 34 | 220 | no := false | |
| 35 | 221 | next := residentChildArgs(Args{Orchestrate: true, NoSkill: false, NoContextFiles: false, BaseURL: "https://stale.example/v1", InsecureTLS: false}, "openai", subagents.ResidentChildSpec{ | |
| @@ -1,11 +1,18 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "encoding/json" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 4 | "strings" | 9 | "strings" | ||
| 10 | + | "sync/atomic" | |||
| 5 | "testing" | 11 | "testing" | ||
| 6 | 12 | ||||
| 7 | "github.com/bnema/zut/packages/agent/subagents" | 13 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 8 | "github.com/bnema/zut/packages/core" | 14 | "github.com/bnema/zut/packages/core" | ||
| 15 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | ) | 16 | ) | ||
| 10 | 17 | ||||
| 11 | func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) { | 18 | func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) { | ||
| @@ -30,6 +37,185 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T | |||||
| 30 | } | 37 | } | ||
| 31 | } | 38 | } | ||
| 32 | 39 | ||||
| 40 | + | func TestConfigureResidentBudgetEnforcesFinalizationAndExhaustion(t *testing.T) { | |||
| 41 | + | agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil}) | |||
| 42 | + | budget := subagents.NewRolloutBudget(1_000, provider.Usage{}) | |||
| 43 | + | configureResidentBudget(agent, budget) | |||
| 44 | + | ||||
| 45 | + | budget.Observe(provider.Usage{InputTokens: 900}) | |||
| 46 | + | allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1) | |||
| 47 | + | if !allowed || reason != "" || !strings.Contains(contextText, "Finalization is required") { | |||
| 48 | + | t.Fatalf("finalization context = allowed=%t reason=%q context=%q", allowed, reason, contextText) | |||
| 49 | + | } | |||
| 50 | + | if len(agent.ToolsSnapshot()) != 0 { | |||
| 51 | + | t.Fatalf("finalization tools = %#v, want none", agent.ToolsSnapshot()) | |||
| 52 | + | } | |||
| 53 | + | if agent.MaxTokens != 100 { | |||
| 54 | + | t.Fatalf("finalization max tokens = %d, want 100", agent.MaxTokens) | |||
| 55 | + | } | |||
| 56 | + | if allowed, reason, _ := agent.BeforeToolExecute(provider.ToolCallBlock{Name: "read"}); allowed || !strings.Contains(reason, "tools are disabled") { | |||
| 57 | + | t.Fatalf("finalization tool guard = allowed=%t reason=%q", allowed, reason) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | budget.Observe(provider.Usage{InputTokens: 1_000}) | |||
| 61 | + | allowed, reason, _ = agent.BeforeTurnContext(t.Context(), 2) | |||
| 62 | + | if allowed || reason != subagents.ErrBudgetExceeded.Error() { | |||
| 63 | + | t.Fatalf("exhausted context = allowed=%t reason=%q", allowed, reason) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestResidentChildRunnerFinalizesWithoutToolsAtNinetyPercent(t *testing.T) { | |||
| 68 | + | var requests atomic.Int32 | |||
| 69 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 70 | + | requestNumber := requests.Add(1) | |||
| 71 | + | var body map[string]any | |||
| 72 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |||
| 73 | + | t.Errorf("decode request: %v", err) | |||
| 74 | + | } | |||
| 75 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 76 | + | if requestNumber == 1 { | |||
| 77 | + | if got := requestMaxTokens(t, body); got != 1_000 { | |||
| 78 | + | t.Errorf("initial max_tokens = %d, want 1000", got) | |||
| 79 | + | } | |||
| 80 | + | writeOpenAIChunk(t, w, map[string]any{ | |||
| 81 | + | "choices": []any{map[string]any{ | |||
| 82 | + | "index": 0, | |||
| 83 | + | "delta": map[string]any{"tool_calls": []any{map[string]any{ | |||
| 84 | + | "index": 0, "id": "call-1", "type": "function", | |||
| 85 | + | "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`}, | |||
| 86 | + | }}}, | |||
| 87 | + | "finish_reason": "tool_calls", | |||
| 88 | + | }}, | |||
| 89 | + | "usage": map[string]int{"prompt_tokens": 900, "completion_tokens": 0}, | |||
| 90 | + | }) | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | if tools, ok := body["tools"].([]any); ok && len(tools) != 0 { | |||
| 94 | + | t.Errorf("finalization request tools = %#v, want none", tools) | |||
| 95 | + | } | |||
| 96 | + | if got := requestMaxTokens(t, body); got != 100 { | |||
| 97 | + | t.Errorf("finalization max_tokens = %d, want 100", got) | |||
| 98 | + | } | |||
| 99 | + | writeOpenAIChunk(t, w, map[string]any{ | |||
| 100 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}}, | |||
| 101 | + | "usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 50}, | |||
| 102 | + | }) | |||
| 103 | + | })) | |||
| 104 | + | defer server.Close() | |||
| 105 | + | ||||
| 106 | + | runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000) | |||
| 107 | + | if err := runner(t.Context(), "review"); err != nil { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | if got := requests.Load(); got != 2 { | |||
| 111 | + | t.Fatalf("provider requests = %d, want 2", got) | |||
| 112 | + | } | |||
| 113 | + | if summary := latestResidentAssistantText(t, journal); summary != "final result" { | |||
| 114 | + | t.Fatalf("latest summary = %q", summary) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestResidentChildRunnerTerminatesAtBudgetLimit(t *testing.T) { | |||
| 119 | + | var requests atomic.Int32 | |||
| 120 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 121 | + | requests.Add(1) | |||
| 122 | + | var body map[string]any | |||
| 123 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |||
| 124 | + | t.Errorf("decode request: %v", err) | |||
| 125 | + | } | |||
| 126 | + | if got := requestMaxTokens(t, body); got != 1_000 { | |||
| 127 | + | t.Errorf("initial max_tokens = %d, want 1000", got) | |||
| 128 | + | } | |||
| 129 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 130 | + | writeOpenAIChunk(t, w, map[string]any{ | |||
| 131 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "best available result"}, "finish_reason": "stop"}}, | |||
| 132 | + | "usage": map[string]int{"prompt_tokens": 1_200, "completion_tokens": 0}, | |||
| 133 | + | }) | |||
| 134 | + | })) | |||
| 135 | + | defer server.Close() | |||
| 136 | + | ||||
| 137 | + | runner, journal := newBudgetedResidentTestRunner(t, server.URL, 1_000) | |||
| 138 | + | if err := runner(t.Context(), "review"); err != nil { | |||
| 139 | + | t.Fatalf("runner error = %v, want terminal result", err) | |||
| 140 | + | } | |||
| 141 | + | if got := requests.Load(); got != 1 { | |||
| 142 | + | t.Fatalf("provider requests = %d, want 1", got) | |||
| 143 | + | } | |||
| 144 | + | if summary := latestResidentAssistantText(t, journal); summary != "best available result" { | |||
| 145 | + | t.Fatalf("latest summary = %q", summary) | |||
| 146 | + | } | |||
| 147 | + | if err := runner(t.Context(), "continue"); !errors.Is(err, subagents.ErrBudgetExceeded) { | |||
| 148 | + | t.Fatalf("exhausted follow-up error = %v", err) | |||
| 149 | + | } | |||
| 150 | + | if got := requests.Load(); got != 1 { | |||
| 151 | + | t.Fatalf("exhausted follow-up made %d provider requests, want 1 total", got) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func newBudgetedResidentTestRunner(t *testing.T, baseURL string, limit int64) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) { | |||
| 156 | + | t.Helper() | |||
| 157 | + | spec := subagents.ResidentChildSpec{ | |||
| 158 | + | ID: "budget-child", SessionID: "budget-session", Provider: "openai", Model: "gpt-4o", | |||
| 159 | + | Tools: []string{"read"}, BudgetLimit: limit, | |||
| 160 | + | } | |||
| 161 | + | journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID) | |||
| 162 | + | if err != nil { | |||
| 163 | + | t.Fatal(err) | |||
| 164 | + | } | |||
| 165 | + | t.Cleanup(func() { _ = journal.Close() }) | |||
| 166 | + | if err := journal.Accept(spec, "review"); err != nil { | |||
| 167 | + | t.Fatal(err) | |||
| 168 | + | } | |||
| 169 | + | runner, err := newResidentChildRunner(Args{ | |||
| 170 | + | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL, | |||
| 171 | + | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, | |||
| 172 | + | }, spec, journal) | |||
| 173 | + | if err != nil { | |||
| 174 | + | t.Fatal(err) | |||
| 175 | + | } | |||
| 176 | + | return runner, journal | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func requestMaxTokens(t *testing.T, body map[string]any) int { | |||
| 180 | + | t.Helper() | |||
| 181 | + | value, ok := body["max_tokens"].(float64) | |||
| 182 | + | if !ok { | |||
| 183 | + | t.Errorf("request max_tokens = %#v", body["max_tokens"]) | |||
| 184 | + | return 0 | |||
| 185 | + | } | |||
| 186 | + | return int(value) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func latestResidentAssistantText(t *testing.T, journal *subagents.ResidentJournal) string { | |||
| 190 | + | t.Helper() | |||
| 191 | + | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | |||
| 192 | + | if err != nil { | |||
| 193 | + | t.Fatal(err) | |||
| 194 | + | } | |||
| 195 | + | for index := len(messages) - 1; index >= 0; index-- { | |||
| 196 | + | if messages[index].Role != provider.RoleAssistant { | |||
| 197 | + | continue | |||
| 198 | + | } | |||
| 199 | + | var text strings.Builder | |||
| 200 | + | for _, content := range messages[index].Content { | |||
| 201 | + | if block, ok := content.(provider.TextBlock); ok { | |||
| 202 | + | text.WriteString(block.Text) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | return text.String() | |||
| 206 | + | } | |||
| 207 | + | return "" | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func writeOpenAIChunk(t *testing.T, w http.ResponseWriter, chunk map[string]any) { | |||
| 211 | + | t.Helper() | |||
| 212 | + | data, err := json.Marshal(chunk) | |||
| 213 | + | if err != nil { | |||
| 214 | + | t.Fatal(err) | |||
| 215 | + | } | |||
| 216 | + | _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 33 | func TestResidentChildArgsPreserveDurableProfileInheritance(t *testing.T) { | 219 | func TestResidentChildArgsPreserveDurableProfileInheritance(t *testing.T) { | ||
| 34 | no := false | 220 | no := false | ||
| 35 | next := residentChildArgs(Args{Orchestrate: true, NoSkill: false, NoContextFiles: false, BaseURL: "https://stale.example/v1", InsecureTLS: false}, "openai", subagents.ResidentChildSpec{ | 221 | next := residentChildArgs(Args{Orchestrate: true, NoSkill: false, NoContextFiles: false, BaseURL: "https://stale.example/v1", InsecureTLS: false}, "openai", subagents.ResidentChildSpec{ | ||
M packages/agent/subagent_runtime.go +61 -11
| @@ -34,6 +34,7 @@ type subagentRuntime struct { | |||
| 34 | 34 | insecureTLS bool | |
| 35 | 35 | fastMode bool | |
| 36 | 36 | repoRoot string | |
| 37 | + | contextWindow int | |
| 37 | 38 | ||
| 38 | 39 | activeProvider func() string | |
| 39 | 40 | activeModel func() string | |
| @@ -57,6 +58,7 @@ type subagentRuntimeConfiguration struct { | |||
| 57 | 58 | insecureTLS bool | |
| 58 | 59 | fastMode bool | |
| 59 | 60 | repoRoot string | |
| 61 | + | contextWindow int | |
| 60 | 62 | webSearchPolicy subagents.WebSearchPolicy | |
| 61 | 63 | } | |
| 62 | 64 | ||
| @@ -68,12 +70,13 @@ type subagentRuntimeConfig struct { | |||
| 68 | 70 | Root string | |
| 69 | 71 | RepoRoot string | |
| 70 | 72 | ||
| 71 | - | Provider string | |
| 72 | - | Model string | |
| 73 | - | Reasoning string | |
| 74 | - | BaseURL string | |
| 75 | - | InsecureTLS bool | |
| 76 | - | FastMode bool | |
| 73 | + | Provider string | |
| 74 | + | Model string | |
| 75 | + | Reasoning string | |
| 76 | + | BaseURL string | |
| 77 | + | InsecureTLS bool | |
| 78 | + | FastMode bool | |
| 79 | + | ContextWindow int | |
| 77 | 80 | ||
| 78 | 81 | Policy subagents.SubagentPolicy | |
| 79 | 82 | WebSearchPolicy subagents.WebSearchPolicy | |
| @@ -97,6 +100,7 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||
| 97 | 100 | insecureTLS: cfg.InsecureTLS, | |
| 98 | 101 | fastMode: cfg.FastMode, | |
| 99 | 102 | repoRoot: cfg.RepoRoot, | |
| 103 | + | contextWindow: cfg.ContextWindow, | |
| 100 | 104 | activeProvider: cfg.ActiveProvider, | |
| 101 | 105 | activeModel: cfg.ActiveModel, | |
| 102 | 106 | activeReasoning: cfg.ActiveReasoning, | |
| @@ -179,6 +183,15 @@ func (rt *subagentRuntime) SetModel(model string) { | |||
| 179 | 183 | rt.settingsMu.Unlock() | |
| 180 | 184 | } | |
| 181 | 185 | ||
| 186 | + | func (rt *subagentRuntime) SetContextWindow(contextWindow int) { | |
| 187 | + | if rt == nil { | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | rt.settingsMu.Lock() | |
| 191 | + | rt.contextWindow = contextWindow | |
| 192 | + | rt.settingsMu.Unlock() | |
| 193 | + | } | |
| 194 | + | ||
| 182 | 195 | func (rt *subagentRuntime) SetReasoning(reasoning string) { | |
| 183 | 196 | if rt == nil { | |
| 184 | 197 | return | |
| @@ -250,6 +263,7 @@ func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration | |||
| 250 | 263 | insecureTLS: rt.insecureTLS, | |
| 251 | 264 | fastMode: rt.fastMode, | |
| 252 | 265 | repoRoot: rt.repoRoot, | |
| 266 | + | contextWindow: rt.contextWindow, | |
| 253 | 267 | webSearchPolicy: rt.webSearchPolicy, | |
| 254 | 268 | } | |
| 255 | 269 | } | |
| @@ -265,6 +279,7 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur | |||
| 265 | 279 | rt.insecureTLS = snapshot.insecureTLS | |
| 266 | 280 | rt.fastMode = snapshot.fastMode | |
| 267 | 281 | rt.repoRoot = snapshot.repoRoot | |
| 282 | + | rt.contextWindow = snapshot.contextWindow | |
| 268 | 283 | rt.webSearchPolicy = snapshot.webSearchPolicy | |
| 269 | 284 | rt.settingsMu.Unlock() | |
| 270 | 285 | } | |
| @@ -292,7 +307,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 292 | 307 | providerID, model, reasoning := activeProvider, rt.model, rt.reasoning | |
| 293 | 308 | baseURL, insecureTLS := rt.baseURL, rt.insecureTLS | |
| 294 | 309 | workspace, parentSession := rt.repoRoot, rt.activeSession | |
| 295 | - | fastMode, policy := rt.fastMode, rt.policy | |
| 310 | + | fastMode, policy, parentContextWindow := rt.fastMode, rt.policy, rt.contextWindow | |
| 296 | 311 | rt.settingsMu.RUnlock() | |
| 297 | 312 | if strings.TrimSpace(request.Provider) != "" { | |
| 298 | 313 | providerID = strings.TrimSpace(request.Provider) | |
| @@ -349,11 +364,16 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 349 | 364 | if workspaceMode == "" { | |
| 350 | 365 | workspaceMode = subagents.WorkspaceShared | |
| 351 | 366 | } | |
| 367 | + | budgetLimit, resolvedBudgetRatio, budgetSource, err := resolveResidentBudget(parentContextWindow, policy.BudgetRatio, policy.BudgetRatioConfigured, request.Profile, request.BudgetRatio, request.BudgetTokens) | |
| 368 | + | if err != nil { | |
| 369 | + | return subagents.ResidentChildSpec{}, err | |
| 370 | + | } | |
| 352 | 371 | spec := subagents.ResidentChildSpec{ | |
| 353 | 372 | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | |
| 354 | 373 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | |
| 355 | 374 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | |
| 356 | 375 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | |
| 376 | + | BudgetLimit: budgetLimit, BudgetRatio: resolvedBudgetRatio, BudgetSource: budgetSource, | |
| 357 | 377 | } | |
| 358 | 378 | if request.Profile != nil { | |
| 359 | 379 | spec.Profile = request.Profile.Name | |
| @@ -365,6 +385,30 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 365 | 385 | return spec, nil | |
| 366 | 386 | } | |
| 367 | 387 | ||
| 388 | + | func resolveResidentBudget(parentContextWindow int, policyRatio float64, policyConfigured bool, profile *subagents.Profile, spawnRatio *float64, spawnTokens *int64) (int64, float64, string, error) { | |
| 389 | + | ratio, source := policyRatio, "default_ratio" | |
| 390 | + | if policyConfigured { | |
| 391 | + | source = "config_ratio" | |
| 392 | + | } | |
| 393 | + | var tokens int64 | |
| 394 | + | if profile != nil { | |
| 395 | + | if profile.BudgetTokens != nil { | |
| 396 | + | tokens, source = *profile.BudgetTokens, "profile_tokens" | |
| 397 | + | } else if profile.BudgetRatio != nil { | |
| 398 | + | ratio, source = *profile.BudgetRatio, "profile_ratio" | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | if spawnTokens != nil { | |
| 402 | + | tokens, source = *spawnTokens, "spawn_tokens" | |
| 403 | + | ratio = 0 | |
| 404 | + | } else if spawnRatio != nil { | |
| 405 | + | ratio, source = *spawnRatio, "spawn_ratio" | |
| 406 | + | tokens = 0 | |
| 407 | + | } | |
| 408 | + | limit, resolvedRatio, err := subagents.BudgetLimit(parentContextWindow, ratio, tokens) | |
| 409 | + | return limit, resolvedRatio, source, err | |
| 410 | + | } | |
| 411 | + | ||
| 368 | 412 | func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string { | |
| 369 | 413 | hasWebTool := false | |
| 370 | 414 | for _, name := range selected { | |
| @@ -524,10 +568,16 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |||
| 524 | 568 | } | |
| 525 | 569 | return parsed | |
| 526 | 570 | } | |
| 571 | + | budgetRatio := subagents.DefaultBudgetRatio | |
| 572 | + | if cfg.BudgetRatio != nil { | |
| 573 | + | budgetRatio = *cfg.BudgetRatio | |
| 574 | + | } | |
| 527 | 575 | return subagents.SubagentPolicy{ | |
| 528 | - | MaxConcurrent: cfg.MaxConcurrent, | |
| 529 | - | QueueTimeout: parseDuration(cfg.QueueTimeout), | |
| 530 | - | AllowedTools: append([]string(nil), cfg.AllowedTools...), | |
| 531 | - | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | |
| 576 | + | MaxConcurrent: cfg.MaxConcurrent, | |
| 577 | + | QueueTimeout: parseDuration(cfg.QueueTimeout), | |
| 578 | + | AllowedTools: append([]string(nil), cfg.AllowedTools...), | |
| 579 | + | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | |
| 580 | + | BudgetRatio: budgetRatio, | |
| 581 | + | BudgetRatioConfigured: cfg.BudgetRatio != nil, | |
| 532 | 582 | } | |
| 533 | 583 | } | |
| @@ -34,6 +34,7 @@ type subagentRuntime struct { | |||||
| 34 | insecureTLS bool | 34 | insecureTLS bool | ||
| 35 | fastMode bool | 35 | fastMode bool | ||
| 36 | repoRoot string | 36 | repoRoot string | ||
| 37 | + | contextWindow int | |||
| 37 | 38 | ||||
| 38 | activeProvider func() string | 39 | activeProvider func() string | ||
| 39 | activeModel func() string | 40 | activeModel func() string | ||
| @@ -57,6 +58,7 @@ type subagentRuntimeConfiguration struct { | |||||
| 57 | insecureTLS bool | 58 | insecureTLS bool | ||
| 58 | fastMode bool | 59 | fastMode bool | ||
| 59 | repoRoot string | 60 | repoRoot string | ||
| 61 | + | contextWindow int | |||
| 60 | webSearchPolicy subagents.WebSearchPolicy | 62 | webSearchPolicy subagents.WebSearchPolicy | ||
| 61 | } | 63 | } | ||
| 62 | 64 | ||||
| @@ -68,12 +70,13 @@ type subagentRuntimeConfig struct { | |||||
| 68 | Root string | 70 | Root string | ||
| 69 | RepoRoot string | 71 | RepoRoot string | ||
| 70 | 72 | ||||
| 71 | - | Provider string | 73 | + | Provider string |
| 72 | - | Model string | 74 | + | Model string |
| 73 | - | Reasoning string | 75 | + | Reasoning string |
| 74 | - | BaseURL string | 76 | + | BaseURL string |
| 75 | - | InsecureTLS bool | 77 | + | InsecureTLS bool |
| 76 | - | FastMode bool | 78 | + | FastMode bool |
| 79 | + | ContextWindow int | |||
| 77 | 80 | ||||
| 78 | Policy subagents.SubagentPolicy | 81 | Policy subagents.SubagentPolicy | ||
| 79 | WebSearchPolicy subagents.WebSearchPolicy | 82 | WebSearchPolicy subagents.WebSearchPolicy | ||
| @@ -97,6 +100,7 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||||
| 97 | insecureTLS: cfg.InsecureTLS, | 100 | insecureTLS: cfg.InsecureTLS, | ||
| 98 | fastMode: cfg.FastMode, | 101 | fastMode: cfg.FastMode, | ||
| 99 | repoRoot: cfg.RepoRoot, | 102 | repoRoot: cfg.RepoRoot, | ||
| 103 | + | contextWindow: cfg.ContextWindow, | |||
| 100 | activeProvider: cfg.ActiveProvider, | 104 | activeProvider: cfg.ActiveProvider, | ||
| 101 | activeModel: cfg.ActiveModel, | 105 | activeModel: cfg.ActiveModel, | ||
| 102 | activeReasoning: cfg.ActiveReasoning, | 106 | activeReasoning: cfg.ActiveReasoning, | ||
| @@ -179,6 +183,15 @@ func (rt *subagentRuntime) SetModel(model string) { | |||||
| 179 | rt.settingsMu.Unlock() | 183 | rt.settingsMu.Unlock() | ||
| 180 | } | 184 | } | ||
| 181 | 185 | ||||
| 186 | + | func (rt *subagentRuntime) SetContextWindow(contextWindow int) { | |||
| 187 | + | if rt == nil { | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | rt.settingsMu.Lock() | |||
| 191 | + | rt.contextWindow = contextWindow | |||
| 192 | + | rt.settingsMu.Unlock() | |||
| 193 | + | } | |||
| 194 | + | ||||
| 182 | func (rt *subagentRuntime) SetReasoning(reasoning string) { | 195 | func (rt *subagentRuntime) SetReasoning(reasoning string) { | ||
| 183 | if rt == nil { | 196 | if rt == nil { | ||
| 184 | return | 197 | return | ||
| @@ -250,6 +263,7 @@ func (rt *subagentRuntime) snapshotConfiguration() subagentRuntimeConfiguration | |||||
| 250 | insecureTLS: rt.insecureTLS, | 263 | insecureTLS: rt.insecureTLS, | ||
| 251 | fastMode: rt.fastMode, | 264 | fastMode: rt.fastMode, | ||
| 252 | repoRoot: rt.repoRoot, | 265 | repoRoot: rt.repoRoot, | ||
| 266 | + | contextWindow: rt.contextWindow, | |||
| 253 | webSearchPolicy: rt.webSearchPolicy, | 267 | webSearchPolicy: rt.webSearchPolicy, | ||
| 254 | } | 268 | } | ||
| 255 | } | 269 | } | ||
| @@ -265,6 +279,7 @@ func (rt *subagentRuntime) restoreConfiguration(snapshot subagentRuntimeConfigur | |||||
| 265 | rt.insecureTLS = snapshot.insecureTLS | 279 | rt.insecureTLS = snapshot.insecureTLS | ||
| 266 | rt.fastMode = snapshot.fastMode | 280 | rt.fastMode = snapshot.fastMode | ||
| 267 | rt.repoRoot = snapshot.repoRoot | 281 | rt.repoRoot = snapshot.repoRoot | ||
| 282 | + | rt.contextWindow = snapshot.contextWindow | |||
| 268 | rt.webSearchPolicy = snapshot.webSearchPolicy | 283 | rt.webSearchPolicy = snapshot.webSearchPolicy | ||
| 269 | rt.settingsMu.Unlock() | 284 | rt.settingsMu.Unlock() | ||
| 270 | } | 285 | } | ||
| @@ -292,7 +307,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 292 | providerID, model, reasoning := activeProvider, rt.model, rt.reasoning | 307 | providerID, model, reasoning := activeProvider, rt.model, rt.reasoning | ||
| 293 | baseURL, insecureTLS := rt.baseURL, rt.insecureTLS | 308 | baseURL, insecureTLS := rt.baseURL, rt.insecureTLS | ||
| 294 | workspace, parentSession := rt.repoRoot, rt.activeSession | 309 | workspace, parentSession := rt.repoRoot, rt.activeSession | ||
| 295 | - | fastMode, policy := rt.fastMode, rt.policy | 310 | + | fastMode, policy, parentContextWindow := rt.fastMode, rt.policy, rt.contextWindow |
| 296 | rt.settingsMu.RUnlock() | 311 | rt.settingsMu.RUnlock() | ||
| 297 | if strings.TrimSpace(request.Provider) != "" { | 312 | if strings.TrimSpace(request.Provider) != "" { | ||
| 298 | providerID = strings.TrimSpace(request.Provider) | 313 | providerID = strings.TrimSpace(request.Provider) | ||
| @@ -349,11 +364,16 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 349 | if workspaceMode == "" { | 364 | if workspaceMode == "" { | ||
| 350 | workspaceMode = subagents.WorkspaceShared | 365 | workspaceMode = subagents.WorkspaceShared | ||
| 351 | } | 366 | } | ||
| 367 | + | budgetLimit, resolvedBudgetRatio, budgetSource, err := resolveResidentBudget(parentContextWindow, policy.BudgetRatio, policy.BudgetRatioConfigured, request.Profile, request.BudgetRatio, request.BudgetTokens) | |||
| 368 | + | if err != nil { | |||
| 369 | + | return subagents.ResidentChildSpec{}, err | |||
| 370 | + | } | |||
| 352 | spec := subagents.ResidentChildSpec{ | 371 | spec := subagents.ResidentChildSpec{ | ||
| 353 | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | 372 | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | ||
| 354 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | 373 | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | ||
| 355 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | 374 | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | ||
| 356 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | 375 | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | ||
| 376 | + | BudgetLimit: budgetLimit, BudgetRatio: resolvedBudgetRatio, BudgetSource: budgetSource, | |||
| 357 | } | 377 | } | ||
| 358 | if request.Profile != nil { | 378 | if request.Profile != nil { | ||
| 359 | spec.Profile = request.Profile.Name | 379 | spec.Profile = request.Profile.Name | ||
| @@ -365,6 +385,30 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 365 | return spec, nil | 385 | return spec, nil | ||
| 366 | } | 386 | } | ||
| 367 | 387 | ||||
| 388 | + | func resolveResidentBudget(parentContextWindow int, policyRatio float64, policyConfigured bool, profile *subagents.Profile, spawnRatio *float64, spawnTokens *int64) (int64, float64, string, error) { | |||
| 389 | + | ratio, source := policyRatio, "default_ratio" | |||
| 390 | + | if policyConfigured { | |||
| 391 | + | source = "config_ratio" | |||
| 392 | + | } | |||
| 393 | + | var tokens int64 | |||
| 394 | + | if profile != nil { | |||
| 395 | + | if profile.BudgetTokens != nil { | |||
| 396 | + | tokens, source = *profile.BudgetTokens, "profile_tokens" | |||
| 397 | + | } else if profile.BudgetRatio != nil { | |||
| 398 | + | ratio, source = *profile.BudgetRatio, "profile_ratio" | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | if spawnTokens != nil { | |||
| 402 | + | tokens, source = *spawnTokens, "spawn_tokens" | |||
| 403 | + | ratio = 0 | |||
| 404 | + | } else if spawnRatio != nil { | |||
| 405 | + | ratio, source = *spawnRatio, "spawn_ratio" | |||
| 406 | + | tokens = 0 | |||
| 407 | + | } | |||
| 408 | + | limit, resolvedRatio, err := subagents.BudgetLimit(parentContextWindow, ratio, tokens) | |||
| 409 | + | return limit, resolvedRatio, source, err | |||
| 410 | + | } | |||
| 411 | + | ||||
| 368 | func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string { | 412 | func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string { | ||
| 369 | hasWebTool := false | 413 | hasWebTool := false | ||
| 370 | for _, name := range selected { | 414 | for _, name := range selected { | ||
| @@ -524,10 +568,16 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |||||
| 524 | } | 568 | } | ||
| 525 | return parsed | 569 | return parsed | ||
| 526 | } | 570 | } | ||
| 571 | + | budgetRatio := subagents.DefaultBudgetRatio | |||
| 572 | + | if cfg.BudgetRatio != nil { | |||
| 573 | + | budgetRatio = *cfg.BudgetRatio | |||
| 574 | + | } | |||
| 527 | return subagents.SubagentPolicy{ | 575 | return subagents.SubagentPolicy{ | ||
| 528 | - | MaxConcurrent: cfg.MaxConcurrent, | 576 | + | MaxConcurrent: cfg.MaxConcurrent, |
| 529 | - | QueueTimeout: parseDuration(cfg.QueueTimeout), | 577 | + | QueueTimeout: parseDuration(cfg.QueueTimeout), |
| 530 | - | AllowedTools: append([]string(nil), cfg.AllowedTools...), | 578 | + | AllowedTools: append([]string(nil), cfg.AllowedTools...), |
| 531 | - | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), | 579 | + | AllowedRoots: append([]string(nil), cfg.AllowedRoots...), |
| 580 | + | BudgetRatio: budgetRatio, | |||
| 581 | + | BudgetRatioConfigured: cfg.BudgetRatio != nil, | |||
| 532 | } | 582 | } | ||
| 533 | } | 583 | } | ||
M packages/agent/subagent_runtime_test.go +74 -4
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 7 | 8 | "github.com/bnema/zut/packages/agent/tools" | |
| 8 | 9 | "github.com/bnema/zut/packages/core" | |
| 9 | 10 | ) | |
| @@ -53,23 +54,92 @@ func TestExpandWebCapabilityToolsPreservesPolicyAndNonWebSelections(t *testing.T | |||
| 53 | 54 | func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) { | |
| 54 | 55 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 55 | 56 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | |
| 56 | - | Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false, | |
| 57 | + | Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false, ContextWindow: 500_000, | |
| 57 | 58 | }) | |
| 58 | 59 | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |
| 59 | 60 | runtime.SetProviderSettings("https://current.example/v1", true) | |
| 61 | + | runtime.SetModel("gpt-current") | |
| 60 | 62 | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil}) | |
| 61 | 63 | if err != nil { | |
| 62 | 64 | t.Fatal(err) | |
| 63 | 65 | } | |
| 64 | - | if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS { | |
| 65 | - | t.Fatalf("resident spec transport = %q insecure=%t", spec.BaseURL, spec.InsecureTLS) | |
| 66 | + | if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS || spec.Model != "gpt-current" { | |
| 67 | + | t.Fatalf("resident spec transport/model = %q insecure=%t model=%q", spec.BaseURL, spec.InsecureTLS, spec.Model) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) { | |
| 72 | + | ratio := 0.6 | |
| 73 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 74 | + | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | |
| 75 | + | Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 500_000, | |
| 76 | + | Policy: subagents.SubagentPolicy{BudgetRatio: ratio, BudgetRatioConfigured: true}, | |
| 77 | + | }) | |
| 78 | + | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |
| 79 | + | ||
| 80 | + | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil}) | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | if spec.BudgetLimit != 300_000 || spec.BudgetRatio != ratio || spec.BudgetSource != "config_ratio" { | |
| 85 | + | t.Fatalf("inherited budget = %#v", spec) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | tokens := int64(42_000) | |
| 89 | + | spec, err = runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |
| 90 | + | Task: "deep review", BudgetTokens: &tokens, | |
| 91 | + | }, core.Registry{"read": nil}) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | if spec.BudgetLimit != tokens || spec.BudgetRatio != 0 || spec.BudgetSource != "spawn_tokens" { | |
| 96 | + | t.Fatalf("absolute budget = %#v", spec) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestResolveResidentBudgetPrecedence(t *testing.T) { | |
| 101 | + | configRatio, profileRatio, spawnRatio := 0.6, 0.5, 0.4 | |
| 102 | + | profileTokens, spawnTokens := int64(200_000), int64(150_000) | |
| 103 | + | for _, tc := range []struct { | |
| 104 | + | name string | |
| 105 | + | profile *subagents.Profile | |
| 106 | + | spawnRatio *float64 | |
| 107 | + | spawnTokens *int64 | |
| 108 | + | wantLimit int64 | |
| 109 | + | wantRatio float64 | |
| 110 | + | wantSource string | |
| 111 | + | }{ | |
| 112 | + | {name: "default", wantLimit: 375_000, wantRatio: 0.75, wantSource: "default_ratio"}, | |
| 113 | + | {name: "config ratio", wantLimit: 300_000, wantRatio: configRatio, wantSource: "config_ratio"}, | |
| 114 | + | {name: "profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, wantLimit: 250_000, wantRatio: profileRatio, wantSource: "profile_ratio"}, | |
| 115 | + | {name: "profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, wantLimit: profileTokens, wantSource: "profile_tokens"}, | |
| 116 | + | {name: "spawn ratio over profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, spawnRatio: &spawnRatio, wantLimit: 200_000, wantRatio: spawnRatio, wantSource: "spawn_ratio"}, | |
| 117 | + | {name: "spawn tokens over profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, spawnTokens: &spawnTokens, wantLimit: spawnTokens, wantSource: "spawn_tokens"}, | |
| 118 | + | } { | |
| 119 | + | t.Run(tc.name, func(t *testing.T) { | |
| 120 | + | policyRatio := configRatio | |
| 121 | + | if tc.name == "default" { | |
| 122 | + | policyRatio = 0 | |
| 123 | + | } | |
| 124 | + | limit, ratio, source, err := resolveResidentBudget(500_000, policyRatio, tc.name != "default", tc.profile, tc.spawnRatio, tc.spawnTokens) | |
| 125 | + | if err != nil { | |
| 126 | + | t.Fatal(err) | |
| 127 | + | } | |
| 128 | + | if limit != tc.wantLimit || ratio != tc.wantRatio || source != tc.wantSource { | |
| 129 | + | t.Fatalf("resolved budget = (%d, %v, %q), want (%d, %v, %q)", limit, ratio, source, tc.wantLimit, tc.wantRatio, tc.wantSource) | |
| 130 | + | } | |
| 131 | + | }) | |
| 132 | + | } | |
| 133 | + | limit, ratio, source, err := resolveResidentBudget(500_000, 0.75, true, nil, nil, nil) | |
| 134 | + | if err != nil || limit != 375_000 || ratio != 0.75 || source != "config_ratio" { | |
| 135 | + | t.Fatalf("explicit default ratio = (%d, %v, %q, %v)", limit, ratio, source, err) | |
| 66 | 136 | } | |
| 67 | 137 | } | |
| 68 | 138 | ||
| 69 | 139 | func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) { | |
| 70 | 140 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 71 | 141 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | |
| 72 | - | Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true, | |
| 142 | + | Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true, ContextWindow: 500_000, | |
| 73 | 143 | }) | |
| 74 | 144 | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |
| 75 | 145 | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 7 | "github.com/bnema/zut/packages/agent/tools" | 8 | "github.com/bnema/zut/packages/agent/tools" | ||
| 8 | "github.com/bnema/zut/packages/core" | 9 | "github.com/bnema/zut/packages/core" | ||
| 9 | ) | 10 | ) | ||
| @@ -53,23 +54,92 @@ func TestExpandWebCapabilityToolsPreservesPolicyAndNonWebSelections(t *testing.T | |||||
| 53 | func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) { | 54 | func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) { | ||
| 54 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | 55 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | ||
| 55 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | 56 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | ||
| 56 | - | Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false, | 57 | + | Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false, ContextWindow: 500_000, |
| 57 | }) | 58 | }) | ||
| 58 | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | 59 | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | ||
| 59 | runtime.SetProviderSettings("https://current.example/v1", true) | 60 | runtime.SetProviderSettings("https://current.example/v1", true) | ||
| 61 | + | runtime.SetModel("gpt-current") | |||
| 60 | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil}) | 62 | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil}) | ||
| 61 | if err != nil { | 63 | if err != nil { | ||
| 62 | t.Fatal(err) | 64 | t.Fatal(err) | ||
| 63 | } | 65 | } | ||
| 64 | - | if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS { | 66 | + | if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS || spec.Model != "gpt-current" { |
| 65 | - | t.Fatalf("resident spec transport = %q insecure=%t", spec.BaseURL, spec.InsecureTLS) | 67 | + | t.Fatalf("resident spec transport/model = %q insecure=%t model=%q", spec.BaseURL, spec.InsecureTLS, spec.Model) |
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestResidentChildSpecInheritsAndOverridesRolloutBudget(t *testing.T) { | |||
| 72 | + | ratio := 0.6 | |||
| 73 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |||
| 74 | + | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | |||
| 75 | + | Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 500_000, | |||
| 76 | + | Policy: subagents.SubagentPolicy{BudgetRatio: ratio, BudgetRatioConfigured: true}, | |||
| 77 | + | }) | |||
| 78 | + | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |||
| 79 | + | ||||
| 80 | + | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil}) | |||
| 81 | + | if err != nil { | |||
| 82 | + | t.Fatal(err) | |||
| 83 | + | } | |||
| 84 | + | if spec.BudgetLimit != 300_000 || spec.BudgetRatio != ratio || spec.BudgetSource != "config_ratio" { | |||
| 85 | + | t.Fatalf("inherited budget = %#v", spec) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | tokens := int64(42_000) | |||
| 89 | + | spec, err = runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |||
| 90 | + | Task: "deep review", BudgetTokens: &tokens, | |||
| 91 | + | }, core.Registry{"read": nil}) | |||
| 92 | + | if err != nil { | |||
| 93 | + | t.Fatal(err) | |||
| 94 | + | } | |||
| 95 | + | if spec.BudgetLimit != tokens || spec.BudgetRatio != 0 || spec.BudgetSource != "spawn_tokens" { | |||
| 96 | + | t.Fatalf("absolute budget = %#v", spec) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestResolveResidentBudgetPrecedence(t *testing.T) { | |||
| 101 | + | configRatio, profileRatio, spawnRatio := 0.6, 0.5, 0.4 | |||
| 102 | + | profileTokens, spawnTokens := int64(200_000), int64(150_000) | |||
| 103 | + | for _, tc := range []struct { | |||
| 104 | + | name string | |||
| 105 | + | profile *subagents.Profile | |||
| 106 | + | spawnRatio *float64 | |||
| 107 | + | spawnTokens *int64 | |||
| 108 | + | wantLimit int64 | |||
| 109 | + | wantRatio float64 | |||
| 110 | + | wantSource string | |||
| 111 | + | }{ | |||
| 112 | + | {name: "default", wantLimit: 375_000, wantRatio: 0.75, wantSource: "default_ratio"}, | |||
| 113 | + | {name: "config ratio", wantLimit: 300_000, wantRatio: configRatio, wantSource: "config_ratio"}, | |||
| 114 | + | {name: "profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, wantLimit: 250_000, wantRatio: profileRatio, wantSource: "profile_ratio"}, | |||
| 115 | + | {name: "profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, wantLimit: profileTokens, wantSource: "profile_tokens"}, | |||
| 116 | + | {name: "spawn ratio over profile tokens", profile: &subagents.Profile{BudgetTokens: &profileTokens}, spawnRatio: &spawnRatio, wantLimit: 200_000, wantRatio: spawnRatio, wantSource: "spawn_ratio"}, | |||
| 117 | + | {name: "spawn tokens over profile ratio", profile: &subagents.Profile{BudgetRatio: &profileRatio}, spawnTokens: &spawnTokens, wantLimit: spawnTokens, wantSource: "spawn_tokens"}, | |||
| 118 | + | } { | |||
| 119 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 120 | + | policyRatio := configRatio | |||
| 121 | + | if tc.name == "default" { | |||
| 122 | + | policyRatio = 0 | |||
| 123 | + | } | |||
| 124 | + | limit, ratio, source, err := resolveResidentBudget(500_000, policyRatio, tc.name != "default", tc.profile, tc.spawnRatio, tc.spawnTokens) | |||
| 125 | + | if err != nil { | |||
| 126 | + | t.Fatal(err) | |||
| 127 | + | } | |||
| 128 | + | if limit != tc.wantLimit || ratio != tc.wantRatio || source != tc.wantSource { | |||
| 129 | + | t.Fatalf("resolved budget = (%d, %v, %q), want (%d, %v, %q)", limit, ratio, source, tc.wantLimit, tc.wantRatio, tc.wantSource) | |||
| 130 | + | } | |||
| 131 | + | }) | |||
| 132 | + | } | |||
| 133 | + | limit, ratio, source, err := resolveResidentBudget(500_000, 0.75, true, nil, nil, nil) | |||
| 134 | + | if err != nil || limit != 375_000 || ratio != 0.75 || source != "config_ratio" { | |||
| 135 | + | t.Fatalf("explicit default ratio = (%d, %v, %q, %v)", limit, ratio, source, err) | |||
| 66 | } | 136 | } | ||
| 67 | } | 137 | } | ||
| 68 | 138 | ||||
| 69 | func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) { | 139 | func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) { | ||
| 70 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | 140 | runtime := newSubagentRuntime(subagentRuntimeConfig{ | ||
| 71 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | 141 | Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(), | ||
| 72 | - | Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true, | 142 | + | Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true, ContextWindow: 500_000, |
| 73 | }) | 143 | }) | ||
| 74 | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | 144 | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | ||
| 75 | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | 145 | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | ||
A packages/agent/subagents/budget.go +211 -0
| @@ -0,0 +1,211 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "math" | |
| 7 | + | "sync" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | DefaultBudgetRatio = 0.75 | |
| 14 | + | cachedInputBudgetWeight = 0.25 | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // BudgetState is the progressive constraint applied to a resident child. | |
| 18 | + | type BudgetState string | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | BudgetNormal BudgetState = "normal" | |
| 22 | + | BudgetNotice BudgetState = "notice" | |
| 23 | + | BudgetFocused BudgetState = "focused" | |
| 24 | + | BudgetVerifying BudgetState = "verifying" | |
| 25 | + | BudgetFinalizing BudgetState = "finalizing" | |
| 26 | + | BudgetExceeded BudgetState = "exceeded" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded") | |
| 30 | + | ||
| 31 | + | // BudgetSnapshot is the bounded public projection of a child's cumulative | |
| 32 | + | // rollout budget. Used is weighted usage, not the active context size. | |
| 33 | + | type BudgetSnapshot struct { | |
| 34 | + | Used int64 `json:"used_tokens"` | |
| 35 | + | Limit int64 `json:"limit_tokens"` | |
| 36 | + | Percent int `json:"percent"` | |
| 37 | + | State BudgetState `json:"state"` | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func BudgetLimit(contextWindow int, ratio float64, tokens int64) (int64, float64, error) { | |
| 41 | + | if tokens < 0 { | |
| 42 | + | return 0, 0, errors.New("budget_tokens must be positive") | |
| 43 | + | } | |
| 44 | + | if tokens > 0 { | |
| 45 | + | return tokens, 0, nil | |
| 46 | + | } | |
| 47 | + | if ratio == 0 { | |
| 48 | + | ratio = DefaultBudgetRatio | |
| 49 | + | } | |
| 50 | + | if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio <= 0 || ratio > 1 { | |
| 51 | + | return 0, 0, errors.New("budget_ratio must be greater than 0 and at most 1") | |
| 52 | + | } | |
| 53 | + | if contextWindow <= 0 { | |
| 54 | + | return 0, ratio, errors.New("cannot derive a subagent budget without a parent context window") | |
| 55 | + | } | |
| 56 | + | limit := int64(math.Floor(float64(contextWindow) * ratio)) | |
| 57 | + | if limit <= 0 { | |
| 58 | + | return 0, ratio, errors.New("derived subagent budget is empty") | |
| 59 | + | } | |
| 60 | + | return limit, ratio, nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // WeightedBudgetUsage charges uncached and cache-write input fully, cache-read | |
| 64 | + | // input at a reduced weight, and output once (provider OutputTokens already | |
| 65 | + | // includes reasoning tokens where the provider reports them). | |
| 66 | + | func WeightedBudgetUsage(usage provider.Usage) int64 { | |
| 67 | + | weighted := float64(max(usage.InputTokens, 0)) + | |
| 68 | + | float64(max(usage.CacheWriteTokens, 0)) + | |
| 69 | + | float64(max(usage.OutputTokens, 0)) + | |
| 70 | + | float64(max(usage.CacheReadTokens, 0))*cachedInputBudgetWeight | |
| 71 | + | return int64(math.Ceil(weighted)) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // EffectiveBudgetLimit preserves budgets accepted by current versions and | |
| 75 | + | // derives the default for resident specs written before budgets were stored. | |
| 76 | + | func EffectiveBudgetLimit(limit int64, contextWindow int) int64 { | |
| 77 | + | if limit > 0 { | |
| 78 | + | return limit | |
| 79 | + | } | |
| 80 | + | derived, _, err := BudgetLimit(contextWindow, DefaultBudgetRatio, 0) | |
| 81 | + | if err != nil { | |
| 82 | + | return 0 | |
| 83 | + | } | |
| 84 | + | return derived | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func BudgetSystemPrompt(limit int64) string { | |
| 88 | + | if limit <= 0 { | |
| 89 | + | return "" | |
| 90 | + | } | |
| 91 | + | return fmt.Sprintf("Your cumulative rollout budget is %d weighted tokens. At 50%% prioritize concrete evidence; at 70%% stop broad exploration; at 85%% verify existing candidates only; at 90%% stop using tools and return the best verified final result. The host enforces the finalization reserve and terminates work at 100%%.", limit) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot { | |
| 95 | + | if limit <= 0 { | |
| 96 | + | return BudgetSnapshot{} | |
| 97 | + | } | |
| 98 | + | used := WeightedBudgetUsage(usage) | |
| 99 | + | percent := int(math.Floor(float64(used) * 100 / float64(limit))) | |
| 100 | + | return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)} | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func budgetState(used, limit int64) BudgetState { | |
| 104 | + | if limit <= 0 { | |
| 105 | + | return "" | |
| 106 | + | } | |
| 107 | + | ratio := float64(used) / float64(limit) | |
| 108 | + | switch { | |
| 109 | + | case ratio >= 1: | |
| 110 | + | return BudgetExceeded | |
| 111 | + | case ratio >= 0.90: | |
| 112 | + | return BudgetFinalizing | |
| 113 | + | case ratio >= 0.85: | |
| 114 | + | return BudgetVerifying | |
| 115 | + | case ratio >= 0.70: | |
| 116 | + | return BudgetFocused | |
| 117 | + | case ratio >= 0.50: | |
| 118 | + | return BudgetNotice | |
| 119 | + | default: | |
| 120 | + | return BudgetNormal | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // RolloutBudget accounts one resident child's cumulative provider usage and | |
| 125 | + | // produces progressive model instructions at threshold transitions. | |
| 126 | + | type RolloutBudget struct { | |
| 127 | + | mu sync.Mutex | |
| 128 | + | limit int64 | |
| 129 | + | snapshot BudgetSnapshot | |
| 130 | + | delivered BudgetState | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget { | |
| 134 | + | budget := &RolloutBudget{limit: limit} | |
| 135 | + | budget.snapshot = BudgetSnapshotFor(usage, limit) | |
| 136 | + | return budget | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (b *RolloutBudget) Observe(usage provider.Usage) BudgetSnapshot { | |
| 140 | + | if b == nil { | |
| 141 | + | return BudgetSnapshot{} | |
| 142 | + | } | |
| 143 | + | b.mu.Lock() | |
| 144 | + | b.snapshot = BudgetSnapshotFor(usage, b.limit) | |
| 145 | + | snapshot := b.snapshot | |
| 146 | + | b.mu.Unlock() | |
| 147 | + | return snapshot | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (b *RolloutBudget) Snapshot() BudgetSnapshot { | |
| 151 | + | if b == nil { | |
| 152 | + | return BudgetSnapshot{} | |
| 153 | + | } | |
| 154 | + | b.mu.Lock() | |
| 155 | + | snapshot := b.snapshot | |
| 156 | + | b.mu.Unlock() | |
| 157 | + | return snapshot | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // TurnContext returns a one-shot model-visible transition alert. Core stores | |
| 161 | + | // the resulting developer context in the transcript, including across compaction. | |
| 162 | + | func (b *RolloutBudget) TurnContext() (string, bool) { | |
| 163 | + | if b == nil { | |
| 164 | + | return "", false | |
| 165 | + | } | |
| 166 | + | b.mu.Lock() | |
| 167 | + | defer b.mu.Unlock() | |
| 168 | + | state := b.snapshot.State | |
| 169 | + | if state == BudgetExceeded { | |
| 170 | + | return "", true | |
| 171 | + | } | |
| 172 | + | if budgetStateRank(state) <= budgetStateRank(b.delivered) { | |
| 173 | + | return "", false | |
| 174 | + | } | |
| 175 | + | b.delivered = state | |
| 176 | + | return budgetInstruction(b.snapshot), false | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func budgetStateRank(state BudgetState) int { | |
| 180 | + | switch state { | |
| 181 | + | case BudgetNotice: | |
| 182 | + | return 1 | |
| 183 | + | case BudgetFocused: | |
| 184 | + | return 2 | |
| 185 | + | case BudgetVerifying: | |
| 186 | + | return 3 | |
| 187 | + | case BudgetFinalizing: | |
| 188 | + | return 4 | |
| 189 | + | case BudgetExceeded: | |
| 190 | + | return 5 | |
| 191 | + | default: | |
| 192 | + | return 0 | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func budgetInstruction(snapshot BudgetSnapshot) string { | |
| 197 | + | remaining := max(snapshot.Limit-snapshot.Used, 0) | |
| 198 | + | prefix := fmt.Sprintf("[subagent budget: %d%% used, %d weighted tokens remaining] ", snapshot.Percent, remaining) | |
| 199 | + | switch snapshot.State { | |
| 200 | + | case BudgetNotice: | |
| 201 | + | return prefix + "Keep the remaining investigation bounded and prioritize concrete evidence." | |
| 202 | + | case BudgetFocused: | |
| 203 | + | return prefix + "Stop broad exploration. Investigate only concrete unresolved hypotheses." | |
| 204 | + | case BudgetVerifying: | |
| 205 | + | return prefix + "Do not identify new candidates. Verify or reject current candidates and prepare the final result." | |
| 206 | + | case BudgetFinalizing: | |
| 207 | + | return prefix + "Finalization is required now. Return the best verified result without using tools or starting new investigations." | |
| 208 | + | default: | |
| 209 | + | return "" | |
| 210 | + | } | |
| 211 | + | } | |
| @@ -0,0 +1,211 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "math" | |||
| 7 | + | "sync" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | DefaultBudgetRatio = 0.75 | |||
| 14 | + | cachedInputBudgetWeight = 0.25 | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // BudgetState is the progressive constraint applied to a resident child. | |||
| 18 | + | type BudgetState string | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | BudgetNormal BudgetState = "normal" | |||
| 22 | + | BudgetNotice BudgetState = "notice" | |||
| 23 | + | BudgetFocused BudgetState = "focused" | |||
| 24 | + | BudgetVerifying BudgetState = "verifying" | |||
| 25 | + | BudgetFinalizing BudgetState = "finalizing" | |||
| 26 | + | BudgetExceeded BudgetState = "exceeded" | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | var ErrBudgetExceeded = errors.New("resident subagent rollout budget exceeded") | |||
| 30 | + | ||||
| 31 | + | // BudgetSnapshot is the bounded public projection of a child's cumulative | |||
| 32 | + | // rollout budget. Used is weighted usage, not the active context size. | |||
| 33 | + | type BudgetSnapshot struct { | |||
| 34 | + | Used int64 `json:"used_tokens"` | |||
| 35 | + | Limit int64 `json:"limit_tokens"` | |||
| 36 | + | Percent int `json:"percent"` | |||
| 37 | + | State BudgetState `json:"state"` | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func BudgetLimit(contextWindow int, ratio float64, tokens int64) (int64, float64, error) { | |||
| 41 | + | if tokens < 0 { | |||
| 42 | + | return 0, 0, errors.New("budget_tokens must be positive") | |||
| 43 | + | } | |||
| 44 | + | if tokens > 0 { | |||
| 45 | + | return tokens, 0, nil | |||
| 46 | + | } | |||
| 47 | + | if ratio == 0 { | |||
| 48 | + | ratio = DefaultBudgetRatio | |||
| 49 | + | } | |||
| 50 | + | if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio <= 0 || ratio > 1 { | |||
| 51 | + | return 0, 0, errors.New("budget_ratio must be greater than 0 and at most 1") | |||
| 52 | + | } | |||
| 53 | + | if contextWindow <= 0 { | |||
| 54 | + | return 0, ratio, errors.New("cannot derive a subagent budget without a parent context window") | |||
| 55 | + | } | |||
| 56 | + | limit := int64(math.Floor(float64(contextWindow) * ratio)) | |||
| 57 | + | if limit <= 0 { | |||
| 58 | + | return 0, ratio, errors.New("derived subagent budget is empty") | |||
| 59 | + | } | |||
| 60 | + | return limit, ratio, nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // WeightedBudgetUsage charges uncached and cache-write input fully, cache-read | |||
| 64 | + | // input at a reduced weight, and output once (provider OutputTokens already | |||
| 65 | + | // includes reasoning tokens where the provider reports them). | |||
| 66 | + | func WeightedBudgetUsage(usage provider.Usage) int64 { | |||
| 67 | + | weighted := float64(max(usage.InputTokens, 0)) + | |||
| 68 | + | float64(max(usage.CacheWriteTokens, 0)) + | |||
| 69 | + | float64(max(usage.OutputTokens, 0)) + | |||
| 70 | + | float64(max(usage.CacheReadTokens, 0))*cachedInputBudgetWeight | |||
| 71 | + | return int64(math.Ceil(weighted)) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // EffectiveBudgetLimit preserves budgets accepted by current versions and | |||
| 75 | + | // derives the default for resident specs written before budgets were stored. | |||
| 76 | + | func EffectiveBudgetLimit(limit int64, contextWindow int) int64 { | |||
| 77 | + | if limit > 0 { | |||
| 78 | + | return limit | |||
| 79 | + | } | |||
| 80 | + | derived, _, err := BudgetLimit(contextWindow, DefaultBudgetRatio, 0) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return 0 | |||
| 83 | + | } | |||
| 84 | + | return derived | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func BudgetSystemPrompt(limit int64) string { | |||
| 88 | + | if limit <= 0 { | |||
| 89 | + | return "" | |||
| 90 | + | } | |||
| 91 | + | return fmt.Sprintf("Your cumulative rollout budget is %d weighted tokens. At 50%% prioritize concrete evidence; at 70%% stop broad exploration; at 85%% verify existing candidates only; at 90%% stop using tools and return the best verified final result. The host enforces the finalization reserve and terminates work at 100%%.", limit) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func BudgetSnapshotFor(usage provider.Usage, limit int64) BudgetSnapshot { | |||
| 95 | + | if limit <= 0 { | |||
| 96 | + | return BudgetSnapshot{} | |||
| 97 | + | } | |||
| 98 | + | used := WeightedBudgetUsage(usage) | |||
| 99 | + | percent := int(math.Floor(float64(used) * 100 / float64(limit))) | |||
| 100 | + | return BudgetSnapshot{Used: used, Limit: limit, Percent: percent, State: budgetState(used, limit)} | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func budgetState(used, limit int64) BudgetState { | |||
| 104 | + | if limit <= 0 { | |||
| 105 | + | return "" | |||
| 106 | + | } | |||
| 107 | + | ratio := float64(used) / float64(limit) | |||
| 108 | + | switch { | |||
| 109 | + | case ratio >= 1: | |||
| 110 | + | return BudgetExceeded | |||
| 111 | + | case ratio >= 0.90: | |||
| 112 | + | return BudgetFinalizing | |||
| 113 | + | case ratio >= 0.85: | |||
| 114 | + | return BudgetVerifying | |||
| 115 | + | case ratio >= 0.70: | |||
| 116 | + | return BudgetFocused | |||
| 117 | + | case ratio >= 0.50: | |||
| 118 | + | return BudgetNotice | |||
| 119 | + | default: | |||
| 120 | + | return BudgetNormal | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // RolloutBudget accounts one resident child's cumulative provider usage and | |||
| 125 | + | // produces progressive model instructions at threshold transitions. | |||
| 126 | + | type RolloutBudget struct { | |||
| 127 | + | mu sync.Mutex | |||
| 128 | + | limit int64 | |||
| 129 | + | snapshot BudgetSnapshot | |||
| 130 | + | delivered BudgetState | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func NewRolloutBudget(limit int64, usage provider.Usage) *RolloutBudget { | |||
| 134 | + | budget := &RolloutBudget{limit: limit} | |||
| 135 | + | budget.snapshot = BudgetSnapshotFor(usage, limit) | |||
| 136 | + | return budget | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (b *RolloutBudget) Observe(usage provider.Usage) BudgetSnapshot { | |||
| 140 | + | if b == nil { | |||
| 141 | + | return BudgetSnapshot{} | |||
| 142 | + | } | |||
| 143 | + | b.mu.Lock() | |||
| 144 | + | b.snapshot = BudgetSnapshotFor(usage, b.limit) | |||
| 145 | + | snapshot := b.snapshot | |||
| 146 | + | b.mu.Unlock() | |||
| 147 | + | return snapshot | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (b *RolloutBudget) Snapshot() BudgetSnapshot { | |||
| 151 | + | if b == nil { | |||
| 152 | + | return BudgetSnapshot{} | |||
| 153 | + | } | |||
| 154 | + | b.mu.Lock() | |||
| 155 | + | snapshot := b.snapshot | |||
| 156 | + | b.mu.Unlock() | |||
| 157 | + | return snapshot | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // TurnContext returns a one-shot model-visible transition alert. Core stores | |||
| 161 | + | // the resulting developer context in the transcript, including across compaction. | |||
| 162 | + | func (b *RolloutBudget) TurnContext() (string, bool) { | |||
| 163 | + | if b == nil { | |||
| 164 | + | return "", false | |||
| 165 | + | } | |||
| 166 | + | b.mu.Lock() | |||
| 167 | + | defer b.mu.Unlock() | |||
| 168 | + | state := b.snapshot.State | |||
| 169 | + | if state == BudgetExceeded { | |||
| 170 | + | return "", true | |||
| 171 | + | } | |||
| 172 | + | if budgetStateRank(state) <= budgetStateRank(b.delivered) { | |||
| 173 | + | return "", false | |||
| 174 | + | } | |||
| 175 | + | b.delivered = state | |||
| 176 | + | return budgetInstruction(b.snapshot), false | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func budgetStateRank(state BudgetState) int { | |||
| 180 | + | switch state { | |||
| 181 | + | case BudgetNotice: | |||
| 182 | + | return 1 | |||
| 183 | + | case BudgetFocused: | |||
| 184 | + | return 2 | |||
| 185 | + | case BudgetVerifying: | |||
| 186 | + | return 3 | |||
| 187 | + | case BudgetFinalizing: | |||
| 188 | + | return 4 | |||
| 189 | + | case BudgetExceeded: | |||
| 190 | + | return 5 | |||
| 191 | + | default: | |||
| 192 | + | return 0 | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func budgetInstruction(snapshot BudgetSnapshot) string { | |||
| 197 | + | remaining := max(snapshot.Limit-snapshot.Used, 0) | |||
| 198 | + | prefix := fmt.Sprintf("[subagent budget: %d%% used, %d weighted tokens remaining] ", snapshot.Percent, remaining) | |||
| 199 | + | switch snapshot.State { | |||
| 200 | + | case BudgetNotice: | |||
| 201 | + | return prefix + "Keep the remaining investigation bounded and prioritize concrete evidence." | |||
| 202 | + | case BudgetFocused: | |||
| 203 | + | return prefix + "Stop broad exploration. Investigate only concrete unresolved hypotheses." | |||
| 204 | + | case BudgetVerifying: | |||
| 205 | + | return prefix + "Do not identify new candidates. Verify or reject current candidates and prepare the final result." | |||
| 206 | + | case BudgetFinalizing: | |||
| 207 | + | return prefix + "Finalization is required now. Return the best verified result without using tools or starting new investigations." | |||
| 208 | + | default: | |||
| 209 | + | return "" | |||
| 210 | + | } | |||
| 211 | + | } | |||
A packages/agent/subagents/budget_test.go +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestBudgetLimitDefaultsToSeventyFivePercent(t *testing.T) { | |
| 11 | + | limit, ratio, err := BudgetLimit(500_000, 0, 0) | |
| 12 | + | if err != nil { | |
| 13 | + | t.Fatal(err) | |
| 14 | + | } | |
| 15 | + | if limit != 375_000 || ratio != 0.75 { | |
| 16 | + | t.Fatalf("BudgetLimit = %d, %v; want 375000, 0.75", limit, ratio) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | limit, ratio, err = BudgetLimit(500_000, 0.5, 0) | |
| 20 | + | if err != nil || limit != 250_000 || ratio != 0.5 { | |
| 21 | + | t.Fatalf("ratio override = %d, %v, %v", limit, ratio, err) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | limit, ratio, err = BudgetLimit(500_000, 0.5, 42_000) | |
| 25 | + | if err != nil || limit != 42_000 || ratio != 0 { | |
| 26 | + | t.Fatalf("token override = %d, %v, %v", limit, ratio, err) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestEffectiveBudgetLimitDerivesLegacyDefault(t *testing.T) { | |
| 31 | + | if got := EffectiveBudgetLimit(0, 500_000); got != 375_000 { | |
| 32 | + | t.Fatalf("legacy budget limit = %d, want 375000", got) | |
| 33 | + | } | |
| 34 | + | if got := EffectiveBudgetLimit(42_000, 500_000); got != 42_000 { | |
| 35 | + | t.Fatalf("persisted budget limit = %d, want 42000", got) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) { | |
| 40 | + | usage := provider.Usage{InputTokens: 100, CacheReadTokens: 100, CacheWriteTokens: 20, OutputTokens: 30, ReasoningTokens: 10, ReasoningTokensKnown: true} | |
| 41 | + | if got := WeightedBudgetUsage(usage); got != 175 { | |
| 42 | + | t.Fatalf("WeightedBudgetUsage = %d, want 175", got) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestBudgetThresholdInstructionsAreProgressiveAndOneShot(t *testing.T) { | |
| 47 | + | budget := NewRolloutBudget(1_000, provider.Usage{}) | |
| 48 | + | cases := []struct { | |
| 49 | + | used int | |
| 50 | + | state BudgetState | |
| 51 | + | text string | |
| 52 | + | }{ | |
| 53 | + | {500, BudgetNotice, "prioritize concrete evidence"}, | |
| 54 | + | {700, BudgetFocused, "Stop broad exploration"}, | |
| 55 | + | {850, BudgetVerifying, "Do not identify new candidates"}, | |
| 56 | + | {900, BudgetFinalizing, "Finalization is required now"}, | |
| 57 | + | } | |
| 58 | + | for _, tc := range cases { | |
| 59 | + | budget.Observe(provider.Usage{InputTokens: tc.used}) | |
| 60 | + | if got := budget.Snapshot().State; got != tc.state { | |
| 61 | + | t.Fatalf("usage %d state = %q, want %q", tc.used, got, tc.state) | |
| 62 | + | } | |
| 63 | + | instruction, exceeded := budget.TurnContext() | |
| 64 | + | if exceeded || !strings.Contains(instruction, tc.text) { | |
| 65 | + | t.Fatalf("usage %d instruction = %q exceeded=%t", tc.used, instruction, exceeded) | |
| 66 | + | } | |
| 67 | + | if repeated, exceeded := budget.TurnContext(); repeated != "" || exceeded { | |
| 68 | + | t.Fatalf("usage %d repeated instruction = %q exceeded=%t", tc.used, repeated, exceeded) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | budget.Observe(provider.Usage{InputTokens: 1_000}) | |
| 73 | + | if instruction, exceeded := budget.TurnContext(); instruction != "" || !exceeded { | |
| 74 | + | t.Fatalf("exhausted instruction = %q exceeded=%t", instruction, exceeded) | |
| 75 | + | } | |
| 76 | + | } | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestBudgetLimitDefaultsToSeventyFivePercent(t *testing.T) { | |||
| 11 | + | limit, ratio, err := BudgetLimit(500_000, 0, 0) | |||
| 12 | + | if err != nil { | |||
| 13 | + | t.Fatal(err) | |||
| 14 | + | } | |||
| 15 | + | if limit != 375_000 || ratio != 0.75 { | |||
| 16 | + | t.Fatalf("BudgetLimit = %d, %v; want 375000, 0.75", limit, ratio) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | limit, ratio, err = BudgetLimit(500_000, 0.5, 0) | |||
| 20 | + | if err != nil || limit != 250_000 || ratio != 0.5 { | |||
| 21 | + | t.Fatalf("ratio override = %d, %v, %v", limit, ratio, err) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | limit, ratio, err = BudgetLimit(500_000, 0.5, 42_000) | |||
| 25 | + | if err != nil || limit != 42_000 || ratio != 0 { | |||
| 26 | + | t.Fatalf("token override = %d, %v, %v", limit, ratio, err) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestEffectiveBudgetLimitDerivesLegacyDefault(t *testing.T) { | |||
| 31 | + | if got := EffectiveBudgetLimit(0, 500_000); got != 375_000 { | |||
| 32 | + | t.Fatalf("legacy budget limit = %d, want 375000", got) | |||
| 33 | + | } | |||
| 34 | + | if got := EffectiveBudgetLimit(42_000, 500_000); got != 42_000 { | |||
| 35 | + | t.Fatalf("persisted budget limit = %d, want 42000", got) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestWeightedBudgetUsageDiscountsCacheReads(t *testing.T) { | |||
| 40 | + | usage := provider.Usage{InputTokens: 100, CacheReadTokens: 100, CacheWriteTokens: 20, OutputTokens: 30, ReasoningTokens: 10, ReasoningTokensKnown: true} | |||
| 41 | + | if got := WeightedBudgetUsage(usage); got != 175 { | |||
| 42 | + | t.Fatalf("WeightedBudgetUsage = %d, want 175", got) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestBudgetThresholdInstructionsAreProgressiveAndOneShot(t *testing.T) { | |||
| 47 | + | budget := NewRolloutBudget(1_000, provider.Usage{}) | |||
| 48 | + | cases := []struct { | |||
| 49 | + | used int | |||
| 50 | + | state BudgetState | |||
| 51 | + | text string | |||
| 52 | + | }{ | |||
| 53 | + | {500, BudgetNotice, "prioritize concrete evidence"}, | |||
| 54 | + | {700, BudgetFocused, "Stop broad exploration"}, | |||
| 55 | + | {850, BudgetVerifying, "Do not identify new candidates"}, | |||
| 56 | + | {900, BudgetFinalizing, "Finalization is required now"}, | |||
| 57 | + | } | |||
| 58 | + | for _, tc := range cases { | |||
| 59 | + | budget.Observe(provider.Usage{InputTokens: tc.used}) | |||
| 60 | + | if got := budget.Snapshot().State; got != tc.state { | |||
| 61 | + | t.Fatalf("usage %d state = %q, want %q", tc.used, got, tc.state) | |||
| 62 | + | } | |||
| 63 | + | instruction, exceeded := budget.TurnContext() | |||
| 64 | + | if exceeded || !strings.Contains(instruction, tc.text) { | |||
| 65 | + | t.Fatalf("usage %d instruction = %q exceeded=%t", tc.used, instruction, exceeded) | |||
| 66 | + | } | |||
| 67 | + | if repeated, exceeded := budget.TurnContext(); repeated != "" || exceeded { | |||
| 68 | + | t.Fatalf("usage %d repeated instruction = %q exceeded=%t", tc.used, repeated, exceeded) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | budget.Observe(provider.Usage{InputTokens: 1_000}) | |||
| 73 | + | if instruction, exceeded := budget.TurnContext(); instruction != "" || !exceeded { | |||
| 74 | + | t.Fatalf("exhausted instruction = %q exceeded=%t", instruction, exceeded) | |||
| 75 | + | } | |||
| 76 | + | } | |||
M packages/agent/subagents/model.go +6 -4
| @@ -47,10 +47,12 @@ const ( | |||
| 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 | |
| 50 | + | MaxConcurrent int | |
| 51 | + | QueueTimeout time.Duration | |
| 52 | + | AllowedTools []string | |
| 53 | + | AllowedRoots []string | |
| 54 | + | BudgetRatio float64 | |
| 55 | + | BudgetRatioConfigured bool | |
| 54 | 56 | } | |
| 55 | 57 | ||
| 56 | 58 | func (p SubagentPolicy) allowedTool(name string) bool { | |
| @@ -47,10 +47,12 @@ const ( | |||||
| 47 | 47 | ||||
| 48 | // SubagentPolicy contains the resident manager's limits and capability ceiling. | 48 | // SubagentPolicy contains the resident manager's limits and capability ceiling. | ||
| 49 | type SubagentPolicy struct { | 49 | type SubagentPolicy struct { | ||
| 50 | - | MaxConcurrent 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 | |||
| 54 | } | 56 | } | ||
| 55 | 57 | ||||
| 56 | func (p SubagentPolicy) allowedTool(name string) bool { | 58 | func (p SubagentPolicy) allowedTool(name string) bool { | ||
M packages/agent/subagents/profiles.go +31 -2
| @@ -11,6 +11,7 @@ package subagents | |||
| 11 | 11 | ||
| 12 | 12 | import ( | |
| 13 | 13 | "fmt" | |
| 14 | + | "math" | |
| 14 | 15 | "os" | |
| 15 | 16 | "path/filepath" | |
| 16 | 17 | "sort" | |
| @@ -36,6 +37,11 @@ type Profile struct { | |||
| 36 | 37 | // Nil means the profile did not specify whether fast mode is enabled. | |
| 37 | 38 | FastMode *bool | |
| 38 | 39 | ||
| 40 | + | // BudgetRatio and BudgetTokens override the host's inherited rollout | |
| 41 | + | // budget. At most one may be declared. | |
| 42 | + | BudgetRatio *float64 | |
| 43 | + | BudgetTokens *int64 | |
| 44 | + | ||
| 39 | 45 | // SystemPromptMode is "append" (the default) or "replace". | |
| 40 | 46 | SystemPromptMode string | |
| 41 | 47 | ||
| @@ -162,7 +168,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||
| 162 | 168 | } | |
| 163 | 169 | var sb strings.Builder | |
| 164 | 170 | sb.WriteString("[subagents_list]\n") | |
| 165 | - | sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n") | |
| 171 | + | sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, fast-mode preference, and budget override are applied to the child.\n") | |
| 166 | 172 | for _, profile := range profiles { | |
| 167 | 173 | if profile == nil { | |
| 168 | 174 | continue | |
| @@ -172,7 +178,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||
| 172 | 178 | if description == "" { | |
| 173 | 179 | description = "(no description)" | |
| 174 | 180 | } | |
| 175 | - | metadata := make([]string, 0, 5) | |
| 181 | + | metadata := make([]string, 0, 7) | |
| 176 | 182 | if model := manifestValue(profile.Model); model != "" { | |
| 177 | 183 | metadata = append(metadata, "model="+model) | |
| 178 | 184 | } | |
| @@ -188,6 +194,12 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||
| 188 | 194 | if profile.FastMode != nil { | |
| 189 | 195 | metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode)) | |
| 190 | 196 | } | |
| 197 | + | if profile.BudgetRatio != nil { | |
| 198 | + | metadata = append(metadata, "budgetRatio="+strconv.FormatFloat(*profile.BudgetRatio, 'f', -1, 64)) | |
| 199 | + | } | |
| 200 | + | if profile.BudgetTokens != nil { | |
| 201 | + | metadata = append(metadata, "budgetTokens="+strconv.FormatInt(*profile.BudgetTokens, 10)) | |
| 202 | + | } | |
| 191 | 203 | if len(metadata) > 0 { | |
| 192 | 204 | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | |
| 193 | 205 | } else { | |
| @@ -312,6 +324,23 @@ func load(path, source string) (*Profile, error) { | |||
| 312 | 324 | } | |
| 313 | 325 | profile.FastMode = &value | |
| 314 | 326 | } | |
| 327 | + | if raw, ok := values["budgetratio"]; ok { | |
| 328 | + | value, err := strconv.ParseFloat(strings.TrimSpace(unquote(raw)), 64) | |
| 329 | + | if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value > 1 { | |
| 330 | + | return nil, fmt.Errorf("budgetRatio must be greater than 0 and at most 1") | |
| 331 | + | } | |
| 332 | + | profile.BudgetRatio = &value | |
| 333 | + | } | |
| 334 | + | if raw, ok := values["budgettokens"]; ok { | |
| 335 | + | value, err := strconv.ParseInt(strings.TrimSpace(unquote(raw)), 10, 64) | |
| 336 | + | if err != nil || value <= 0 { | |
| 337 | + | return nil, fmt.Errorf("budgetTokens must be a positive integer") | |
| 338 | + | } | |
| 339 | + | profile.BudgetTokens = &value | |
| 340 | + | } | |
| 341 | + | if profile.BudgetRatio != nil && profile.BudgetTokens != nil { | |
| 342 | + | return nil, fmt.Errorf("budgetRatio and budgetTokens are mutually exclusive") | |
| 343 | + | } | |
| 315 | 344 | return profile, nil | |
| 316 | 345 | } | |
| 317 | 346 | ||
| @@ -11,6 +11,7 @@ package subagents | |||||
| 11 | 11 | ||||
| 12 | import ( | 12 | import ( | ||
| 13 | "fmt" | 13 | "fmt" | ||
| 14 | + | "math" | |||
| 14 | "os" | 15 | "os" | ||
| 15 | "path/filepath" | 16 | "path/filepath" | ||
| 16 | "sort" | 17 | "sort" | ||
| @@ -36,6 +37,11 @@ type Profile struct { | |||||
| 36 | // Nil means the profile did not specify whether fast mode is enabled. | 37 | // Nil means the profile did not specify whether fast mode is enabled. | ||
| 37 | FastMode *bool | 38 | FastMode *bool | ||
| 38 | 39 | ||||
| 40 | + | // BudgetRatio and BudgetTokens override the host's inherited rollout | |||
| 41 | + | // budget. At most one may be declared. | |||
| 42 | + | BudgetRatio *float64 | |||
| 43 | + | BudgetTokens *int64 | |||
| 44 | + | ||||
| 39 | // SystemPromptMode is "append" (the default) or "replace". | 45 | // SystemPromptMode is "append" (the default) or "replace". | ||
| 40 | SystemPromptMode string | 46 | SystemPromptMode string | ||
| 41 | 47 | ||||
| @@ -162,7 +168,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 162 | } | 168 | } | ||
| 163 | var sb strings.Builder | 169 | var sb strings.Builder | ||
| 164 | sb.WriteString("[subagents_list]\n") | 170 | sb.WriteString("[subagents_list]\n") | ||
| 165 | - | sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n") | 171 | + | sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, fast-mode preference, and budget override are applied to the child.\n") |
| 166 | for _, profile := range profiles { | 172 | for _, profile := range profiles { | ||
| 167 | if profile == nil { | 173 | if profile == nil { | ||
| 168 | continue | 174 | continue | ||
| @@ -172,7 +178,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 172 | if description == "" { | 178 | if description == "" { | ||
| 173 | description = "(no description)" | 179 | description = "(no description)" | ||
| 174 | } | 180 | } | ||
| 175 | - | metadata := make([]string, 0, 5) | 181 | + | metadata := make([]string, 0, 7) |
| 176 | if model := manifestValue(profile.Model); model != "" { | 182 | if model := manifestValue(profile.Model); model != "" { | ||
| 177 | metadata = append(metadata, "model="+model) | 183 | metadata = append(metadata, "model="+model) | ||
| 178 | } | 184 | } | ||
| @@ -188,6 +194,12 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 188 | if profile.FastMode != nil { | 194 | if profile.FastMode != nil { | ||
| 189 | metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode)) | 195 | metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode)) | ||
| 190 | } | 196 | } | ||
| 197 | + | if profile.BudgetRatio != nil { | |||
| 198 | + | metadata = append(metadata, "budgetRatio="+strconv.FormatFloat(*profile.BudgetRatio, 'f', -1, 64)) | |||
| 199 | + | } | |||
| 200 | + | if profile.BudgetTokens != nil { | |||
| 201 | + | metadata = append(metadata, "budgetTokens="+strconv.FormatInt(*profile.BudgetTokens, 10)) | |||
| 202 | + | } | |||
| 191 | if len(metadata) > 0 { | 203 | if len(metadata) > 0 { | ||
| 192 | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | 204 | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | ||
| 193 | } else { | 205 | } else { | ||
| @@ -312,6 +324,23 @@ func load(path, source string) (*Profile, error) { | |||||
| 312 | } | 324 | } | ||
| 313 | profile.FastMode = &value | 325 | profile.FastMode = &value | ||
| 314 | } | 326 | } | ||
| 327 | + | if raw, ok := values["budgetratio"]; ok { | |||
| 328 | + | value, err := strconv.ParseFloat(strings.TrimSpace(unquote(raw)), 64) | |||
| 329 | + | if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 || value > 1 { | |||
| 330 | + | return nil, fmt.Errorf("budgetRatio must be greater than 0 and at most 1") | |||
| 331 | + | } | |||
| 332 | + | profile.BudgetRatio = &value | |||
| 333 | + | } | |||
| 334 | + | if raw, ok := values["budgettokens"]; ok { | |||
| 335 | + | value, err := strconv.ParseInt(strings.TrimSpace(unquote(raw)), 10, 64) | |||
| 336 | + | if err != nil || value <= 0 { | |||
| 337 | + | return nil, fmt.Errorf("budgetTokens must be a positive integer") | |||
| 338 | + | } | |||
| 339 | + | profile.BudgetTokens = &value | |||
| 340 | + | } | |||
| 341 | + | if profile.BudgetRatio != nil && profile.BudgetTokens != nil { | |||
| 342 | + | return nil, fmt.Errorf("budgetRatio and budgetTokens are mutually exclusive") | |||
| 343 | + | } | |||
| 315 | return profile, nil | 344 | return profile, nil | ||
| 316 | } | 345 | } | ||
| 317 | 346 | ||||
M packages/agent/subagents/profiles_test.go +15 -0
| @@ -164,6 +164,9 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||
| 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"}, | |
| 167 | 170 | } | |
| 168 | 171 | for _, tc := range cases { | |
| 169 | 172 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -176,6 +179,18 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||
| 176 | 179 | } | |
| 177 | 180 | } | |
| 178 | 181 | ||
| 182 | + | func TestLoadProfileBudgetOverride(t *testing.T) { | |
| 183 | + | path := filepath.Join(t.TempDir(), "profile.md") | |
| 184 | + | writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\n---\nInstructions.\n") | |
| 185 | + | profile, err := load(path, "test") | |
| 186 | + | if err != nil { | |
| 187 | + | t.Fatal(err) | |
| 188 | + | } | |
| 189 | + | if profile.BudgetRatio == nil || *profile.BudgetRatio != 0.6 || profile.BudgetTokens != nil { | |
| 190 | + | t.Fatalf("profile budget = ratio %v tokens %v", profile.BudgetRatio, profile.BudgetTokens) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 179 | 194 | func TestProfileModelSelection(t *testing.T) { | |
| 180 | 195 | profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"} | |
| 181 | 196 | provider, model := profile.ModelSelection() | |
| @@ -164,6 +164,9 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||||
| 164 | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | 164 | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | ||
| 165 | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | 165 | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | ||
| 166 | {name: "invalid-fast-mode", field: "fastMode: sometimes"}, | 166 | {name: "invalid-fast-mode", field: "fastMode: sometimes"}, | ||
| 167 | + | {name: "invalid-budget-ratio", field: "budgetRatio: 1.2"}, | |||
| 168 | + | {name: "invalid-budget-tokens", field: "budgetTokens: 0"}, | |||
| 169 | + | {name: "conflicting-budget", field: "budgetRatio: 0.5\nbudgetTokens: 1000"}, | |||
| 167 | } | 170 | } | ||
| 168 | for _, tc := range cases { | 171 | for _, tc := range cases { | ||
| 169 | t.Run(tc.name, func(t *testing.T) { | 172 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -176,6 +179,18 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||||
| 176 | } | 179 | } | ||
| 177 | } | 180 | } | ||
| 178 | 181 | ||||
| 182 | + | func TestLoadProfileBudgetOverride(t *testing.T) { | |||
| 183 | + | path := filepath.Join(t.TempDir(), "profile.md") | |||
| 184 | + | writeProfile(t, path, "---\nname: reviewer\nbudgetRatio: 0.6\n---\nInstructions.\n") | |||
| 185 | + | profile, err := load(path, "test") | |||
| 186 | + | if err != nil { | |||
| 187 | + | t.Fatal(err) | |||
| 188 | + | } | |||
| 189 | + | if profile.BudgetRatio == nil || *profile.BudgetRatio != 0.6 || profile.BudgetTokens != nil { | |||
| 190 | + | t.Fatalf("profile budget = ratio %v tokens %v", profile.BudgetRatio, profile.BudgetTokens) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 179 | func TestProfileModelSelection(t *testing.T) { | 194 | func TestProfileModelSelection(t *testing.T) { | ||
| 180 | profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"} | 195 | profile := &Profile{Model: "openrouter/anthropic/claude-sonnet"} | ||
| 181 | provider, model := profile.ModelSelection() | 196 | provider, model := profile.ModelSelection() | ||
M packages/agent/subagents/resident_journal.go +3 -0
| @@ -91,6 +91,9 @@ type ResidentChildSpec struct { | |||
| 91 | 91 | InheritSkills *bool `json:"inherit_skills,omitempty"` | |
| 92 | 92 | Permissions []string `json:"permissions,omitempty"` | |
| 93 | 93 | Required bool `json:"required,omitempty"` | |
| 94 | + | BudgetLimit int64 `json:"budget_limit,omitempty"` | |
| 95 | + | BudgetRatio float64 `json:"budget_ratio,omitempty"` | |
| 96 | + | BudgetSource string `json:"budget_source,omitempty"` | |
| 94 | 97 | } | |
| 95 | 98 | ||
| 96 | 99 | type residentRecord struct { | |
| @@ -91,6 +91,9 @@ type ResidentChildSpec struct { | |||||
| 91 | InheritSkills *bool `json:"inherit_skills,omitempty"` | 91 | InheritSkills *bool `json:"inherit_skills,omitempty"` | ||
| 92 | Permissions []string `json:"permissions,omitempty"` | 92 | Permissions []string `json:"permissions,omitempty"` | ||
| 93 | Required bool `json:"required,omitempty"` | 93 | Required bool `json:"required,omitempty"` | ||
| 94 | + | BudgetLimit int64 `json:"budget_limit,omitempty"` | |||
| 95 | + | BudgetRatio float64 `json:"budget_ratio,omitempty"` | |||
| 96 | + | BudgetSource string `json:"budget_source,omitempty"` | |||
| 94 | } | 97 | } | ||
| 95 | 98 | ||||
| 96 | type residentRecord struct { | 99 | type residentRecord struct { | ||
M packages/agent/subagents/resident_manager.go +22 -1
| @@ -221,6 +221,8 @@ type ResidentSnapshot struct { | |||
| 221 | 221 | ContextUsed int | |
| 222 | 222 | ContextMax int | |
| 223 | 223 | Subscription bool | |
| 224 | + | Budget BudgetSnapshot | |
| 225 | + | BudgetSource string | |
| 224 | 226 | } | |
| 225 | 227 | ||
| 226 | 228 | func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |
| @@ -228,12 +230,22 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||
| 228 | 230 | return ResidentSnapshot{} | |
| 229 | 231 | } | |
| 230 | 232 | live := child.Live() | |
| 233 | + | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, live.ContextMax) | |
| 231 | 234 | return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile, | |
| 232 | 235 | Provider: child.spec.Provider, Model: child.spec.Model, | |
| 233 | 236 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | |
| 234 | 237 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | |
| 235 | 238 | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, | |
| 236 | - | Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription} | |
| 239 | + | Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription, | |
| 240 | + | Budget: budget, BudgetSource: budgetSource} | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func residentBudgetSnapshot(usage provider.Usage, limit int64, source string, contextWindow int) (BudgetSnapshot, string) { | |
| 244 | + | effective := EffectiveBudgetLimit(limit, contextWindow) | |
| 245 | + | if effective > 0 && source == "" { | |
| 246 | + | source = "default_ratio" | |
| 247 | + | } | |
| 248 | + | return BudgetSnapshotFor(usage, effective), source | |
| 237 | 249 | } | |
| 238 | 250 | ||
| 239 | 251 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | |
| @@ -462,6 +474,10 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||
| 462 | 474 | if child == nil && snapshot.OwnedElsewhere { | |
| 463 | 475 | recovered = true | |
| 464 | 476 | } | |
| 477 | + | if child == nil && recovered && snapshot.Budget.State == BudgetExceeded { | |
| 478 | + | m.mu.Unlock() | |
| 479 | + | return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override") | |
| 480 | + | } | |
| 465 | 481 | if child == nil && recovered { | |
| 466 | 482 | if _, pending := m.pending[childID]; pending { | |
| 467 | 483 | m.mu.Unlock() | |
| @@ -470,6 +486,9 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||
| 470 | 486 | m.pending[childID] = struct{}{} | |
| 471 | 487 | } | |
| 472 | 488 | m.mu.Unlock() | |
| 489 | + | if child != nil && residentSnapshot(child).Budget.State == BudgetExceeded { | |
| 490 | + | return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override") | |
| 491 | + | } | |
| 473 | 492 | if child == nil && recovered { | |
| 474 | 493 | defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }() | |
| 475 | 494 | journal, err := OpenResidentJournal(m.root, childID) | |
| @@ -861,12 +880,14 @@ func (m *ResidentManager) Reconcile() []error { | |||
| 861 | 880 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | |
| 862 | 881 | continue | |
| 863 | 882 | } | |
| 883 | + | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.ContextMax) | |
| 864 | 884 | m.mu.Lock() | |
| 865 | 885 | if _, live := m.children[childID]; !live { | |
| 866 | 886 | m.recovered[childID] = ResidentSnapshot{ | |
| 867 | 887 | ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, | |
| 868 | 888 | WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt, | |
| 869 | 889 | Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription, | |
| 890 | + | Budget: budget, BudgetSource: budgetSource, | |
| 870 | 891 | } | |
| 871 | 892 | m.recoveredSpec[childID] = spec | |
| 872 | 893 | } | |
| @@ -221,6 +221,8 @@ type ResidentSnapshot struct { | |||||
| 221 | ContextUsed int | 221 | ContextUsed int | ||
| 222 | ContextMax int | 222 | ContextMax int | ||
| 223 | Subscription bool | 223 | Subscription bool | ||
| 224 | + | Budget BudgetSnapshot | |||
| 225 | + | BudgetSource string | |||
| 224 | } | 226 | } | ||
| 225 | 227 | ||||
| 226 | func residentSnapshot(child *ResidentChild) ResidentSnapshot { | 228 | func residentSnapshot(child *ResidentChild) ResidentSnapshot { | ||
| @@ -228,12 +230,22 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||||
| 228 | return ResidentSnapshot{} | 230 | return ResidentSnapshot{} | ||
| 229 | } | 231 | } | ||
| 230 | live := child.Live() | 232 | live := child.Live() | ||
| 233 | + | budget, budgetSource := residentBudgetSnapshot(live.Usage, child.spec.BudgetLimit, child.spec.BudgetSource, live.ContextMax) | |||
| 231 | return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile, | 234 | return ResidentSnapshot{ID: child.spec.ID, State: child.State(), Profile: child.spec.Profile, | ||
| 232 | Provider: child.spec.Provider, Model: child.spec.Model, | 235 | Provider: child.spec.Provider, Model: child.spec.Model, | ||
| 233 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | 236 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | ||
| 234 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | 237 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | ||
| 235 | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, | 238 | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, | ||
| 236 | - | Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription} | 239 | + | Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription, |
| 240 | + | Budget: budget, BudgetSource: budgetSource} | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func residentBudgetSnapshot(usage provider.Usage, limit int64, source string, contextWindow int) (BudgetSnapshot, string) { | |||
| 244 | + | effective := EffectiveBudgetLimit(limit, contextWindow) | |||
| 245 | + | if effective > 0 && source == "" { | |||
| 246 | + | source = "default_ratio" | |||
| 247 | + | } | |||
| 248 | + | return BudgetSnapshotFor(usage, effective), source | |||
| 237 | } | 249 | } | ||
| 238 | 250 | ||||
| 239 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | 251 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | ||
| @@ -462,6 +474,10 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||||
| 462 | if child == nil && snapshot.OwnedElsewhere { | 474 | if child == nil && snapshot.OwnedElsewhere { | ||
| 463 | recovered = true | 475 | recovered = true | ||
| 464 | } | 476 | } | ||
| 477 | + | if child == nil && recovered && snapshot.Budget.State == BudgetExceeded { | |||
| 478 | + | m.mu.Unlock() | |||
| 479 | + | return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override") | |||
| 480 | + | } | |||
| 465 | if child == nil && recovered { | 481 | if child == nil && recovered { | ||
| 466 | if _, pending := m.pending[childID]; pending { | 482 | if _, pending := m.pending[childID]; pending { | ||
| 467 | m.mu.Unlock() | 483 | m.mu.Unlock() | ||
| @@ -470,6 +486,9 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er | |||||
| 470 | m.pending[childID] = struct{}{} | 486 | m.pending[childID] = struct{}{} | ||
| 471 | } | 487 | } | ||
| 472 | m.mu.Unlock() | 488 | m.mu.Unlock() | ||
| 489 | + | if child != nil && residentSnapshot(child).Budget.State == BudgetExceeded { | |||
| 490 | + | return errors.New("resident manager: child rollout budget is exhausted; spawn a replacement with a larger override") | |||
| 491 | + | } | |||
| 473 | if child == nil && recovered { | 492 | if child == nil && recovered { | ||
| 474 | defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }() | 493 | defer func() { m.mu.Lock(); delete(m.pending, childID); m.mu.Unlock() }() | ||
| 475 | journal, err := OpenResidentJournal(m.root, childID) | 494 | journal, err := OpenResidentJournal(m.root, childID) | ||
| @@ -861,12 +880,14 @@ func (m *ResidentManager) Reconcile() []error { | |||||
| 861 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | 880 | errs = append(errs, fmt.Errorf("resident child %s: %w", childID, reconcileErr)) | ||
| 862 | continue | 881 | continue | ||
| 863 | } | 882 | } | ||
| 883 | + | budget, budgetSource := residentBudgetSnapshot(metadata.Usage, spec.BudgetLimit, spec.BudgetSource, metadata.ContextMax) | |||
| 864 | m.mu.Lock() | 884 | m.mu.Lock() | ||
| 865 | if _, live := m.children[childID]; !live { | 885 | if _, live := m.children[childID]; !live { | ||
| 866 | m.recovered[childID] = ResidentSnapshot{ | 886 | m.recovered[childID] = ResidentSnapshot{ | ||
| 867 | ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, | 887 | ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, | ||
| 868 | WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt, | 888 | WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt, | ||
| 869 | Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription, | 889 | Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription, | ||
| 890 | + | Budget: budget, BudgetSource: budgetSource, | |||
| 870 | } | 891 | } | ||
| 871 | m.recoveredSpec[childID] = spec | 892 | m.recoveredSpec[childID] = spec | ||
| 872 | } | 893 | } | ||
M packages/agent/subagents/resident_manager_test.go +26 -0
| @@ -55,6 +55,32 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) { | |||
| 55 | 55 | } | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | + | func TestResidentManagerRejectsResumeAfterBudgetExhaustion(t *testing.T) { | |
| 59 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |
| 60 | + | return func(context.Context, string) error { | |
| 61 | + | return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}}) | |
| 62 | + | }, nil | |
| 63 | + | }) | |
| 64 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 65 | + | spec := ResidentChildSpec{ID: "budget-exhausted", SessionID: "budget-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5", BudgetLimit: 100} | |
| 66 | + | completion, cancelWait := manager.WatchCompletion(spec.ID, spec.InitialTurnID) | |
| 67 | + | defer cancelWait() | |
| 68 | + | if _, err := manager.Spawn(context.Background(), spec, "review"); err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | result := <-completion | |
| 72 | + | if result.Err != nil { | |
| 73 | + | t.Fatalf("initial completion error = %v", result.Err) | |
| 74 | + | } | |
| 75 | + | snapshot, ok := manager.SnapshotFor(spec.ID) | |
| 76 | + | if !ok || snapshot.State != ResidentIdle || snapshot.Budget.State != BudgetExceeded { | |
| 77 | + | t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok) | |
| 78 | + | } | |
| 79 | + | if err := manager.Resume(context.Background(), spec.ID, "continue"); err == nil || !strings.Contains(err.Error(), "budget is exhausted") { | |
| 80 | + | t.Fatalf("resume error = %v", err) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 58 | 84 | func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) { | |
| 59 | 85 | completed := make(chan ResidentCompletion, 1) | |
| 60 | 86 | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |
| @@ -55,6 +55,32 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) { | |||||
| 55 | } | 55 | } | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | + | func TestResidentManagerRejectsResumeAfterBudgetExhaustion(t *testing.T) { | |||
| 59 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |||
| 60 | + | return func(context.Context, string) error { | |||
| 61 | + | return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}}) | |||
| 62 | + | }, nil | |||
| 63 | + | }) | |||
| 64 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 65 | + | spec := ResidentChildSpec{ID: "budget-exhausted", SessionID: "budget-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5", BudgetLimit: 100} | |||
| 66 | + | completion, cancelWait := manager.WatchCompletion(spec.ID, spec.InitialTurnID) | |||
| 67 | + | defer cancelWait() | |||
| 68 | + | if _, err := manager.Spawn(context.Background(), spec, "review"); err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | result := <-completion | |||
| 72 | + | if result.Err != nil { | |||
| 73 | + | t.Fatalf("initial completion error = %v", result.Err) | |||
| 74 | + | } | |||
| 75 | + | snapshot, ok := manager.SnapshotFor(spec.ID) | |||
| 76 | + | if !ok || snapshot.State != ResidentIdle || snapshot.Budget.State != BudgetExceeded { | |||
| 77 | + | t.Fatalf("terminal snapshot = %#v, found=%t", snapshot, ok) | |||
| 78 | + | } | |||
| 79 | + | if err := manager.Resume(context.Background(), spec.ID, "continue"); err == nil || !strings.Contains(err.Error(), "budget is exhausted") { | |||
| 80 | + | t.Fatalf("resume error = %v", err) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 58 | func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) { | 84 | func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) { | ||
| 59 | completed := make(chan ResidentCompletion, 1) | 85 | completed := make(chan ResidentCompletion, 1) | ||
| 60 | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | 86 | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | ||
M packages/agent/tools/resident_tools_test.go +16 -3
| @@ -187,6 +187,9 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |||
| 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 | 193 | } { | |
| 191 | 194 | result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil) | |
| 192 | 195 | if err != nil && !strings.Contains(err.Error(), "invalid args") { | |
| @@ -207,6 +210,7 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | |||
| 207 | 210 | }) | |
| 208 | 211 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 209 | 212 | profileFast := false | |
| 213 | + | profileBudget := 0.6 | |
| 210 | 214 | var got ResidentSpawnRequest | |
| 211 | 215 | spawn := &SubagentSpawnTool{ | |
| 212 | 216 | ResidentManager: manager, | |
| @@ -215,18 +219,18 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | |||
| 215 | 219 | DefaultProvider: func() string { return "host-provider" }, | |
| 216 | 220 | DefaultReasoning: func() string { return "medium" }, | |
| 217 | 221 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 218 | - | return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil | |
| 222 | + | return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast, BudgetRatio: &profileBudget}, nil | |
| 219 | 223 | }, | |
| 220 | 224 | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | |
| 221 | 225 | got = request | |
| 222 | 226 | return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil | |
| 223 | 227 | }, | |
| 224 | 228 | } | |
| 225 | - | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil) | |
| 229 | + | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true,"budget_tokens":42000}`), nil) | |
| 226 | 230 | if err != nil || result.IsError { | |
| 227 | 231 | t.Fatalf("Execute = (%#v, %v)", result, err) | |
| 228 | 232 | } | |
| 229 | - | if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode { | |
| 233 | + | if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode || got.BudgetTokens == nil || *got.BudgetTokens != 42_000 { | |
| 230 | 234 | t.Fatalf("factory request = %#v", got) | |
| 231 | 235 | } | |
| 232 | 236 | } | |
| @@ -258,6 +262,15 @@ func toolResultText(t *testing.T, result core.ToolResult) string { | |||
| 258 | 262 | return block.Text | |
| 259 | 263 | } | |
| 260 | 264 | ||
| 265 | + | func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) { | |
| 266 | + | entry := publicResidentStatus(subagents.ResidentSnapshot{ | |
| 267 | + | ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetFocused}, BudgetSource: "default_ratio", | |
| 268 | + | }) | |
| 269 | + | if entry.Budget == nil || entry.Budget.State != subagents.BudgetFocused || entry.BudgetSource != "default_ratio" { | |
| 270 | + | t.Fatalf("status budget = %#v", entry) | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 261 | 274 | func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) { | |
| 262 | 275 | _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab") | |
| 263 | 276 | if ok { | |
| @@ -187,6 +187,9 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |||||
| 187 | `{"task":"x","wait":0}`, | 187 | `{"task":"x","wait":0}`, | ||
| 188 | `{"task":"x","wait":301}`, | 188 | `{"task":"x","wait":301}`, | ||
| 189 | `{"task":"x","isolation":"outside"}`, | 189 | `{"task":"x","isolation":"outside"}`, | ||
| 190 | + | `{"task":"x","budget_ratio":0}`, | |||
| 191 | + | `{"task":"x","budget_tokens":-1}`, | |||
| 192 | + | `{"task":"x","budget_ratio":0.5,"budget_tokens":1000}`, | |||
| 190 | } { | 193 | } { | ||
| 191 | result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil) | 194 | result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil) | ||
| 192 | if err != nil && !strings.Contains(err.Error(), "invalid args") { | 195 | if err != nil && !strings.Contains(err.Error(), "invalid args") { | ||
| @@ -207,6 +210,7 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | |||||
| 207 | }) | 210 | }) | ||
| 208 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 211 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 209 | profileFast := false | 212 | profileFast := false | ||
| 213 | + | profileBudget := 0.6 | |||
| 210 | var got ResidentSpawnRequest | 214 | var got ResidentSpawnRequest | ||
| 211 | spawn := &SubagentSpawnTool{ | 215 | spawn := &SubagentSpawnTool{ | ||
| 212 | ResidentManager: manager, | 216 | ResidentManager: manager, | ||
| @@ -215,18 +219,18 @@ func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | |||||
| 215 | DefaultProvider: func() string { return "host-provider" }, | 219 | DefaultProvider: func() string { return "host-provider" }, | ||
| 216 | DefaultReasoning: func() string { return "medium" }, | 220 | DefaultReasoning: func() string { return "medium" }, | ||
| 217 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | 221 | ResolveSubagent: func(name string) (*subagents.Profile, error) { | ||
| 218 | - | return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil | 222 | + | return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast, BudgetRatio: &profileBudget}, nil |
| 219 | }, | 223 | }, | ||
| 220 | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | 224 | BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { | ||
| 221 | got = request | 225 | got = request | ||
| 222 | return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil | 226 | return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil | ||
| 223 | }, | 227 | }, | ||
| 224 | } | 228 | } | ||
| 225 | - | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil) | 229 | + | result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true,"budget_tokens":42000}`), nil) |
| 226 | if err != nil || result.IsError { | 230 | if err != nil || result.IsError { | ||
| 227 | t.Fatalf("Execute = (%#v, %v)", result, err) | 231 | t.Fatalf("Execute = (%#v, %v)", result, err) | ||
| 228 | } | 232 | } | ||
| 229 | - | if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode { | 233 | + | if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode || got.BudgetTokens == nil || *got.BudgetTokens != 42_000 { |
| 230 | t.Fatalf("factory request = %#v", got) | 234 | t.Fatalf("factory request = %#v", got) | ||
| 231 | } | 235 | } | ||
| 232 | } | 236 | } | ||
| @@ -258,6 +262,15 @@ func toolResultText(t *testing.T, result core.ToolResult) string { | |||||
| 258 | return block.Text | 262 | return block.Text | ||
| 259 | } | 263 | } | ||
| 260 | 264 | ||||
| 265 | + | func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) { | |||
| 266 | + | entry := publicResidentStatus(subagents.ResidentSnapshot{ | |||
| 267 | + | ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetFocused}, BudgetSource: "default_ratio", | |||
| 268 | + | }) | |||
| 269 | + | if entry.Budget == nil || entry.Budget.State != subagents.BudgetFocused || entry.BudgetSource != "default_ratio" { | |||
| 270 | + | t.Fatalf("status budget = %#v", entry) | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | ||||
| 261 | func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) { | 274 | func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) { | ||
| 262 | _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab") | 275 | _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab") | ||
| 263 | if ok { | 276 | if ok { | ||
M packages/agent/tools/subagent_spawn.go +37 -11
| @@ -65,18 +65,22 @@ type ResidentSpawnRequest struct { | |||
| 65 | 65 | FastMode *bool | |
| 66 | 66 | Required bool | |
| 67 | 67 | WorkspaceMode subagents.WorkspaceMode | |
| 68 | + | BudgetRatio *float64 | |
| 69 | + | BudgetTokens *int64 | |
| 68 | 70 | } | |
| 69 | 71 | ||
| 70 | 72 | type subagentSpawnArgs struct { | |
| 71 | - | Task string `json:"task"` | |
| 72 | - | Agent string `json:"agent,omitempty"` | |
| 73 | - | Model string `json:"model,omitempty"` | |
| 74 | - | Provider string `json:"provider,omitempty"` | |
| 75 | - | Reasoning string `json:"reasoning,omitempty"` | |
| 76 | - | FastMode *bool `json:"fast_mode,omitempty"` | |
| 77 | - | Required bool `json:"required,omitempty"` | |
| 78 | - | Wait *int `json:"wait,omitempty"` | |
| 79 | - | Isolation string `json:"isolation,omitempty"` | |
| 73 | + | Task string `json:"task"` | |
| 74 | + | Agent string `json:"agent,omitempty"` | |
| 75 | + | Model string `json:"model,omitempty"` | |
| 76 | + | Provider string `json:"provider,omitempty"` | |
| 77 | + | Reasoning string `json:"reasoning,omitempty"` | |
| 78 | + | FastMode *bool `json:"fast_mode,omitempty"` | |
| 79 | + | Required bool `json:"required,omitempty"` | |
| 80 | + | Wait *int `json:"wait,omitempty"` | |
| 81 | + | Isolation string `json:"isolation,omitempty"` | |
| 82 | + | BudgetRatio *float64 `json:"budget_ratio,omitempty"` | |
| 83 | + | BudgetTokens *int64 `json:"budget_tokens,omitempty"` | |
| 80 | 84 | } | |
| 81 | 85 | ||
| 82 | 86 | const subagentSpawnSchemaTemplate = `{ | |
| @@ -121,6 +125,17 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 121 | 125 | "type": "string", | |
| 122 | 126 | "enum": ["shared", "worktree"], | |
| 123 | 127 | "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it." | |
| 128 | + | }, | |
| 129 | + | "budget_ratio": { | |
| 130 | + | "type": "number", | |
| 131 | + | "exclusiveMinimum": 0, | |
| 132 | + | "maximum": 1, | |
| 133 | + | "description": "Optional cumulative rollout budget as a fraction of the parent model context window. Overrides the selected profile and host default. Mutually exclusive with budget_tokens." | |
| 134 | + | }, | |
| 135 | + | "budget_tokens": { | |
| 136 | + | "type": "integer", | |
| 137 | + | "minimum": 1, | |
| 138 | + | "description": "Optional absolute cumulative weighted-token budget. Overrides the selected profile and host default. Mutually exclusive with budget_ratio." | |
| 124 | 139 | } | |
| 125 | 140 | }, | |
| 126 | 141 | "required": ["task"] | |
| @@ -153,6 +168,15 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 153 | 168 | if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) { | |
| 154 | 169 | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | |
| 155 | 170 | } | |
| 171 | + | if a.BudgetRatio != nil && (*a.BudgetRatio <= 0 || *a.BudgetRatio > 1) { | |
| 172 | + | return protocolToolError(prefix + ": budget_ratio must be greater than 0 and at most 1") | |
| 173 | + | } | |
| 174 | + | if a.BudgetTokens != nil && *a.BudgetTokens <= 0 { | |
| 175 | + | return protocolToolError(prefix + ": budget_tokens must be positive") | |
| 176 | + | } | |
| 177 | + | if a.BudgetRatio != nil && a.BudgetTokens != nil { | |
| 178 | + | return protocolToolError(prefix + ": budget_ratio and budget_tokens are mutually exclusive") | |
| 179 | + | } | |
| 156 | 180 | ||
| 157 | 181 | workspaceMode := subagents.WorkspaceShared | |
| 158 | 182 | if value := strings.TrimSpace(a.Isolation); value != "" { | |
| @@ -181,7 +205,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 181 | 205 | if a.FastMode != nil { | |
| 182 | 206 | fastModeOverride = a.FastMode | |
| 183 | 207 | } | |
| 184 | - | ||
| 185 | 208 | model := strings.TrimSpace(a.Model) | |
| 186 | 209 | providerID := strings.TrimSpace(a.Provider) | |
| 187 | 210 | if (model == "") != (providerID == "") { | |
| @@ -228,7 +251,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 228 | 251 | spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{ | |
| 229 | 252 | Task: task, Profile: profile, Model: model, Provider: providerID, | |
| 230 | 253 | Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required, | |
| 231 | - | WorkspaceMode: workspaceMode, | |
| 254 | + | WorkspaceMode: workspaceMode, BudgetRatio: a.BudgetRatio, BudgetTokens: a.BudgetTokens, | |
| 232 | 255 | }) | |
| 233 | 256 | if err != nil { | |
| 234 | 257 | return protocolToolError(prefix + ": " + err.Error()) | |
| @@ -288,6 +311,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 288 | 311 | if spec.Reasoning != "" { | |
| 289 | 312 | fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning) | |
| 290 | 313 | } | |
| 314 | + | if spec.BudgetLimit > 0 { | |
| 315 | + | fmt.Fprintf(&sb, "budget: %d weighted tokens (%s)\n", spec.BudgetLimit, spec.BudgetSource) | |
| 316 | + | } | |
| 291 | 317 | if spec.Required { | |
| 292 | 318 | fmt.Fprintf(&sb, "required: %s\n", state) | |
| 293 | 319 | } | |
| @@ -65,18 +65,22 @@ 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 | |||
| 68 | } | 70 | } | ||
| 69 | 71 | ||||
| 70 | type subagentSpawnArgs struct { | 72 | type subagentSpawnArgs struct { | ||
| 71 | - | Task string `json:"task"` | 73 | + | Task string `json:"task"` |
| 72 | - | Agent string `json:"agent,omitempty"` | 74 | + | Agent string `json:"agent,omitempty"` |
| 73 | - | Model string `json:"model,omitempty"` | 75 | + | Model string `json:"model,omitempty"` |
| 74 | - | Provider string `json:"provider,omitempty"` | 76 | + | Provider string `json:"provider,omitempty"` |
| 75 | - | Reasoning string `json:"reasoning,omitempty"` | 77 | + | Reasoning string `json:"reasoning,omitempty"` |
| 76 | - | FastMode *bool `json:"fast_mode,omitempty"` | 78 | + | FastMode *bool `json:"fast_mode,omitempty"` |
| 77 | - | Required bool `json:"required,omitempty"` | 79 | + | Required bool `json:"required,omitempty"` |
| 78 | - | Wait *int `json:"wait,omitempty"` | 80 | + | Wait *int `json:"wait,omitempty"` |
| 79 | - | Isolation string `json:"isolation,omitempty"` | 81 | + | Isolation string `json:"isolation,omitempty"` |
| 82 | + | BudgetRatio *float64 `json:"budget_ratio,omitempty"` | |||
| 83 | + | BudgetTokens *int64 `json:"budget_tokens,omitempty"` | |||
| 80 | } | 84 | } | ||
| 81 | 85 | ||||
| 82 | const subagentSpawnSchemaTemplate = `{ | 86 | const subagentSpawnSchemaTemplate = `{ | ||
| @@ -121,6 +125,17 @@ const subagentSpawnSchemaTemplate = `{ | |||||
| 121 | "type": "string", | 125 | "type": "string", | ||
| 122 | "enum": ["shared", "worktree"], | 126 | "enum": ["shared", "worktree"], | ||
| 123 | "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it." | 127 | "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it." | ||
| 128 | + | }, | |||
| 129 | + | "budget_ratio": { | |||
| 130 | + | "type": "number", | |||
| 131 | + | "exclusiveMinimum": 0, | |||
| 132 | + | "maximum": 1, | |||
| 133 | + | "description": "Optional cumulative rollout budget as a fraction of the parent model context window. Overrides the selected profile and host default. Mutually exclusive with budget_tokens." | |||
| 134 | + | }, | |||
| 135 | + | "budget_tokens": { | |||
| 136 | + | "type": "integer", | |||
| 137 | + | "minimum": 1, | |||
| 138 | + | "description": "Optional absolute cumulative weighted-token budget. Overrides the selected profile and host default. Mutually exclusive with budget_ratio." | |||
| 124 | } | 139 | } | ||
| 125 | }, | 140 | }, | ||
| 126 | "required": ["task"] | 141 | "required": ["task"] | ||
| @@ -153,6 +168,15 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 153 | if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) { | 168 | if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) { | ||
| 154 | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | 169 | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | ||
| 155 | } | 170 | } | ||
| 171 | + | if a.BudgetRatio != nil && (*a.BudgetRatio <= 0 || *a.BudgetRatio > 1) { | |||
| 172 | + | return protocolToolError(prefix + ": budget_ratio must be greater than 0 and at most 1") | |||
| 173 | + | } | |||
| 174 | + | if a.BudgetTokens != nil && *a.BudgetTokens <= 0 { | |||
| 175 | + | return protocolToolError(prefix + ": budget_tokens must be positive") | |||
| 176 | + | } | |||
| 177 | + | if a.BudgetRatio != nil && a.BudgetTokens != nil { | |||
| 178 | + | return protocolToolError(prefix + ": budget_ratio and budget_tokens are mutually exclusive") | |||
| 179 | + | } | |||
| 156 | 180 | ||||
| 157 | workspaceMode := subagents.WorkspaceShared | 181 | workspaceMode := subagents.WorkspaceShared | ||
| 158 | if value := strings.TrimSpace(a.Isolation); value != "" { | 182 | if value := strings.TrimSpace(a.Isolation); value != "" { | ||
| @@ -181,7 +205,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 181 | if a.FastMode != nil { | 205 | if a.FastMode != nil { | ||
| 182 | fastModeOverride = a.FastMode | 206 | fastModeOverride = a.FastMode | ||
| 183 | } | 207 | } | ||
| 184 | - | ||||
| 185 | model := strings.TrimSpace(a.Model) | 208 | model := strings.TrimSpace(a.Model) | ||
| 186 | providerID := strings.TrimSpace(a.Provider) | 209 | providerID := strings.TrimSpace(a.Provider) | ||
| 187 | if (model == "") != (providerID == "") { | 210 | if (model == "") != (providerID == "") { | ||
| @@ -228,7 +251,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 228 | spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{ | 251 | spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{ | ||
| 229 | Task: task, Profile: profile, Model: model, Provider: providerID, | 252 | Task: task, Profile: profile, Model: model, Provider: providerID, | ||
| 230 | Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required, | 253 | Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required, | ||
| 231 | - | WorkspaceMode: workspaceMode, | 254 | + | WorkspaceMode: workspaceMode, BudgetRatio: a.BudgetRatio, BudgetTokens: a.BudgetTokens, |
| 232 | }) | 255 | }) | ||
| 233 | if err != nil { | 256 | if err != nil { | ||
| 234 | return protocolToolError(prefix + ": " + err.Error()) | 257 | return protocolToolError(prefix + ": " + err.Error()) | ||
| @@ -288,6 +311,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 288 | if spec.Reasoning != "" { | 311 | if spec.Reasoning != "" { | ||
| 289 | fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning) | 312 | fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning) | ||
| 290 | } | 313 | } | ||
| 314 | + | if spec.BudgetLimit > 0 { | |||
| 315 | + | fmt.Fprintf(&sb, "budget: %d weighted tokens (%s)\n", spec.BudgetLimit, spec.BudgetSource) | |||
| 316 | + | } | |||
| 291 | if spec.Required { | 317 | if spec.Required { | ||
| 292 | fmt.Fprintf(&sb, "required: %s\n", state) | 318 | fmt.Fprintf(&sb, "required: %s\n", state) | ||
| 293 | } | 319 | } | ||
M packages/agent/tools/subagent_status.go +16 -9
| @@ -32,14 +32,16 @@ type subagentStatusResponse struct { | |||
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | 34 | type subagentStatusEntry struct { | |
| 35 | - | ID string `json:"agent_id"` | |
| 36 | - | State subagents.ResidentState `json:"state"` | |
| 37 | - | Profile string `json:"profile,omitempty"` | |
| 38 | - | Provider string `json:"provider"` | |
| 39 | - | Model string `json:"model"` | |
| 40 | - | Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"` | |
| 41 | - | Required bool `json:"required,omitempty"` | |
| 42 | - | OwnedElsewhere bool `json:"owned_elsewhere,omitempty"` | |
| 35 | + | ID string `json:"agent_id"` | |
| 36 | + | State subagents.ResidentState `json:"state"` | |
| 37 | + | Profile string `json:"profile,omitempty"` | |
| 38 | + | Provider string `json:"provider"` | |
| 39 | + | Model string `json:"model"` | |
| 40 | + | Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"` | |
| 41 | + | Required bool `json:"required,omitempty"` | |
| 42 | + | OwnedElsewhere bool `json:"owned_elsewhere,omitempty"` | |
| 43 | + | Budget *subagents.BudgetSnapshot `json:"budget,omitempty"` | |
| 44 | + | BudgetSource string `json:"budget_source,omitempty"` | |
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | 47 | const subagentStatusSchema = `{ | |
| @@ -117,7 +119,12 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin | |||
| 117 | 119 | } | |
| 118 | 120 | ||
| 119 | 121 | func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry { | |
| 120 | - | return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere} | |
| 122 | + | entry := subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, BudgetSource: snapshot.BudgetSource} | |
| 123 | + | if snapshot.Budget.Limit > 0 { | |
| 124 | + | budget := snapshot.Budget | |
| 125 | + | entry.Budget = &budget | |
| 126 | + | } | |
| 127 | + | return entry | |
| 121 | 128 | } | |
| 122 | 129 | ||
| 123 | 130 | func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) { | |
| @@ -32,14 +32,16 @@ type subagentStatusResponse struct { | |||||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | type subagentStatusEntry struct { | 34 | type subagentStatusEntry struct { | ||
| 35 | - | ID string `json:"agent_id"` | 35 | + | ID string `json:"agent_id"` |
| 36 | - | State subagents.ResidentState `json:"state"` | 36 | + | State subagents.ResidentState `json:"state"` |
| 37 | - | Profile string `json:"profile,omitempty"` | 37 | + | Profile string `json:"profile,omitempty"` |
| 38 | - | Provider string `json:"provider"` | 38 | + | Provider string `json:"provider"` |
| 39 | - | Model string `json:"model"` | 39 | + | Model string `json:"model"` |
| 40 | - | Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"` | 40 | + | Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"` |
| 41 | - | Required bool `json:"required,omitempty"` | 41 | + | Required bool `json:"required,omitempty"` |
| 42 | - | OwnedElsewhere bool `json:"owned_elsewhere,omitempty"` | 42 | + | OwnedElsewhere bool `json:"owned_elsewhere,omitempty"` |
| 43 | + | Budget *subagents.BudgetSnapshot `json:"budget,omitempty"` | |||
| 44 | + | BudgetSource string `json:"budget_source,omitempty"` | |||
| 43 | } | 45 | } | ||
| 44 | 46 | ||||
| 45 | const subagentStatusSchema = `{ | 47 | const subagentStatusSchema = `{ | ||
| @@ -117,7 +119,12 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin | |||||
| 117 | } | 119 | } | ||
| 118 | 120 | ||||
| 119 | func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry { | 121 | func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry { | ||
| 120 | - | return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere} | 122 | + | entry := subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, BudgetSource: snapshot.BudgetSource} |
| 123 | + | if snapshot.Budget.Limit > 0 { | |||
| 124 | + | budget := snapshot.Budget | |||
| 125 | + | entry.Budget = &budget | |||
| 126 | + | } | |||
| 127 | + | return entry | |||
| 121 | } | 128 | } | ||
| 122 | 129 | ||||
| 123 | func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) { | 130 | func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) { | ||