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