tiny_agent/tools/default_tools/
mod.rs1mod ask_followup_question;
2mod bash;
3mod read_file;
4mod read_skill;
5mod spawn_agent;
6mod write_file;
7
8pub use spawn_agent::register_spawn_agent;
9
10use crate::skills::SkillManager;
11use crate::tools::ToolRegistry;
12use std::sync::Arc;
13
14pub fn register_default_tools(registry: &mut ToolRegistry) {
19 registry.register(
20 "askFollowupQuestion",
21 "向用户提出一个追问,并让 agent 暂停到 WaitingForUser 状态",
22 false,
23 ask_followup_question::ask_followup_question,
24 );
25 registry.register(
26 "read_file",
27 "读取指定文件的内容",
28 true,
29 read_file::read_file,
30 );
31 registry.register(
32 "write_file",
33 "写入或局部编辑文件,支持 write/replace/insert_after/insert_before/append",
34 false,
35 write_file::write_file,
36 );
37 registry.register("bash", "执行一条 bash 命令并返回输出", false, bash::bash);
38}
39
40pub fn register_skill_tools(registry: &mut ToolRegistry, skills: Arc<SkillManager>) {
41 read_skill::register_read_skill(registry, skills);
42}
43
44pub fn sh_quote(s: &str) -> String {
46 let mut out = String::with_capacity(s.len() + 2);
47 out.push('\'');
48 for ch in s.chars() {
49 if ch == '\'' {
50 out.push_str("'\\''");
51 } else {
52 out.push(ch);
53 }
54 }
55 out.push('\'');
56 out
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use crate::sandbox::{HostSandbox, Sandbox};
63 use crate::tools::ToolOutcome;
64 use read_file::ReadFileArgs;
65 use serde_json::{Value, json};
66 use std::sync::Arc;
67 use write_file::{WriteFileArgs, WriteFileMode};
68
69 fn completed(outcome: ToolOutcome) -> Value {
71 match outcome {
72 ToolOutcome::Completed(value) => value,
73 other => panic!("expected Completed, got {other:?}"),
74 }
75 }
76
77 #[test]
78 fn sh_quote_wraps_plain_string() {
79 assert_eq!(sh_quote("foo.txt"), "'foo.txt'");
80 }
81
82 #[test]
83 fn sh_quote_escapes_single_quote() {
84 assert_eq!(sh_quote("a'b"), "'a'\\''b'");
85 }
86
87 #[tokio::test]
88 async fn write_then_read_round_trips_through_sandbox() {
89 let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
90 let dir = std::env::temp_dir();
91 let path = dir.join(format!("tiny_agent_test_{}.txt", uuid::Uuid::new_v4()));
92 let path_str = path.to_string_lossy().to_string();
93 let content = "line one\nline 'two'\n";
94
95 let write_args = WriteFileArgs {
96 path: path_str.clone(),
97 mode: Some(WriteFileMode::Write),
98 content: Some(content.to_string()),
99 old_string: None,
100 new_string: None,
101 replace_all: None,
102 expected_replacements: None,
103 };
104 write_file::write_file(sb.clone(), write_args)
105 .await
106 .unwrap();
107
108 let read_args = ReadFileArgs {
109 path: path_str.clone(),
110 };
111 let out = read_file::read_file(sb.clone(), read_args).await.unwrap();
112 assert_eq!(completed(out)["content"], content);
113
114 let _ = tokio::fs::remove_file(&path).await;
115 }
116
117 #[tokio::test]
118 async fn ask_followup_question_returns_needs_user_interaction() {
119 let mut registry = ToolRegistry::new();
120 register_default_tools(&mut registry);
121 let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
122
123 let outcome = registry
124 .call(
125 "askFollowupQuestion",
126 json!({
127 "question": "Which file should I edit?",
128 "options": ["src/main.rs", "Cargo.toml"]
129 }),
130 sb,
131 )
132 .await
133 .unwrap();
134
135 assert!(matches!(
136 outcome,
137 ToolOutcome::NeedsUserInteraction(interaction)
138 if interaction.kind == "followup_question"
139 && interaction.payload["question"] == "Which file should I edit?"
140 && interaction.payload["options"] == json!(["src/main.rs", "Cargo.toml"])
141 ));
142 }
143
144 #[tokio::test]
145 async fn write_file_replaces_unique_old_string() {
146 let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
147 let dir = std::env::temp_dir();
148 let path = dir.join(format!("tiny_agent_test_{}.txt", uuid::Uuid::new_v4()));
149 let path_str = path.to_string_lossy().to_string();
150
151 write_file::write_file(
152 sb.clone(),
153 WriteFileArgs {
154 path: path_str.clone(),
155 mode: Some(WriteFileMode::Write),
156 content: Some("alpha\nbeta\ngamma\n".to_string()),
157 old_string: None,
158 new_string: None,
159 replace_all: None,
160 expected_replacements: None,
161 },
162 )
163 .await
164 .unwrap();
165
166 write_file::write_file(
167 sb.clone(),
168 WriteFileArgs {
169 path: path_str.clone(),
170 mode: Some(WriteFileMode::Replace),
171 content: None,
172 old_string: Some("beta".to_string()),
173 new_string: Some("BETA".to_string()),
174 replace_all: None,
175 expected_replacements: None,
176 },
177 )
178 .await
179 .unwrap();
180
181 let out = read_file::read_file(
182 sb.clone(),
183 ReadFileArgs {
184 path: path_str.clone(),
185 },
186 )
187 .await
188 .unwrap();
189 assert_eq!(completed(out)["content"], "alpha\nBETA\ngamma\n");
190
191 let _ = tokio::fs::remove_file(&path).await;
192 }
193
194 #[tokio::test]
195 async fn write_file_rejects_ambiguous_replace_by_default() {
196 let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
197 let dir = std::env::temp_dir();
198 let path = dir.join(format!("tiny_agent_test_{}.txt", uuid::Uuid::new_v4()));
199 let path_str = path.to_string_lossy().to_string();
200
201 write_file::write_file(
202 sb.clone(),
203 WriteFileArgs {
204 path: path_str.clone(),
205 mode: Some(WriteFileMode::Write),
206 content: Some("same\nsame\n".to_string()),
207 old_string: None,
208 new_string: None,
209 replace_all: None,
210 expected_replacements: None,
211 },
212 )
213 .await
214 .unwrap();
215
216 let result = write_file::write_file(
217 sb.clone(),
218 WriteFileArgs {
219 path: path_str.clone(),
220 mode: Some(WriteFileMode::Replace),
221 content: None,
222 old_string: Some("same".to_string()),
223 new_string: Some("changed".to_string()),
224 replace_all: None,
225 expected_replacements: None,
226 },
227 )
228 .await;
229
230 assert!(result.is_err());
231 let _ = tokio::fs::remove_file(&path).await;
232 }
233
234 #[tokio::test]
235 async fn write_file_can_insert_and_append() {
236 let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
237 let dir = std::env::temp_dir();
238 let path = dir.join(format!("tiny_agent_test_{}.txt", uuid::Uuid::new_v4()));
239 let path_str = path.to_string_lossy().to_string();
240
241 write_file::write_file(
242 sb.clone(),
243 WriteFileArgs {
244 path: path_str.clone(),
245 mode: Some(WriteFileMode::Write),
246 content: Some("fn main() {\n}\n".to_string()),
247 old_string: None,
248 new_string: None,
249 replace_all: None,
250 expected_replacements: None,
251 },
252 )
253 .await
254 .unwrap();
255
256 write_file::write_file(
257 sb.clone(),
258 WriteFileArgs {
259 path: path_str.clone(),
260 mode: Some(WriteFileMode::InsertAfter),
261 content: Some("\n println!(\"hi\");".to_string()),
262 old_string: Some("fn main() {".to_string()),
263 new_string: None,
264 replace_all: None,
265 expected_replacements: None,
266 },
267 )
268 .await
269 .unwrap();
270
271 write_file::write_file(
272 sb.clone(),
273 WriteFileArgs {
274 path: path_str.clone(),
275 mode: Some(WriteFileMode::Append),
276 content: Some("// end\n".to_string()),
277 old_string: None,
278 new_string: None,
279 replace_all: None,
280 expected_replacements: None,
281 },
282 )
283 .await
284 .unwrap();
285
286 let out = read_file::read_file(
287 sb.clone(),
288 ReadFileArgs {
289 path: path_str.clone(),
290 },
291 )
292 .await
293 .unwrap();
294 assert_eq!(
295 completed(out)["content"],
296 "fn main() {\n println!(\"hi\");\n}\n// end\n"
297 );
298
299 let _ = tokio::fs::remove_file(&path).await;
300 }
301
302 #[tokio::test]
303 async fn read_missing_file_returns_err() {
304 let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
305 let args = ReadFileArgs {
306 path: "/no/such/path/really_missing_12345".to_string(),
307 };
308 assert!(read_file::read_file(sb, args).await.is_err());
309 }
310}