feat: add ecc2 agent profiles

This commit is contained in:
Affaan Mustafa 2026-04-09 22:43:16 -07:00
parent e48468a9e7
commit 1e4d6a4161
6 changed files with 873 additions and 42 deletions

View File

@ -1,6 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use crate::notifications::{ use crate::notifications::{
@ -48,6 +49,35 @@ pub struct ConflictResolutionConfig {
pub notify_lead: bool, pub notify_lead: bool,
} }
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentProfileConfig {
pub inherits: Option<String>,
pub agent: Option<String>,
pub model: Option<String>,
pub allowed_tools: Vec<String>,
pub disallowed_tools: Vec<String>,
pub permission_mode: Option<String>,
pub add_dirs: Vec<PathBuf>,
pub max_budget_usd: Option<f64>,
pub token_budget: Option<u64>,
pub append_system_prompt: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ResolvedAgentProfile {
pub profile_name: String,
pub agent: Option<String>,
pub model: Option<String>,
pub allowed_tools: Vec<String>,
pub disallowed_tools: Vec<String>,
pub permission_mode: Option<String>,
pub add_dirs: Vec<PathBuf>,
pub max_budget_usd: Option<f64>,
pub token_budget: Option<u64>,
pub append_system_prompt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
@ -61,6 +91,8 @@ pub struct Config {
pub heartbeat_interval_secs: u64, pub heartbeat_interval_secs: u64,
pub auto_terminate_stale_sessions: bool, pub auto_terminate_stale_sessions: bool,
pub default_agent: String, pub default_agent: String,
pub default_agent_profile: Option<String>,
pub agent_profiles: BTreeMap<String, AgentProfileConfig>,
pub auto_dispatch_unread_handoffs: bool, pub auto_dispatch_unread_handoffs: bool,
pub auto_dispatch_limit_per_session: usize, pub auto_dispatch_limit_per_session: usize,
pub auto_create_worktrees: bool, pub auto_create_worktrees: bool,
@ -122,6 +154,8 @@ impl Default for Config {
heartbeat_interval_secs: 30, heartbeat_interval_secs: 30,
auto_terminate_stale_sessions: false, auto_terminate_stale_sessions: false,
default_agent: "claude".to_string(), default_agent: "claude".to_string(),
default_agent_profile: None,
agent_profiles: BTreeMap::new(),
auto_dispatch_unread_handoffs: false, auto_dispatch_unread_handoffs: false,
auto_dispatch_limit_per_session: 5, auto_dispatch_limit_per_session: 5,
auto_create_worktrees: true, auto_create_worktrees: true,
@ -180,6 +214,41 @@ impl Config {
self.budget_alert_thresholds.sanitized() self.budget_alert_thresholds.sanitized()
} }
pub fn resolve_agent_profile(&self, name: &str) -> Result<ResolvedAgentProfile> {
let mut chain = Vec::new();
self.resolve_agent_profile_inner(name, &mut chain)
}
fn resolve_agent_profile_inner(
&self,
name: &str,
chain: &mut Vec<String>,
) -> Result<ResolvedAgentProfile> {
if chain.iter().any(|existing| existing == name) {
chain.push(name.to_string());
anyhow::bail!(
"agent profile inheritance cycle: {}",
chain.join(" -> ")
);
}
let profile = self
.agent_profiles
.get(name)
.ok_or_else(|| anyhow::anyhow!("Unknown agent profile: {name}"))?;
chain.push(name.to_string());
let mut resolved = if let Some(parent) = profile.inherits.as_deref() {
self.resolve_agent_profile_inner(parent, chain)?
} else {
ResolvedAgentProfile::default()
};
chain.pop();
resolved.apply(name, profile);
Ok(resolved)
}
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
let global_paths = Self::global_config_paths(); let global_paths = Self::global_config_paths();
let project_paths = std::env::current_dir() let project_paths = std::env::current_dir()
@ -437,6 +506,50 @@ impl Default for ConflictResolutionConfig {
} }
} }
impl ResolvedAgentProfile {
fn apply(&mut self, profile_name: &str, config: &AgentProfileConfig) {
self.profile_name = profile_name.to_string();
if let Some(agent) = config.agent.as_ref() {
self.agent = Some(agent.clone());
}
if let Some(model) = config.model.as_ref() {
self.model = Some(model.clone());
}
merge_unique(&mut self.allowed_tools, &config.allowed_tools);
merge_unique(&mut self.disallowed_tools, &config.disallowed_tools);
if let Some(permission_mode) = config.permission_mode.as_ref() {
self.permission_mode = Some(permission_mode.clone());
}
merge_unique(&mut self.add_dirs, &config.add_dirs);
if let Some(max_budget_usd) = config.max_budget_usd {
self.max_budget_usd = Some(max_budget_usd);
}
if let Some(token_budget) = config.token_budget {
self.token_budget = Some(token_budget);
}
self.append_system_prompt = match (
self.append_system_prompt.take(),
config.append_system_prompt.as_ref(),
) {
(Some(parent), Some(child)) => Some(format!("{parent}\n\n{child}")),
(Some(parent), None) => Some(parent),
(None, Some(child)) => Some(child.clone()),
(None, None) => None,
};
}
}
fn merge_unique<T>(base: &mut Vec<T>, additions: &[T])
where
T: Clone + PartialEq,
{
for value in additions {
if !base.contains(value) {
base.push(value.clone());
}
}
}
impl BudgetAlertThresholds { impl BudgetAlertThresholds {
pub fn sanitized(self) -> Self { pub fn sanitized(self) -> Self {
let values = [self.advisory, self.warning, self.critical]; let values = [self.advisory, self.warning, self.critical];
@ -461,6 +574,7 @@ mod tests {
PaneLayout, PaneLayout,
}; };
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::path::PathBuf;
use uuid::Uuid; use uuid::Uuid;
#[test] #[test]
@ -806,6 +920,65 @@ notify_lead = false
); );
} }
#[test]
fn agent_profiles_resolve_inheritance_and_defaults() {
let config: Config = toml::from_str(
r#"
default_agent_profile = "reviewer"
[agent_profiles.base]
model = "sonnet"
allowed_tools = ["Read"]
permission_mode = "plan"
add_dirs = ["docs"]
append_system_prompt = "Be careful."
[agent_profiles.reviewer]
inherits = "base"
allowed_tools = ["Edit"]
disallowed_tools = ["Bash"]
token_budget = 1200
append_system_prompt = "Review thoroughly."
"#,
)
.unwrap();
let profile = config.resolve_agent_profile("reviewer").unwrap();
assert_eq!(config.default_agent_profile.as_deref(), Some("reviewer"));
assert_eq!(profile.profile_name, "reviewer");
assert_eq!(profile.model.as_deref(), Some("sonnet"));
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
assert_eq!(profile.permission_mode.as_deref(), Some("plan"));
assert_eq!(profile.add_dirs, vec![PathBuf::from("docs")]);
assert_eq!(profile.token_budget, Some(1200));
assert_eq!(
profile.append_system_prompt.as_deref(),
Some("Be careful.\n\nReview thoroughly.")
);
}
#[test]
fn agent_profile_resolution_rejects_inheritance_cycles() {
let config: Config = toml::from_str(
r#"
[agent_profiles.a]
inherits = "b"
[agent_profiles.b]
inherits = "a"
"#,
)
.unwrap();
let error = config
.resolve_agent_profile("a")
.expect_err("profile inheritance cycles must fail");
assert!(error
.to_string()
.contains("agent profile inheritance cycle"));
}
#[test] #[test]
fn completion_summary_notifications_deserialize_from_toml() { fn completion_summary_notifications_deserialize_from_toml() {
let config: Config = toml::from_str( let config: Config = toml::from_str(

View File

@ -53,6 +53,9 @@ enum Commands {
/// Agent type (claude, codex, custom) /// Agent type (claude, codex, custom)
#[arg(short, long, default_value = "claude")] #[arg(short, long, default_value = "claude")]
agent: String, agent: String,
/// Agent profile defined in ecc2.toml
#[arg(long)]
profile: Option<String>,
#[command(flatten)] #[command(flatten)]
worktree: WorktreePolicyArgs, worktree: WorktreePolicyArgs,
/// Source session to delegate from /// Source session to delegate from
@ -69,6 +72,9 @@ enum Commands {
/// Agent type (claude, codex, custom) /// Agent type (claude, codex, custom)
#[arg(short, long, default_value = "claude")] #[arg(short, long, default_value = "claude")]
agent: String, agent: String,
/// Agent profile defined in ecc2.toml
#[arg(long)]
profile: Option<String>,
#[command(flatten)] #[command(flatten)]
worktree: WorktreePolicyArgs, worktree: WorktreePolicyArgs,
}, },
@ -82,6 +88,9 @@ enum Commands {
/// Agent type (claude, codex, custom) /// Agent type (claude, codex, custom)
#[arg(short, long, default_value = "claude")] #[arg(short, long, default_value = "claude")]
agent: String, agent: String,
/// Agent profile defined in ecc2.toml
#[arg(long)]
profile: Option<String>,
#[command(flatten)] #[command(flatten)]
worktree: WorktreePolicyArgs, worktree: WorktreePolicyArgs,
}, },
@ -381,6 +390,7 @@ async fn main() -> Result<()> {
Some(Commands::Start { Some(Commands::Start {
task, task,
agent, agent,
profile,
worktree, worktree,
from_session, from_session,
}) => { }) => {
@ -394,18 +404,34 @@ async fn main() -> Result<()> {
} else { } else {
None None
}; };
let session_id = session::manager::create_session_with_grouping( let grouping = session::SessionGrouping {
&db, project: source.as_ref().map(|session| session.project.clone()),
&cfg, task_group: source.as_ref().map(|session| session.task_group.clone()),
&task, };
&agent, let session_id = if let Some(source) = source.as_ref() {
use_worktree, session::manager::create_session_from_source_with_profile_and_grouping(
session::SessionGrouping { &db,
project: source.as_ref().map(|session| session.project.clone()), &cfg,
task_group: source.as_ref().map(|session| session.task_group.clone()), &task,
}, &agent,
) use_worktree,
.await?; profile.as_deref(),
&source.id,
grouping,
)
.await?
} else {
session::manager::create_session_with_profile_and_grouping(
&db,
&cfg,
&task,
&agent,
use_worktree,
profile.as_deref(),
grouping,
)
.await?
};
if let Some(source) = source { if let Some(source) = source {
let from_id = source.id; let from_id = source.id;
send_handoff_message(&db, &from_id, &session_id)?; send_handoff_message(&db, &from_id, &session_id)?;
@ -416,6 +442,7 @@ async fn main() -> Result<()> {
from_session, from_session,
task, task,
agent, agent,
profile,
worktree, worktree,
}) => { }) => {
let use_worktree = worktree.resolve(&cfg); let use_worktree = worktree.resolve(&cfg);
@ -431,12 +458,14 @@ async fn main() -> Result<()> {
) )
}); });
let session_id = session::manager::create_session_with_grouping( let session_id = session::manager::create_session_from_source_with_profile_and_grouping(
&db, &db,
&cfg, &cfg,
&task, &task,
&agent, &agent,
use_worktree, use_worktree,
profile.as_deref(),
&source.id,
session::SessionGrouping { session::SessionGrouping {
project: Some(source.project.clone()), project: Some(source.project.clone()),
task_group: Some(source.task_group.clone()), task_group: Some(source.task_group.clone()),
@ -454,13 +483,22 @@ async fn main() -> Result<()> {
from_session, from_session,
task, task,
agent, agent,
profile,
worktree, worktree,
}) => { }) => {
let use_worktree = worktree.resolve(&cfg); let use_worktree = worktree.resolve(&cfg);
let lead_id = resolve_session_id(&db, &from_session)?; let lead_id = resolve_session_id(&db, &from_session)?;
let outcome = let outcome = session::manager::assign_session_with_profile_and_grouping(
session::manager::assign_session(&db, &cfg, &lead_id, &task, &agent, use_worktree) &db,
.await?; &cfg,
&lead_id,
&task,
&agent,
use_worktree,
profile.as_deref(),
session::SessionGrouping::default(),
)
.await?;
if session::manager::assignment_action_routes_work(outcome.action) { if session::manager::assignment_action_routes_work(outcome.action) {
println!( println!(
"Assignment routed: {} -> {} ({})", "Assignment routed: {} -> {} ({})",

View File

@ -11,7 +11,7 @@ use super::runtime::capture_command_output;
use super::store::StateStore; use super::store::StateStore;
use super::{ use super::{
default_project_label, default_task_group_label, normalize_group_label, Session, default_project_label, default_task_group_label, normalize_group_label, Session,
SessionGrouping, SessionMetrics, SessionState, SessionAgentProfile, SessionGrouping, SessionMetrics, SessionState,
}; };
use crate::comms::{self, MessageType}; use crate::comms::{self, MessageType};
use crate::config::Config; use crate::config::Config;
@ -25,12 +25,13 @@ pub async fn create_session(
agent_type: &str, agent_type: &str,
use_worktree: bool, use_worktree: bool,
) -> Result<String> { ) -> Result<String> {
create_session_with_grouping( create_session_with_profile_and_grouping(
db, db,
cfg, cfg,
task, task,
agent_type, agent_type,
use_worktree, use_worktree,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await .await
@ -43,6 +44,27 @@ pub async fn create_session_with_grouping(
agent_type: &str, agent_type: &str,
use_worktree: bool, use_worktree: bool,
grouping: SessionGrouping, grouping: SessionGrouping,
) -> Result<String> {
create_session_with_profile_and_grouping(
db,
cfg,
task,
agent_type,
use_worktree,
None,
grouping,
)
.await
}
pub async fn create_session_with_profile_and_grouping(
db: &StateStore,
cfg: &Config,
task: &str,
agent_type: &str,
use_worktree: bool,
profile_name: Option<&str>,
grouping: SessionGrouping,
) -> Result<String> { ) -> Result<String> {
let repo_root = let repo_root =
std::env::current_dir().context("Failed to resolve current working directory")?; std::env::current_dir().context("Failed to resolve current working directory")?;
@ -53,6 +75,34 @@ pub async fn create_session_with_grouping(
agent_type, agent_type,
use_worktree, use_worktree,
&repo_root, &repo_root,
profile_name,
None,
grouping,
)
.await
}
pub async fn create_session_from_source_with_profile_and_grouping(
db: &StateStore,
cfg: &Config,
task: &str,
agent_type: &str,
use_worktree: bool,
profile_name: Option<&str>,
source_session_id: &str,
grouping: SessionGrouping,
) -> Result<String> {
let repo_root =
std::env::current_dir().context("Failed to resolve current working directory")?;
queue_session_in_dir(
db,
cfg,
task,
agent_type,
use_worktree,
&repo_root,
profile_name,
Some(source_session_id),
grouping, grouping,
) )
.await .await
@ -66,6 +116,7 @@ pub fn get_status(db: &StateStore, id: &str) -> Result<SessionStatus> {
let session = resolve_session(db, id)?; let session = resolve_session(db, id)?;
let session_id = session.id.clone(); let session_id = session.id.clone();
Ok(SessionStatus { Ok(SessionStatus {
profile: db.get_session_profile(&session_id)?,
session, session,
parent_session: db.latest_task_handoff_source(&session_id)?, parent_session: db.latest_task_handoff_source(&session_id)?,
delegated_children: db.delegated_children(&session_id, 5)?, delegated_children: db.delegated_children(&session_id, 5)?,
@ -159,13 +210,14 @@ pub async fn assign_session(
agent_type: &str, agent_type: &str,
use_worktree: bool, use_worktree: bool,
) -> Result<AssignmentOutcome> { ) -> Result<AssignmentOutcome> {
assign_session_with_grouping( assign_session_with_profile_and_grouping(
db, db,
cfg, cfg,
lead_id, lead_id,
task, task,
agent_type, agent_type,
use_worktree, use_worktree,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await .await
@ -179,6 +231,29 @@ pub async fn assign_session_with_grouping(
agent_type: &str, agent_type: &str,
use_worktree: bool, use_worktree: bool,
grouping: SessionGrouping, grouping: SessionGrouping,
) -> Result<AssignmentOutcome> {
assign_session_with_profile_and_grouping(
db,
cfg,
lead_id,
task,
agent_type,
use_worktree,
None,
grouping,
)
.await
}
pub async fn assign_session_with_profile_and_grouping(
db: &StateStore,
cfg: &Config,
lead_id: &str,
task: &str,
agent_type: &str,
use_worktree: bool,
profile_name: Option<&str>,
grouping: SessionGrouping,
) -> Result<AssignmentOutcome> { ) -> Result<AssignmentOutcome> {
let repo_root = let repo_root =
std::env::current_dir().context("Failed to resolve current working directory")?; std::env::current_dir().context("Failed to resolve current working directory")?;
@ -191,6 +266,7 @@ pub async fn assign_session_with_grouping(
use_worktree, use_worktree,
&repo_root, &repo_root,
&std::env::current_exe().context("Failed to resolve ECC executable path")?, &std::env::current_exe().context("Failed to resolve ECC executable path")?,
profile_name,
grouping, grouping,
) )
.await .await
@ -228,6 +304,7 @@ pub async fn drain_inbox(
use_worktree, use_worktree,
&repo_root, &repo_root,
&runner_program, &runner_program,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@ -434,6 +511,7 @@ pub async fn rebalance_team_backlog(
use_worktree, use_worktree,
&repo_root, &repo_root,
&runner_program, &runner_program,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@ -464,12 +542,15 @@ pub async fn stop_session(db: &StateStore, id: &str) -> Result<()> {
pub struct BudgetEnforcementOutcome { pub struct BudgetEnforcementOutcome {
pub token_budget_exceeded: bool, pub token_budget_exceeded: bool,
pub cost_budget_exceeded: bool, pub cost_budget_exceeded: bool,
pub profile_token_budget_exceeded: bool,
pub paused_sessions: Vec<String>, pub paused_sessions: Vec<String>,
} }
impl BudgetEnforcementOutcome { impl BudgetEnforcementOutcome {
pub fn hard_limit_exceeded(&self) -> bool { pub fn hard_limit_exceeded(&self) -> bool {
self.token_budget_exceeded || self.cost_budget_exceeded self.token_budget_exceeded
|| self.cost_budget_exceeded
|| self.profile_token_budget_exceeded
} }
} }
@ -490,18 +571,51 @@ pub fn enforce_budget_hard_limits(
let mut outcome = BudgetEnforcementOutcome { let mut outcome = BudgetEnforcementOutcome {
token_budget_exceeded: cfg.token_budget > 0 && total_tokens >= cfg.token_budget, token_budget_exceeded: cfg.token_budget > 0 && total_tokens >= cfg.token_budget,
cost_budget_exceeded: cfg.cost_budget_usd > 0.0 && total_cost >= cfg.cost_budget_usd, cost_budget_exceeded: cfg.cost_budget_usd > 0.0 && total_cost >= cfg.cost_budget_usd,
profile_token_budget_exceeded: false,
paused_sessions: Vec::new(), paused_sessions: Vec::new(),
}; };
let mut sessions_to_pause = HashSet::new();
if outcome.token_budget_exceeded || outcome.cost_budget_exceeded {
for session in sessions.iter().filter(|session| {
matches!(
session.state,
SessionState::Pending | SessionState::Running | SessionState::Idle
)
}) {
sessions_to_pause.insert(session.id.clone());
}
}
for session in sessions.iter().filter(|session| {
matches!(
session.state,
SessionState::Pending | SessionState::Running | SessionState::Idle
)
}) {
let Some(profile) = db.get_session_profile(&session.id)? else {
continue;
};
let Some(token_budget) = profile.token_budget else {
continue;
};
if token_budget > 0 && session.metrics.tokens_used >= token_budget {
outcome.profile_token_budget_exceeded = true;
sessions_to_pause.insert(session.id.clone());
}
}
if !outcome.hard_limit_exceeded() { if !outcome.hard_limit_exceeded() {
return Ok(outcome); return Ok(outcome);
} }
for session in sessions.into_iter().filter(|session| { for session in sessions.into_iter().filter(|session| {
matches!( sessions_to_pause.contains(&session.id)
session.state, && matches!(
SessionState::Pending | SessionState::Running | SessionState::Idle session.state,
) SessionState::Pending | SessionState::Running | SessionState::Idle
)
}) { }) {
stop_session_recorded(db, &session, false)?; stop_session_recorded(db, &session, false)?;
outcome.paused_sessions.push(session.id); outcome.paused_sessions.push(session.id);
@ -820,6 +934,7 @@ async fn assign_session_in_dir_with_runner_program(
use_worktree: bool, use_worktree: bool,
repo_root: &Path, repo_root: &Path,
runner_program: &Path, runner_program: &Path,
profile_name: Option<&str>,
grouping: SessionGrouping, grouping: SessionGrouping,
) -> Result<AssignmentOutcome> { ) -> Result<AssignmentOutcome> {
let lead = resolve_session(db, lead_id)?; let lead = resolve_session(db, lead_id)?;
@ -868,6 +983,8 @@ async fn assign_session_in_dir_with_runner_program(
use_worktree, use_worktree,
repo_root, repo_root,
runner_program, runner_program,
profile_name,
Some(&lead.id),
inherited_grouping.clone(), inherited_grouping.clone(),
) )
.await?; .await?;
@ -943,6 +1060,8 @@ async fn assign_session_in_dir_with_runner_program(
use_worktree, use_worktree,
repo_root, repo_root,
runner_program, runner_program,
profile_name,
Some(&lead.id),
inherited_grouping, inherited_grouping,
) )
.await?; .await?;
@ -1623,7 +1742,8 @@ pub async fn run_session(
} }
let agent_program = agent_program(agent_type)?; let agent_program = agent_program(agent_type)?;
let command = build_agent_command(&agent_program, task, session_id, working_dir); let profile = db.get_session_profile(session_id)?;
let command = build_agent_command(&agent_program, task, session_id, working_dir, profile.as_ref());
capture_command_output( capture_command_output(
cfg.db_path.clone(), cfg.db_path.clone(),
session_id.to_string(), session_id.to_string(),
@ -1750,6 +1870,8 @@ async fn queue_session_in_dir(
agent_type: &str, agent_type: &str,
use_worktree: bool, use_worktree: bool,
repo_root: &Path, repo_root: &Path,
profile_name: Option<&str>,
inherited_profile_session_id: Option<&str>,
grouping: SessionGrouping, grouping: SessionGrouping,
) -> Result<String> { ) -> Result<String> {
queue_session_in_dir_with_runner_program( queue_session_in_dir_with_runner_program(
@ -1760,6 +1882,8 @@ async fn queue_session_in_dir(
use_worktree, use_worktree,
repo_root, repo_root,
&std::env::current_exe().context("Failed to resolve ECC executable path")?, &std::env::current_exe().context("Failed to resolve ECC executable path")?,
profile_name,
inherited_profile_session_id,
grouping, grouping,
) )
.await .await
@ -1773,11 +1897,29 @@ async fn queue_session_in_dir_with_runner_program(
use_worktree: bool, use_worktree: bool,
repo_root: &Path, repo_root: &Path,
runner_program: &Path, runner_program: &Path,
profile_name: Option<&str>,
inherited_profile_session_id: Option<&str>,
grouping: SessionGrouping, grouping: SessionGrouping,
) -> Result<String> { ) -> Result<String> {
let session = let profile =
build_session_record(db, task, agent_type, use_worktree, cfg, repo_root, grouping)?; resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?;
let effective_agent_type = profile
.as_ref()
.and_then(|profile| profile.agent.as_deref())
.unwrap_or(agent_type);
let session = build_session_record(
db,
task,
effective_agent_type,
use_worktree,
cfg,
repo_root,
grouping,
)?;
db.insert_session(&session)?; db.insert_session(&session)?;
if let Some(profile) = profile.as_ref() {
db.upsert_session_profile(&session.id, profile)?;
}
if use_worktree && session.worktree.is_none() { if use_worktree && session.worktree.is_none() {
db.enqueue_pending_worktree(&session.id, repo_root)?; db.enqueue_pending_worktree(&session.id, repo_root)?;
@ -1793,7 +1935,7 @@ async fn queue_session_in_dir_with_runner_program(
match spawn_session_runner_for_program( match spawn_session_runner_for_program(
task, task,
&session.id, &session.id,
agent_type, &session.agent_type,
working_dir, working_dir,
runner_program, runner_program,
) )
@ -1911,6 +2053,27 @@ async fn create_session_in_dir(
} }
} }
fn resolve_launch_profile(
db: &StateStore,
cfg: &Config,
explicit_profile_name: Option<&str>,
inherited_profile_session_id: Option<&str>,
) -> Result<Option<SessionAgentProfile>> {
let inherited_profile_name = match inherited_profile_session_id {
Some(session_id) => db.get_session_profile(session_id)?.map(|profile| profile.profile_name),
None => None,
};
let profile_name = explicit_profile_name
.map(ToOwned::to_owned)
.or(inherited_profile_name)
.or_else(|| cfg.default_agent_profile.clone());
profile_name
.as_deref()
.map(|name| cfg.resolve_agent_profile(name))
.transpose()
}
fn attached_worktree_count(db: &StateStore) -> Result<usize> { fn attached_worktree_count(db: &StateStore) -> Result<usize> {
Ok(db Ok(db
.list_sessions()? .list_sessions()?
@ -2075,16 +2238,44 @@ fn build_agent_command(
task: &str, task: &str,
session_id: &str, session_id: &str,
working_dir: &Path, working_dir: &Path,
profile: Option<&SessionAgentProfile>,
) -> Command { ) -> Command {
let mut command = Command::new(agent_program); let mut command = Command::new(agent_program);
command.env("ECC_SESSION_ID", session_id);
command command
.env("ECC_SESSION_ID", session_id)
.arg("--print") .arg("--print")
.arg("--name") .arg("--name")
.arg(format!("ecc-{session_id}")) .arg(format!("ecc-{session_id}"));
.arg(task) if let Some(profile) = profile {
.current_dir(working_dir) if let Some(model) = profile.model.as_ref() {
.stdin(Stdio::null()); command.arg("--model").arg(model);
}
if !profile.allowed_tools.is_empty() {
command
.arg("--allowed-tools")
.arg(profile.allowed_tools.join(","));
}
if !profile.disallowed_tools.is_empty() {
command
.arg("--disallowed-tools")
.arg(profile.disallowed_tools.join(","));
}
if let Some(permission_mode) = profile.permission_mode.as_ref() {
command.arg("--permission-mode").arg(permission_mode);
}
for dir in &profile.add_dirs {
command.arg("--add-dir").arg(dir);
}
if let Some(max_budget_usd) = profile.max_budget_usd {
command
.arg("--max-budget-usd")
.arg(max_budget_usd.to_string());
}
if let Some(prompt) = profile.append_system_prompt.as_ref() {
command.arg("--append-system-prompt").arg(prompt);
}
}
command.arg(task).current_dir(working_dir).stdin(Stdio::null());
command command
} }
@ -2094,7 +2285,7 @@ async fn spawn_claude_code(
session_id: &str, session_id: &str,
working_dir: &Path, working_dir: &Path,
) -> Result<u32> { ) -> Result<u32> {
let mut command = build_agent_command(agent_program, task, session_id, working_dir); let mut command = build_agent_command(agent_program, task, session_id, working_dir, None);
let child = command let child = command
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
@ -2194,6 +2385,7 @@ async fn kill_process(pid: u32) -> Result<()> {
} }
pub struct SessionStatus { pub struct SessionStatus {
profile: Option<SessionAgentProfile>,
session: Session, session: Session,
parent_session: Option<String>, parent_session: Option<String>,
delegated_children: Vec<String>, delegated_children: Vec<String>,
@ -2363,6 +2555,21 @@ impl fmt::Display for SessionStatus {
writeln!(f, "Task: {}", s.task)?; writeln!(f, "Task: {}", s.task)?;
writeln!(f, "Agent: {}", s.agent_type)?; writeln!(f, "Agent: {}", s.agent_type)?;
writeln!(f, "State: {}", s.state)?; writeln!(f, "State: {}", s.state)?;
if let Some(profile) = self.profile.as_ref() {
writeln!(f, "Profile: {}", profile.profile_name)?;
if let Some(model) = profile.model.as_ref() {
writeln!(f, "Model: {}", model)?;
}
if let Some(permission_mode) = profile.permission_mode.as_ref() {
writeln!(f, "Perms: {}", permission_mode)?;
}
if let Some(token_budget) = profile.token_budget {
writeln!(f, "Profile tokens: {}", token_budget)?;
}
if let Some(max_budget_usd) = profile.max_budget_usd {
writeln!(f, "Profile cost: ${max_budget_usd:.4}")?;
}
}
if let Some(parent) = self.parent_session.as_ref() { if let Some(parent) = self.parent_session.as_ref() {
writeln!(f, "Parent: {}", parent)?; writeln!(f, "Parent: {}", parent)?;
} }
@ -2590,7 +2797,7 @@ fn session_state_label(state: &SessionState) -> &'static str {
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{Config, PaneLayout, Theme}; use crate::config::{Config, PaneLayout, Theme};
use crate::session::{Session, SessionMetrics, SessionState}; use crate::session::{Session, SessionAgentProfile, SessionMetrics, SessionState};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use std::fs; use std::fs;
@ -2635,6 +2842,8 @@ mod tests {
heartbeat_interval_secs: 5, heartbeat_interval_secs: 5,
auto_terminate_stale_sessions: false, auto_terminate_stale_sessions: false,
default_agent: "claude".to_string(), default_agent: "claude".to_string(),
default_agent_profile: None,
agent_profiles: Default::default(),
auto_dispatch_unread_handoffs: false, auto_dispatch_unread_handoffs: false,
auto_dispatch_limit_per_session: 5, auto_dispatch_limit_per_session: 5,
auto_create_worktrees: true, auto_create_worktrees: true,
@ -2674,6 +2883,61 @@ mod tests {
} }
} }
#[test]
fn build_agent_command_applies_profile_runner_flags() {
let profile = SessionAgentProfile {
profile_name: "reviewer".to_string(),
agent: None,
model: Some("sonnet".to_string()),
allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
disallowed_tools: vec!["Bash".to_string()],
permission_mode: Some("plan".to_string()),
add_dirs: vec![PathBuf::from("docs"), PathBuf::from("specs")],
max_budget_usd: Some(1.25),
token_budget: Some(750),
append_system_prompt: Some("Review thoroughly.".to_string()),
};
let command = build_agent_command(
Path::new("claude"),
"review this change",
"sess-1234",
Path::new("/tmp/repo"),
Some(&profile),
);
let args = command
.as_std()
.get_args()
.map(|value| value.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert_eq!(
args,
vec![
"--print",
"--name",
"ecc-sess-1234",
"--model",
"sonnet",
"--allowed-tools",
"Read,Edit",
"--disallowed-tools",
"Bash",
"--permission-mode",
"plan",
"--add-dir",
"docs",
"--add-dir",
"specs",
"--max-budget-usd",
"1.25",
"--append-system-prompt",
"Review thoroughly.",
"review this change",
]
);
}
#[test] #[test]
fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> { fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> {
let tempdir = TestDir::new("manager-heartbeat-stale")?; let tempdir = TestDir::new("manager-heartbeat-stale")?;
@ -3099,6 +3363,62 @@ mod tests {
Ok(()) Ok(())
} }
#[tokio::test(flavor = "current_thread")]
async fn create_session_uses_default_agent_profile_and_persists_launch_settings() -> Result<()> {
let tempdir = TestDir::new("manager-default-agent-profile")?;
let repo_root = tempdir.path().join("repo");
init_git_repo(&repo_root)?;
let mut cfg = build_config(tempdir.path());
cfg.default_agent_profile = Some("reviewer".to_string());
cfg.agent_profiles.insert(
"reviewer".to_string(),
crate::config::AgentProfileConfig {
model: Some("sonnet".to_string()),
allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
disallowed_tools: vec!["Bash".to_string()],
permission_mode: Some("plan".to_string()),
add_dirs: vec![PathBuf::from("docs")],
token_budget: Some(800),
append_system_prompt: Some("Review thoroughly.".to_string()),
..Default::default()
},
);
let db = StateStore::open(&cfg.db_path)?;
let (fake_runner, _) = write_fake_claude(tempdir.path())?;
let session_id = queue_session_in_dir_with_runner_program(
&db,
&cfg,
"review work",
"claude",
false,
&repo_root,
&fake_runner,
None,
None,
SessionGrouping::default(),
)
.await?;
let profile = db
.get_session_profile(&session_id)?
.context("session profile should be persisted")?;
assert_eq!(profile.profile_name, "reviewer");
assert_eq!(profile.model.as_deref(), Some("sonnet"));
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
assert_eq!(profile.permission_mode.as_deref(), Some("plan"));
assert_eq!(profile.add_dirs, vec![PathBuf::from("docs")]);
assert_eq!(profile.token_budget, Some(800));
assert_eq!(
profile.append_system_prompt.as_deref(),
Some("Review thoroughly.")
);
Ok(())
}
#[test] #[test]
fn enforce_budget_hard_limits_stops_active_sessions_without_cleaning_worktrees() -> Result<()> { fn enforce_budget_hard_limits_stops_active_sessions_without_cleaning_worktrees() -> Result<()> {
let tempdir = TestDir::new("manager-budget-pause")?; let tempdir = TestDir::new("manager-budget-pause")?;
@ -3214,6 +3534,73 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn enforce_budget_hard_limits_pauses_sessions_over_profile_token_budget() -> Result<()> {
let tempdir = TestDir::new("manager-profile-token-budget")?;
let cfg = build_config(tempdir.path());
let db = StateStore::open(&cfg.db_path)?;
let now = Utc::now();
db.insert_session(&Session {
id: "profile-over-budget".to_string(),
task: "review work".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "claude".to_string(),
working_dir: tempdir.path().to_path_buf(),
state: SessionState::Running,
pid: Some(999_998),
worktree: None,
created_at: now - Duration::minutes(1),
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
})?;
db.upsert_session_profile(
"profile-over-budget",
&SessionAgentProfile {
profile_name: "reviewer".to_string(),
agent: None,
model: Some("sonnet".to_string()),
allowed_tools: vec!["Read".to_string()],
disallowed_tools: Vec::new(),
permission_mode: Some("plan".to_string()),
add_dirs: Vec::new(),
max_budget_usd: None,
token_budget: Some(75),
append_system_prompt: None,
},
)?;
db.update_metrics(
"profile-over-budget",
&SessionMetrics {
input_tokens: 60,
output_tokens: 30,
tokens_used: 90,
tool_calls: 0,
files_changed: 0,
duration_secs: 60,
cost_usd: 0.0,
},
)?;
let outcome = enforce_budget_hard_limits(&db, &cfg)?;
assert!(!outcome.token_budget_exceeded);
assert!(!outcome.cost_budget_exceeded);
assert!(outcome.profile_token_budget_exceeded);
assert_eq!(
outcome.paused_sessions,
vec!["profile-over-budget".to_string()]
);
let session = db
.get_session("profile-over-budget")?
.context("session should still exist")?;
assert_eq!(session.state, SessionState::Stopped);
Ok(())
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn resume_session_requeues_failed_session() -> Result<()> { async fn resume_session_requeues_failed_session() -> Result<()> {
let tempdir = TestDir::new("manager-resume-session")?; let tempdir = TestDir::new("manager-resume-session")?;
@ -4108,6 +4495,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@ -4181,6 +4569,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@ -4266,6 +4655,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@ -4338,6 +4728,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@ -4394,6 +4785,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@ -4467,6 +4859,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
None,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;

View File

@ -10,6 +10,8 @@ use std::fmt;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
pub type SessionAgentProfile = crate::config::ResolvedAgentProfile;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session { pub struct Session {
pub id: String, pub id: String,

View File

@ -14,8 +14,8 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage};
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
use super::{ use super::{
default_project_label, default_task_group_label, normalize_group_label, DecisionLogEntry, default_project_label, default_task_group_label, normalize_group_label, DecisionLogEntry,
FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, SessionMessage,
WorktreeInfo, SessionMetrics, SessionState, WorktreeInfo,
}; };
pub struct StateStore { pub struct StateStore {
@ -194,6 +194,19 @@ impl StateStore {
file_events_json TEXT NOT NULL DEFAULT '[]' file_events_json TEXT NOT NULL DEFAULT '[]'
); );
CREATE TABLE IF NOT EXISTS session_profiles (
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
profile_name TEXT NOT NULL,
model TEXT,
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
disallowed_tools_json TEXT NOT NULL DEFAULT '[]',
permission_mode TEXT,
add_dirs_json TEXT NOT NULL DEFAULT '[]',
max_budget_usd REAL,
token_budget INTEGER,
append_system_prompt TEXT
);
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
from_session TEXT NOT NULL, from_session TEXT NOT NULL,
@ -569,6 +582,98 @@ impl StateStore {
Ok(()) Ok(())
} }
pub fn upsert_session_profile(
&self,
session_id: &str,
profile: &SessionAgentProfile,
) -> Result<()> {
let allowed_tools_json = serde_json::to_string(&profile.allowed_tools)
.context("serialize allowed agent profile tools")?;
let disallowed_tools_json = serde_json::to_string(&profile.disallowed_tools)
.context("serialize disallowed agent profile tools")?;
let add_dirs_json = serde_json::to_string(&profile.add_dirs)
.context("serialize agent profile add_dirs")?;
self.conn.execute(
"INSERT INTO session_profiles (
session_id,
profile_name,
model,
allowed_tools_json,
disallowed_tools_json,
permission_mode,
add_dirs_json,
max_budget_usd,
token_budget,
append_system_prompt
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(session_id) DO UPDATE SET
profile_name = excluded.profile_name,
model = excluded.model,
allowed_tools_json = excluded.allowed_tools_json,
disallowed_tools_json = excluded.disallowed_tools_json,
permission_mode = excluded.permission_mode,
add_dirs_json = excluded.add_dirs_json,
max_budget_usd = excluded.max_budget_usd,
token_budget = excluded.token_budget,
append_system_prompt = excluded.append_system_prompt",
rusqlite::params![
session_id,
profile.profile_name,
profile.model,
allowed_tools_json,
disallowed_tools_json,
profile.permission_mode,
add_dirs_json,
profile.max_budget_usd,
profile.token_budget,
profile.append_system_prompt,
],
)?;
Ok(())
}
pub fn get_session_profile(&self, session_id: &str) -> Result<Option<SessionAgentProfile>> {
self.conn
.query_row(
"SELECT
profile_name,
model,
allowed_tools_json,
disallowed_tools_json,
permission_mode,
add_dirs_json,
max_budget_usd,
token_budget,
append_system_prompt
FROM session_profiles
WHERE session_id = ?1",
[session_id],
|row| {
let allowed_tools_json: String = row.get(2)?;
let disallowed_tools_json: String = row.get(3)?;
let add_dirs_json: String = row.get(5)?;
Ok(SessionAgentProfile {
profile_name: row.get(0)?,
model: row.get(1)?,
allowed_tools: serde_json::from_str(&allowed_tools_json)
.unwrap_or_default(),
disallowed_tools: serde_json::from_str(&disallowed_tools_json)
.unwrap_or_default(),
permission_mode: row.get(4)?,
add_dirs: serde_json::from_str(&add_dirs_json).unwrap_or_default(),
max_budget_usd: row.get(6)?,
token_budget: row.get(7)?,
append_system_prompt: row.get(8)?,
agent: None,
})
},
)
.optional()
.map_err(Into::into)
}
pub fn update_state_and_pid( pub fn update_state_and_pid(
&self, &self,
session_id: &str, session_id: &str,
@ -2532,6 +2637,63 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn session_profile_round_trips_with_launch_settings() -> Result<()> {
let tempdir = TestDir::new("store-session-profile")?;
let db = StateStore::open(&tempdir.path().join("state.db"))?;
let now = Utc::now();
db.insert_session(&Session {
id: "session-1".to_string(),
task: "review work".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "claude".to_string(),
working_dir: PathBuf::from("/tmp"),
state: SessionState::Pending,
pid: None,
worktree: None,
created_at: now,
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
})?;
db.upsert_session_profile(
"session-1",
&crate::session::SessionAgentProfile {
agent: None,
profile_name: "reviewer".to_string(),
model: Some("sonnet".to_string()),
allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
disallowed_tools: vec!["Bash".to_string()],
permission_mode: Some("plan".to_string()),
add_dirs: vec![PathBuf::from("docs"), PathBuf::from("specs")],
max_budget_usd: Some(1.5),
token_budget: Some(1200),
append_system_prompt: Some("Review thoroughly.".to_string()),
},
)?;
let profile = db
.get_session_profile("session-1")?
.expect("profile should be stored");
assert_eq!(profile.profile_name, "reviewer");
assert_eq!(profile.model.as_deref(), Some("sonnet"));
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
assert_eq!(profile.permission_mode.as_deref(), Some("plan"));
assert_eq!(profile.add_dirs, vec![PathBuf::from("docs"), PathBuf::from("specs")]);
assert_eq!(profile.max_budget_usd, Some(1.5));
assert_eq!(profile.token_budget, Some(1200));
assert_eq!(
profile.append_system_prompt.as_deref(),
Some("Review thoroughly.")
);
Ok(())
}
#[test] #[test]
fn sync_cost_tracker_metrics_aggregates_usage_into_sessions() -> Result<()> { fn sync_cost_tracker_metrics_aggregates_usage_into_sessions() -> Result<()> {
let tempdir = TestDir::new("store-cost-metrics")?; let tempdir = TestDir::new("store-cost-metrics")?;

View File

@ -5392,6 +5392,11 @@ impl Dashboard {
fn selected_session_metrics_text(&self) -> String { fn selected_session_metrics_text(&self) -> String {
if let Some(session) = self.sessions.get(self.selected_session) { if let Some(session) = self.sessions.get(self.selected_session) {
let metrics = &session.metrics; let metrics = &session.metrics;
let selected_profile = self
.db
.get_session_profile(&session.id)
.ok()
.flatten();
let group_peers = self let group_peers = self
.sessions .sessions
.iter() .iter()
@ -5413,6 +5418,57 @@ impl Dashboard {
), ),
]; ];
if let Some(profile) = selected_profile.as_ref() {
let model = profile.model.as_deref().unwrap_or("default");
let permission_mode = profile.permission_mode.as_deref().unwrap_or("default");
lines.push(format!(
"Profile {} | Model {} | Permissions {}",
profile.profile_name, model, permission_mode
));
let mut profile_details = Vec::new();
if let Some(token_budget) = profile.token_budget {
profile_details.push(format!(
"Profile tokens {}",
format_token_count(token_budget)
));
}
if let Some(max_budget_usd) = profile.max_budget_usd {
profile_details.push(format!(
"Profile cost {}",
format_currency(max_budget_usd)
));
}
if !profile.allowed_tools.is_empty() {
profile_details.push(format!(
"Allow {}",
truncate_for_dashboard(&profile.allowed_tools.join(", "), 36)
));
}
if !profile.disallowed_tools.is_empty() {
profile_details.push(format!(
"Deny {}",
truncate_for_dashboard(&profile.disallowed_tools.join(", "), 36)
));
}
if !profile.add_dirs.is_empty() {
profile_details.push(format!(
"Dirs {}",
truncate_for_dashboard(
&profile
.add_dirs
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", "),
36
)
));
}
if !profile_details.is_empty() {
lines.push(profile_details.join(" | "));
}
}
if let Some(parent) = self.selected_parent_session.as_ref() { if let Some(parent) = self.selected_parent_session.as_ref() {
lines.push(format!("Delegated from {}", format_session_id(parent))); lines.push(format!("Delegated from {}", format_session_id(parent)));
} }
@ -7878,11 +7934,16 @@ fn heartbeat_enforcement_note(outcome: &manager::HeartbeatEnforcementOutcome) ->
} }
fn budget_auto_pause_note(outcome: &manager::BudgetEnforcementOutcome) -> String { fn budget_auto_pause_note(outcome: &manager::BudgetEnforcementOutcome) -> String {
let cause = match (outcome.token_budget_exceeded, outcome.cost_budget_exceeded) { let cause = match (
(true, true) => "token and cost budgets exceeded", outcome.token_budget_exceeded,
(true, false) => "token budget exceeded", outcome.cost_budget_exceeded,
(false, true) => "cost budget exceeded", outcome.profile_token_budget_exceeded,
(false, false) => "budget exceeded", ) {
(true, true, _) => "token and cost budgets exceeded",
(true, false, _) => "token budget exceeded",
(false, true, _) => "cost budget exceeded",
(false, false, true) => "profile token budget exceeded",
(false, false, false) => "budget exceeded",
}; };
format!( format!(
@ -13011,6 +13072,8 @@ diff --git a/src/lib.rs b/src/lib.rs
heartbeat_interval_secs: 5, heartbeat_interval_secs: 5,
auto_terminate_stale_sessions: false, auto_terminate_stale_sessions: false,
default_agent: "claude".to_string(), default_agent: "claude".to_string(),
default_agent_profile: None,
agent_profiles: Default::default(),
auto_dispatch_unread_handoffs: false, auto_dispatch_unread_handoffs: false,
auto_dispatch_limit_per_session: 5, auto_dispatch_limit_per_session: 5,
auto_create_worktrees: true, auto_create_worktrees: true,