2026-04-24 13:40:47 +00:00
|
|
|
//! 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.
|
2026-04-24 13:40:47 +00:00
|
|
|
pub mod selection;
|
2026-04-29 10:41:32 +00:00
|
|
|
/// Token usage tracking and budget enforcement.
|
2026-04-24 13:40:47 +00:00
|
|
|
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,
|
2026-04-24 13:40:47 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-04-24 13:40:47 +00:00
|
|
|
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"),
|
|
|
|
|
),
|
2026-04-24 13:40:47 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let work_dir = project_root.join(".huskies").join("work");
|
|
|
|
|
let filename = format!("{story_id}.md");
|
|
|
|
|
|
2026-05-12 20:13:17 +01:00
|
|
|
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());
|
2026-05-12 20:13:17 +01:00
|
|
|
let crdt_agent = crdt_view
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|v| v.agent().map(str::to_string));
|
|
|
|
|
|
2026-05-13 05:02:52 +00:00
|
|
|
for (stage_dir, stage) in &stages {
|
2026-04-24 13:40:47 +00:00
|
|
|
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(),
|
2026-05-12 20:13:17 +01:00
|
|
|
name: crdt_name.clone(),
|
|
|
|
|
agent: crdt_agent.clone(),
|
2026-04-24 13:40:47 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)) {
|
2026-04-24 13:40:47 +00:00
|
|
|
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
|
|
|
};
|
2026-04-24 13:40:47 +00:00
|
|
|
return Ok(WorkItemContent {
|
|
|
|
|
content,
|
|
|
|
|
stage,
|
2026-05-12 20:13:17 +01:00
|
|
|
name: crdt_name,
|
|
|
|
|
agent: crdt_agent,
|
2026-04-24 13:40:47 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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::*;
|
2026-04-24 16:02:37 +00:00
|
|
|
use io::test_helpers::*;
|
2026-04-24 13:40:47 +00:00
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
// ── get_agent_config ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn get_agent_config_returns_default_when_no_toml() {
|
|
|
|
|
let tmp = TempDir::new().unwrap();
|
2026-04-24 16:02:37 +00:00
|
|
|
make_huskies_dir(&tmp);
|
2026-04-24 13:40:47 +00:00
|
|
|
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() {
|
2026-05-12 20:13:17 +01:00
|
|
|
crate::crdt_state::init_for_test();
|
2026-04-24 13:40:47 +00:00
|
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
|
make_stage_dirs(&tmp);
|
2026-04-24 16:02:37 +00:00
|
|
|
write_story_file(
|
|
|
|
|
&tmp,
|
|
|
|
|
".huskies/work/1_backlog/42_story_foo.md",
|
2026-04-24 13:40:47 +00:00
|
|
|
"---\nname: \"Foo Story\"\n---\n\nSome content.",
|
2026-04-24 16:02:37 +00:00
|
|
|
);
|
2026-05-12 20:13:17 +01:00
|
|
|
// Story 929: name lives in the CRDT register.
|
2026-05-12 22:31:59 +01:00
|
|
|
crate::crdt_state::write_item_str(
|
2026-05-12 20:13:17 +01:00
|
|
|
"42_story_foo",
|
|
|
|
|
"1_backlog",
|
|
|
|
|
Some("Foo Story"),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
);
|
2026-04-24 13:40:47 +00:00
|
|
|
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);
|
2026-04-24 13:40:47 +00:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|