Skip to main content

agent_air_runtime/controller/tools/
read_plan.rs

1//! ReadPlan tool implementation.
2//!
3//! Reads a specific plan by its ID and returns the full markdown content.
4//!
5//! Plan files are internal agent artifacts so this tool handles its own
6//! permissions and never prompts the user.
7
8use std::collections::HashMap;
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use tokio::fs;
14
15use super::plan_store::PlanStore;
16use super::types::{
17    DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
18};
19
20/// ReadPlan tool name constant.
21pub const READ_PLAN_TOOL_NAME: &str = "read_plan";
22
23/// ReadPlan tool description constant.
24pub const READ_PLAN_TOOL_DESCRIPTION: &str = r#"Reads a plan by its ID and returns the full markdown content.
25
26Usage:
27- Provide the plan_id to read (e.g., "plan-001")
28- Use list_plans first to discover available plan IDs
29
30Returns:
31- The full markdown content of the plan file"#;
32
33/// ReadPlan tool JSON schema constant.
34pub const READ_PLAN_TOOL_SCHEMA: &str = r#"{
35    "type": "object",
36    "properties": {
37        "plan_id": {
38            "type": "string",
39            "description": "Plan ID to read (e.g., plan-001)"
40        }
41    },
42    "required": ["plan_id"]
43}"#;
44
45/// Tool that reads a specific plan file by ID.
46///
47/// Plan files live in `.agent-air/plans/` and are internal agent artifacts,
48/// so no user permission is required.
49pub struct ReadPlanTool {
50    /// Shared plan store for directory paths.
51    plan_store: Arc<PlanStore>,
52}
53
54impl ReadPlanTool {
55    /// Create a new ReadPlanTool.
56    pub fn new(plan_store: Arc<PlanStore>) -> Self {
57        Self { plan_store }
58    }
59}
60
61impl Executable for ReadPlanTool {
62    fn name(&self) -> &str {
63        READ_PLAN_TOOL_NAME
64    }
65
66    fn description(&self) -> &str {
67        READ_PLAN_TOOL_DESCRIPTION
68    }
69
70    fn input_schema(&self) -> &str {
71        READ_PLAN_TOOL_SCHEMA
72    }
73
74    fn tool_type(&self) -> ToolType {
75        ToolType::Custom
76    }
77
78    fn execute(
79        &self,
80        _context: ToolContext,
81        input: HashMap<String, serde_json::Value>,
82    ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
83        let plan_store = self.plan_store.clone();
84
85        Box::pin(async move {
86            let plan_id = input
87                .get("plan_id")
88                .and_then(|v| v.as_str())
89                .ok_or_else(|| "Missing required 'plan_id' parameter".to_string())?;
90
91            let plans_dir = plan_store.plans_dir();
92            let file_path = plans_dir.join(format!("{}.md", plan_id));
93
94            if !file_path.exists() {
95                return Err(format!(
96                    "Plan '{}' not found. Use list_plans to see available plans.",
97                    plan_id
98                ));
99            }
100
101            let content = fs::read_to_string(&file_path)
102                .await
103                .map_err(|e| format!("Failed to read plan file: {}", e))?;
104
105            Ok(content)
106        })
107    }
108
109    fn handles_own_permissions(&self) -> bool {
110        true
111    }
112
113    fn display_config(&self) -> DisplayConfig {
114        DisplayConfig {
115            display_name: "Read Plan".to_string(),
116            display_title: Box::new(|input| {
117                input
118                    .get("plan_id")
119                    .and_then(|v| v.as_str())
120                    .unwrap_or("unknown")
121                    .to_string()
122            }),
123            display_content: Box::new(|_input, result| {
124                let lines: Vec<&str> = result.lines().take(15).collect();
125                let total_lines = result.lines().count();
126                let truncated = total_lines > 15;
127                let content = if truncated {
128                    format!("{}...\n[truncated]", lines.join("\n"))
129                } else {
130                    lines.join("\n")
131                };
132
133                DisplayResult {
134                    content,
135                    content_type: ResultContentType::Markdown,
136                    is_truncated: truncated,
137                    full_length: total_lines,
138                }
139            }),
140        }
141    }
142
143    fn compact_summary(&self, input: &HashMap<String, serde_json::Value>, _result: &str) -> String {
144        let plan_id = input
145            .get("plan_id")
146            .and_then(|v| v.as_str())
147            .unwrap_or("unknown");
148        format!("[ReadPlan: {}]", plan_id)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use tempfile::TempDir;
156
157    fn make_context(tool_use_id: &str) -> ToolContext {
158        ToolContext {
159            session_id: 1,
160            tool_use_id: tool_use_id.to_string(),
161            turn_id: None,
162            permissions_pre_approved: false,
163        }
164    }
165
166    #[tokio::test]
167    async fn test_read_existing_plan() {
168        let temp_dir = TempDir::new().unwrap();
169        let plans_dir = temp_dir.path().join(".agent-air/plans");
170        fs::create_dir_all(&plans_dir).await.unwrap();
171
172        let plan_content = "# Plan: My Plan\n\n**ID**: plan-001\n**Status**: active\n\n## Steps\n\n1. [ ] Do something\n";
173        fs::write(plans_dir.join("plan-001.md"), plan_content)
174            .await
175            .unwrap();
176
177        let plan_store = Arc::new(PlanStore::new(temp_dir.path().to_path_buf()));
178        let tool = ReadPlanTool::new(plan_store);
179
180        let mut input = HashMap::new();
181        input.insert(
182            "plan_id".to_string(),
183            serde_json::Value::String("plan-001".to_string()),
184        );
185
186        let result = tool.execute(make_context("test-read"), input).await;
187        assert!(result.is_ok());
188
189        let output = result.unwrap();
190        assert!(output.contains("# Plan: My Plan"));
191        assert!(output.contains("1. [ ] Do something"));
192    }
193
194    #[tokio::test]
195    async fn test_read_nonexistent_plan() {
196        let temp_dir = TempDir::new().unwrap();
197        let plan_store = Arc::new(PlanStore::new(temp_dir.path().to_path_buf()));
198        let tool = ReadPlanTool::new(plan_store);
199
200        let mut input = HashMap::new();
201        input.insert(
202            "plan_id".to_string(),
203            serde_json::Value::String("plan-999".to_string()),
204        );
205
206        let result = tool.execute(make_context("test-missing"), input).await;
207        assert!(result.is_err());
208        let err = result.unwrap_err();
209        assert!(err.contains("not found"));
210        assert!(err.contains("list_plans"));
211    }
212
213    #[tokio::test]
214    async fn test_read_missing_plan_id() {
215        let temp_dir = TempDir::new().unwrap();
216        let plan_store = Arc::new(PlanStore::new(temp_dir.path().to_path_buf()));
217        let tool = ReadPlanTool::new(plan_store);
218
219        let result = tool
220            .execute(make_context("test-no-id"), HashMap::new())
221            .await;
222        assert!(result.is_err());
223        assert!(result.unwrap_err().contains("Missing required 'plan_id'"));
224    }
225
226    #[test]
227    fn test_compact_summary() {
228        let plan_store = Arc::new(PlanStore::new(std::path::PathBuf::from("/tmp")));
229        let tool = ReadPlanTool::new(plan_store);
230
231        let mut input = HashMap::new();
232        input.insert(
233            "plan_id".to_string(),
234            serde_json::Value::String("plan-001".to_string()),
235        );
236
237        let summary = tool.compact_summary(&input, "# Plan content...");
238        assert_eq!(summary, "[ReadPlan: plan-001]");
239    }
240
241    #[test]
242    fn test_handles_own_permissions() {
243        let plan_store = Arc::new(PlanStore::new(std::path::PathBuf::from("/tmp")));
244        let tool = ReadPlanTool::new(plan_store);
245        assert!(tool.handles_own_permissions());
246    }
247}