From 348d64b750428871836fd38919bd23288bc764c5 Mon Sep 17 00:00:00 2001 From: David Hagerty Date: Mon, 9 Feb 2026 10:45:47 -0500 Subject: [PATCH] feat(agent): Agent trait, AgentId, AgentContext, AgentOutcome types Implements P1d.AC2.1 and P1d.AC2.3 - core agent types needed for the agentic loop. - Add Agent trait with id(), profile(), run(), and cancel() methods - Define AgentId as String type alias - Create AgentContext struct containing work packages, decisions, handoff notes, AGENTS.md summaries, profile, project path, and graph store reference - Define AgentOutcome enum covering Completed, Blocked, Failed, and TokenBudgetExhausted variants - Add comprehensive tests in tests/agent_types_test.rs verifying: * Agent trait can be implemented * AgentContext can be constructed with all fields * All AgentOutcome variants can be constructed and pattern-matched * Mock agent can successfully run and return outcomes Co-Authored-By: Claude Opus 4.6 --- src/agent/mod.rs | 84 +++++++++++ src/agent/profile.rs | 112 +++++++++++++++ src/lib.rs | 1 + src/security/mod.rs | 3 + src/security/scope.rs | 36 +++++ tests/agent_types_test.rs | 286 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 522 insertions(+) create mode 100644 src/agent/mod.rs create mode 100644 src/agent/profile.rs create mode 100644 src/security/scope.rs create mode 100644 tests/agent_types_test.rs diff --git a/src/agent/mod.rs b/src/agent/mod.rs new file mode 100644 index 0000000..b67edc6 --- /dev/null +++ b/src/agent/mod.rs @@ -0,0 +1,84 @@ +pub mod profile; + +use crate::graph::GraphNode; +use crate::graph::store::GraphStore; +use anyhow::Result; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; + +pub use profile::AgentProfile; + +/// Type alias for agent identifiers +pub type AgentId = String; + +/// Agent trait defining the interface for executing work +#[async_trait] +pub trait Agent: Send + Sync { + /// Get the agent's unique identifier + fn id(&self) -> &AgentId; + + /// Get the agent's profile (configuration) + fn profile(&self) -> &AgentProfile; + + /// Run the agent with the given context + async fn run(&self, ctx: AgentContext) -> Result; + + /// Cancel the agent's execution (no-op stub in Phase 1d) + fn cancel(&self); +} + +/// Outcome of an agent run +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AgentOutcome { + /// Task completed successfully + Completed { summary: String }, + + /// Agent blocked due to unresolvable issues + Blocked { reason: String }, + + /// Task failed with error + Failed { error: String }, + + /// Token budget was exhausted + TokenBudgetExhausted { summary: String, tokens_used: usize }, +} + +/// Context provided to an agent when running +#[derive(Clone)] +pub struct AgentContext { + /// Tasks to work on in this package + pub work_package_tasks: Vec, + + /// Relevant decisions from the graph + pub relevant_decisions: Vec, + + /// Handoff notes from previous agent or orchestrator + pub handoff_notes: Option, + + /// Summaries extracted from AGENTS.md files (path, heading summary) + pub agents_md_summaries: Vec<(String, String)>, + + /// Agent profile controlling behavior + pub profile: AgentProfile, + + /// Project path for file operations + pub project_path: PathBuf, + + /// Graph store for querying and updating nodes + pub graph_store: Arc, +} + +impl std::fmt::Debug for AgentContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AgentContext") + .field("work_package_tasks", &self.work_package_tasks.len()) + .field("relevant_decisions", &self.relevant_decisions.len()) + .field("handoff_notes", &self.handoff_notes) + .field("agents_md_summaries", &self.agents_md_summaries.len()) + .field("profile", &self.profile) + .field("project_path", &self.project_path) + .finish() + } +} diff --git a/src/agent/profile.rs b/src/agent/profile.rs new file mode 100644 index 0000000..2cd1322 --- /dev/null +++ b/src/agent/profile.rs @@ -0,0 +1,112 @@ +use crate::security::SecurityScope; +use serde::{Deserialize, Serialize}; + +/// LLM configuration for an agent profile +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProfileLlmConfig { + /// Model name to use (e.g., "claude-3-sonnet-20250219") + pub model: Option, + + /// Temperature for sampling (0.0 to 1.0+) + pub temperature: Option, + + /// Maximum tokens to generate + pub max_tokens: Option, +} + +/// Agent profile describing behavior and capabilities +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentProfile { + /// Name of the profile (e.g., "coder", "reviewer") + pub name: String, + + /// Optional parent profile to inherit from + pub extends: Option, + + /// Role description (e.g., "Implementation specialist") + pub role: String, + + /// System prompt to guide behavior + pub system_prompt: String, + + /// List of tools the agent is allowed to use + pub allowed_tools: Vec, + + /// Security configuration for this profile + pub security: SecurityScope, + + /// LLM configuration overrides + #[serde(default)] + pub llm: ProfileLlmConfig, + + /// Maximum turns before stopping (None = no limit) + pub turn_limit: Option, + + /// Token budget for this run (None = no limit) + pub token_budget: Option, +} + +impl AgentProfile { + /// Apply inheritance from a parent profile + /// + /// Rules: + /// - Scalar fields: only override if self has a meaningful value + /// - List fields: child replaces parent entirely (not merged) + /// - system_prompt: child appended to parent with separator + /// - Optional fields: Some in child wins, falls through to parent if None + pub fn apply_inheritance(&mut self, parent: &AgentProfile) { + // Scalar fields: child wins only if non-empty + if self.role.is_empty() { + self.role.clone_from(&parent.role); + } + + // system_prompt: append child to parent + if !self.system_prompt.is_empty() && !parent.system_prompt.is_empty() { + self.system_prompt = format!( + "{}\n\n## Project-Specific Instructions\n{}", + parent.system_prompt, self.system_prompt + ); + } else if self.system_prompt.is_empty() { + self.system_prompt.clone_from(&parent.system_prompt); + } + + // List fields: child replaces parent entirely + if self.allowed_tools.is_empty() { + self.allowed_tools.clone_from(&parent.allowed_tools); + } + + // Security: take from parent if not set in child + // Simple heuristic: if child has default values, use parent's + if self.security.allowed_paths == vec!["*"] && parent.security.allowed_paths != vec!["*"] { + self.security.allowed_paths.clone_from(&parent.security.allowed_paths); + } + if self.security.denied_paths.is_empty() && !parent.security.denied_paths.is_empty() { + self.security.denied_paths.clone_from(&parent.security.denied_paths); + } + if self.security.allowed_commands == vec!["*"] + && parent.security.allowed_commands != vec!["*"] + { + self.security.allowed_commands + .clone_from(&parent.security.allowed_commands); + } + + // LLM config: child Some wins, falls through to parent if None + if self.llm.model.is_none() { + self.llm.model.clone_from(&parent.llm.model); + } + if self.llm.temperature.is_none() { + self.llm.temperature = parent.llm.temperature; + } + if self.llm.max_tokens.is_none() { + self.llm.max_tokens = parent.llm.max_tokens; + } + + // Optional numeric fields: child Some wins, falls through to parent if None + if self.turn_limit.is_none() { + self.turn_limit = parent.turn_limit; + } + if self.token_budget.is_none() { + self.token_budget = parent.token_budget; + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 3ced02e..bb58ce8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod config; pub mod db; pub mod graph; diff --git a/src/security/mod.rs b/src/security/mod.rs index afceb47..401b301 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -4,6 +4,9 @@ use regex::Regex; use std::path::{Component, Path, PathBuf}; pub mod permission; +pub mod scope; + +pub use scope::SecurityScope; pub struct SecurityValidator { config: SecurityConfig, diff --git a/src/security/scope.rs b/src/security/scope.rs new file mode 100644 index 0000000..c4e7d38 --- /dev/null +++ b/src/security/scope.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +/// Security scope for an agent, controlling what it can access +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityScope { + /// Paths the agent is allowed to access + pub allowed_paths: Vec, + + /// Paths the agent is explicitly denied access to + pub denied_paths: Vec, + + /// Shell commands the agent is allowed to run + pub allowed_commands: Vec, + + /// Whether the agent has read-only access + pub read_only: bool, + + /// Whether the agent can create new files + pub can_create_files: bool, + + /// Whether the agent can access network resources + pub network_access: bool, +} + +impl Default for SecurityScope { + fn default() -> Self { + Self { + allowed_paths: vec!["*".to_string()], + denied_paths: vec![], + allowed_commands: vec!["*".to_string()], + read_only: false, + can_create_files: true, + network_access: false, + } + } +} diff --git a/tests/agent_types_test.rs b/tests/agent_types_test.rs new file mode 100644 index 0000000..0e0f98c --- /dev/null +++ b/tests/agent_types_test.rs @@ -0,0 +1,286 @@ +use rustagent::agent::{Agent, AgentContext, AgentId, AgentOutcome}; +use rustagent::agent::profile::AgentProfile; +use rustagent::graph::{GraphNode, NodeStatus, EdgeType}; +use rustagent::security::SecurityScope; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use async_trait::async_trait; + +/// Mock agent for testing trait implementation +struct MockAgent { + id: AgentId, + profile: AgentProfile, +} + +#[async_trait] +impl Agent for MockAgent { + fn id(&self) -> &AgentId { + &self.id + } + + fn profile(&self) -> &AgentProfile { + &self.profile + } + + async fn run(&self, _ctx: AgentContext) -> anyhow::Result { + Ok(AgentOutcome::Completed { + summary: "mock completed".to_string(), + }) + } + + fn cancel(&self) { + // No-op stub for Phase 1d + } +} + +#[test] +fn test_agent_trait_compiles() { + // P1d.AC2.1: Verify Agent trait can be implemented + let profile = AgentProfile { + name: "test".to_string(), + extends: None, + role: "test role".to_string(), + system_prompt: "test prompt".to_string(), + allowed_tools: vec![], + security: SecurityScope::default(), + llm: Default::default(), + turn_limit: None, + token_budget: None, + }; + + let _agent = MockAgent { + id: "agent-1".to_string(), + profile, + }; + // If this compiles, the trait is correctly defined +} + +#[test] +fn test_agent_context_construction() { + // P1d.AC2.2: Verify AgentContext can be constructed with all fields + let profile = AgentProfile { + name: "test".to_string(), + extends: None, + role: "test role".to_string(), + system_prompt: "test prompt".to_string(), + allowed_tools: vec![], + security: SecurityScope::default(), + llm: Default::default(), + turn_limit: None, + token_budget: None, + }; + + let _ctx = AgentContext { + work_package_tasks: vec![], + relevant_decisions: vec![], + handoff_notes: Some("test notes".to_string()), + agents_md_summaries: vec![("path".to_string(), "summary".to_string())], + profile, + project_path: PathBuf::from("/tmp"), + graph_store: Arc::new(MockGraphStore), + }; + // If this compiles, the struct is correctly defined +} + +#[test] +fn test_agent_outcome_completed() { + // P1d.AC2.3: Verify Completed variant + let outcome = AgentOutcome::Completed { + summary: "task completed".to_string(), + }; + + match outcome { + AgentOutcome::Completed { summary } => { + assert_eq!(summary, "task completed"); + } + _ => panic!("Expected Completed variant"), + } +} + +#[test] +fn test_agent_outcome_blocked() { + // P1d.AC2.3: Verify Blocked variant + let outcome = AgentOutcome::Blocked { + reason: "blocked by dependency".to_string(), + }; + + match outcome { + AgentOutcome::Blocked { reason } => { + assert_eq!(reason, "blocked by dependency"); + } + _ => panic!("Expected Blocked variant"), + } +} + +#[test] +fn test_agent_outcome_failed() { + // P1d.AC2.3: Verify Failed variant + let outcome = AgentOutcome::Failed { + error: "something went wrong".to_string(), + }; + + match outcome { + AgentOutcome::Failed { error } => { + assert_eq!(error, "something went wrong"); + } + _ => panic!("Expected Failed variant"), + } +} + +#[test] +fn test_agent_outcome_token_budget_exhausted() { + // P1d.AC2.3: Verify TokenBudgetExhausted variant + let outcome = AgentOutcome::TokenBudgetExhausted { + summary: "partial work done".to_string(), + tokens_used: 5000, + }; + + match outcome { + AgentOutcome::TokenBudgetExhausted { + summary, + tokens_used, + } => { + assert_eq!(summary, "partial work done"); + assert_eq!(tokens_used, 5000); + } + _ => panic!("Expected TokenBudgetExhausted variant"), + } +} + +#[tokio::test] +async fn test_mock_agent_run() { + // P1d.AC2.1 & P1d.AC2.3: Verify mock agent can be run + let profile = AgentProfile { + name: "test".to_string(), + extends: None, + role: "test role".to_string(), + system_prompt: "test prompt".to_string(), + allowed_tools: vec![], + security: SecurityScope::default(), + llm: Default::default(), + turn_limit: None, + token_budget: None, + }; + + let agent = MockAgent { + id: "agent-1".to_string(), + profile, + }; + + let ctx = AgentContext { + work_package_tasks: vec![], + relevant_decisions: vec![], + handoff_notes: None, + agents_md_summaries: vec![], + profile: agent.profile().clone(), + project_path: PathBuf::from("/tmp"), + graph_store: Arc::new(MockGraphStore), + }; + + let result = agent.run(ctx).await; + assert!(result.is_ok()); + + match result.unwrap() { + AgentOutcome::Completed { summary } => { + assert_eq!(summary, "mock completed"); + } + _ => panic!("Expected Completed outcome"), + } +} + +// Mock GraphStore for testing +struct MockGraphStore; + +#[async_trait] +impl rustagent::graph::store::GraphStore for MockGraphStore { + async fn create_node(&self, _node: &GraphNode) -> anyhow::Result<()> { + Ok(()) + } + + async fn update_node( + &self, + _id: &str, + _status: Option, + _title: Option<&str>, + _description: Option<&str>, + _metadata: Option<&HashMap>, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn get_node(&self, _id: &str) -> anyhow::Result> { + Ok(None) + } + + async fn query_nodes( + &self, + _query: &rustagent::graph::store::NodeQuery, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn claim_task(&self, _node_id: &str, _agent_id: &str) -> anyhow::Result { + Ok(false) + } + + async fn get_ready_tasks(&self, _goal_id: &str) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_next_task(&self, _goal_id: &str) -> anyhow::Result> { + Ok(None) + } + + async fn add_edge(&self, _edge: &rustagent::graph::GraphEdge) -> anyhow::Result<()> { + Ok(()) + } + + async fn remove_edge(&self, _edge_id: &str) -> anyhow::Result<()> { + Ok(()) + } + + async fn get_edges( + &self, + _node_id: &str, + _direction: rustagent::graph::store::EdgeDirection, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_children( + &self, + _node_id: &str, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_subtree(&self, _node_id: &str) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_active_decisions(&self, _project_id: &str) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_full_graph(&self, _goal_id: &str) -> anyhow::Result { + Ok(rustagent::graph::store::WorkGraph { + nodes: vec![], + edges: vec![], + }) + } + + async fn search_nodes( + &self, + _query: &str, + _project_id: Option<&str>, + _node_type: Option, + _limit: usize, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn next_child_seq(&self, _parent_id: &str) -> anyhow::Result { + Ok(1) + } +} -- 2.51.2