//! Agent service — public API for the agent domain. //! //! This module orchestrates calls to `io.rs` (side effects) and the pure //! topic modules (`selection`, `token`) to implement the full agent service //! surface. HTTP handlers call these functions instead of reaching directly //! into `AgentPool` or the filesystem. //! //! Conventions: `docs/architecture/service-modules.md` /// In-memory cost rollup register — written by the cost-rollup subscriber. pub mod cost_rollup; mod io; /// Agent selection heuristics — pick the best agent for a story. pub mod selection; /// Token usage tracking and budget enforcement. pub mod token; use crate::agents::AgentInfo; use crate::agents::AgentPool; use crate::agents::token_usage::TokenUsageRecord; use crate::config::ProjectConfig; use crate::workflow::StoryTestResults; use std::path::Path; pub use io::is_archived; pub use token::TokenCostSummary; // ── Error type ──────────────────────────────────────────────────────────────── /// Typed errors returned by `service::agents` functions. /// /// HTTP handlers map these to specific status codes — see the conventions doc /// for the full mapping table. #[derive(Debug)] pub enum Error { /// No agent with the given name/story exists in the pool. AgentNotFound(String), /// No work item found for the requested story ID. WorkItemNotFound(String), /// Project configuration could not be loaded. Config(String), /// A filesystem or I/O operation failed. Io(String), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::AgentNotFound(msg) => write!(f, "Agent not found: {msg}"), Self::WorkItemNotFound(msg) => write!(f, "Work item not found: {msg}"), Self::Config(msg) => write!(f, "Config error: {msg}"), Self::Io(msg) => write!(f, "I/O error: {msg}"), } } } // ── Shared service types ───────────────────────────────────────────────────── /// Content and metadata for a work-item (story) file. #[derive(Debug, Clone)] pub struct WorkItemContent { pub content: String, pub stage: crate::pipeline_state::Stage, pub name: String, pub agent: Option, } /// A single entry in the project's configured agent roster. #[derive(Debug, Clone)] pub struct AgentConfigEntry { pub name: String, pub role: String, pub stage: Option, pub model: Option, pub allowed_tools: Option>, pub max_turns: Option, pub max_budget_usd: Option, } // ── Public API ──────────────────────────────────────────────────────────────── /// Start an agent for a story. /// /// Takes only what it needs: the pool (for spawning) and the project root /// (for config and worktree creation). Does not touch `AppContext`. pub async fn start_agent( pool: &AgentPool, project_root: &Path, story_id: &str, agent_name: Option<&str>, resume_context: Option<&str>, session_id_to_resume: Option, ) -> Result { pool.start_agent( project_root, story_id, agent_name, resume_context, session_id_to_resume, ) .await .map_err(Error::AgentNotFound) } /// Stop a running agent. pub async fn stop_agent( pool: &AgentPool, project_root: &Path, story_id: &str, agent_name: &str, ) -> Result<(), Error> { pool.stop_agent(project_root, story_id, agent_name) .await .map_err(Error::AgentNotFound) } /// Get the configured agent roster from `project.toml`. pub fn get_agent_config(project_root: &Path) -> Result, Error> { let config = io::load_config(project_root)?; Ok(config_to_entries(&config)) } /// Get the concatenated output text for an agent's most recent session. /// /// Returns an empty string when no log file exists yet. pub fn get_agent_output( project_root: &Path, story_id: &str, agent_name: &str, ) -> Result { let entries = io::read_agent_log(project_root, story_id, agent_name)?; Ok(selection::collect_output_text(&entries)) } /// Get the markdown content and metadata for a work item. /// /// Searches all pipeline stage directories, falling back to the CRDT content /// store when no file is present on disk. Returns `Error::WorkItemNotFound` /// when neither source has the item. pub fn get_work_item_content( project_root: &Path, story_id: &str, ) -> Result { use crate::pipeline_state::Stage; let stages = [ ("1_backlog", Stage::Backlog), ( "2_current", Stage::Coding { claim: None, plan: Default::default(), }, ), ("3_qa", Stage::Qa), ( "4_merge", Stage::from_dir("merge").expect("merge is a valid stage dir"), ), ( "5_done", Stage::from_dir("done").expect("done is a valid stage dir"), ), ( "6_archived", Stage::from_dir("archived").expect("archived is a valid stage dir"), ), ]; let work_dir = project_root.join(".huskies").join("work"); let filename = format!("{story_id}.md"); let crdt_view = crate::crdt_state::read_item(story_id); let crdt_name = crdt_view .as_ref() .map(|v| v.name().to_string()) .unwrap_or_default(); let crdt_agent = crdt_view.as_ref().and_then(|v| v.agent()); for (stage_dir, stage) in &stages { if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? { return Ok(WorkItemContent { content, stage: stage.clone(), name: crdt_name.clone(), agent: crdt_agent, }); } } // CRDT-only fallback if let Some(content) = crate::db::read_content(crate::db::ContentKey::Story(story_id)) { let item = crate::pipeline_state::read_typed(story_id) .map_err(|e| Error::Io(format!("Pipeline read error: {e}")))?; let stage = match item.as_ref() { Some(i) => i.stage.clone(), None => Stage::Upcoming, }; return Ok(WorkItemContent { content, stage, name: crdt_name, agent: crdt_agent, }); } Err(Error::WorkItemNotFound(format!( "Work item not found: {story_id}" ))) } /// Get test results for a work item. /// /// Checks in-memory workflow state first (fast path), then falls back to /// results persisted in the story file. pub fn get_test_results( project_root: &Path, story_id: &str, workflow: &crate::workflow::WorkflowState, ) -> Option { if let Some(results) = workflow.results.get(story_id) { return Some(results.clone()); } io::read_test_results_from_file(project_root, story_id) } /// Get the aggregated token cost for a specific story from the rollup register. /// /// Returns a zero-cost summary when no rollup has been recorded yet (story /// still in progress or no token records exist). pub fn get_work_item_token_cost( project_root: &Path, story_id: &str, ) -> Result { Ok(cost_rollup::get_rollup(project_root, story_id) .map(|r| r.as_summary()) .unwrap_or(TokenCostSummary { total_cost_usd: 0.0, agents: vec![], })) } /// Get all token usage records across all stories. pub fn get_all_token_usage(project_root: &Path) -> Result, Error> { io::read_token_records(project_root) } // ── Helpers ─────────────────────────────────────────────────────────────────── fn config_to_entries(config: &ProjectConfig) -> Vec { config .agent .iter() .map(|a| AgentConfigEntry { name: a.name.clone(), role: a.role.clone(), stage: a.stage.clone(), model: a.model.clone(), allowed_tools: a.allowed_tools.clone(), max_turns: a.max_turns, max_budget_usd: a.max_budget_usd, }) .collect() } // ── Integration tests ───────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use io::test_helpers::*; use tempfile::TempDir; // ── get_agent_config ────────────────────────────────────────────────────── #[test] fn get_agent_config_returns_default_when_no_toml() { let tmp = TempDir::new().unwrap(); make_huskies_dir(&tmp); let entries = get_agent_config(tmp.path()).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "default"); } #[test] fn get_agent_config_returns_configured_agents() { let tmp = TempDir::new().unwrap(); make_project_toml( &tmp, r#" [[agent]] name = "coder-1" role = "Full-stack engineer" model = "sonnet" max_turns = 30 max_budget_usd = 5.0 "#, ); let entries = get_agent_config(tmp.path()).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "coder-1"); assert_eq!(entries[0].model, Some(crate::agents::AgentModel::Sonnet)); assert_eq!(entries[0].max_turns, Some(30)); } // ── get_agent_output ────────────────────────────────────────────────────── #[test] fn get_agent_output_returns_empty_when_no_log() { let tmp = TempDir::new().unwrap(); let output = get_agent_output(tmp.path(), "42_story_foo", "coder-1").unwrap(); assert_eq!(output, ""); } // ── get_work_item_content ───────────────────────────────────────────────── #[test] fn get_work_item_content_reads_from_backlog() { crate::crdt_state::init_for_test(); let tmp = TempDir::new().unwrap(); make_stage_dirs(&tmp); write_story_file( &tmp, ".huskies/work/1_backlog/42_story_foo.md", "---\nname: \"Foo Story\"\n---\n\nSome content.", ); // Story 929: name lives in the CRDT register. crate::crdt_state::write_item_str( "42_story_foo", "1_backlog", Some("Foo Story"), None, None, None, None, ); let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap(); assert!(item.content.contains("Some content.")); assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog); assert_eq!(item.name, "Foo Story"); } #[test] fn get_work_item_content_returns_not_found_for_absent_story() { let tmp = TempDir::new().unwrap(); make_stage_dirs(&tmp); let result = get_work_item_content(tmp.path(), "99_story_nonexistent"); assert!(matches!(result, Err(Error::WorkItemNotFound(_)))); } // ── get_work_item_token_cost ────────────────────────────────────────────── #[test] fn get_work_item_token_cost_returns_zero_when_no_records() { let tmp = TempDir::new().unwrap(); let summary = get_work_item_token_cost(tmp.path(), "42_story_foo").unwrap(); assert_eq!(summary.total_cost_usd, 0.0); assert!(summary.agents.is_empty()); } // ── get_all_token_usage ─────────────────────────────────────────────────── #[test] fn get_all_token_usage_returns_empty_when_no_file() { let tmp = TempDir::new().unwrap(); let records = get_all_token_usage(tmp.path()).unwrap(); assert!(records.is_empty()); } // ── get_test_results ────────────────────────────────────────────────────── #[test] fn get_test_results_returns_none_when_no_results() { let tmp = TempDir::new().unwrap(); let workflow = crate::workflow::WorkflowState::default(); let result = get_test_results(tmp.path(), "42_story_foo", &workflow); assert!(result.is_none()); } #[test] fn get_test_results_returns_in_memory_results_first() { let tmp = TempDir::new().unwrap(); let mut workflow = crate::workflow::WorkflowState::default(); workflow .record_test_results_validated( "42_story_foo".to_string(), vec![crate::workflow::TestCaseResult { name: "test1".to_string(), status: crate::workflow::TestStatus::Pass, details: None, }], vec![], ) .unwrap(); let result = get_test_results(tmp.path(), "42_story_foo", &workflow).expect("should have results"); assert_eq!(result.unit.len(), 1); assert_eq!(result.unit[0].name, "test1"); } }