Skip to main content

tiny_agent/tools/default_tools/
mod.rs

1mod ask_followup_question;
2mod bash;
3mod check;
4mod kill;
5mod read_file;
6mod read_skill;
7mod spawn_agent;
8mod write_file;
9
10pub use spawn_agent::register_spawn_agent;
11
12use crate::skills::SkillManager;
13use crate::tools::ToolRegistry;
14use std::sync::Arc;
15
16/// 把内置工具一次性注册进 registry。
17///
18/// 工具函数签名都是 `fn(Arc<dyn Sandbox>, Args)`,sandbox 在 `ToolRegistry::call`
19/// 时按会话注入,所以这里直接注册函数本身,无需包装闭包。
20pub fn register_default_tools(registry: &mut ToolRegistry) {
21    registry.register(
22        "askFollowupQuestion",
23        "向用户提出一个追问,并让 agent 暂停到 WaitingForUser 状态",
24        false,
25        ask_followup_question::ask_followup_question,
26    );
27    registry.register(
28        "read_file",
29        "读取指定文件的内容",
30        true,
31        read_file::read_file,
32    );
33    registry.register(
34        "write_file",
35        "写入或局部编辑文件,支持 write/replace/insert_after/insert_before/append",
36        false,
37        write_file::write_file,
38    );
39    registry.register_with_ctx(
40        "bash",
41        "执行一条 bash 命令。命令在 2 秒内跑完则直接返回 {status:\"completed\", exit_code, output};\
42         超过 2 秒未结束则转入后台,返回 {id, status:\"running\", output:<已有输出>}——此时进程仍在跑,\
43         请稍后用 check 工具凭 id 回来查看新增输出和最终状态(模型不会被自动通知完成),或用 kill 终止。",
44        false,
45        bash::bash,
46    );
47    registry.register_with_ctx(
48        "check",
49        "查看某个后台任务(bash 返回的 running 任务)自上次以来的新增输出与当前状态。\
50         可传 wait_secs 让 check 先等待指定秒数再返回,避免过于频繁轮询。\
51         返回 {status:\"running\"|\"completed\", exit_code, output:<新增>}。",
52        true,
53        check::check,
54    );
55    registry.register_with_ctx(
56        "kill",
57        "终止一个后台任务(按 bash 返回的 id)。",
58        false,
59        kill::kill,
60    );
61}
62
63pub fn register_skill_tools(registry: &mut ToolRegistry, skills: Arc<SkillManager>) {
64    read_skill::register_read_skill(registry, skills);
65}
66
67/// 用单引号包裹字符串,使其能安全地作为单个 shell 参数传递。
68pub fn sh_quote(s: &str) -> String {
69    let mut out = String::with_capacity(s.len() + 2);
70    out.push('\'');
71    for ch in s.chars() {
72        if ch == '\'' {
73            out.push_str("'\\''");
74        } else {
75            out.push(ch);
76        }
77    }
78    out.push('\'');
79    out
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::sandbox::{HostSandbox, Sandbox};
86    use crate::tools::ToolOutcome;
87    use read_file::ReadFileArgs;
88    use serde_json::{Value, json};
89    use std::sync::Arc;
90    use std::time::Duration;
91    use tokio::time::Instant;
92    use write_file::{WriteFileArgs, WriteFileMode};
93
94    /// 取出普通完成态的 `Value`,测试里断言工具输出时用。
95    fn completed(outcome: ToolOutcome) -> Value {
96        match outcome {
97            ToolOutcome::Completed(value) => value,
98            other => panic!("expected Completed, got {other:?}"),
99        }
100    }
101
102    #[test]
103    fn sh_quote_wraps_plain_string() {
104        assert_eq!(sh_quote("foo.txt"), "'foo.txt'");
105    }
106
107    #[test]
108    fn sh_quote_escapes_single_quote() {
109        assert_eq!(sh_quote("a'b"), "'a'\\''b'");
110    }
111
112    #[tokio::test]
113    async fn write_then_read_round_trips_through_sandbox() {
114        let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
115        let dir = std::env::temp_dir();
116        let path = dir.join(format!("tiny_agent_test_{}.txt", uuid::Uuid::new_v4()));
117        let path_str = path.to_string_lossy().to_string();
118        let content = "line one\nline 'two'\n";
119
120        let write_args = WriteFileArgs {
121            path: path_str.clone(),
122            mode: Some(WriteFileMode::Write),
123            content: Some(content.to_string()),
124            old_string: None,
125            new_string: None,
126            replace_all: None,
127            expected_replacements: None,
128        };
129        write_file::write_file(sb.clone(), write_args)
130            .await
131            .unwrap();
132
133        let read_args = ReadFileArgs {
134            path: path_str.clone(),
135        };
136        let out = read_file::read_file(sb.clone(), read_args).await.unwrap();
137        assert_eq!(completed(out)["content"], content);
138
139        let _ = tokio::fs::remove_file(&path).await;
140    }
141
142    #[tokio::test]
143    async fn ask_followup_question_returns_needs_user_interaction() {
144        let mut registry = ToolRegistry::new();
145        register_default_tools(&mut registry);
146        let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
147
148        let outcome = registry
149            .call(
150                "askFollowupQuestion",
151                json!({
152                    "question": "Which file should I edit?",
153                    "options": ["src/main.rs", "Cargo.toml"]
154                }),
155                sb,
156                crate::tools::ToolCtx::default(),
157            )
158            .await
159            .unwrap();
160
161        assert!(matches!(
162            outcome,
163            ToolOutcome::NeedsUserInteraction(interaction)
164                if interaction.kind == "followup_question"
165                    && interaction.payload["question"] == "Which file should I edit?"
166                    && interaction.payload["options"] == json!(["src/main.rs", "Cargo.toml"])
167        ));
168    }
169
170    #[tokio::test]
171    async fn write_file_replaces_unique_old_string() {
172        let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
173        let dir = std::env::temp_dir();
174        let path = dir.join(format!("tiny_agent_test_{}.txt", uuid::Uuid::new_v4()));
175        let path_str = path.to_string_lossy().to_string();
176
177        write_file::write_file(
178            sb.clone(),
179            WriteFileArgs {
180                path: path_str.clone(),
181                mode: Some(WriteFileMode::Write),
182                content: Some("alpha\nbeta\ngamma\n".to_string()),
183                old_string: None,
184                new_string: None,
185                replace_all: None,
186                expected_replacements: None,
187            },
188        )
189        .await
190        .unwrap();
191
192        write_file::write_file(
193            sb.clone(),
194            WriteFileArgs {
195                path: path_str.clone(),
196                mode: Some(WriteFileMode::Replace),
197                content: None,
198                old_string: Some("beta".to_string()),
199                new_string: Some("BETA".to_string()),
200                replace_all: None,
201                expected_replacements: None,
202            },
203        )
204        .await
205        .unwrap();
206
207        let out = read_file::read_file(
208            sb.clone(),
209            ReadFileArgs {
210                path: path_str.clone(),
211            },
212        )
213        .await
214        .unwrap();
215        assert_eq!(completed(out)["content"], "alpha\nBETA\ngamma\n");
216
217        let _ = tokio::fs::remove_file(&path).await;
218    }
219
220    #[tokio::test]
221    async fn write_file_rejects_ambiguous_replace_by_default() {
222        let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
223        let dir = std::env::temp_dir();
224        let path = dir.join(format!("tiny_agent_test_{}.txt", uuid::Uuid::new_v4()));
225        let path_str = path.to_string_lossy().to_string();
226
227        write_file::write_file(
228            sb.clone(),
229            WriteFileArgs {
230                path: path_str.clone(),
231                mode: Some(WriteFileMode::Write),
232                content: Some("same\nsame\n".to_string()),
233                old_string: None,
234                new_string: None,
235                replace_all: None,
236                expected_replacements: None,
237            },
238        )
239        .await
240        .unwrap();
241
242        let result = write_file::write_file(
243            sb.clone(),
244            WriteFileArgs {
245                path: path_str.clone(),
246                mode: Some(WriteFileMode::Replace),
247                content: None,
248                old_string: Some("same".to_string()),
249                new_string: Some("changed".to_string()),
250                replace_all: None,
251                expected_replacements: None,
252            },
253        )
254        .await;
255
256        assert!(result.is_err());
257        let _ = tokio::fs::remove_file(&path).await;
258    }
259
260    #[tokio::test]
261    async fn write_file_can_insert_and_append() {
262        let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
263        let dir = std::env::temp_dir();
264        let path = dir.join(format!("tiny_agent_test_{}.txt", uuid::Uuid::new_v4()));
265        let path_str = path.to_string_lossy().to_string();
266
267        write_file::write_file(
268            sb.clone(),
269            WriteFileArgs {
270                path: path_str.clone(),
271                mode: Some(WriteFileMode::Write),
272                content: Some("fn main() {\n}\n".to_string()),
273                old_string: None,
274                new_string: None,
275                replace_all: None,
276                expected_replacements: None,
277            },
278        )
279        .await
280        .unwrap();
281
282        write_file::write_file(
283            sb.clone(),
284            WriteFileArgs {
285                path: path_str.clone(),
286                mode: Some(WriteFileMode::InsertAfter),
287                content: Some("\n    println!(\"hi\");".to_string()),
288                old_string: Some("fn main() {".to_string()),
289                new_string: None,
290                replace_all: None,
291                expected_replacements: None,
292            },
293        )
294        .await
295        .unwrap();
296
297        write_file::write_file(
298            sb.clone(),
299            WriteFileArgs {
300                path: path_str.clone(),
301                mode: Some(WriteFileMode::Append),
302                content: Some("// end\n".to_string()),
303                old_string: None,
304                new_string: None,
305                replace_all: None,
306                expected_replacements: None,
307            },
308        )
309        .await
310        .unwrap();
311
312        let out = read_file::read_file(
313            sb.clone(),
314            ReadFileArgs {
315                path: path_str.clone(),
316            },
317        )
318        .await
319        .unwrap();
320        assert_eq!(
321            completed(out)["content"],
322            "fn main() {\n    println!(\"hi\");\n}\n// end\n"
323        );
324
325        let _ = tokio::fs::remove_file(&path).await;
326    }
327
328    #[tokio::test]
329    async fn bash_completes_fast_command_inline() {
330        let mut registry = ToolRegistry::new();
331        register_default_tools(&mut registry);
332        let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
333
334        let out = registry
335            .call(
336                "bash",
337                json!({ "command": "echo hi" }),
338                sb,
339                crate::tools::ToolCtx::default(),
340            )
341            .await
342            .unwrap();
343        let v = completed(out);
344        assert_eq!(v["status"], "completed");
345        assert_eq!(v["exit_code"], 0);
346        assert!(v["output"].as_str().unwrap().contains("hi"));
347    }
348
349    #[tokio::test]
350    async fn bash_yields_long_command_then_check_sees_output_and_kill_stops_it() {
351        let mut registry = ToolRegistry::new();
352        register_default_tools(&mut registry);
353        let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
354        // 同一个 ToolCtx 的 TaskManager 是 Arc 共享的:bash 登记的任务,check/kill 能查到。
355        let ctx = crate::tools::ToolCtx::default();
356
357        // 先打印一行,再 sleep 超过 2s 让步窗口。
358        let out = registry
359            .call(
360                "bash",
361                json!({ "command": "echo first; sleep 5" }),
362                sb.clone(),
363                ctx.clone(),
364            )
365            .await
366            .unwrap();
367        let v = completed(out);
368        assert_eq!(v["status"], "running");
369        assert!(v["output"].as_str().unwrap().contains("first"));
370        let id = v["id"].as_str().unwrap().to_string();
371
372        // check:任务仍在跑(还没到 exit)。
373        let cout = registry
374            .call("check", json!({ "id": id }), sb.clone(), ctx.clone())
375            .await
376            .unwrap();
377        assert_eq!(completed(cout)["status"], "running");
378
379        // kill:能找到并终止。
380        let kout = registry
381            .call("kill", json!({ "id": id }), sb.clone(), ctx.clone())
382            .await
383            .unwrap();
384        assert_eq!(completed(kout)["status"], "killed");
385
386        // kill 之后再 check:任务已不在登记表。
387        let err = registry.call("check", json!({ "id": id }), sb, ctx).await;
388        assert!(err.is_err());
389    }
390
391    #[tokio::test]
392    async fn check_wait_secs_sleeps_before_polling() {
393        let mut registry = ToolRegistry::new();
394        register_default_tools(&mut registry);
395        let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
396        let ctx = crate::tools::ToolCtx::default();
397
398        let out = registry
399            .call(
400                "bash",
401                json!({ "command": "echo first; sleep 4" }),
402                sb.clone(),
403                ctx.clone(),
404            )
405            .await
406            .unwrap();
407        let id = completed(out)["id"].as_str().unwrap().to_string();
408
409        let started = Instant::now();
410        let cout = registry
411            .call(
412                "check",
413                json!({ "id": id, "wait_secs": 1 }),
414                sb.clone(),
415                ctx.clone(),
416            )
417            .await
418            .unwrap();
419        assert!(started.elapsed() >= Duration::from_millis(900));
420        assert_eq!(completed(cout)["status"], "running");
421
422        let _ = registry.call("kill", json!({ "id": id }), sb, ctx).await;
423    }
424
425    #[tokio::test]
426    async fn read_missing_file_returns_err() {
427        let sb: Arc<dyn Sandbox> = Arc::new(HostSandbox::new());
428        let args = ReadFileArgs {
429            path: "/no/such/path/really_missing_12345".to_string(),
430        };
431        assert!(read_file::read_file(sb, args).await.is_err());
432    }
433}