An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at new-directions 942 lines 33 kB view raw
1pub mod agents_md; 2 3use crate::agent::AgentContext; 4use crate::tools::Tool; 5use anyhow::Result; 6use async_trait::async_trait; 7use serde_json::json; 8use std::path::PathBuf; 9 10pub use agents_md::resolve_agents_md; 11 12/// Token budget for context assembly 13#[derive(Clone, Debug)] 14pub struct ContextBudget { 15 /// Total token budget for the system prompt (default: 4000) 16 pub max_tokens: usize, 17} 18 19impl Default for ContextBudget { 20 fn default() -> Self { 21 Self { max_tokens: 4000 } 22 } 23} 24 25/// Builds a compact structured system prompt from an AgentContext 26pub struct ContextBuilder; 27 28impl ContextBuilder { 29 /// Build a system prompt string from the given agent context 30 pub fn build_system_prompt(ctx: &AgentContext) -> String { 31 let mut prompt = String::new(); 32 33 // Role section 34 prompt.push_str("## Role\n"); 35 prompt.push_str(&ctx.profile.role); 36 prompt.push('\n'); 37 prompt.push('\n'); 38 39 // Task section - show work package tasks 40 if !ctx.work_package_tasks.is_empty() { 41 prompt.push_str("## Task\n"); 42 for task in &ctx.work_package_tasks { 43 prompt.push_str(&format!( 44 "[TASK] {} | {} | priority={}\n", 45 task.id, 46 task.title, 47 task.priority 48 .map(|p| p.to_string()) 49 .unwrap_or_else(|| "medium".to_string()) 50 )); 51 52 // Add acceptance criteria if present in metadata 53 if let Some(criteria) = task.metadata.get("acceptance_criteria") { 54 prompt.push_str(&format!("[CRITERIA] {}\n", criteria)); 55 } 56 } 57 prompt.push('\n'); 58 } 59 60 // Dependency status section 61 if !ctx.dependency_statuses.is_empty() { 62 for (id, title, is_done) in &ctx.dependency_statuses { 63 if *is_done { 64 prompt.push_str(&format!("[DEP:DONE] {}{} (completed)\n", id, title)); 65 } else { 66 prompt.push_str(&format!("[DEP:PENDING] {}{} (pending)\n", id, title)); 67 } 68 } 69 } 70 71 // Previous attempt section 72 if let Some(prev) = &ctx.previous_attempt { 73 prompt.push_str("\n## Previous Attempt\n"); 74 prompt.push_str(&format!("[PREV_ATTEMPT] {}\n\n", prev)); 75 } 76 77 // Session continuity - handoff notes 78 if let Some(handoff) = &ctx.handoff_notes { 79 prompt.push_str("## Session Continuity\n"); 80 prompt.push_str(&format!("[HANDOFF] {}\n", handoff)); 81 prompt.push('\n'); 82 } 83 84 // Active decisions section 85 if !ctx.relevant_decisions.is_empty() { 86 prompt.push_str("## Active Decisions\n"); 87 for decision in &ctx.relevant_decisions { 88 prompt.push_str(&format!( 89 "[DECISION] {} | {} | status={}\n", 90 decision.id, decision.title, decision.status 91 )); 92 93 // Add chosen option if present 94 if let Some(chosen) = decision.metadata.get("chosen_option") { 95 prompt.push_str(&format!(" chosen: {}\n", chosen)); 96 } 97 } 98 prompt.push('\n'); 99 } 100 101 // Relevant observations 102 if !ctx.work_package_tasks.is_empty() { 103 prompt.push_str("## Relevant Observations (use query_nodes(id) for full detail)\n"); 104 for task in &ctx.work_package_tasks { 105 prompt.push_str(&format!("- {}: {}\n", task.id, task.description)); 106 } 107 prompt.push('\n'); 108 } 109 110 // Project conventions 111 if !ctx.agents_md_summaries.is_empty() { 112 prompt.push_str("## Project Conventions (use read_agents_md(path) for full text)\n"); 113 for (path, heading_summary) in &ctx.agents_md_summaries { 114 prompt.push_str(&format!("- {}: {}\n", path, heading_summary)); 115 } 116 prompt.push('\n'); 117 } 118 119 // Rules from the profile 120 prompt.push_str("## Rules\n"); 121 prompt.push_str(&ctx.profile.system_prompt); 122 prompt.push('\n'); 123 124 prompt 125 } 126 127 /// Build a system prompt with token budget awareness 128 /// 129 /// Prioritizes required sections (Role, Task, Dependencies, Previous Attempt, Rules) and trims optional sections 130 /// (Session Continuity, Active Decisions, Observations, Project Conventions) when over budget. 131 pub fn build_system_prompt_with_budget(ctx: &AgentContext, budget: &ContextBudget) -> String { 132 // Token estimate: ~1 token per 4 characters 133 let estimate_tokens = |text: &str| text.len() / 4; 134 135 // Build required sections first (these are never trimmed) 136 let mut required = String::new(); 137 138 // Role section 139 required.push_str("## Role\n"); 140 required.push_str(&ctx.profile.role); 141 required.push('\n'); 142 required.push('\n'); 143 144 // Task section - show work package tasks 145 if !ctx.work_package_tasks.is_empty() { 146 required.push_str("## Task\n"); 147 for task in &ctx.work_package_tasks { 148 required.push_str(&format!( 149 "[TASK] {} | {} | priority={}\n", 150 task.id, 151 task.title, 152 task.priority 153 .map(|p| p.to_string()) 154 .unwrap_or_else(|| "medium".to_string()) 155 )); 156 157 // Add acceptance criteria if present in metadata 158 if let Some(criteria) = task.metadata.get("acceptance_criteria") { 159 required.push_str(&format!("[CRITERIA] {}\n", criteria)); 160 } 161 } 162 required.push('\n'); 163 } 164 165 // Dependency status section (required - agents need to know dependency status) 166 if !ctx.dependency_statuses.is_empty() { 167 for (id, title, is_done) in &ctx.dependency_statuses { 168 if *is_done { 169 required.push_str(&format!("[DEP:DONE] {}{} (completed)\n", id, title)); 170 } else { 171 required.push_str(&format!("[DEP:PENDING] {}{} (pending)\n", id, title)); 172 } 173 } 174 } 175 176 // Previous attempt section (required - agents retrying need this context) 177 if let Some(prev) = &ctx.previous_attempt { 178 required.push_str("\n## Previous Attempt\n"); 179 required.push_str(&format!("[PREV_ATTEMPT] {}\n\n", prev)); 180 } 181 182 // Rules from the profile (required - critical for agent behavior) 183 required.push_str("## Rules\n"); 184 required.push_str(&ctx.profile.system_prompt); 185 required.push('\n'); 186 187 let required_tokens = estimate_tokens(&required); 188 189 // If required sections already exceed budget, just return them 190 if required_tokens >= budget.max_tokens { 191 return required; 192 } 193 194 // Budget remaining for optional sections 195 let mut remaining_budget = budget.max_tokens - required_tokens; 196 let mut prompt = required; 197 198 // Build optional sections in priority order 199 // Priority 1 (highest): Session Continuity 200 let mut session_section = String::new(); 201 if let Some(handoff) = &ctx.handoff_notes { 202 session_section.push_str("## Session Continuity\n"); 203 session_section.push_str(&format!("[HANDOFF] {}\n", handoff)); 204 session_section.push('\n'); 205 } 206 207 // Priority 2: Active Decisions 208 let mut decisions_section = String::new(); 209 if !ctx.relevant_decisions.is_empty() { 210 decisions_section.push_str("## Active Decisions\n"); 211 for decision in &ctx.relevant_decisions { 212 decisions_section.push_str(&format!( 213 "[DECISION] {} | {} | status={}\n", 214 decision.id, decision.title, decision.status 215 )); 216 217 // Add chosen option if present 218 if let Some(chosen) = decision.metadata.get("chosen_option") { 219 decisions_section.push_str(&format!(" chosen: {}\n", chosen)); 220 } 221 } 222 decisions_section.push('\n'); 223 } 224 225 // Priority 3: Relevant Observations 226 let mut observations_section = String::new(); 227 if !ctx.work_package_tasks.is_empty() { 228 observations_section 229 .push_str("## Relevant Observations (use query_nodes(id) for full detail)\n"); 230 for task in &ctx.work_package_tasks { 231 observations_section.push_str(&format!("- {}: {}\n", task.id, task.description)); 232 } 233 observations_section.push('\n'); 234 } 235 236 // Priority 4 (lowest): Project Conventions 237 let mut conventions_section = String::new(); 238 if !ctx.agents_md_summaries.is_empty() { 239 conventions_section 240 .push_str("## Project Conventions (use read_agents_md(path) for full text)\n"); 241 for (path, heading_summary) in &ctx.agents_md_summaries { 242 conventions_section.push_str(&format!("- {}: {}\n", path, heading_summary)); 243 } 244 conventions_section.push('\n'); 245 } 246 247 // Add sections in priority order if they fit 248 // Priority 1: Session Continuity 249 let session_tokens = estimate_tokens(&session_section); 250 if session_tokens > 0 && session_tokens <= remaining_budget { 251 prompt.push_str(&session_section); 252 remaining_budget -= session_tokens; 253 } 254 255 // Priority 2: Active Decisions 256 let decisions_tokens = estimate_tokens(&decisions_section); 257 if decisions_tokens > 0 && decisions_tokens <= remaining_budget { 258 prompt.push_str(&decisions_section); 259 remaining_budget -= decisions_tokens; 260 } 261 262 // Priority 3: Relevant Observations 263 let observations_tokens = estimate_tokens(&observations_section); 264 if observations_tokens > 0 && observations_tokens <= remaining_budget { 265 prompt.push_str(&observations_section); 266 remaining_budget -= observations_tokens; 267 } 268 269 // Priority 4: Project Conventions 270 let conventions_tokens = estimate_tokens(&conventions_section); 271 if conventions_tokens > 0 && conventions_tokens <= remaining_budget { 272 prompt.push_str(&conventions_section); 273 } 274 275 prompt 276 } 277} 278 279/// Tool for reading AGENTS.md files 280pub struct ReadAgentsMdTool; 281 282impl ReadAgentsMdTool { 283 pub fn new() -> Self { 284 Self 285 } 286} 287 288#[async_trait] 289impl Tool for ReadAgentsMdTool { 290 fn name(&self) -> &str { 291 "read_agents_md" 292 } 293 294 fn description(&self) -> &str { 295 "Read the full contents of an AGENTS.md file to see detailed project conventions and guidelines. Accepts either a directory path (e.g., 'src/auth') or a full file path (e.g., 'src/auth/AGENTS.md')" 296 } 297 298 fn parameters(&self) -> serde_json::Value { 299 json!({ 300 "type": "object", 301 "properties": { 302 "path": { 303 "type": "string", 304 "description": "Path to the AGENTS.md file or a directory containing AGENTS.md" 305 } 306 }, 307 "required": ["path"] 308 }) 309 } 310 311 async fn execute(&self, params: serde_json::Value) -> Result<String> { 312 let path = params 313 .get("path") 314 .and_then(|v| v.as_str()) 315 .ok_or_else(|| anyhow::anyhow!("missing 'path' parameter"))?; 316 317 let path_buf = PathBuf::from(path); 318 319 // Check if path ends with AGENTS.md (direct file path) 320 let final_path = if path.ends_with("AGENTS.md") { 321 // Validate that the file is named AGENTS.md 322 if path_buf.file_name() != Some(std::ffi::OsStr::new("AGENTS.md")) { 323 return Err(anyhow::anyhow!( 324 "read_agents_md can only read AGENTS.md files" 325 )); 326 } 327 path_buf 328 } else if path_buf.extension().is_some() { 329 // If path has a file extension but is not AGENTS.md, reject it explicitly 330 return Err(anyhow::anyhow!( 331 "read_agents_md can only read AGENTS.md files" 332 )); 333 } else { 334 // Treat as directory and append /AGENTS.md 335 path_buf.join("AGENTS.md") 336 }; 337 338 let content = std::fs::read_to_string(&final_path) 339 .map_err(|e| anyhow::anyhow!("failed to read {}: {}", final_path.display(), e))?; 340 341 Ok(content) 342 } 343} 344 345impl Default for ReadAgentsMdTool { 346 fn default() -> Self { 347 Self::new() 348 } 349} 350 351#[cfg(test)] 352mod tests { 353 use super::*; 354 use crate::agent::profile::{AgentProfile, ProfileLlmConfig}; 355 use crate::graph::store::GraphStore; 356 use crate::graph::{GraphNode, NodeStatus, NodeType, Priority}; 357 use crate::security::SecurityScope; 358 use anyhow::Result; 359 use async_trait::async_trait; 360 use chrono::Utc; 361 use std::collections::HashMap; 362 use std::sync::Arc; 363 364 // Shared TestGraphStore mock for tests 365 struct TestGraphStore; 366 367 #[async_trait] 368 impl GraphStore for TestGraphStore { 369 async fn create_node(&self, _node: &GraphNode) -> Result<()> { 370 Ok(()) 371 } 372 async fn update_node( 373 &self, 374 _id: &str, 375 _status: Option<NodeStatus>, 376 _title: Option<&str>, 377 _description: Option<&str>, 378 _blocked_reason: Option<&str>, 379 _metadata: Option<&HashMap<String, String>>, 380 ) -> Result<()> { 381 Ok(()) 382 } 383 async fn get_node(&self, _id: &str) -> Result<Option<GraphNode>> { 384 Ok(None) 385 } 386 async fn query_nodes( 387 &self, 388 _query: &crate::graph::store::NodeQuery, 389 ) -> Result<Vec<GraphNode>> { 390 Ok(vec![]) 391 } 392 async fn claim_task(&self, _node_id: &str, _agent_id: &str) -> Result<bool> { 393 Ok(false) 394 } 395 async fn get_ready_tasks(&self, _goal_id: &str) -> Result<Vec<GraphNode>> { 396 Ok(vec![]) 397 } 398 async fn get_next_task(&self, _goal_id: &str) -> Result<Option<GraphNode>> { 399 Ok(None) 400 } 401 async fn add_edge(&self, _edge: &crate::graph::GraphEdge) -> Result<()> { 402 Ok(()) 403 } 404 async fn remove_edge(&self, _edge_id: &str) -> Result<()> { 405 Ok(()) 406 } 407 async fn get_edges( 408 &self, 409 _node_id: &str, 410 _direction: crate::graph::store::EdgeDirection, 411 ) -> Result<Vec<(crate::graph::GraphEdge, GraphNode)>> { 412 Ok(vec![]) 413 } 414 async fn get_children( 415 &self, 416 _node_id: &str, 417 ) -> Result<Vec<(GraphNode, crate::graph::EdgeType)>> { 418 Ok(vec![]) 419 } 420 async fn get_subtree(&self, _node_id: &str) -> Result<Vec<GraphNode>> { 421 Ok(vec![]) 422 } 423 async fn get_active_decisions(&self, _project_id: &str) -> Result<Vec<GraphNode>> { 424 Ok(vec![]) 425 } 426 async fn get_full_graph(&self, _goal_id: &str) -> Result<crate::graph::store::WorkGraph> { 427 Ok(crate::graph::store::WorkGraph { 428 nodes: vec![], 429 edges: vec![], 430 }) 431 } 432 async fn search_nodes( 433 &self, 434 _query: &str, 435 _project_id: Option<&str>, 436 _node_type: Option<NodeType>, 437 _limit: usize, 438 ) -> Result<Vec<GraphNode>> { 439 Ok(vec![]) 440 } 441 async fn next_child_seq(&self, _parent_id: &str) -> Result<u32> { 442 Ok(1) 443 } 444 async fn import_nodes_and_edges( 445 &self, 446 _nodes: Vec<GraphNode>, 447 _edges: Vec<crate::graph::GraphEdge>, 448 ) -> Result<()> { 449 Ok(()) 450 } 451 } 452 453 // Helper function to create a test profile and context 454 fn create_test_context( 455 work_package_tasks: Vec<GraphNode>, 456 relevant_decisions: Vec<GraphNode>, 457 previous_attempt: Option<String>, 458 dependency_statuses: Vec<(String, String, bool)>, 459 ) -> AgentContext { 460 let profile = AgentProfile { 461 name: "test_coder".to_string(), 462 extends: None, 463 role: "You are a helpful code assistant".to_string(), 464 system_prompt: "Follow these rules carefully".to_string(), 465 allowed_tools: vec!["read_file".to_string(), "write_file".to_string()], 466 security: SecurityScope { 467 allowed_paths: vec!["*".to_string()], 468 denied_paths: vec![], 469 allowed_commands: vec!["*".to_string()], 470 read_only: false, 471 can_create_files: true, 472 network_access: false, 473 }, 474 llm: ProfileLlmConfig::default(), 475 turn_limit: Some(100), 476 token_budget: Some(100_000), 477 }; 478 479 AgentContext { 480 work_package_tasks, 481 relevant_decisions, 482 handoff_notes: Some("Previous session notes".to_string()), 483 agents_md_summaries: vec![("src/AGENTS.md".to_string(), "Code standards".to_string())], 484 profile, 485 project_path: PathBuf::from("/test/project"), 486 graph_store: Arc::new(TestGraphStore), 487 previous_attempt, 488 dependency_statuses, 489 } 490 } 491 492 #[test] 493 fn test_read_agents_md_tool_name() { 494 let tool = ReadAgentsMdTool::new(); 495 assert_eq!(tool.name(), "read_agents_md"); 496 } 497 498 #[test] 499 fn test_read_agents_md_tool_description() { 500 let tool = ReadAgentsMdTool::new(); 501 let desc = tool.description(); 502 assert!(!desc.is_empty()); 503 assert!(desc.contains("AGENTS.md")); 504 } 505 506 #[test] 507 fn test_read_agents_md_tool_parameters() { 508 let tool = ReadAgentsMdTool::new(); 509 let params = tool.parameters(); 510 assert!(params.is_object()); 511 assert!(params["properties"]["path"].is_object()); 512 assert_eq!(params["required"][0], "path"); 513 } 514 515 #[tokio::test] 516 async fn test_read_agents_md_tool_execute() -> Result<()> { 517 let tmpdir = tempfile::TempDir::new()?; 518 let agents_md = tmpdir.path().join("AGENTS.md"); 519 std::fs::write(&agents_md, "# Test Guidelines\n\nContent here")?; 520 521 let tool = ReadAgentsMdTool::new(); 522 let result = tool 523 .execute(json!({ 524 "path": agents_md.to_string_lossy().to_string() 525 })) 526 .await?; 527 528 assert!(result.contains("Test Guidelines")); 529 assert!(result.contains("Content here")); 530 Ok(()) 531 } 532 533 #[tokio::test] 534 async fn test_read_agents_md_tool_missing_file() { 535 let tool = ReadAgentsMdTool::new(); 536 let result = tool 537 .execute(json!({ 538 "path": "/nonexistent/AGENTS.md" 539 })) 540 .await; 541 542 assert!(result.is_err()); 543 } 544 545 #[tokio::test] 546 async fn test_read_agents_md_tool_missing_path_param() { 547 let tool = ReadAgentsMdTool::new(); 548 let result = tool.execute(json!({})).await; 549 assert!(result.is_err()); 550 } 551 552 #[tokio::test] 553 async fn test_read_agents_md_tool_with_directory_path() -> Result<()> { 554 let tmpdir = tempfile::TempDir::new()?; 555 // Create AGENTS.md in the directory 556 std::fs::write( 557 tmpdir.path().join("AGENTS.md"), 558 "# Test Guidelines\n\nContent", 559 )?; 560 561 let tool = ReadAgentsMdTool::new(); 562 let result = tool 563 .execute(json!({ 564 "path": tmpdir.path().to_string_lossy().to_string() 565 })) 566 .await?; 567 568 assert!(result.contains("Test Guidelines")); 569 assert!(result.contains("Content")); 570 Ok(()) 571 } 572 573 #[tokio::test] 574 async fn test_read_agents_md_tool_with_full_file_path() -> Result<()> { 575 let tmpdir = tempfile::TempDir::new()?; 576 let agents_md = tmpdir.path().join("AGENTS.md"); 577 std::fs::write(&agents_md, "# Test Guidelines\n\nContent")?; 578 579 let tool = ReadAgentsMdTool::new(); 580 let result = tool 581 .execute(json!({ 582 "path": agents_md.to_string_lossy().to_string() 583 })) 584 .await?; 585 586 assert!(result.contains("Test Guidelines")); 587 assert!(result.contains("Content")); 588 Ok(()) 589 } 590 591 #[tokio::test] 592 async fn test_read_agents_md_tool_invalid_filename() { 593 let tool = ReadAgentsMdTool::new(); 594 let result = tool 595 .execute(json!({ 596 "path": "/some/path/README.md" 597 })) 598 .await; 599 600 assert!(result.is_err()); 601 assert!( 602 result 603 .unwrap_err() 604 .to_string() 605 .contains("read_agents_md can only read AGENTS.md files") 606 ); 607 } 608 609 #[tokio::test] 610 async fn test_read_agents_md_tool_restricts_to_agents_md() { 611 let tool = ReadAgentsMdTool::new(); 612 613 // Try to read a different file 614 let result = tool 615 .execute(json!({ 616 "path": "/etc/passwd" 617 })) 618 .await; 619 620 assert!(result.is_err()); 621 assert!(result.unwrap_err().to_string().contains("AGENTS.md")); 622 } 623 624 #[test] 625 fn test_build_system_prompt_output_format() { 626 // Create mock work package tasks 627 let mut task_metadata = HashMap::new(); 628 task_metadata.insert( 629 "acceptance_criteria".to_string(), 630 "AC1: Task should pass tests".to_string(), 631 ); 632 633 let work_package_tasks = vec![GraphNode { 634 id: "task-1".to_string(), 635 project_id: "proj-1".to_string(), 636 node_type: NodeType::Task, 637 title: "Implement feature".to_string(), 638 description: "Implement a new feature".to_string(), 639 status: NodeStatus::Ready, 640 priority: Some(Priority::High), 641 assigned_to: None, 642 created_by: None, 643 labels: vec![], 644 created_at: Utc::now(), 645 started_at: None, 646 completed_at: None, 647 blocked_reason: None, 648 metadata: task_metadata, 649 }]; 650 651 // Create mock decisions 652 let mut decision_metadata = HashMap::new(); 653 decision_metadata.insert("chosen_option".to_string(), "Option B".to_string()); 654 655 let relevant_decisions = vec![GraphNode { 656 id: "decision-1".to_string(), 657 project_id: "proj-1".to_string(), 658 node_type: NodeType::Decision, 659 title: "Architecture decision".to_string(), 660 description: "Choose architecture".to_string(), 661 status: NodeStatus::Decided, 662 priority: None, 663 assigned_to: None, 664 created_by: None, 665 labels: vec![], 666 created_at: Utc::now(), 667 started_at: None, 668 completed_at: None, 669 blocked_reason: None, 670 metadata: decision_metadata, 671 }]; 672 673 // Create agent context using helper 674 let ctx = create_test_context(work_package_tasks, relevant_decisions, None, vec![]); 675 676 // Build system prompt 677 let prompt = ContextBuilder::build_system_prompt(&ctx); 678 679 // Verify expected sections are present 680 assert!(prompt.contains("## Role"), "Should contain Role section"); 681 assert!( 682 prompt.contains("You are a helpful code assistant"), 683 "Should contain profile role" 684 ); 685 686 assert!(prompt.contains("## Task"), "Should contain Task section"); 687 assert!(prompt.contains("[TASK]"), "Should contain task marker"); 688 assert!(prompt.contains("task-1"), "Should contain task ID"); 689 assert!( 690 prompt.contains("[CRITERIA]"), 691 "Should contain acceptance criteria marker" 692 ); 693 694 assert!( 695 prompt.contains("## Session Continuity"), 696 "Should contain Session Continuity section" 697 ); 698 assert!( 699 prompt.contains("[HANDOFF]"), 700 "Should contain handoff marker" 701 ); 702 assert!( 703 prompt.contains("Previous session notes"), 704 "Should contain handoff notes" 705 ); 706 707 assert!( 708 prompt.contains("## Active Decisions"), 709 "Should contain Active Decisions section" 710 ); 711 assert!( 712 prompt.contains("[DECISION]"), 713 "Should contain decision marker" 714 ); 715 assert!(prompt.contains("decision-1"), "Should contain decision ID"); 716 assert!(prompt.contains("chosen:"), "Should contain chosen option"); 717 718 assert!( 719 prompt.contains("## Relevant Observations"), 720 "Should contain Relevant Observations section" 721 ); 722 723 assert!( 724 prompt.contains("## Project Conventions"), 725 "Should contain Project Conventions section" 726 ); 727 assert!( 728 prompt.contains("src/AGENTS.md"), 729 "Should contain agents_md path" 730 ); 731 732 assert!(prompt.contains("## Rules"), "Should contain Rules section"); 733 assert!( 734 prompt.contains("Follow these rules carefully"), 735 "Should contain system prompt rules" 736 ); 737 } 738 739 #[test] 740 fn test_v2_phase5_ac3_1_dependency_status_done() { 741 // v2-phase5.AC3.1: System prompt includes [DEP:DONE] lines for completed dependencies 742 let ctx = create_test_context( 743 vec![], 744 vec![], 745 None, 746 vec![ 747 ("ra-1234".to_string(), "Setup database".to_string(), true), 748 ("ra-5678".to_string(), "Configure auth".to_string(), false), 749 ], 750 ); 751 752 let prompt = ContextBuilder::build_system_prompt(&ctx); 753 754 assert!( 755 prompt.contains("[DEP:DONE] ra-1234 → Setup database (completed)"), 756 "Should contain completed dependency with [DEP:DONE]" 757 ); 758 assert!( 759 prompt.contains("[DEP:PENDING] ra-5678 → Configure auth (pending)"), 760 "Should contain pending dependency with [DEP:PENDING]" 761 ); 762 } 763 764 #[test] 765 fn test_v2_phase5_ac3_2_previous_attempt_present() { 766 // v2-phase5.AC3.2: System prompt includes [PREV_ATTEMPT] section when previous_attempt is Some 767 let ctx = create_test_context( 768 vec![], 769 vec![], 770 Some("Previous attempt failed: file not found".to_string()), 771 vec![], 772 ); 773 774 let prompt = ContextBuilder::build_system_prompt(&ctx); 775 776 assert!( 777 prompt.contains("## Previous Attempt"), 778 "Should contain Previous Attempt section when previous_attempt is Some" 779 ); 780 assert!( 781 prompt.contains("[PREV_ATTEMPT] Previous attempt failed: file not found"), 782 "Should contain previous attempt details with [PREV_ATTEMPT] marker" 783 ); 784 } 785 786 #[test] 787 fn test_v2_phase5_ac3_3_previous_attempt_absent() { 788 // v2-phase5.AC3.3: System prompt omits Previous Attempt section entirely when no previous attempt exists 789 let ctx = create_test_context(vec![], vec![], None, vec![]); 790 791 let prompt = ContextBuilder::build_system_prompt(&ctx); 792 793 assert!( 794 !prompt.contains("## Previous Attempt"), 795 "Should NOT contain Previous Attempt section when previous_attempt is None" 796 ); 797 } 798 799 #[test] 800 fn test_v2_phase5_ac4_1_budget_aware_trimming() { 801 // v2-phase5.AC4.1: With a very small budget, only required sections appear; optional sections are trimmed 802 // Create tasks with long descriptions to trigger trimming 803 let work_package_tasks = vec![GraphNode { 804 id: "task-1".to_string(), 805 project_id: "proj-1".to_string(), 806 node_type: NodeType::Task, 807 title: "Implement feature".to_string(), 808 description: "This is a very long description that should be trimmed when budget is tight. It contains multiple sentences and spans several lines of text to ensure we have enough content to test budget trimming behavior.".to_string(), 809 status: NodeStatus::Ready, 810 priority: Some(Priority::High), 811 assigned_to: None, 812 created_by: None, 813 labels: vec![], 814 created_at: Utc::now(), 815 started_at: None, 816 completed_at: None, 817 blocked_reason: None, 818 metadata: HashMap::new(), 819 }]; 820 821 // Create decisions 822 let relevant_decisions = vec![GraphNode { 823 id: "decision-1".to_string(), 824 project_id: "proj-1".to_string(), 825 node_type: NodeType::Decision, 826 title: "Architecture decision".to_string(), 827 description: "Choose the right architecture for the system by considering scalability, maintainability, and performance requirements.".to_string(), 828 status: NodeStatus::Decided, 829 priority: None, 830 assigned_to: None, 831 created_by: None, 832 labels: vec![], 833 created_at: Utc::now(), 834 started_at: None, 835 completed_at: None, 836 blocked_reason: None, 837 metadata: { 838 let mut m = HashMap::new(); 839 m.insert("chosen_option".to_string(), "Option B".to_string()); 840 m 841 }, 842 }]; 843 844 let ctx = create_test_context(work_package_tasks, relevant_decisions, None, vec![]); 845 // Override handoff_notes and agents_md_summaries for this test 846 let ctx = AgentContext { 847 handoff_notes: Some( 848 "Previous session notes that are quite detailed and span multiple concepts" 849 .to_string(), 850 ), 851 agents_md_summaries: vec![( 852 "src/AGENTS.md".to_string(), 853 "Code standards and conventions for the project".to_string(), 854 )], 855 ..ctx 856 }; 857 858 // Test with very small budget (100 tokens - forces trimming of optional sections) 859 let small_budget = ContextBudget { max_tokens: 100 }; 860 861 let prompt = ContextBuilder::build_system_prompt_with_budget(&ctx, &small_budget); 862 863 // Required sections should always be present 864 assert!( 865 prompt.contains("## Role"), 866 "Should contain Role section (required)" 867 ); 868 assert!( 869 prompt.contains("## Task"), 870 "Should contain Task section (required)" 871 ); 872 assert!( 873 prompt.contains("## Rules"), 874 "Should contain Rules section (required)" 875 ); 876 877 // With tiny budget, optional sections should be trimmed 878 // Project Conventions is lowest priority and should be trimmed 879 assert!( 880 !prompt.contains("## Project Conventions"), 881 "Should trim Project Conventions (lowest priority) with very small budget" 882 ); 883 } 884 885 #[test] 886 fn test_v2_phase5_ac4_2_required_sections_never_trimmed() { 887 // v2-phase5.AC4.2: Required sections (Role, Task, Rules) are never trimmed regardless of budget 888 let work_package_tasks = vec![GraphNode { 889 id: "task-1".to_string(), 890 project_id: "proj-1".to_string(), 891 node_type: NodeType::Task, 892 title: "Implement feature".to_string(), 893 description: "A detailed implementation task".to_string(), 894 status: NodeStatus::Ready, 895 priority: Some(Priority::High), 896 assigned_to: None, 897 created_by: None, 898 labels: vec![], 899 created_at: Utc::now(), 900 started_at: None, 901 completed_at: None, 902 blocked_reason: None, 903 metadata: HashMap::new(), 904 }]; 905 906 let ctx = create_test_context(work_package_tasks, vec![], None, vec![]); 907 908 // Test with extremely small budget (10 tokens - smaller than required sections) 909 let tiny_budget = ContextBudget { max_tokens: 10 }; 910 911 let prompt = ContextBuilder::build_system_prompt_with_budget(&ctx, &tiny_budget); 912 913 // Required sections must always be present, even with a tiny budget 914 assert!( 915 prompt.contains("## Role"), 916 "Should contain Role section even with tiny budget (required)" 917 ); 918 assert!( 919 prompt.contains("## Task"), 920 "Should contain Task section even with tiny budget (required)" 921 ); 922 assert!( 923 prompt.contains("## Rules"), 924 "Should contain Rules section even with tiny budget (required)" 925 ); 926 } 927 928 #[test] 929 fn test_context_budget_default() { 930 let budget = ContextBudget::default(); 931 assert_eq!( 932 budget.max_tokens, 4000, 933 "Default budget should be 4000 tokens" 934 ); 935 } 936 937 #[test] 938 fn test_context_budget_custom() { 939 let budget = ContextBudget { max_tokens: 2000 }; 940 assert_eq!(budget.max_tokens, 2000); 941 } 942}