mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
ec56224829
commit
6532eb8b8f
10 files changed
+486
-15
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user