Files
huskies/server/src/service/agents/mod.rs
T

386 lines
13 KiB
Rust
Raw Normal View History

//! 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`
mod io;
2026-04-29 10:41:32 +00:00
/// Agent selection heuristics — pick the best agent for a story.
pub mod selection;
2026-04-29 10:41:32 +00:00
/// 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,
2026-05-13 05:02:52 +00:00
pub stage: crate::pipeline_state::Stage,
pub name: Option<String>,
pub agent: Option<String>,
}
/// 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<String>,
pub model: Option<String>,
pub allowed_tools: Option<Vec<String>>,
pub max_turns: Option<u32>,
pub max_budget_usd: Option<f64>,
}
// ── 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<String>,
) -> Result<AgentInfo, Error> {
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<Vec<AgentConfigEntry>, 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<String, Error> {
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<WorkItemContent, Error> {
2026-05-13 05:02:52 +00:00
use crate::pipeline_state::Stage;
let stages = [
2026-05-13 05:02:52 +00:00
("1_backlog", Stage::Backlog),
("2_current", Stage::Coding),
("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);
2026-05-13 07:54:50 +00:00
let crdt_name = crdt_view.as_ref().map(|v| v.name().to_string());
let crdt_agent = crdt_view
.as_ref()
2026-05-13 11:58:50 +00:00
.and_then(|v| v.agent().map(|a| a.to_string()));
2026-05-13 05:02:52 +00:00
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,
2026-05-13 05:02:52 +00:00
stage: stage.clone(),
name: crdt_name.clone(),
agent: crdt_agent.clone(),
});
}
}
// CRDT-only fallback
2026-05-13 11:22:57 +00:00
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}")))?;
2026-05-13 08:41:57 +00:00
let stage = match item.as_ref() {
Some(i) => i.stage.clone(),
None => Stage::Upcoming,
2026-05-13 05:02:52 +00:00
};
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<StoryTestResults> {
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.
pub fn get_work_item_token_cost(
project_root: &Path,
story_id: &str,
) -> Result<TokenCostSummary, Error> {
let records = io::read_token_records(project_root)?;
Ok(token::aggregate_for_story(&records, story_id))
}
/// Get all token usage records across all stories.
pub fn get_all_token_usage(project_root: &Path) -> Result<Vec<TokenUsageRecord>, Error> {
io::read_token_records(project_root)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn config_to_entries(config: &ProjectConfig) -> Vec<AgentConfigEntry> {
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("sonnet".to_string()));
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,
None,
None,
);
let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap();
assert!(item.content.contains("Some content."));
2026-05-13 05:02:52 +00:00
assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog);
assert_eq!(item.name, Some("Foo Story".to_string()));
}
#[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");
}
}