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
17const DEFAULT_YIELD_AFTER: Duration = Duration::from_secs(2);
20
21pub 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
70pub 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 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 let ctx = crate::tools::ToolCtx::default();
364
365 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 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 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 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}