feat(tui): govern subagent token budgets

Add optional [subagents] token_budget and per-agent token_budget/max_tokens overrides. Root agents and descendants share a budget scope by default, provider-reported usage now updates worker records, and exhausted scopes reject further descendant spawns with an actionable error.

Refs #3319.

Verification: cargo test -p codewhale-tui --bin codewhale-tui token_budget --locked; cargo test -p codewhale-tui --bin codewhale-tui worker_record_usage_accumulates_provider_tokens --locked; cargo test -p codewhale-tui --bin codewhale-tui subagent --locked; git diff --check; cargo fmt --all -- --check.
This commit is contained in:
Hunter B committed 2026-06-18 21:29:53 -07:00
1 parent ec56224829
commit 6532eb8b8f
10 files changed
+486 -15

No files matched your search

+40
View File
@@ -1800,6 +1800,11 @@ pub struct SubagentsConfig {
/// throttle); explicit values are clamped to [1, max_subagents].
#[serde(default)]
pub launch_concurrency: Option<usize>,
/// Optional aggregate token budget shared by a root `agent` run and its
/// descendants. When unset or 0, sub-agents keep legacy unlimited spend
/// behavior unless an individual `agent` call supplies a per-run override.
#[serde(default)]
pub token_budget: Option<u64>,
/// Deprecated pre-v0.8.61 alias for `launch_concurrency`. Honored only
/// when `launch_concurrency` is unset, so the new key always wins.
#[serde(default, rename = "interactive_max_launch")]
@@ -3236,6 +3241,18 @@ impl Config {
.clamp(1, max)
}
/// Optional aggregate token budget for each root `agent` run.
///
/// Reads `[subagents] token_budget`. `None` and `0` both mean unlimited,
/// preserving legacy behavior until a budget is explicitly configured.
#[must_use]
pub fn subagent_token_budget(&self) -> Option<u64> {
self.subagents
.as_ref()
.and_then(|cfg| cfg.token_budget)
.filter(|budget| *budget > 0)
}
/// Resolved per-step DeepSeek API timeout for sub-agents, in seconds.
///
/// Reads `[subagents] api_timeout_secs` and clamps to
@@ -7383,6 +7400,29 @@ action = "session.compact"
assert_eq!(config.launch_concurrency(), 3);
}
#[test]
fn subagent_token_budget_is_optional_and_zero_disables() {
assert_eq!(Config::default().subagent_token_budget(), None);
let disabled = Config {
subagents: Some(SubagentsConfig {
token_budget: Some(0),
..SubagentsConfig::default()
}),
..Config::default()
};
assert_eq!(disabled.subagent_token_budget(), None);
let configured = Config {
subagents: Some(SubagentsConfig {
token_budget: Some(50_000),
..SubagentsConfig::default()
}),
..Config::default()
};
assert_eq!(configured.subagent_token_budget(), Some(50_000));
}
#[test]
fn subagents_max_concurrent_overrides_top_level_cap() {
let config = Config {
+7
View File
@@ -297,6 +297,10 @@ pub struct EngineConfig {
/// `SubAgentRuntime::max_spawn_depth`. Override via
/// `[subagents] max_depth = N` in `~/.codewhale/config.toml`.
pub max_spawn_depth: u32,
/// Optional aggregate token budget for each root sub-agent run.
/// Descendant agents inherit the root pool unless a child starts a new
/// budget scope with an explicit per-call override.
pub subagent_token_budget: Option<u64>,
/// Per-domain network policy decider (#135). Shared across the session so
/// session-scoped approvals (`/network allow <host>`) persist for the
/// remainder of the run.
@@ -413,6 +417,7 @@ impl Default for EngineConfig {
plan_state: new_shared_plan_state(),
goal_state: new_shared_goal_state(),
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
subagent_token_budget: None,
network_policy: None,
snapshots_enabled: true,
snapshots_max_workspace_bytes:
@@ -829,6 +834,7 @@ impl Engine {
config.max_subagents,
config.subagent_heartbeat_timeout,
config.launch_concurrency,
config.subagent_token_budget,
);
let shell_manager = config
.runtime_services
@@ -1476,6 +1482,7 @@ impl Engine {
self.config.max_subagents,
self.config.subagent_heartbeat_timeout,
self.config.launch_concurrency,
self.config.subagent_token_budget,
)
};
let launch_note = if launch_gate_applied {
+1
View File
@@ -6346,6 +6346,7 @@ async fn run_exec_agent(
plan_state: new_shared_plan_state(),
goal_state: crate::tools::goal::new_shared_goal_state(),
max_spawn_depth: execution_config.subagent_max_spawn_depth(),
subagent_token_budget: execution_config.subagent_token_budget(),
network_policy,
snapshots_enabled: execution_config.snapshots_config().enabled,
snapshots_max_workspace_bytes: execution_config
+6
View File
@@ -4593,7 +4593,13 @@ mod tests {
}],
usage: AgentRunUsage {
status: "unknown".to_string(),
input_tokens: None,
output_tokens: None,
total_tokens: None,
token_budget: None,
budget_spent_tokens: None,
budget_remaining_tokens: None,
budget_scope: None,
note: "not reported".to_string(),
},
verification: AgentRunVerificationSummary {
+1
View File
@@ -2414,6 +2414,7 @@ impl RuntimeThreadManager {
plan_state: new_shared_plan_state(),
goal_state: crate::tools::goal::new_shared_goal_state(),
max_spawn_depth: self.config.subagent_max_spawn_depth(),
subagent_token_budget: self.config.subagent_token_budget(),
network_policy,
snapshots_enabled: self.config.snapshots_config().enabled,
snapshots_max_workspace_bytes: self
+264 -1
View File
@@ -29,7 +29,9 @@ use crate::config::MAX_SUBAGENTS;
use crate::core::events::Event;
use crate::dependencies::{ExternalTool, Git};
use crate::llm_client::LlmClient;
use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Tool};
use crate::models::{
ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Tool, Usage,
};
use crate::request_tuning::RequestTuning;
use crate::tools::handle::VarHandle;
use crate::tools::plan::{PlanState, SharedPlanState};
@@ -74,6 +76,7 @@ fn release_resident_leases_for(agent_id: &str) {
/// the `SubAgentManager`.
const DEFAULT_MAX_STEPS: u32 = u32::MAX;
const TOOL_TIMEOUT: Duration = Duration::from_secs(30);
const MIN_SUBAGENT_SPAWN_TOKEN_RESERVE: u64 = 1;
/// Format a step counter for sub-agent progress messages.
///
@@ -754,7 +757,19 @@ pub struct AgentRunArtifactRef {
pub struct AgentRunUsage {
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_budget: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget_spent_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget_remaining_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget_scope: Option<String>,
pub note: String,
}
@@ -894,11 +909,54 @@ fn default_agent_run_takeover() -> AgentRunTakeoverTarget {
fn default_agent_run_usage() -> AgentRunUsage {
AgentRunUsage {
status: "unknown".to_string(),
input_tokens: None,
output_tokens: None,
total_tokens: None,
token_budget: None,
budget_spent_tokens: None,
budget_remaining_tokens: None,
budget_scope: None,
note: "Token usage is not yet reported by the sub-agent worker ledger.".to_string(),
}
}
fn positive_token_budget(budget: Option<u64>) -> Option<u64> {
budget.filter(|value| *value > 0)
}
fn usage_total_tokens(usage: &Usage) -> u64 {
u64::from(usage.input_tokens).saturating_add(u64::from(usage.output_tokens))
}
fn refresh_usage_note(usage: &mut AgentRunUsage) {
let worker_total = usage.total_tokens.unwrap_or(0);
if let Some(limit) = usage.token_budget {
let spent = usage.budget_spent_tokens.unwrap_or(worker_total);
let remaining = usage
.budget_remaining_tokens
.unwrap_or_else(|| limit.saturating_sub(spent));
usage.status = if remaining == 0 {
"budget_exhausted".to_string()
} else if worker_total > 0 {
"reported".to_string()
} else {
"tracking".to_string()
};
usage.note = if worker_total > 0 {
format!(
"Token budget: {spent}/{limit} spent, {remaining} remaining. This worker reported {worker_total} tokens."
)
} else {
format!("Token budget: {spent}/{limit} spent, {remaining} remaining.")
};
} else if worker_total > 0 {
usage.status = "reported".to_string();
usage.note = format!("Provider reported {worker_total} tokens for this worker.");
} else if usage.status.is_empty() {
*usage = default_agent_run_usage();
}
}
fn default_agent_run_verification() -> AgentRunVerificationSummary {
AgentRunVerificationSummary {
status: "self_report_only".to_string(),
@@ -1124,6 +1182,8 @@ fn normalize_worker_record(mut record: AgentWorkerRecord) -> AgentWorkerRecord {
}
if record.usage.status.is_empty() {
record.usage = default_agent_run_usage();
} else {
refresh_usage_note(&mut record.usage);
}
if record.verification.status.is_empty() {
record.verification = default_agent_run_verification();
@@ -1165,6 +1225,7 @@ pub(crate) struct SubAgentSpawnOptions {
pub model_route: Option<ModelRoute>,
pub nickname: Option<String>,
pub fork_context: bool,
pub token_budget: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1252,6 +1313,18 @@ struct SpawnRequest {
/// Legacy recursion budget for descendants. The model-facing child tool
/// surface is leaf-only; this remains for persisted/internal records.
max_depth: Option<u32>,
/// Optional aggregate token budget for this child and its descendants.
/// When unset, the child inherits the parent's budget pool or the
/// configured root default.
token_budget: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AgentUsageBudgetScope {
scope_id: String,
limit: u64,
spent: u64,
remaining: u64,
}
/// Durable recovery point for an interrupted sub-agent session.
@@ -1759,6 +1832,7 @@ pub struct SubAgentManager {
state_path: Option<PathBuf>,
max_steps: u32,
max_agents: usize,
default_token_budget: Option<u64>,
running_heartbeat_timeout: Duration,
/// Stable id assigned at manager construction (#405). Stamped on
/// every agent the manager spawns; agents loaded from the
@@ -1795,6 +1869,7 @@ impl SubAgentManager {
state_path: None,
max_steps: DEFAULT_MAX_STEPS,
max_agents,
default_token_budget: None,
running_heartbeat_timeout: Duration::from_secs(
crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
),
@@ -1817,6 +1892,14 @@ impl SubAgentManager {
self
}
/// Set the default aggregate token budget for root sub-agent runs.
/// `None` and `Some(0)` both preserve unlimited legacy behavior.
#[must_use]
pub fn with_default_token_budget(mut self, budget: Option<u64>) -> Self {
self.default_token_budget = positive_token_budget(budget);
self
}
/// Return the boot id this manager stamps on agents it spawns.
/// Exposed for tests; internal callers use the field directly.
#[cfg(test)]
@@ -1856,8 +1939,10 @@ impl SubAgentManager {
max_agents: usize,
running_heartbeat_timeout: Duration,
launch_concurrency: usize,
default_token_budget: Option<u64>,
) -> bool {
self.max_agents = max_agents.clamp(1, crate::config::MAX_SUBAGENTS);
self.default_token_budget = positive_token_budget(default_token_budget);
self.running_heartbeat_timeout = if running_heartbeat_timeout.is_zero() {
Duration::from_secs(crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS)
} else {
@@ -2048,6 +2133,7 @@ impl SubAgentManager {
self.worker_records
.insert(worker.spec.worker_id.clone(), worker);
}
self.refresh_all_budget_scopes();
self.prune_worker_records();
Ok(())
@@ -2101,6 +2187,139 @@ impl SubAgentManager {
self.worker_records.get(worker_id).cloned()
}
fn aggregate_budget_spent(&self, scope_id: &str) -> u64 {
self.worker_records
.values()
.filter(|record| record.usage.budget_scope.as_deref() == Some(scope_id))
.fold(0_u64, |total, record| {
total.saturating_add(record.usage.total_tokens.unwrap_or(0))
})
}
fn inherited_budget_scope(&self, parent_run_id: Option<&str>) -> Option<(String, u64)> {
let parent = self.worker_records.get(parent_run_id?)?;
let limit = parent.usage.token_budget?;
let scope_id = parent
.usage
.budget_scope
.clone()
.unwrap_or_else(|| parent.spec.worker_id.clone());
Some((scope_id, limit))
}
fn resolve_spawn_budget_scope(
&self,
worker_id: &str,
parent_run_id: Option<&str>,
requested_budget: Option<u64>,
) -> Result<Option<AgentUsageBudgetScope>> {
let scope = if let Some(limit) = positive_token_budget(requested_budget) {
Some((worker_id.to_string(), limit))
} else if let Some(parent_scope) = self.inherited_budget_scope(parent_run_id) {
Some(parent_scope)
} else {
self.default_token_budget
.map(|limit| (worker_id.to_string(), limit))
};
let Some((scope_id, limit)) = scope else {
return Ok(None);
};
let spent = self.aggregate_budget_spent(&scope_id);
let remaining = limit.saturating_sub(spent);
if remaining < MIN_SUBAGENT_SPAWN_TOKEN_RESERVE {
return Err(anyhow!(
"Sub-agent token budget exhausted for scope {scope_id}: {spent}/{limit} tokens spent, {remaining} remaining. Wait for the parent/Workflow to summarize results or start a new agent run with an explicit token_budget override."
));
}
Ok(Some(AgentUsageBudgetScope {
scope_id,
limit,
spent,
remaining,
}))
}
fn attach_budget_scope(&mut self, worker_id: &str, scope: AgentUsageBudgetScope) {
let Some(record) = self.worker_records.get_mut(worker_id) else {
return;
};
record.usage.token_budget = Some(scope.limit);
record.usage.budget_scope = Some(scope.scope_id.clone());
record.usage.budget_spent_tokens = Some(scope.spent);
record.usage.budget_remaining_tokens = Some(scope.remaining);
refresh_usage_note(&mut record.usage);
self.refresh_budget_scope(&scope.scope_id);
}
fn refresh_budget_scope(&mut self, scope_id: &str) {
let Some(limit) = self
.worker_records
.values()
.find(|record| record.usage.budget_scope.as_deref() == Some(scope_id))
.and_then(|record| record.usage.token_budget)
else {
return;
};
let spent = self.aggregate_budget_spent(scope_id);
let remaining = limit.saturating_sub(spent);
for record in self.worker_records.values_mut() {
if record.usage.budget_scope.as_deref() == Some(scope_id) {
record.usage.token_budget = Some(limit);
record.usage.budget_spent_tokens = Some(spent);
record.usage.budget_remaining_tokens = Some(remaining);
refresh_usage_note(&mut record.usage);
}
}
}
fn refresh_all_budget_scopes(&mut self) {
let scope_ids = self
.worker_records
.values()
.filter_map(|record| record.usage.budget_scope.clone())
.collect::<std::collections::HashSet<_>>();
for scope_id in scope_ids {
self.refresh_budget_scope(&scope_id);
}
}
fn record_worker_usage(&mut self, worker_id: &str, usage: &Usage) {
let now_ms = epoch_millis_now();
let total_delta = usage_total_tokens(usage);
let Some(record) = self.worker_records.get_mut(worker_id) else {
return;
};
record.updated_at_ms = now_ms;
record.usage.input_tokens = Some(
record
.usage
.input_tokens
.unwrap_or(0)
.saturating_add(u64::from(usage.input_tokens)),
);
record.usage.output_tokens = Some(
record
.usage
.output_tokens
.unwrap_or(0)
.saturating_add(u64::from(usage.output_tokens)),
);
record.usage.total_tokens = Some(
record
.usage
.total_tokens
.unwrap_or(0)
.saturating_add(total_delta),
);
let scope_id = record.usage.budget_scope.clone();
refresh_usage_note(&mut record.usage);
if let Some(scope_id) = scope_id {
self.refresh_budget_scope(&scope_id);
}
self.persist_state_debounced();
}
fn push_worker_event(
&mut self,
record: &mut AgentWorkerRecord,
@@ -2334,6 +2553,11 @@ impl SubAgentManager {
}
let effective_model = runtime.model.clone();
let agent_id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
let budget_scope = self.resolve_spawn_budget_scope(
&agent_id,
runtime.parent_agent_id.as_deref(),
options.token_budget,
)?;
let active_names: std::collections::HashSet<String> = self
.agents
.values()
@@ -2428,6 +2652,9 @@ impl SubAgentManager {
max_spawn_depth: runtime.max_spawn_depth,
};
self.register_worker(worker_spec);
if let Some(scope) = budget_scope {
self.attach_budget_scope(&agent_id, scope);
}
if let Some(event_tx) = runtime.event_tx.clone() {
let _ = event_tx.try_send(Event::AgentSpawned {
@@ -2998,6 +3225,7 @@ pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> Sha
max_agents,
Duration::from_secs(crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS),
max_agents,
None,
)
}
@@ -3009,12 +3237,14 @@ pub fn new_shared_subagent_manager_with_timeout(
max_agents: usize,
running_heartbeat_timeout: Duration,
launch_concurrency: usize,
default_token_budget: Option<u64>,
) -> SharedSubAgentManager {
let max_agents = max_agents.clamp(1, MAX_SUBAGENTS);
let state_path = default_state_path(&workspace);
let mut manager = SubAgentManager::new(workspace, max_agents)
.with_running_heartbeat_timeout(running_heartbeat_timeout)
.with_launch_concurrency(launch_concurrency)
.with_default_token_budget(default_token_budget)
.with_state_path(state_path);
if let Err(err) = manager.load_state() {
// Routed through tracing instead of stderr — see comment in
@@ -3096,6 +3326,11 @@ impl ToolSpec for AgentTool {
"minimum": 0,
"maximum": 3,
"description": "Optional remaining nested-agent depth budget for this child. Defaults to the configured runtime budget."
},
"token_budget": {
"type": "integer",
"minimum": 1,
"description": "Optional aggregate token budget for this child and descendants. When unset, the child inherits the parent budget pool or the configured root default."
}
},
"required": ["prompt"]
@@ -3264,6 +3499,7 @@ async fn spawn_subagent_from_input(
model_route: Some(model_route),
nickname: None,
fork_context: spawn_request.fork_context,
token_budget: spawn_request.token_budget,
},
)
.map_err(|e| ToolError::execution_failed(format!("Failed to spawn sub-agent: {e}")))?;
@@ -4135,6 +4371,10 @@ async fn run_subagent(
response.usage.clone(),
));
}
{
let mut manager = runtime.manager.write().await;
manager.record_worker_usage(&agent_id, &response.usage);
}
for block in &response.content {
match block {
@@ -4629,6 +4869,8 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
})
})
.transpose()?;
let token_budget =
parse_optional_positive_u64(input, &["token_budget", "tokenBudget", "max_tokens"])?;
Ok(SpawnRequest {
session_name,
@@ -4643,6 +4885,7 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
resident_file,
fork_context,
max_depth,
token_budget,
})
}
@@ -4674,6 +4917,26 @@ fn parse_optional_bool(input: &Value, names: &[&str]) -> Option<bool> {
.and_then(Value::as_bool)
}
fn parse_optional_positive_u64(input: &Value, names: &[&str]) -> Result<Option<u64>, ToolError> {
for name in names {
let Some(value) = input.get(*name) else {
continue;
};
let Some(parsed) = value.as_u64() else {
return Err(ToolError::invalid_input(format!(
"{name} must be a positive integer token count"
)));
};
if parsed == 0 {
return Err(ToolError::invalid_input(format!(
"{name} must be greater than zero; omit it to inherit or disable the budget"
)));
}
return Ok(Some(parsed));
}
Ok(None)
}
fn with_default_fork_context(mut input: Value, default: bool) -> Value {
let Some(object) = input.as_object_mut() else {
return input;
+134
View File
@@ -153,6 +153,113 @@ fn headless_worker_record_tracks_lifecycle_without_tui_projection() {
);
}
#[test]
fn worker_record_usage_accumulates_provider_tokens() {
let tmp = tempdir().expect("tempdir");
let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 4);
manager.register_worker(make_worker_spec("agent_usage", tmp.path().to_path_buf()));
manager.record_worker_usage(
"agent_usage",
&Usage {
input_tokens: 100,
output_tokens: 25,
prompt_cache_hit_tokens: Some(70),
prompt_cache_miss_tokens: Some(30),
..Usage::default()
},
);
manager.record_worker_usage(
"agent_usage",
&Usage {
input_tokens: 40,
output_tokens: 10,
..Usage::default()
},
);
let record = manager
.get_worker_record("agent_usage")
.expect("worker record");
assert_eq!(record.usage.status, "reported");
assert_eq!(record.usage.input_tokens, Some(140));
assert_eq!(record.usage.output_tokens, Some(35));
assert_eq!(record.usage.total_tokens, Some(175));
assert_eq!(record.usage.token_budget, None);
assert!(
record.usage.note.contains("175 tokens"),
"usage note includes reported total: {}",
record.usage.note
);
}
#[test]
fn token_budget_scope_is_shared_across_nested_workers_and_blocks_when_spent() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().to_path_buf();
let mut manager =
SubAgentManager::new(workspace.clone(), 4).with_default_token_budget(Some(100));
manager.register_worker(make_worker_spec("agent_root", workspace.clone()));
let root_scope = manager
.resolve_spawn_budget_scope("agent_root", None, None)
.expect("root budget resolves")
.expect("root budget present");
manager.attach_budget_scope("agent_root", root_scope);
manager.record_worker_usage(
"agent_root",
&Usage {
input_tokens: 40,
output_tokens: 10,
..Usage::default()
},
);
let mut child_spec = make_worker_spec("agent_child", workspace);
child_spec.parent_run_id = Some("agent_root".to_string());
let child_scope = manager
.resolve_spawn_budget_scope("agent_child", Some("agent_root"), None)
.expect("child inherits budget")
.expect("child budget present");
assert_eq!(child_scope.scope_id, "agent_root");
assert_eq!(child_scope.limit, 100);
assert_eq!(child_scope.spent, 50);
manager.register_worker(child_spec);
manager.attach_budget_scope("agent_child", child_scope);
manager.record_worker_usage(
"agent_child",
&Usage {
input_tokens: 30,
output_tokens: 20,
..Usage::default()
},
);
let root = manager.get_worker_record("agent_root").expect("root");
let child = manager.get_worker_record("agent_child").expect("child");
assert_eq!(root.usage.budget_spent_tokens, Some(100));
assert_eq!(child.usage.budget_spent_tokens, Some(100));
assert_eq!(root.usage.budget_remaining_tokens, Some(0));
assert_eq!(child.usage.budget_remaining_tokens, Some(0));
assert_eq!(root.usage.status, "budget_exhausted");
let err = manager
.resolve_spawn_budget_scope("agent_grandchild", Some("agent_child"), None)
.expect_err("spent shared budget blocks further child spawn");
assert!(
err.to_string().contains("token budget exhausted"),
"actionable exhaustion error: {err}"
);
let override_scope = manager
.resolve_spawn_budget_scope("agent_override", Some("agent_child"), Some(20))
.expect("explicit override starts new scope")
.expect("override budget present");
assert_eq!(override_scope.scope_id, "agent_override");
assert_eq!(override_scope.limit, 20);
assert_eq!(override_scope.spent, 0);
}
#[test]
fn agent_worker_profile_derives_from_parent_without_escalation() {
let mut runtime = stub_runtime();
@@ -969,6 +1076,33 @@ fn test_delegate_defaults_to_fork_context() {
assert!(!parsed.fork_context);
}
#[test]
fn spawn_request_parses_token_budget_override() {
let parsed = parse_spawn_request(&json!({
"prompt": "fan out safely",
"token_budget": 12_345
}))
.expect("token budget parses");
assert_eq!(parsed.token_budget, Some(12_345));
let parsed = parse_spawn_request(&json!({
"prompt": "fleet-shaped alias",
"max_tokens": 4_000
}))
.expect("max_tokens alias parses");
assert_eq!(parsed.token_budget, Some(4_000));
let err = parse_spawn_request(&json!({
"prompt": "bad budget",
"token_budget": 0
}))
.expect_err("zero budget is invalid in tool input");
assert!(
err.to_string().contains("must be greater than zero"),
"clear token budget error: {err}"
);
}
#[test]
fn forked_subagent_messages_preserve_parent_prefix_then_append_task() {
let parent_system = SystemPrompt::Text("parent system".to_string());
+1
View File
@@ -1099,6 +1099,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
app.hunt.verdict.goal_status(),
),
max_spawn_depth: config.subagent_max_spawn_depth(),
subagent_token_budget: config.subagent_token_budget(),
allowed_tools: app.active_allowed_tools.clone(),
disallowed_tools: None,
hook_executor: app.runtime_services.hook_executor.clone(),
+14 -12
View File
@@ -986,18 +986,20 @@ If you are upgrading from older releases:
overrides, then the parent runtime model. Supported convenience keys are
`default_model`, `worker_model`, `explorer_model`, `awaiter_model`,
`review_model`, `custom_model`, `max_concurrent`, `launch_concurrency`,
`api_timeout_secs`, and `heartbeat_timeout_secs`. The `[subagents]
max_concurrent` value overrides top-level `max_subagents` and is also clamped
to `1..=20`. `[subagents] launch_concurrency` sets how many direct children
start at once before the rest queue for a launch slot; it defaults to the
resolved `max_subagents` cap and is clamped to `1..=max_subagents` (the
deprecated `interactive_max_launch` key is accepted as an alias, with the new
key winning when both are set). `[subagents]
api_timeout_secs` controls the per-step API timeout for sub-agent model calls
and is clamped to `1..=1800`, with `0` or unset preserving the legacy 120
second default. `[subagents] heartbeat_timeout_secs` controls stale running
agent cleanup, defaults to `300`, and is clamped to `30..=3600` while staying
above the resolved API timeout.
`token_budget`, `api_timeout_secs`, and `heartbeat_timeout_secs`. The
`[subagents] max_concurrent` value overrides top-level `max_subagents` and is
also clamped to `1..=20`. `[subagents] launch_concurrency` sets how many
direct children start at once before the rest queue for a launch slot; it
defaults to the resolved `max_subagents` cap and is clamped to
`1..=max_subagents` (the deprecated `interactive_max_launch` key is accepted
as an alias, with the new key winning when both are set). `[subagents]
token_budget` is an optional aggregate token ceiling for each root `agent`
run and its descendants; unset or `0` preserves unlimited legacy behavior.
`[subagents] api_timeout_secs` controls the per-step API timeout for
sub-agent model calls and is clamped to `1..=1800`, with `0` or unset
preserving the legacy 120 second default. `[subagents] heartbeat_timeout_secs`
controls stale running agent cleanup, defaults to `300`, and is clamped to
`30..=3600` while staying above the resolved API timeout.
`[subagents.models]` accepts lower-case role or type keys such as `worker`,
`explorer`, `general`, `explore`, `plan`, and `review`. Values must normalize
to a supported DeepSeek model id before an agent is spawned.
+18 -2
View File
@@ -199,6 +199,21 @@ cancelled records persist for inspection but don't occupy a slot.
Agents that lost their `task_handle` (e.g. across a process
restart) also don't count against the cap.
## Token budget governor
Set `[subagents].token_budget` to give each root `agent` run an aggregate
token ceiling shared by that child and all of its descendants. A child can also
start a new scoped budget with the model-facing `agent` tool's
`token_budget` field (the `max_tokens` alias is accepted for Workflow-shaped
callers). When no budget is configured or supplied, behavior is unchanged.
Provider-reported input and output tokens are folded into the worker record as
each child model call completes. The persisted `usage` object shows the
worker's own totals plus aggregate `budget_spent_tokens` and
`budget_remaining_tokens` for the shared scope. Once the shared scope is
exhausted, further descendant spawns are rejected with an actionable message
instead of opening more agents into a spent pool.
## Per-role models (#3018)
Children can run on a different model than the parent. Two config surfaces
@@ -325,8 +340,9 @@ child's assignment no longer fits.
Artifacts are symbolic refs. Use `handle_read` on the returned
`transcript_handle` for transcript details, and treat `result_summary` as a
child self-report unless `verification.status` points to a separate gate or
receipt. `usage.status` is `unknown` until sub-agent token accounting is wired
into the worker ledger.
receipt. `usage.status` is `unknown` until provider usage is reported; then it
switches to `reported`, or `budget_exhausted` when a configured shared token
budget has no remaining tokens.
## Output contract