An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
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}