agent_air_runtime/controller/tools/
read_plan.rs1use 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
20pub const READ_PLAN_TOOL_NAME: &str = "read_plan";
22
23pub 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
33pub 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
45pub struct ReadPlanTool {
50 plan_store: Arc<PlanStore>,
52}
53
54impl ReadPlanTool {
55 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}