1use std::sync::Arc;
16
17use async_trait::async_trait;
18use rpi_agent::agent_tool::AgentTool;
19use rpi_agent::error::AgentError;
20use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
21use rpi_ai::types::Tool;
22use schemars::JsonSchema;
23use serde::Deserialize;
24use tokio_util::sync::CancellationToken;
25
26use crate::env::ExecutionEnv;
27use crate::env::FileKind;
28use crate::path_utils::resolve_read_tool_path;
29use crate::tools::tool_context::ExecutionToolContext;
30use crate::truncate::{
31 format_size, truncate_head, TruncationOptions, TruncationResult, DEFAULT_MAX_BYTES,
32};
33
34const DEFAULT_LIMIT: u32 = 500;
36
37#[derive(Debug, Clone, JsonSchema, Deserialize)]
39pub struct LsInput {
40 #[serde(default)]
42 pub path: Option<String>,
43 #[serde(default)]
45 pub limit: Option<u32>,
46}
47
48#[derive(Debug, Clone, serde::Serialize)]
50pub struct LsToolDetails {
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub truncation: Option<TruncationResult>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub entry_limit_reached: Option<u32>,
55}
56
57#[derive(Debug, Clone, Default)]
62pub struct LsToolOptions {}
63
64pub struct LsTool {
66 schema: Tool,
67 env: Arc<dyn ExecutionEnv>,
68}
69
70impl LsTool {
71 fn schema() -> Tool {
72 let params = schemars::schema_for!(LsInput);
73 Tool {
74 name: "ls".to_string(),
75 description: format!(
76 "List directory contents. Returns entries sorted alphabetically, with a '/' suffix \
77 for directories. Includes dotfiles. Output is truncated to {DEFAULT_LIMIT} entries \
78 or {kb}KB (whichever is hit first).",
79 kb = DEFAULT_MAX_BYTES / 1024
80 ),
81 parameters: rpi_ai::types::Schema::new(serde_json::to_value(params).unwrap_or_default()),
82 constrained_sampling: None,
83 }
84 }
85}
86
87pub fn create_ls_tool(
89 context: &ExecutionToolContext,
90 options: Option<LsToolOptions>,
91) -> Arc<dyn AgentTool> {
92 let _ = options; Arc::new(LsTool {
94 schema: LsTool::schema(),
95 env: context.env().clone(),
96 })
97}
98
99#[async_trait]
100impl AgentTool for LsTool {
101 fn schema(&self) -> &Tool {
102 &self.schema
103 }
104 fn label(&self) -> &str {
105 "ls"
106 }
107
108 async fn execute(
109 &self,
110 _tool_call_id: &str,
111 params: serde_json::Value,
112 signal: CancellationToken,
113 _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
114 ) -> Result<AgentToolResult, AgentError> {
115 let input: LsInput = serde_json::from_value(params)
116 .map_err(|e| AgentError::Validation(format!("ls input invalid: {e}")))?;
117
118 let cancel = Some(&signal);
119 let effective_limit = input.limit.unwrap_or(DEFAULT_LIMIT).max(1) as usize;
120
121 let dir_path =
123 resolve_read_tool_path(&*self.env, input.path.as_deref().unwrap_or("."), cancel)
124 .await
125 .map_err(file_err_to_agent)?;
126
127 if !self
129 .env
130 .exists(&dir_path, cancel)
131 .await
132 .map_err(file_err_to_agent)?
133 {
134 return Err(AgentError::Tool(format!("Path not found: {dir_path}")));
135 }
136 let info = self
138 .env
139 .file_info(&dir_path, cancel)
140 .await
141 .map_err(file_err_to_agent)?;
142 if info.kind != FileKind::Directory {
143 return Err(AgentError::Tool(format!("Not a directory: {dir_path}")));
144 }
145
146 let mut entries = self
149 .env
150 .list_dir(&dir_path, cancel)
151 .await
152 .map_err(file_err_to_agent)?;
153
154 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
157
158 let mut results: Vec<String> = Vec::new();
160 let mut entry_limit_reached = false;
161 for entry in &entries {
162 if results.len() >= effective_limit {
163 entry_limit_reached = true;
164 break;
165 }
166 let suffix = if entry.kind == FileKind::Directory {
167 "/"
168 } else {
169 ""
170 };
171 results.push(format!("{}{suffix}", entry.name));
172 }
173
174 if results.is_empty() {
175 return Ok(AgentToolResult {
176 content: vec![TextContentOrImage::text("(empty directory)")],
177 details: serde_json::Value::Null,
178 ..Default::default()
179 });
180 }
181
182 let raw_output = results.join("\n");
183 let truncation = truncate_head(
185 &raw_output,
186 TruncationOptions {
187 max_lines: Some(usize::MAX),
188 max_bytes: None,
189 },
190 );
191 let was_truncated = truncation.truncated;
192 let mut output = truncation.content.clone();
193 let mut details = LsToolDetails {
194 truncation: None,
195 entry_limit_reached: None,
196 };
197 let mut notices: Vec<String> = Vec::new();
198 if entry_limit_reached {
199 notices.push(format!(
200 "{effective_limit} entries limit reached. Use limit={} for more",
201 effective_limit * 2
202 ));
203 details.entry_limit_reached = Some(effective_limit as u32);
204 }
205 if was_truncated {
206 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
207 details.truncation = Some(truncation);
208 }
209 if !notices.is_empty() {
210 output.push_str(&format!("\n\n[{}]", notices.join(". ")));
211 }
212
213 let details_value = serde_json::to_value(&details).unwrap_or(serde_json::Value::Null);
214 Ok(AgentToolResult {
215 content: vec![TextContentOrImage::text(output)],
216 details: details_value,
217 ..Default::default()
218 })
219 }
220}
221
222fn file_err_to_agent(e: crate::error::FileError) -> AgentError {
224 AgentError::Tool(e.to_string())
225}