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
16pub 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
67pub 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 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 let ctx = crate::tools::ToolCtx::default();
356
357 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 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 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 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}