Skip to main content

tiny_agent/tools/default_tools/
mod.rs

1mod 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
14/// 把内置工具一次性注册进 registry。
15///
16/// 工具函数签名都是 `fn(Arc<dyn Sandbox>, Args)`,sandbox 在 `ToolRegistry::call`
17/// 时按会话注入,所以这里直接注册函数本身,无需包装闭包。
18pub 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
44/// 用单引号包裹字符串,使其能安全地作为单个 shell 参数传递。
45pub 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    /// 取出普通完成态的 `Value`,测试里断言工具输出时用。
70    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}