1 //! Agent tools: schema definitions + execution for siGit Code.
2
3 use regex::Regex;
4 use serde_json::{Value, json};
5 use std::fs;
6 use std::path::{Path, PathBuf};
7 use std::process::Command;
8 use std::sync::{Arc, OnceLock};
9
10 use crate::backend::{InferenceBackend, ToolResult, ToolSpec};
11
12 const WEBSITE_READ_CHAR_LIMIT: usize = 20_000;
13 const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
14 const WEBSITE_USER_AGENT: &str =
15 "siGit/0.1 (+https://github.com/getsigit/sigit; website-reading tool)";
16
17 const READ_FILE_CHAR_LIMIT: usize = 10_000;
18 const SEARCH_FILES_MATCH_LIMIT: usize = 50;
19 /// Upper bound on `max_results` for `search_files` and the number of paths
20 /// returned by `glob`, so a broad pattern can't flood the context window.
21 const SEARCH_RESULTS_HARD_CAP: usize = 1_000;
22
23 // ── Tool schemas ─────────────────────────────────────────────────────────────
24
25 pub struct AgentTool {
26 pub name: &'static str,
27 pub description: &'static str,
28 pub parameters_schema: Value,
29 }
30
31 pub fn all_tools() -> Vec<AgentTool> {
32 vec![
33 AgentTool {
34 name: "read_file",
35 description: "Read the contents of a file at the given path. \
36 Prefer an absolute path when possible. Use start_line and \
37 end_line to read a specific range instead of the whole file — \
38 strongly prefer this when you already know which lines matter. \
39 Output is truncated to 10 000 characters.",
40 parameters_schema: json!({
41 "type": "object",
42 "properties": {
43 "path": {
44 "type": "string",
45 "description": "Absolute or relative path to the file to read."
46 },
47 "start_line": {
48 "type": "integer",
49 "description": "First line to read (1-based, inclusive). Omit to start from the beginning."
50 },
51 "end_line": {
52 "type": "integer",
53 "description": "Last line to read (1-based, inclusive). Omit to read to the end."
54 }
55 },
56 "required": ["path"],
57 "additionalProperties": false
58 }),
59 },
60 AgentTool {
61 name: "create_directory",
62 description: "Create a directory at the given path. \
63 Prefer an absolute path when possible. Missing parent \
64 directories are created automatically. Use this before \
65 create_file when the parent path does not exist. Succeeds \
66 if the directory already exists.",
67 parameters_schema: json!({
68 "type": "object",
69 "properties": {
70 "path": {
71 "type": "string",
72 "description": "Absolute or relative path to the directory to create."
73 }
74 },
75 "required": ["path"],
76 "additionalProperties": false
77 }),
78 },
79 AgentTool {
80 name: "list_directory",
81 description: "List files and directories at the given path. \
82 Prefer an absolute path when possible. Each entry is \
83 prefixed with [DIR] or [FILE]. Directories are listed \
84 first, sorted alphabetically.",
85 parameters_schema: json!({
86 "type": "object",
87 "properties": {
88 "path": {
89 "type": "string",
90 "description": "Absolute or relative path to the directory to list."
91 }
92 },
93 "required": ["path"],
94 "additionalProperties": false
95 }),
96 },
97 AgentTool {
98 name: "search_files",
99 description: "Search for a regex pattern across files in a directory tree. \
100 Prefer an absolute root path when possible. Returns matching \
101 lines in `file:line_number: content` format. Skips binary \
102 files and hidden directories. Pass `file_glob` to restrict the \
103 search to files whose name matches a glob (e.g. \"*.rs\"), and \
104 `max_results` to raise or lower the default cap of 50 matches.",
105 parameters_schema: json!({
106 "type": "object",
107 "properties": {
108 "pattern": {
109 "type": "string",
110 "description": "Regular expression pattern to search for."
111 },
112 "path": {
113 "type": "string",
114 "description": "Root directory to search in. Defaults to \".\" (current directory)."
115 },
116 "file_glob": {
117 "type": "string",
118 "description": "Optional glob on the file name (not the full path), e.g. \"*.rs\" or \"*.{ts,tsx}\". Only matching files are searched."
119 },
120 "max_results": {
121 "type": "integer",
122 "description": "Maximum number of matching lines to return (default 50, capped at 1000)."
123 }
124 },
125 "required": ["pattern"],
126 "additionalProperties": false
127 }),
128 },
129 AgentTool {
130 name: "read_website",
131 description: "Fetch a web page and return readable text content. \
132 Use this when the user gives you a URL and asks you to read, \
133 summarize, inspect, or extract information from the page. \
134 Supports normal http and https URLs. Output is truncated if the \
135 page is very large.",
136 parameters_schema: json!({
137 "type": "object",
138 "properties": {
139 "url": {
140 "type": "string",
141 "description": "Absolute http or https URL to fetch."
142 }
143 },
144 "required": ["url"],
145 "additionalProperties": false
146 }),
147 },
148 AgentTool {
149 name: "create_file",
150 description: "Create a new file at the given path with the provided content. \
151 Prefer an absolute path when possible. Parent directories are \
152 created automatically if they do not exist. Fails if the file \
153 already exists — use edit_file to modify existing files.",
154 parameters_schema: json!({
155 "type": "object",
156 "properties": {
157 "path": {
158 "type": "string",
159 "description": "Absolute or relative path for the new file."
160 },
161 "content": {
162 "type": "string",
163 "description": "The full text content to write into the new file."
164 }
165 },
166 "required": ["path", "content"],
167 "additionalProperties": false
168 }),
169 },
170 AgentTool {
171 name: "edit_file",
172 description: "Edit an existing file by replacing an exact substring (old_text) with \
173 new text (new_text). Prefer an absolute path when possible. By \
174 default old_text must appear exactly once; set replace_all to true \
175 to replace every occurrence (useful for renaming a symbol). Use \
176 read_file first to see the current content and identify the exact \
177 text to replace. To append to a file, match the last few lines as \
178 old_text and include them plus the new content as new_text.",
179 parameters_schema: json!({
180 "type": "object",
181 "properties": {
182 "path": {
183 "type": "string",
184 "description": "Path to the existing file to edit."
185 },
186 "old_text": {
187 "type": "string",
188 "description": "The exact text span to find and replace. Must match exactly once unless replace_all is true."
189 },
190 "new_text": {
191 "type": "string",
192 "description": "The replacement text that will take the place of old_text."
193 },
194 "replace_all": {
195 "type": "boolean",
196 "description": "Replace every occurrence of old_text instead of requiring a unique match. Defaults to false."
197 }
198 },
199 "required": ["path", "old_text", "new_text"],
200 "additionalProperties": false
201 }),
202 },
203 AgentTool {
204 name: "delete_file",
205 description: "Delete a file or empty directory at the given path. \
206 Prefer an absolute path when possible. Refuses to delete \
207 non-empty directories to prevent accidental data loss. \
208 Use read_file or list_directory first to confirm the target.",
209 parameters_schema: json!({
210 "type": "object",
211 "properties": {
212 "path": {
213 "type": "string",
214 "description": "Absolute or relative path to the file or empty directory to delete."
215 }
216 },
217 "required": ["path"],
218 "additionalProperties": false
219 }),
220 },
221 AgentTool {
222 name: "run_command",
223 description: "Run a shell command and return its combined stdout and stderr output. \
224 The command runs in the given working directory (defaults to the \
225 user's home directory). Always use an absolute working directory \
226 path. Use this for build tools (cargo, npm, make), package managers, \
227 linters, test runners, and git commands, including git init, \
228 porcelain commands like status/add/commit/checkout, and plumbing \
229 commands like rev-parse, hash-object, update-ref, and cat-file. \
230 For `git clone`, always specify the full absolute destination path \
231 as the last argument (e.g. `git clone <url> /absolute/path/to/dir`) \
232 and set cwd to the parent directory. Never run `git clone` without \
233 an explicit destination. If the user asks for a new repo or scaffold, \
234 use this for `git clone`, `git init`, and normal repo setup steps. \
235 In smbCloud repos, prefer existing workspace commands, Rails \
236 conventions, and deploy flows over inventing new command sequences. \
237 Commands that run indefinitely (servers, watchers) will be killed \
238 after 120 seconds.",
239 parameters_schema: json!({
240 "type": "object",
241 "properties": {
242 "command": {
243 "type": "string",
244 "description": "The shell command to execute (e.g. \"cargo update\", \"git status\", \"git rev-parse HEAD\")."
245 },
246 "cwd": {
247 "type": "string",
248 "description": "Working directory for the command. Defaults to \".\" (current directory)."
249 }
250 },
251 "required": ["command"],
252 "additionalProperties": false
253 }),
254 },
255 AgentTool {
256 name: "multi_edit",
257 description: "Apply several exact-substring edits to a single file in one call. \
258 Edits are applied in order, each to the result of the previous one, \
259 and the whole batch is atomic — if any edit fails to match, the file \
260 is left untouched and an error explains which edit failed. Prefer \
261 this over multiple edit_file calls when changing several spots in the \
262 same file. Each edit has old_text (must match exactly once, or every \
263 time when replace_all is true) and new_text.",
264 parameters_schema: json!({
265 "type": "object",
266 "properties": {
267 "path": {
268 "type": "string",
269 "description": "Path to the existing file to edit."
270 },
271 "edits": {
272 "type": "array",
273 "description": "Ordered list of edits to apply to the file.",
274 "items": {
275 "type": "object",
276 "properties": {
277 "old_text": {
278 "type": "string",
279 "description": "The exact text span to find and replace."
280 },
281 "new_text": {
282 "type": "string",
283 "description": "The replacement text."
284 },
285 "replace_all": {
286 "type": "boolean",
287 "description": "Replace every occurrence instead of requiring a unique match. Defaults to false."
288 }
289 },
290 "required": ["old_text", "new_text"],
291 "additionalProperties": false
292 }
293 }
294 },
295 "required": ["path", "edits"],
296 "additionalProperties": false
297 }),
298 },
299 AgentTool {
300 name: "glob",
301 description: "Find files by name using a glob pattern (e.g. \"**/*.rs\", \
302 \"src/**/*.{ts,tsx}\", \"Cargo.toml\"). Returns matching file paths, \
303 most-recently-modified first. Supports `*` (any run of non-separator \
304 characters), `**` (any number of directories), `?` (one character), \
305 and `{a,b}` alternation. Use this to locate files by name; use \
306 search_files to search file contents.",
307 parameters_schema: json!({
308 "type": "object",
309 "properties": {
310 "pattern": {
311 "type": "string",
312 "description": "Glob pattern matched against paths relative to the search root."
313 },
314 "path": {
315 "type": "string",
316 "description": "Root directory to search in. Defaults to \".\" (current directory)."
317 }
318 },
319 "required": ["pattern"],
320 "additionalProperties": false
321 }),
322 },
323 AgentTool {
324 name: "write_todos",
325 description: "Record or update a checklist of the steps for the current task. \
326 Use this for any multi-step task to plan the work and show progress: \
327 call it once up front with all the steps as `pending`, then call it \
328 again whenever a step's status changes. Mark exactly one step \
329 `in_progress` at a time and `completed` as soon as it is done. \
330 Keep the list short and outcome-focused.",
331 parameters_schema: json!({
332 "type": "object",
333 "properties": {
334 "todos": {
335 "type": "array",
336 "description": "The full, current checklist (replaces any previous list).",
337 "items": {
338 "type": "object",
339 "properties": {
340 "content": {
341 "type": "string",
342 "description": "Short imperative description of the step."
343 },
344 "status": {
345 "type": "string",
346 "enum": ["pending", "in_progress", "completed"],
347 "description": "Current status of the step."
348 }
349 },
350 "required": ["content", "status"],
351 "additionalProperties": false
352 }
353 }
354 },
355 "required": ["todos"],
356 "additionalProperties": false
357 }),
358 },
359 AgentTool {
360 name: "remember",
361 description: "Persist a durable note, preference, or convention by appending it to \
362 this project's instruction file (AGENTS.md / CLAUDE.md). Use this \
363 when the user asks you to remember something for next time, or states \
364 a lasting preference about how to work in this project. The note is \
365 written to the nearest existing instruction file, or a new CLAUDE.md \
366 at the repository root if none exists yet.",
367 parameters_schema: json!({
368 "type": "object",
369 "properties": {
370 "note": {
371 "type": "string",
372 "description": "The fact or preference to remember, phrased as a standalone instruction."
373 }
374 },
375 "required": ["note"],
376 "additionalProperties": false
377 }),
378 },
379 ]
380 }
381
382 // ── Tool execution ───────────────────────────────────────────────────────────
383
384 pub async fn execute_tool(name: &str, arguments: &str) -> String {
385 match name {
386 "read_file" => exec_read_file(arguments),
387 "list_directory" => exec_list_directory(arguments),
388 "search_files" => exec_search_files(arguments),
389 "read_website" => {
390 // reqwest::blocking panics inside a tokio runtime, so run on the blocking pool.
391 let args = arguments.to_owned();
392 tokio::task::spawn_blocking(move || exec_read_website(&args))
393 .await
394 .unwrap_or_else(|err| format!("Error: read_website task failed: {err}"))
395 }
396 "create_directory" => exec_create_directory(arguments),
397 "create_file" => exec_create_file(arguments),
398 "edit_file" => exec_edit_file(arguments),
399 "multi_edit" => exec_multi_edit(arguments),
400 "glob" => exec_glob(arguments),
401 "write_todos" => exec_write_todos(arguments),
402 "remember" => exec_remember(arguments),
403 "delete_file" => exec_delete_file(arguments),
404 "run_command" => exec_run_command(arguments),
405 "skill" => crate::skills::activate_skill(arguments),
406 TASK_TOOL_NAME => exec_task(arguments).await,
407 // Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>`
408 // and forwarded to the owning server.
409 _ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await,
410 _ => format!("Unknown tool: {name}"),
411 }
412 }
413
414 // ── task (subagent) ──────────────────────────────────────────────────────────
415 //
416 // The `task` tool delegates a self-contained research question to a *nested*
417 // agent loop running in a fresh conversation, so the main thread receives only
418 // the final answer instead of every intermediate file read. The subagent's
419 // toolset is strictly read-only and never includes `task` itself (no
420 // recursion), so a delegated agent can research but not mutate state.
421 //
422 // This module is backend-agnostic and cannot construct a backend, so the
423 // surface that knows the active provider (`run_acp_server` / `run_interactive`
424 // in `main.rs`) registers a factory at startup. The factory returns `None`
425 // when inference runs on-device: onde has a single shared conversation
426 // history, so a second concurrent context is not possible yet.
427
428 pub const TASK_TOOL_NAME: &str = "task";
429
430 /// The tool names a subagent may call, filtered from [`all_tools`].
431 const SUBAGENT_TOOL_NAMES: &[&str] = &[
432 "read_file",
433 "list_directory",
434 "search_files",
435 "glob",
436 "read_website",
437 ];
438
439 /// System prompt seeding every subagent conversation.
440 pub const SUBAGENT_SYSTEM_PROMPT: &str = "You are a focused research subagent. \
441 You are given one self-contained task by a calling agent. Investigate it using \
442 the read-only tools available to you (read_file, list_directory, search_files, \
443 glob, read_website) and answer it thoroughly but concisely. You cannot modify \
444 files or run commands. Your final message is returned verbatim to the caller, \
445 so make it a complete, self-contained answer — include the concrete facts, \
446 paths, and code excerpts the caller needs, and nothing else.";
447
448 /// Rounds of tool calls a subagent may use before it is forced to answer.
449 const SUBAGENT_MAX_TOOL_ROUNDS: usize = 8;
450 /// Cap on the answer text returned to the caller.
451 const SUBAGENT_RESULT_CHAR_LIMIT: usize = 8_000;
452
453 /// Returned when `task` is called but no subagent backend can be built.
454 const SUBAGENT_UNAVAILABLE: &str = "The task tool is not available on-device \
455 yet: on-device inference has a single conversation context. Do the research \
456 yourself with the read-only tools.";
457
458 /// Builds a fresh backend for one subagent run, or `None` when the active
459 /// inference cannot host a second conversation (on-device).
460 pub type SubagentFactory = Box<dyn Fn() -> Option<Arc<dyn InferenceBackend>> + Send + Sync>;
461
462 static SUBAGENT_FACTORY: OnceLock<SubagentFactory> = OnceLock::new();
463
464 /// Register the process-wide subagent factory. Called once at startup by the
465 /// surface that resolved the inference provider; later calls are ignored.
466 pub fn set_subagent_factory(factory: SubagentFactory) {
467 let _ = SUBAGENT_FACTORY.set(factory);
468 }
469
470 /// Whether a `task` call could run right now: a factory is registered and it
471 /// can build a backend. The spec builders (`agent_tools_as_specs` /
472 /// `build_tool_specs`) use this to offer the tool only when it works, the same
473 /// conditional pattern as the `skill` tool.
474 pub fn subagent_available() -> bool {
475 SUBAGENT_FACTORY
476 .get()
477 .is_some_and(|factory| factory().is_some())
478 }
479
480 /// The read-only toolset offered to a subagent, filtered from [`all_tools`] by
481 /// name. Never contains `task` (recursion) or any mutating tool.
482 pub fn subagent_tool_specs() -> Vec<ToolSpec> {
483 all_tools()
484 .into_iter()
485 .filter(|tool| SUBAGENT_TOOL_NAMES.contains(&tool.name))
486 .map(|tool| ToolSpec {
487 name: tool.name.to_string(),
488 description: tool.description.to_string(),
489 parameters_schema: tool.parameters_schema.to_string(),
490 })
491 .collect()
492 }
493
494 /// Spec for the `task` tool. Lives in the `*_as_specs`/`build_tool_specs`
495 /// layer (like `skill` and MCP tools), not in [`all_tools`], because it is
496 /// only offered when [`subagent_available`] is true.
497 pub fn task_tool_spec() -> ToolSpec {
498 ToolSpec {
499 name: TASK_TOOL_NAME.to_string(),
500 description: "Delegate a self-contained research task to a subagent that \
501 runs in a fresh conversation and returns only its final answer. The \
502 subagent can read files, list directories, search, glob, and read \
503 websites, but cannot modify anything. Use this for exploratory \
504 questions whose intermediate file reads would otherwise clutter this \
505 conversation (e.g. \"where is X implemented and how does it work?\"). \
506 The subagent cannot see this conversation, so the prompt must be \
507 fully self-contained: include absolute paths, symbol names, and \
508 exactly what the answer should contain."
509 .to_string(),
510 parameters_schema: json!({
511 "type": "object",
512 "properties": {
513 "description": {
514 "type": "string",
515 "description": "A short (3-5 word) summary of the task, for progress display."
516 },
517 "prompt": {
518 "type": "string",
519 "description": "The full task for the subagent. Must be self-contained: the subagent sees nothing of this conversation."
520 }
521 },
522 "required": ["description", "prompt"],
523 "additionalProperties": false
524 })
525 .to_string(),
526 }
527 }
528
529 /// The `task` tool entry point used by [`execute_tool`].
530 async fn exec_task(arguments: &str) -> String {
531 exec_task_with(arguments, SUBAGENT_FACTORY.get()).await
532 }
533
534 /// Core of the `task` tool, parameterized on the factory so tests can exercise
535 /// the unavailable path without touching the process-global `OnceLock`.
536 async fn exec_task_with(arguments: &str, factory: Option<&SubagentFactory>) -> String {
537 let args: Value = match serde_json::from_str(arguments) {
538 Ok(v) => v,
539 Err(err) => return format!("Error: failed to parse arguments: {err}"),
540 };
541
542 let prompt = match args.get("prompt").and_then(Value::as_str) {
543 Some(p) if !p.trim().is_empty() => p,
544 _ => return "Error: missing required parameter \"prompt\"".to_string(),
545 };
546 let description = args
547 .get("description")
548 .and_then(Value::as_str)
549 .unwrap_or("(no description)");
550
551 let Some(backend) = factory.and_then(|build| build()) else {
552 return SUBAGENT_UNAVAILABLE.to_string();
553 };
554
555 log::info!("task: running subagent — {description}");
556 run_subagent(backend.as_ref(), prompt).await
557 }
558
559 /// The nested agent loop: a fresh conversation, read-only tools, a round cap,
560 /// and only the final text returned. Mirrors the main loops in `main.rs` /
561 /// `chat.rs`: offer tools each round, and pass `tools = None` on the last
562 /// round to force a text answer.
563 async fn run_subagent(backend: &dyn InferenceBackend, prompt: &str) -> String {
564 let specs = subagent_tool_specs();
565
566 let mut result = match backend.send_message_with_tools(prompt, &specs, None).await {
567 Ok(r) => r,
568 Err(err) => return format!("Error: subagent inference failed: {err}"),
569 };
570
571 let mut round = 0;
572 while !result.tool_calls.is_empty() && round < SUBAGENT_MAX_TOOL_ROUNDS {
573 round += 1;
574 log::info!(
575 "task: subagent tool round {round} — {} call(s)",
576 result.tool_calls.len()
577 );
578
579 let mut tool_results = Vec::with_capacity(result.tool_calls.len());
580 for call in &result.tool_calls {
581 // Hard gate, not just advertisement: even if the model asks for a
582 // tool outside the offered set, only read-only tools execute here.
583 let content = if SUBAGENT_TOOL_NAMES.contains(&call.name.as_str()) {
584 // Boxed to break the async cycle: execute_tool → task →
585 // run_subagent → execute_tool.
586 Box::pin(execute_tool(&call.name, &call.arguments)).await
587 } else {
588 format!(
589 "Error: `{}` is not available to a subagent. Only these \
590 read-only tools are: {}.",
591 call.name,
592 SUBAGENT_TOOL_NAMES.join(", ")
593 )
594 };
595 tool_results.push(ToolResult {
596 tool_call_id: call.id.clone(),
597 content,
598 });
599 }
600
601 // On the last round, offer no tools so the model must produce text.
602 let next_tools = if round < SUBAGENT_MAX_TOOL_ROUNDS {
603 Some(specs.as_slice())
604 } else {
605 None
606 };
607 result = match backend
608 .send_tool_results(tool_results, next_tools, None)
609 .await
610 {
611 Ok(r) => r,
612 Err(err) => return format!("Error: subagent inference failed: {err}"),
613 };
614 }
615
616 let text = result.text.trim();
617 if text.is_empty() {
618 return "The subagent finished without a text answer.".to_string();
619 }
620
621 let total = text.chars().count();
622 if total > SUBAGENT_RESULT_CHAR_LIMIT {
623 let truncated: String = text.chars().take(SUBAGENT_RESULT_CHAR_LIMIT).collect();
624 return format!(
625 "{truncated}\n\n--- truncated (showing {SUBAGENT_RESULT_CHAR_LIMIT} of {total} \
626 characters of the subagent's answer) ---"
627 );
628 }
629 text.to_string()
630 }
631
632 fn absolute_path(path: &Path) -> PathBuf {
633 if path.is_absolute() {
634 path.to_path_buf()
635 } else {
636 std::env::current_dir()
637 .unwrap_or_else(|_| PathBuf::from("."))
638 .join(path)
639 }
640 }
641
642 fn absolute_path_string(path: &Path) -> String {
643 absolute_path(path).display().to_string()
644 }
645
646 // ── read_file ────────────────────────────────────────────────────────────────
647
648 fn exec_read_file(arguments: &str) -> String {
649 let args: Value = match serde_json::from_str(arguments) {
650 Ok(v) => v,
651 Err(err) => return format!("Error: failed to parse arguments: {err}"),
652 };
653
654 let path_str = match args.get("path").and_then(Value::as_str) {
655 Some(p) => p,
656 None => return "Error: missing required parameter \"path\"".to_string(),
657 };
658
659 let start_line = args
660 .get("start_line")
661 .and_then(Value::as_u64)
662 .map(|n| n as usize);
663 let end_line = args
664 .get("end_line")
665 .and_then(Value::as_u64)
666 .map(|n| n as usize);
667
668 let path = Path::new(path_str);
669 let absolute_path = absolute_path(path);
670 let absolute_path_str = absolute_path.display().to_string();
671
672 if !absolute_path.exists() {
673 return format!("Error: path does not exist: {absolute_path_str}");
674 }
675
676 if !absolute_path.is_file() {
677 return format!("Error: path is not a file: {absolute_path_str}");
678 }
679
680 match fs::read_to_string(&absolute_path) {
681 Ok(contents) => {
682 if start_line.is_some() || end_line.is_some() {
683 let lines: Vec<&str> = contents.lines().collect();
684 let total = lines.len();
685 let start = start_line.unwrap_or(1).max(1);
686 let end = end_line.unwrap_or(total).min(total);
687
688 if start > total {
689 return format!(
690 "Error: start_line {start} is beyond end of file ({total} lines)"
691 );
692 }
693
694 let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
695 let range_text = selected.join("\n");
696 format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
697 } else if contents.len() > READ_FILE_CHAR_LIMIT {
698 let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
699 format!(
700 "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
701 contents.len()
702 )
703 } else {
704 contents
705 }
706 }
707 Err(err) => format!("Error: could not read file: {err}"),
708 }
709 }
710
711 // ── list_directory ───────────────────────────────────────────────────────────
712
713 fn exec_list_directory(arguments: &str) -> String {
714 let args: Value = match serde_json::from_str(arguments) {
715 Ok(v) => v,
716 Err(err) => return format!("Error: failed to parse arguments: {err}"),
717 };
718
719 let path_str = match args.get("path").and_then(Value::as_str) {
720 Some(p) => p,
721 None => return "Error: missing required parameter \"path\"".to_string(),
722 };
723
724 let path = Path::new(path_str);
725 let absolute_path = absolute_path(path);
726 let absolute_path_str = absolute_path.display().to_string();
727
728 if !absolute_path.exists() {
729 return format!("Error: path does not exist: {absolute_path_str}");
730 }
731
732 if !absolute_path.is_dir() {
733 return format!("Error: path is not a directory: {absolute_path_str}");
734 }
735
736 let entries = match fs::read_dir(&absolute_path) {
737 Ok(rd) => rd,
738 Err(err) => return format!("Error: could not read directory: {err}"),
739 };
740
741 let mut dirs: Vec<String> = Vec::new();
742 let mut files: Vec<String> = Vec::new();
743
744 for entry in entries {
745 let entry = match entry {
746 Ok(e) => e,
747 Err(err) => {
748 files.push(format!("[ERR] {err}"));
749 continue;
750 }
751 };
752
753 let name = entry.file_name().to_string_lossy().to_string();
754
755 let is_dir = match entry.file_type() {
756 Ok(ft) => ft.is_dir(),
757 Err(_) => false,
758 };
759
760 if is_dir {
761 dirs.push(format!("[DIR] {name}"));
762 } else {
763 files.push(format!("[FILE] {name}"));
764 }
765 }
766
767 dirs.sort();
768 files.sort();
769
770 dirs.extend(files);
771
772 if dirs.is_empty() {
773 return format!("(empty directory: {absolute_path_str})");
774 }
775
776 dirs.join("\n")
777 }
778
779 // ── search_files ─────────────────────────────────────────────────────────────
780
781 fn exec_search_files(arguments: &str) -> String {
782 let args: Value = match serde_json::from_str(arguments) {
783 Ok(v) => v,
784 Err(err) => return format!("Error: failed to parse arguments: {err}"),
785 };
786
787 let pattern_str = match args.get("pattern").and_then(Value::as_str) {
788 Some(p) => p,
789 None => return "Error: missing required parameter \"pattern\"".to_string(),
790 };
791
792 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
793
794 let re = match Regex::new(pattern_str) {
795 Ok(r) => r,
796 Err(err) => return format!("Error: invalid regex pattern: {err}"),
797 };
798
799 // Optional file-name filter compiled from a glob (e.g. "*.rs").
800 let name_filter = match args.get("file_glob").and_then(Value::as_str) {
801 Some(glob) => match Regex::new(&glob_to_regex(glob)) {
802 Ok(r) => Some(r),
803 Err(err) => return format!("Error: invalid file_glob: {err}"),
804 },
805 None => None,
806 };
807
808 let limit = args
809 .get("max_results")
810 .and_then(Value::as_u64)
811 .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
812 .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
813
814 let root = Path::new(root_str);
815 let absolute_root = absolute_path(root);
816 let absolute_root_str = absolute_root.display().to_string();
817
818 if !absolute_root.exists() {
819 return format!("Error: path does not exist: {absolute_root_str}");
820 }
821
822 if !absolute_root.is_dir() {
823 return format!("Error: path is not a directory: {absolute_root_str}");
824 }
825
826 let mut matches: Vec<String> = Vec::new();
827 walk_and_search(
828 &absolute_root,
829 &re,
830 name_filter.as_ref(),
831 limit,
832 &mut matches,
833 );
834
835 if matches.is_empty() {
836 return format!("No matches found for pattern: {pattern_str}");
837 }
838
839 let total = matches.len();
840 if total > limit {
841 matches.truncate(limit);
842 matches.push(format!(
843 "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
844 ));
845 }
846
847 matches.join("\n")
848 }
849
850 /// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
851 /// pattern can't walk an entire tree once enough hits are found.
852 fn walk_and_search(
853 dir: &Path,
854 re: &Regex,
855 name_filter: Option<&Regex>,
856 limit: usize,
857 matches: &mut Vec<String>,
858 ) {
859 let entries = match fs::read_dir(dir) {
860 Ok(rd) => rd,
861 Err(_) => return,
862 };
863
864 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
865 sorted.sort_by_key(|e| e.file_name());
866
867 for entry in sorted {
868 if matches.len() > limit {
869 return;
870 }
871
872 let path = entry.path();
873 let name = entry.file_name();
874 let name_str = name.to_string_lossy();
875
876 if name_str.starts_with('.') {
877 continue;
878 }
879
880 if path.is_dir() {
881 walk_and_search(&path, re, name_filter, limit, matches);
882 } else if path.is_file() {
883 if let Some(filter) = name_filter
884 && !filter.is_match(&name_str)
885 {
886 continue;
887 }
888 search_file(&path, re, matches);
889 }
890 }
891 }
892
893 /// skips non-UTF-8 files (probably binary).
894 fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
895 let contents = match fs::read_to_string(path) {
896 Ok(c) => c,
897 Err(_) => return,
898 };
899
900 let display_path = absolute_path_string(path);
901
902 for (line_idx, line) in contents.lines().enumerate() {
903 if re.is_match(line) {
904 let line_number = line_idx + 1;
905 matches.push(format!("{display_path}:{line_number}: {line}"));
906 }
907 }
908 }
909
910 // ── read_website ─────────────────────────────────────────────────────────────
911
912 fn exec_read_website(arguments: &str) -> String {
913 let args: Value = match serde_json::from_str(arguments) {
914 Ok(v) => v,
915 Err(err) => return format!("Error: failed to parse arguments: {err}"),
916 };
917
918 let url = match args.get("url").and_then(Value::as_str) {
919 Some(u) => u,
920 None => return "Error: missing required parameter \"url\"".to_string(),
921 };
922
923 if !(url.starts_with("http://") || url.starts_with("https://")) {
924 return format!("Error: url must start with http:// or https://: {url}");
925 }
926
927 let client = match reqwest::blocking::Client::builder()
928 .timeout(WEBSITE_READ_TIMEOUT)
929 .user_agent(WEBSITE_USER_AGENT)
930 .build()
931 {
932 Ok(client) => client,
933 Err(err) => return format!("Error: failed to build website client: {err}"),
934 };
935
936 let response = match client.get(url).send() {
937 Ok(r) => r,
938 Err(err) => return format!("Error: failed to fetch website: {err}"),
939 };
940
941 let final_url = response.url().to_string();
942 let status = response.status();
943 if !status.is_success() {
944 return format!("Error: website returned HTTP {status} for {final_url}");
945 }
946
947 let body = match response.text() {
948 Ok(text) => text,
949 Err(err) => return format!("Error: failed to read website body: {err}"),
950 };
951
952 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
953 .unwrap()
954 .captures(&body)
955 .and_then(|captures| captures.get(1))
956 .map(|m| {
957 Regex::new(r"\s+")
958 .unwrap()
959 .replace_all(m.as_str(), " ")
960 .trim()
961 .to_string()
962 })
963 .filter(|title| !title.is_empty());
964
965 let with_block_breaks = Regex::new(
966 r"(?is)</?(?:p|div|section|article|main|aside|header|footer|nav|li|ul|ol|h1|h2|h3|h4|h5|h6|br|tr|td|th)[^>]*>",
967 )
968 .unwrap()
969 .replace_all(&body, "\n");
970 let without_scripts = Regex::new(r"(?is)<script[^>]*>.*?</script>")
971 .unwrap()
972 .replace_all(&with_block_breaks, " ");
973 let without_styles = Regex::new(r"(?is)<style[^>]*>.*?</style>")
974 .unwrap()
975 .replace_all(&without_scripts, " ");
976 let without_tags = Regex::new(r"(?is)<[^>]+>")
977 .unwrap()
978 .replace_all(&without_styles, " ");
979 let normalized_newlines = without_tags
980 .replace("&nbsp;", " ")
981 .replace("&amp;", "&")
982 .replace("&lt;", "<")
983 .replace("&gt;", ">")
984 .replace("&quot;", "\"")
985 .replace("&#39;", "'");
986 let collapsed_lines = Regex::new(r"[ \t]+")
987 .unwrap()
988 .replace_all(&normalized_newlines, " ");
989 let collapsed_breaks = Regex::new(r"\n\s*\n+")
990 .unwrap()
991 .replace_all(&collapsed_lines, "\n\n");
992 let cleaned = collapsed_breaks
993 .lines()
994 .map(str::trim)
995 .filter(|line| !line.is_empty())
996 .collect::<Vec<_>>()
997 .join("\n");
998
999 if cleaned.is_empty() {
1000 return format!("Fetched {url}, but no readable text content was found.");
1001 }
1002
1003 let mut metadata = vec![format!("URL: {final_url}")];
1004 if let Some(title) = &title {
1005 metadata.push(format!("Title: {title}"));
1006 }
1007
1008 let body_text = match title {
1009 Some(title) if !cleaned.starts_with(&title) => cleaned,
1010 _ => cleaned,
1011 };
1012
1013 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
1014
1015 if output.len() > WEBSITE_READ_CHAR_LIMIT {
1016 let truncated: String = output.chars().take(WEBSITE_READ_CHAR_LIMIT).collect();
1017 return format!(
1018 "{truncated}\n\n--- truncated (showing {WEBSITE_READ_CHAR_LIMIT} of {} characters) ---",
1019 output.len()
1020 );
1021 }
1022
1023 output
1024 }
1025
1026 // ── create_directory ─────────────────────────────────────────────────────────
1027
1028 fn exec_create_directory(arguments: &str) -> String {
1029 let args: Value = match serde_json::from_str(arguments) {
1030 Ok(v) => v,
1031 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1032 };
1033
1034 let path_str = match args.get("path").and_then(Value::as_str) {
1035 Some(p) => p,
1036 None => return "Error: missing required parameter \"path\"".to_string(),
1037 };
1038
1039 let path = Path::new(path_str);
1040 let absolute_path = absolute_path(path);
1041 let absolute_path_str = absolute_path.display().to_string();
1042
1043 if absolute_path.exists() {
1044 if absolute_path.is_dir() {
1045 return format!("Directory already exists: {absolute_path_str}");
1046 }
1047 return format!("Error: path exists and is not a directory: {absolute_path_str}");
1048 }
1049
1050 match fs::create_dir_all(&absolute_path) {
1051 Ok(()) => format!("Created directory: {absolute_path_str}"),
1052 Err(err) => format!("Error: could not create directory: {err}"),
1053 }
1054 }
1055
1056 /// fails if file exists so the LLM is forced to use `edit_file` for modifications.
1057 fn exec_create_file(arguments: &str) -> String {
1058 let args: Value = match serde_json::from_str(arguments) {
1059 Ok(v) => v,
1060 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1061 };
1062
1063 let path_str = match args.get("path").and_then(Value::as_str) {
1064 Some(p) => p,
1065 None => return "Error: missing required parameter \"path\"".to_string(),
1066 };
1067
1068 let content = match args.get("content").and_then(Value::as_str) {
1069 Some(c) => c,
1070 None => return "Error: missing required parameter \"content\"".to_string(),
1071 };
1072
1073 let path = Path::new(path_str);
1074 let absolute_path = absolute_path(path);
1075 let absolute_path_str = absolute_path.display().to_string();
1076
1077 if absolute_path.exists() {
1078 return format!(
1079 "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files"
1080 );
1081 }
1082
1083 if let Some(parent) = absolute_path.parent()
1084 && !parent.as_os_str().is_empty()
1085 && !parent.exists()
1086 && let Err(err) = fs::create_dir_all(parent)
1087 {
1088 return format!("Error: could not create parent directories: {err}");
1089 }
1090
1091 match fs::write(&absolute_path, content) {
1092 Ok(()) => format!(
1093 "Created file: {absolute_path_str} ({} bytes)",
1094 content.len()
1095 ),
1096 Err(err) => format!("Error: could not write file: {err}"),
1097 }
1098 }
1099
1100 // ── edit_file / multi_edit ─────────────────────────────────────────────────
1101
1102 /// Apply one exact-substring replacement to `contents`. Returns the updated
1103 /// string, or a human-readable explanation of why the match failed so the model
1104 /// can correct itself in a single follow-up instead of guessing blindly.
1105 fn apply_edit(
1106 contents: &str,
1107 old_text: &str,
1108 new_text: &str,
1109 replace_all: bool,
1110 ) -> Result<String, String> {
1111 if old_text.is_empty() {
1112 return Err("old_text is empty; nothing to match".to_string());
1113 }
1114 if old_text == new_text {
1115 return Err("old_text and new_text are identical; no change to make".to_string());
1116 }
1117
1118 let occurrences = contents.matches(old_text).count();
1119
1120 if occurrences == 0 {
1121 return Err(format!(
1122 "old_text not found. Use read_file to copy the exact text \
1123 (including whitespace and indentation) to replace.{}",
1124 nearest_line_hint(contents, old_text)
1125 ));
1126 }
1127
1128 if occurrences > 1 && !replace_all {
1129 return Err(format!(
1130 "old_text appears {occurrences} times; include more surrounding context so it \
1131 matches exactly once, or set replace_all to true to change every occurrence."
1132 ));
1133 }
1134
1135 if replace_all {
1136 Ok(contents.replace(old_text, new_text))
1137 } else {
1138 Ok(contents.replacen(old_text, new_text, 1))
1139 }
1140 }
1141
1142 /// When `old_text` doesn't match verbatim, point at the line whose trimmed text
1143 /// equals the first trimmed line of `old_text` — the usual culprit is a
1144 /// whitespace/indentation mismatch, and naming the line lets the model fix it.
1145 fn nearest_line_hint(contents: &str, old_text: &str) -> String {
1146 let first = old_text.lines().find(|l| !l.trim().is_empty());
1147 let Some(first) = first.map(str::trim) else {
1148 return String::new();
1149 };
1150 for (idx, line) in contents.lines().enumerate() {
1151 if line.trim() == first {
1152 return format!(
1153 " (the first line of old_text appears at line {}, so the difference is likely \
1154 whitespace or indentation)",
1155 idx + 1
1156 );
1157 }
1158 }
1159 String::new()
1160 }
1161
1162 fn exec_edit_file(arguments: &str) -> String {
1163 let args: Value = match serde_json::from_str(arguments) {
1164 Ok(v) => v,
1165 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1166 };
1167
1168 let path_str = match args.get("path").and_then(Value::as_str) {
1169 Some(p) => p,
1170 None => return "Error: missing required parameter \"path\"".to_string(),
1171 };
1172
1173 let old_text = match args.get("old_text").and_then(Value::as_str) {
1174 Some(t) => t,
1175 None => return "Error: missing required parameter \"old_text\"".to_string(),
1176 };
1177
1178 let new_text = match args.get("new_text").and_then(Value::as_str) {
1179 Some(t) => t,
1180 None => return "Error: missing required parameter \"new_text\"".to_string(),
1181 };
1182
1183 let replace_all = args
1184 .get("replace_all")
1185 .and_then(Value::as_bool)
1186 .unwrap_or(false);
1187
1188 let path = Path::new(path_str);
1189 let absolute_path = absolute_path(path);
1190 let absolute_path_str = absolute_path.display().to_string();
1191
1192 if !absolute_path.exists() {
1193 return format!(
1194 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1195 );
1196 }
1197
1198 if !absolute_path.is_file() {
1199 return format!("Error: path is not a file: {absolute_path_str}");
1200 }
1201
1202 let contents = match fs::read_to_string(&absolute_path) {
1203 Ok(c) => c,
1204 Err(err) => return format!("Error: could not read file: {err}"),
1205 };
1206
1207 let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
1208 Ok(updated) => updated,
1209 Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
1210 };
1211
1212 match fs::write(&absolute_path, &updated) {
1213 Ok(()) => format!(
1214 "Edited file: {absolute_path_str} ({} bytes written)",
1215 updated.len()
1216 ),
1217 Err(err) => format!("Error: could not write file: {err}"),
1218 }
1219 }
1220
1221 /// Apply a batch of edits to one file atomically: each edit is applied to the
1222 /// result of the previous one, and the file is only written if *every* edit
1223 /// matches. A failure leaves the file untouched.
1224 fn exec_multi_edit(arguments: &str) -> String {
1225 let args: Value = match serde_json::from_str(arguments) {
1226 Ok(v) => v,
1227 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1228 };
1229
1230 let path_str = match args.get("path").and_then(Value::as_str) {
1231 Some(p) => p,
1232 None => return "Error: missing required parameter \"path\"".to_string(),
1233 };
1234
1235 let edits = match args.get("edits").and_then(Value::as_array) {
1236 Some(e) if !e.is_empty() => e,
1237 Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1238 None => return "Error: missing required parameter \"edits\"".to_string(),
1239 };
1240
1241 let path = Path::new(path_str);
1242 let absolute_path = absolute_path(path);
1243 let absolute_path_str = absolute_path.display().to_string();
1244
1245 if !absolute_path.exists() {
1246 return format!(
1247 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1248 );
1249 }
1250
1251 if !absolute_path.is_file() {
1252 return format!("Error: path is not a file: {absolute_path_str}");
1253 }
1254
1255 let mut working = match fs::read_to_string(&absolute_path) {
1256 Ok(c) => c,
1257 Err(err) => return format!("Error: could not read file: {err}"),
1258 };
1259
1260 for (idx, edit) in edits.iter().enumerate() {
1261 let old_text = match edit.get("old_text").and_then(Value::as_str) {
1262 Some(t) => t,
1263 None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1264 };
1265 let new_text = match edit.get("new_text").and_then(Value::as_str) {
1266 Some(t) => t,
1267 None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1268 };
1269 let replace_all = edit
1270 .get("replace_all")
1271 .and_then(Value::as_bool)
1272 .unwrap_or(false);
1273
1274 match apply_edit(&working, old_text, new_text, replace_all) {
1275 Ok(updated) => working = updated,
1276 Err(why) => {
1277 return format!(
1278 "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1279 idx + 1
1280 );
1281 }
1282 }
1283 }
1284
1285 match fs::write(&absolute_path, &working) {
1286 Ok(()) => format!(
1287 "Applied {} edits to {absolute_path_str} ({} bytes written)",
1288 edits.len(),
1289 working.len()
1290 ),
1291 Err(err) => format!("Error: could not write file: {err}"),
1292 }
1293 }
1294
1295 // ── glob ─────────────────────────────────────────────────────────────────────
1296
1297 /// Translate a shell-style glob into an anchored regex. Supports `*`
1298 /// (non-separator run), `**` (any number of directories), `?` (one
1299 /// non-separator), and `{a,b}` alternation; everything else is matched
1300 /// literally. Used both by the `glob` tool (against relative paths) and by
1301 /// `search_files`' `file_glob` filter (against bare file names).
1302 fn glob_to_regex(glob: &str) -> String {
1303 let chars: Vec<char> = glob.chars().collect();
1304 let mut re = String::from("^");
1305 let mut brace_depth = 0usize;
1306 let mut i = 0;
1307
1308 while i < chars.len() {
1309 let c = chars[i];
1310 match c {
1311 '*' => {
1312 if i + 1 < chars.len() && chars[i + 1] == '*' {
1313 i += 1; // consume the second '*'
1314 if i + 1 < chars.len() && chars[i + 1] == '/' {
1315 // `**/` matches zero or more leading directories.
1316 re.push_str("(?:.*/)?");
1317 i += 1; // consume the '/'
1318 } else {
1319 re.push_str(".*");
1320 }
1321 } else {
1322 re.push_str("[^/]*");
1323 }
1324 }
1325 '?' => re.push_str("[^/]"),
1326 '{' => {
1327 brace_depth += 1;
1328 re.push_str("(?:");
1329 }
1330 '}' if brace_depth > 0 => {
1331 brace_depth -= 1;
1332 re.push(')');
1333 }
1334 ',' if brace_depth > 0 => re.push('|'),
1335 // Escape regex metacharacters so they match literally. (`{` is always
1336 // consumed by the brace arm above; an unmatched `}` lands here.)
1337 '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1338 re.push('\\');
1339 re.push(c);
1340 }
1341 other => re.push(other),
1342 }
1343 i += 1;
1344 }
1345
1346 re.push('$');
1347 re
1348 }
1349
1350 fn exec_glob(arguments: &str) -> String {
1351 let args: Value = match serde_json::from_str(arguments) {
1352 Ok(v) => v,
1353 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1354 };
1355
1356 let pattern = match args.get("pattern").and_then(Value::as_str) {
1357 Some(p) => p,
1358 None => return "Error: missing required parameter \"pattern\"".to_string(),
1359 };
1360
1361 let re = match Regex::new(&glob_to_regex(pattern)) {
1362 Ok(r) => r,
1363 Err(err) => return format!("Error: invalid glob pattern: {err}"),
1364 };
1365
1366 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1367 let absolute_root = absolute_path(Path::new(root_str));
1368 let absolute_root_str = absolute_root.display().to_string();
1369
1370 if !absolute_root.exists() {
1371 return format!("Error: path does not exist: {absolute_root_str}");
1372 }
1373 if !absolute_root.is_dir() {
1374 return format!("Error: path is not a directory: {absolute_root_str}");
1375 }
1376
1377 let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1378 glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1379
1380 if found.is_empty() {
1381 return format!("No files match glob: {pattern}");
1382 }
1383
1384 // Most-recently-modified first.
1385 found.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
1386
1387 let total = found.len();
1388 let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1389 if total > SEARCH_RESULTS_HARD_CAP {
1390 paths.truncate(SEARCH_RESULTS_HARD_CAP);
1391 paths.push(format!(
1392 "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1393 ));
1394 }
1395
1396 paths.join("\n")
1397 }
1398
1399 fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1400 if out.len() > SEARCH_RESULTS_HARD_CAP {
1401 return;
1402 }
1403
1404 let entries = match fs::read_dir(dir) {
1405 Ok(rd) => rd,
1406 Err(_) => return,
1407 };
1408
1409 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1410 sorted.sort_by_key(|e| e.file_name());
1411
1412 for entry in sorted {
1413 if out.len() > SEARCH_RESULTS_HARD_CAP {
1414 return;
1415 }
1416
1417 let path = entry.path();
1418 let name = entry.file_name();
1419 if name.to_string_lossy().starts_with('.') {
1420 continue;
1421 }
1422
1423 if path.is_dir() {
1424 glob_walk(root, &path, re, out);
1425 } else if path.is_file() {
1426 let relative = path
1427 .strip_prefix(root)
1428 .unwrap_or(&path)
1429 .to_string_lossy()
1430 .replace('\\', "/");
1431 if re.is_match(&relative) {
1432 let mtime = entry
1433 .metadata()
1434 .and_then(|m| m.modified())
1435 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1436 out.push((mtime, absolute_path_string(&path)));
1437 }
1438 }
1439 }
1440 }
1441
1442 // ── write_todos ──────────────────────────────────────────────────────────────
1443
1444 /// Renders the model's task checklist back as the tool result so the surface
1445 /// (TUI / ACP client) can show live progress. Pure presentation — the list is
1446 /// owned by the model, not persisted here.
1447 fn exec_write_todos(arguments: &str) -> String {
1448 let args: Value = match serde_json::from_str(arguments) {
1449 Ok(v) => v,
1450 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1451 };
1452
1453 let todos = match args.get("todos").and_then(Value::as_array) {
1454 Some(t) if !t.is_empty() => t,
1455 Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1456 None => return "Error: missing required parameter \"todos\"".to_string(),
1457 };
1458
1459 let mut lines = Vec::with_capacity(todos.len());
1460 let mut completed = 0usize;
1461
1462 for (idx, todo) in todos.iter().enumerate() {
1463 let content = match todo.get("content").and_then(Value::as_str) {
1464 Some(c) => c.trim(),
1465 None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1466 };
1467 let status = todo
1468 .get("status")
1469 .and_then(Value::as_str)
1470 .unwrap_or("pending");
1471
1472 let marker = match status {
1473 "completed" => {
1474 completed += 1;
1475 "[x]"
1476 }
1477 "in_progress" => "[~]",
1478 _ => "[ ]",
1479 };
1480 lines.push(format!("{marker} {content}"));
1481 }
1482
1483 format!(
1484 "Task list updated ({completed}/{} done):\n{}",
1485 todos.len(),
1486 lines.join("\n")
1487 )
1488 }
1489
1490 // ── remember ─────────────────────────────────────────────────────────────────
1491
1492 /// Appends a durable note to the project's instruction file so it persists
1493 /// across sessions (the always-on counterpart to a one-off chat message).
1494 fn exec_remember(arguments: &str) -> String {
1495 let args: Value = match serde_json::from_str(arguments) {
1496 Ok(v) => v,
1497 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1498 };
1499
1500 let note = match args.get("note").and_then(Value::as_str) {
1501 Some(n) if !n.trim().is_empty() => n.trim(),
1502 Some(_) => return "Error: \"note\" must not be empty".to_string(),
1503 None => return "Error: missing required parameter \"note\"".to_string(),
1504 };
1505
1506 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1507 remember_at(&cwd, note)
1508 }
1509
1510 /// Core of `remember`, parameterized on the working directory so it can be
1511 /// tested without mutating the process-global current directory.
1512 fn remember_at(cwd: &Path, note: &str) -> String {
1513 let target = crate::instructions::memory_file(cwd);
1514 let target_str = target.display().to_string();
1515
1516 let existed = target.exists();
1517 let mut body = if existed {
1518 match fs::read_to_string(&target) {
1519 Ok(c) => c,
1520 Err(err) => return format!("Error: could not read {target_str}: {err}"),
1521 }
1522 } else {
1523 if let Some(parent) = target.parent()
1524 && !parent.as_os_str().is_empty()
1525 && !parent.exists()
1526 && let Err(err) = fs::create_dir_all(parent)
1527 {
1528 return format!("Error: could not create parent directories: {err}");
1529 }
1530 String::new()
1531 };
1532
1533 // Keep remembered notes grouped under one heading so the file stays tidy.
1534 const HEADING: &str = "## Remembered notes";
1535 if !body.contains(HEADING) {
1536 if !body.is_empty() && !body.ends_with('\n') {
1537 body.push('\n');
1538 }
1539 if !body.is_empty() {
1540 body.push('\n');
1541 }
1542 body.push_str(HEADING);
1543 body.push('\n');
1544 }
1545 if !body.ends_with('\n') {
1546 body.push('\n');
1547 }
1548 body.push_str("- ");
1549 body.push_str(note);
1550 body.push('\n');
1551
1552 match fs::write(&target, &body) {
1553 Ok(()) => {
1554 let verb = if existed { "Appended to" } else { "Created" };
1555 format!("{verb} {target_str}: remembered \"{note}\"")
1556 }
1557 Err(err) => format!("Error: could not write {target_str}: {err}"),
1558 }
1559 }
1560
1561 // ── delete_file ──────────────────────────────────────────────────────────────
1562
1563 /// only removes files or *empty* directories — no recursive deletes.
1564 fn exec_delete_file(arguments: &str) -> String {
1565 let args: Value = match serde_json::from_str(arguments) {
1566 Ok(v) => v,
1567 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1568 };
1569
1570 let path_str = match args.get("path").and_then(Value::as_str) {
1571 Some(p) => p,
1572 None => return "Error: missing required parameter \"path\"".to_string(),
1573 };
1574
1575 let path = Path::new(path_str);
1576 let absolute_path = absolute_path(path);
1577 let absolute_path_str = absolute_path.display().to_string();
1578
1579 if !absolute_path.exists() {
1580 return format!("Error: path does not exist: {absolute_path_str}");
1581 }
1582
1583 if absolute_path.is_dir() {
1584 match fs::remove_dir(&absolute_path) {
1585 Ok(()) => format!("Deleted empty directory: {absolute_path_str}"),
1586 Err(err) => format!(
1587 "Error: could not delete directory: {err}. \
1588 Only empty directories can be deleted."
1589 ),
1590 }
1591 } else {
1592 match fs::remove_file(&absolute_path) {
1593 Ok(()) => format!("Deleted file: {absolute_path_str}"),
1594 Err(err) => format!("Error: could not delete file: {err}"),
1595 }
1596 }
1597 }
1598
1599 // ── run_command ──────────────────────────────────────────────────────────────
1600
1601 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1602 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1603
1604 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT.
1605 fn exec_run_command(arguments: &str) -> String {
1606 let args: Value = match serde_json::from_str(arguments) {
1607 Ok(v) => v,
1608 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1609 };
1610
1611 let command_str = match args.get("command").and_then(Value::as_str) {
1612 Some(c) => c,
1613 None => return "Error: missing required parameter \"command\"".to_string(),
1614 };
1615
1616 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1617 let cwd = args
1618 .get("cwd")
1619 .and_then(Value::as_str)
1620 .unwrap_or(&default_cwd);
1621 let cwd_path = absolute_path(Path::new(cwd));
1622 let cwd_str = cwd_path.display().to_string();
1623
1624 if !cwd_path.exists() {
1625 return format!("Error: working directory does not exist: {cwd_str}");
1626 }
1627
1628 log::info!("run_command: `{command_str}` in `{cwd_str}`");
1629
1630 #[cfg(unix)]
1631 let mut child = match Command::new("sh")
1632 .arg("-c")
1633 .arg(command_str)
1634 .current_dir(&cwd_path)
1635 .stdout(std::process::Stdio::piped())
1636 .stderr(std::process::Stdio::piped())
1637 .spawn()
1638 {
1639 Ok(c) => c,
1640 Err(err) => return format!("Error: failed to spawn command: {err}"),
1641 };
1642
1643 #[cfg(windows)]
1644 let mut child = match Command::new("cmd")
1645 .arg("/C")
1646 .arg(command_str)
1647 .current_dir(&cwd_path)
1648 .stdout(std::process::Stdio::piped())
1649 .stderr(std::process::Stdio::piped())
1650 .spawn()
1651 {
1652 Ok(c) => c,
1653 Err(err) => return format!("Error: failed to spawn command: {err}"),
1654 };
1655
1656 let start = std::time::Instant::now();
1657 loop {
1658 match child.try_wait() {
1659 Ok(Some(_status)) => break,
1660 Ok(None) => {
1661 if start.elapsed() >= COMMAND_TIMEOUT {
1662 let _ = child.kill();
1663 return format!(
1664 "Error: command timed out after {} seconds and was killed.",
1665 COMMAND_TIMEOUT.as_secs()
1666 );
1667 }
1668 std::thread::sleep(std::time::Duration::from_millis(100));
1669 }
1670 Err(err) => return format!("Error: failed to wait on command: {err}"),
1671 }
1672 }
1673
1674 let output = match child.wait_with_output() {
1675 Ok(o) => o,
1676 Err(err) => return format!("Error: failed to read command output: {err}"),
1677 };
1678
1679 let exit_code = output.status.code().unwrap_or(-1);
1680 let mut combined = String::new();
1681 combined.push_str(&String::from_utf8_lossy(&output.stdout));
1682 combined.push_str(&String::from_utf8_lossy(&output.stderr));
1683
1684 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
1685 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
1686 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
1687 } else {
1688 combined
1689 };
1690
1691 if output.status.success() {
1692 if truncated.is_empty() {
1693 format!("Command succeeded (exit code {exit_code}) with no output.")
1694 } else {
1695 format!("Exit code {exit_code}:\n{truncated}")
1696 }
1697 } else {
1698 format!("Command failed (exit code {exit_code}):\n{truncated}")
1699 }
1700 }
1701
1702 #[cfg(test)]
1703 mod tests {
1704 use super::*;
1705 use std::fs;
1706
1707 #[tokio::test]
1708 async fn test_execute_unknown_tool() {
1709 let result = execute_tool("nonexistent", "{}").await;
1710 assert!(result.starts_with("Unknown tool:"));
1711 }
1712
1713 #[test]
1714 fn test_read_file_missing_path_param() {
1715 let result = exec_read_file("{}");
1716 assert!(result.contains("missing required parameter"));
1717 }
1718
1719 #[test]
1720 fn test_read_file_nonexistent() {
1721 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
1722 assert!(result.contains("does not exist"));
1723 }
1724
1725 #[test]
1726 fn test_read_file_success() {
1727 let dir = std::env::temp_dir().join("sigit_test_read_file");
1728 let _ = fs::create_dir_all(&dir);
1729 let file_path = dir.join("hello.txt");
1730 fs::write(&file_path, "hello world").unwrap();
1731
1732 let args = serde_json::json!({ "path": file_path }).to_string();
1733 let result = exec_read_file(&args);
1734 assert_eq!(result, "hello world");
1735
1736 let _ = fs::remove_dir_all(&dir);
1737 }
1738
1739 #[test]
1740 fn test_list_directory_missing_path_param() {
1741 let result = exec_list_directory("{}");
1742 assert!(result.contains("missing required parameter"));
1743 }
1744
1745 #[test]
1746 fn test_list_directory_success() {
1747 let dir = std::env::temp_dir().join("sigit_test_list_dir");
1748 let _ = fs::remove_dir_all(&dir);
1749 fs::create_dir_all(dir.join("subdir")).unwrap();
1750 fs::write(dir.join("aaa.txt"), "").unwrap();
1751 fs::write(dir.join("bbb.rs"), "").unwrap();
1752
1753 let args = serde_json::json!({ "path": dir }).to_string();
1754 let result = exec_list_directory(&args);
1755
1756 assert!(result.contains("[DIR] subdir"));
1757 assert!(result.contains("[FILE] aaa.txt"));
1758 assert!(result.contains("[FILE] bbb.rs"));
1759
1760 // Directories should appear before files.
1761 let dir_pos = result.find("[DIR]").unwrap();
1762 let file_pos = result.find("[FILE]").unwrap();
1763 assert!(dir_pos < file_pos);
1764
1765 let _ = fs::remove_dir_all(&dir);
1766 }
1767
1768 #[test]
1769 fn test_search_files_invalid_regex() {
1770 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
1771 assert!(result.contains("invalid regex"));
1772 }
1773
1774 #[test]
1775 fn test_search_files_success() {
1776 let dir = std::env::temp_dir().join("sigit_test_search");
1777 let _ = fs::remove_dir_all(&dir);
1778 fs::create_dir_all(&dir).unwrap();
1779 fs::write(
1780 dir.join("code.rs"),
1781 "fn main() {\n println!(\"hello\");\n}\n",
1782 )
1783 .unwrap();
1784 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
1785
1786 let args = serde_json::json!({
1787 "pattern": "println",
1788 "path": dir
1789 })
1790 .to_string();
1791 let result = exec_search_files(&args);
1792
1793 assert!(result.contains("code.rs:2:"));
1794 assert!(result.contains("println"));
1795 assert!(!result.contains("other.txt"));
1796
1797 let _ = fs::remove_dir_all(&dir);
1798 }
1799
1800 #[test]
1801 fn test_search_files_no_matches() {
1802 let dir = std::env::temp_dir().join("sigit_test_search_none");
1803 let _ = fs::remove_dir_all(&dir);
1804 fs::create_dir_all(&dir).unwrap();
1805 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
1806
1807 let args = serde_json::json!({
1808 "pattern": "zzz_will_not_match_42",
1809 "path": dir
1810 })
1811 .to_string();
1812 let result = exec_search_files(&args);
1813 assert!(result.contains("No matches found"));
1814
1815 let _ = fs::remove_dir_all(&dir);
1816 }
1817
1818 #[test]
1819 fn test_all_tools_count() {
1820 let tools = all_tools();
1821 assert_eq!(tools.len(), 13);
1822 assert_eq!(tools[0].name, "read_file");
1823 assert_eq!(tools[1].name, "create_directory");
1824 assert_eq!(tools[2].name, "list_directory");
1825 assert_eq!(tools[3].name, "search_files");
1826 assert_eq!(tools[4].name, "read_website");
1827 assert_eq!(tools[5].name, "create_file");
1828 assert_eq!(tools[6].name, "edit_file");
1829 assert_eq!(tools[7].name, "delete_file");
1830 assert_eq!(tools[8].name, "run_command");
1831 assert_eq!(tools[9].name, "multi_edit");
1832 assert_eq!(tools[10].name, "glob");
1833 assert_eq!(tools[11].name, "write_todos");
1834 assert_eq!(tools[12].name, "remember");
1835 }
1836
1837 #[test]
1838 fn test_edit_file_replace_all() {
1839 let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
1840 let _ = fs::remove_dir_all(&dir);
1841 fs::create_dir_all(&dir).unwrap();
1842 let file = dir.join("f.txt");
1843 fs::write(&file, "foo foo foo").unwrap();
1844
1845 // Without replace_all an ambiguous match is rejected.
1846 let args =
1847 serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
1848 let result = exec_edit_file(&args);
1849 assert!(result.contains("appears 3 times"), "{result}");
1850
1851 // With replace_all every occurrence is changed.
1852 let args = serde_json::json!({
1853 "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
1854 })
1855 .to_string();
1856 let result = exec_edit_file(&args);
1857 assert!(result.starts_with("Edited file:"), "{result}");
1858 assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
1859
1860 let _ = fs::remove_dir_all(&dir);
1861 }
1862
1863 #[test]
1864 fn test_edit_file_whitespace_hint() {
1865 let dir = std::env::temp_dir().join("sigit_test_edit_hint");
1866 let _ = fs::remove_dir_all(&dir);
1867 fs::create_dir_all(&dir).unwrap();
1868 let file = dir.join("f.txt");
1869 fs::write(&file, "line one\n indented\nline three\n").unwrap();
1870
1871 // old_text has more indentation than the file, so it isn't a substring,
1872 // but its trimmed content still locates the intended line.
1873 let args = serde_json::json!({
1874 "path": &file, "old_text": " indented", "new_text": "x"
1875 })
1876 .to_string();
1877 let result = exec_edit_file(&args);
1878 assert!(result.contains("line 2"), "{result}");
1879 assert!(result.contains("whitespace"), "{result}");
1880
1881 let _ = fs::remove_dir_all(&dir);
1882 }
1883
1884 #[test]
1885 fn test_multi_edit_atomic_on_failure() {
1886 let dir = std::env::temp_dir().join("sigit_test_multi_edit");
1887 let _ = fs::remove_dir_all(&dir);
1888 fs::create_dir_all(&dir).unwrap();
1889 let file = dir.join("f.txt");
1890 fs::write(&file, "alpha beta gamma").unwrap();
1891
1892 // Second edit can't match -> nothing should be written.
1893 let args = serde_json::json!({
1894 "path": &file,
1895 "edits": [
1896 { "old_text": "alpha", "new_text": "ALPHA" },
1897 { "old_text": "nope", "new_text": "x" }
1898 ]
1899 })
1900 .to_string();
1901 let result = exec_multi_edit(&args);
1902 assert!(result.contains("edit #2 failed"), "{result}");
1903 assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
1904
1905 // All-matching batch applies in sequence.
1906 let args = serde_json::json!({
1907 "path": &file,
1908 "edits": [
1909 { "old_text": "alpha", "new_text": "ALPHA" },
1910 { "old_text": "gamma", "new_text": "GAMMA" }
1911 ]
1912 })
1913 .to_string();
1914 let result = exec_multi_edit(&args);
1915 assert!(result.contains("Applied 2 edits"), "{result}");
1916 assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
1917
1918 let _ = fs::remove_dir_all(&dir);
1919 }
1920
1921 #[test]
1922 fn test_glob_to_regex() {
1923 let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
1924 assert!(re.is_match("src/tools.rs"));
1925 assert!(re.is_match("main.rs")); // `**/` matches zero directories too
1926 assert!(!re.is_match("src/tools.txt"));
1927
1928 let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
1929 assert!(re.is_match("app.ts"));
1930 assert!(re.is_match("app.tsx"));
1931 assert!(!re.is_match("app.js"));
1932 }
1933
1934 #[test]
1935 fn test_glob_tool_success() {
1936 let dir = std::env::temp_dir().join("sigit_test_glob");
1937 let _ = fs::remove_dir_all(&dir);
1938 fs::create_dir_all(dir.join("src")).unwrap();
1939 fs::write(dir.join("Cargo.toml"), "").unwrap();
1940 fs::write(dir.join("src/main.rs"), "").unwrap();
1941 fs::write(dir.join("src/lib.rs"), "").unwrap();
1942
1943 let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
1944 let result = exec_glob(&args);
1945 assert!(result.contains("main.rs"), "{result}");
1946 assert!(result.contains("lib.rs"), "{result}");
1947 assert!(!result.contains("Cargo.toml"), "{result}");
1948
1949 let _ = fs::remove_dir_all(&dir);
1950 }
1951
1952 #[test]
1953 fn test_search_files_file_glob_filter() {
1954 let dir = std::env::temp_dir().join("sigit_test_search_glob");
1955 let _ = fs::remove_dir_all(&dir);
1956 fs::create_dir_all(&dir).unwrap();
1957 fs::write(dir.join("code.rs"), "needle here\n").unwrap();
1958 fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
1959
1960 let args = serde_json::json!({
1961 "pattern": "needle", "path": &dir, "file_glob": "*.rs"
1962 })
1963 .to_string();
1964 let result = exec_search_files(&args);
1965 assert!(result.contains("code.rs"), "{result}");
1966 assert!(!result.contains("notes.txt"), "{result}");
1967
1968 let _ = fs::remove_dir_all(&dir);
1969 }
1970
1971 #[test]
1972 fn test_write_todos_renders_checklist() {
1973 let args = serde_json::json!({
1974 "todos": [
1975 { "content": "Read code", "status": "completed" },
1976 { "content": "Make change", "status": "in_progress" },
1977 { "content": "Run tests", "status": "pending" }
1978 ]
1979 })
1980 .to_string();
1981 let result = exec_write_todos(&args);
1982 assert!(result.contains("1/3 done"), "{result}");
1983 assert!(result.contains("[x] Read code"), "{result}");
1984 assert!(result.contains("[~] Make change"), "{result}");
1985 assert!(result.contains("[ ] Run tests"), "{result}");
1986 }
1987
1988 #[test]
1989 fn test_remember_appends_to_instruction_file() {
1990 let dir = std::env::temp_dir().join("sigit_test_remember");
1991 let _ = fs::remove_dir_all(&dir);
1992 fs::create_dir_all(dir.join(".git")).unwrap();
1993 let claude_md = dir.join("CLAUDE.md");
1994 fs::write(&claude_md, "# Project\n").unwrap();
1995
1996 let target = crate::instructions::memory_file(&dir);
1997 // Should pick the existing CLAUDE.md at the repo root.
1998 assert_eq!(
1999 target.canonicalize().unwrap(),
2000 claude_md.canonicalize().unwrap()
2001 );
2002
2003 let result = remember_at(&dir, "remembered text");
2004 assert!(result.contains("remembered"), "{result}");
2005
2006 let updated = fs::read_to_string(&claude_md).unwrap();
2007 assert!(updated.contains("## Remembered notes"), "{updated}");
2008 assert!(updated.contains("- remembered text"), "{updated}");
2009
2010 let _ = fs::remove_dir_all(&dir);
2011 }
2012
2013 #[test]
2014 fn test_all_tools_schemas_are_valid_json_objects() {
2015 for tool in all_tools() {
2016 assert!(
2017 tool.parameters_schema.is_object(),
2018 "schema for {} is not an object",
2019 tool.name
2020 );
2021 let obj = tool.parameters_schema.as_object().unwrap();
2022 assert!(obj.contains_key("type"));
2023 assert!(obj.contains_key("properties"));
2024 assert!(obj.contains_key("required"));
2025 }
2026 }
2027
2028 // ── read_website tests ───────────────────────────────────────────────
2029
2030 #[test]
2031 fn test_read_website_missing_url() {
2032 let result = exec_read_website("{}");
2033 assert!(result.contains("missing required parameter"));
2034 }
2035
2036 #[test]
2037 fn test_read_website_invalid_scheme() {
2038 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
2039 assert!(result.contains("url must start with http:// or https://"));
2040 }
2041
2042 #[test]
2043 fn test_read_website_extracts_title_from_html() {
2044 let body = r#"
2045 <html>
2046 <head>
2047 <title>Qwen 3.6 27B</title>
2048 </head>
2049 <body>
2050 <h1>Model card</h1>
2051 <p>Large language model.</p>
2052 </body>
2053 </html>
2054 "#;
2055
2056 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
2057 .unwrap()
2058 .captures(body)
2059 .and_then(|captures| captures.get(1))
2060 .map(|m| {
2061 Regex::new(r"\s+")
2062 .unwrap()
2063 .replace_all(m.as_str(), " ")
2064 .trim()
2065 .to_string()
2066 })
2067 .filter(|title| !title.is_empty());
2068
2069 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
2070 }
2071
2072 #[test]
2073 fn test_read_website_metadata_includes_final_url_header() {
2074 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
2075 let title = Some("Qwen 3.6 27B".to_string());
2076 let cleaned = "Model card\nLarge language model.".to_string();
2077
2078 let mut metadata = vec![format!("URL: {final_url}")];
2079 if let Some(title) = &title {
2080 metadata.push(format!("Title: {title}"));
2081 }
2082
2083 let body_text = match title {
2084 Some(_) => cleaned,
2085 None => cleaned,
2086 };
2087
2088 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
2089
2090 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
2091 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
2092 }
2093
2094 // ── create_directory tests ───────────────────────────────────────────
2095
2096 #[test]
2097 fn test_create_directory_missing_path() {
2098 let result = exec_create_directory("{}");
2099 assert!(result.contains("missing required parameter"));
2100 }
2101
2102 #[test]
2103 fn test_create_directory_success() {
2104 let dir = std::env::temp_dir()
2105 .join("sigit_test_create_directory")
2106 .join("nested")
2107 .join("child");
2108 let _ = fs::remove_dir_all(dir.parent().unwrap());
2109
2110 let args = serde_json::json!({ "path": dir }).to_string();
2111 let result = exec_create_directory(&args);
2112 assert!(result.starts_with("Created directory:"), "got: {result}");
2113 assert!(dir.exists());
2114 assert!(dir.is_dir());
2115
2116 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
2117 }
2118
2119 #[test]
2120 fn test_create_directory_already_exists() {
2121 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
2122 let _ = fs::remove_dir_all(&dir);
2123 fs::create_dir_all(&dir).unwrap();
2124
2125 let args = serde_json::json!({ "path": dir }).to_string();
2126 let result = exec_create_directory(&args);
2127 assert!(result.contains("Directory already exists"), "got: {result}");
2128
2129 let _ = fs::remove_dir_all(&dir);
2130 }
2131
2132 // ── create_file tests ────────────────────────────────────────────────
2133
2134 #[test]
2135 fn test_create_file_missing_path() {
2136 let result = exec_create_file(r#"{"content": "hello"}"#);
2137 assert!(result.contains("missing required parameter"));
2138 }
2139
2140 #[test]
2141 fn test_create_file_missing_content() {
2142 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
2143 assert!(result.contains("missing required parameter"));
2144 }
2145
2146 #[test]
2147 fn test_create_file_success() {
2148 let dir = std::env::temp_dir().join("sigit_test_create_file");
2149 let _ = fs::remove_dir_all(&dir);
2150
2151 let file_path = dir.join("sub").join("new_file.txt");
2152 let args = serde_json::json!({
2153 "path": file_path,
2154 "content": "hello world"
2155 })
2156 .to_string();
2157
2158 let result = exec_create_file(&args);
2159 assert!(result.starts_with("Created file:"), "got: {result}");
2160 assert!(file_path.exists());
2161 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
2162
2163 let _ = fs::remove_dir_all(&dir);
2164 }
2165
2166 #[test]
2167 fn test_create_file_already_exists() {
2168 let dir = std::env::temp_dir().join("sigit_test_create_exists");
2169 let _ = fs::remove_dir_all(&dir);
2170 fs::create_dir_all(&dir).unwrap();
2171
2172 let file_path = dir.join("existing.txt");
2173 fs::write(&file_path, "original").unwrap();
2174
2175 let args = serde_json::json!({
2176 "path": file_path,
2177 "content": "overwrite attempt"
2178 })
2179 .to_string();
2180
2181 let result = exec_create_file(&args);
2182 assert!(result.contains("already exists"), "got: {result}");
2183 // Original content untouched.
2184 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2185
2186 let _ = fs::remove_dir_all(&dir);
2187 }
2188
2189 // ── edit_file tests ──────────────────────────────────────────────────
2190
2191 #[test]
2192 fn test_edit_file_missing_params() {
2193 let result = exec_edit_file(r#"{"path": "x"}"#);
2194 assert!(result.contains("missing required parameter"));
2195
2196 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
2197 assert!(result.contains("missing required parameter"));
2198 }
2199
2200 #[test]
2201 fn test_edit_file_nonexistent() {
2202 let result = exec_edit_file(
2203 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
2204 );
2205 assert!(result.contains("does not exist"));
2206 }
2207
2208 #[test]
2209 fn test_edit_file_success() {
2210 let dir = std::env::temp_dir().join("sigit_test_edit_file");
2211 let _ = fs::remove_dir_all(&dir);
2212 fs::create_dir_all(&dir).unwrap();
2213
2214 let file_path = dir.join("code.rs");
2215 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
2216
2217 let args = serde_json::json!({
2218 "path": file_path,
2219 "old_text": "println!(\"hello\")",
2220 "new_text": "println!(\"world\")"
2221 })
2222 .to_string();
2223
2224 let result = exec_edit_file(&args);
2225 assert!(result.starts_with("Edited file:"), "got: {result}");
2226
2227 let updated = fs::read_to_string(&file_path).unwrap();
2228 assert!(updated.contains("println!(\"world\")"));
2229 assert!(!updated.contains("println!(\"hello\")"));
2230
2231 let _ = fs::remove_dir_all(&dir);
2232 }
2233
2234 #[test]
2235 fn test_edit_file_old_text_not_found() {
2236 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
2237 let _ = fs::remove_dir_all(&dir);
2238 fs::create_dir_all(&dir).unwrap();
2239
2240 let file_path = dir.join("data.txt");
2241 fs::write(&file_path, "aaa bbb ccc").unwrap();
2242
2243 let args = serde_json::json!({
2244 "path": file_path,
2245 "old_text": "zzz",
2246 "new_text": "yyy"
2247 })
2248 .to_string();
2249
2250 let result = exec_edit_file(&args);
2251 assert!(result.contains("old_text not found"), "got: {result}");
2252
2253 let _ = fs::remove_dir_all(&dir);
2254 }
2255
2256 #[test]
2257 fn test_edit_file_ambiguous_match() {
2258 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
2259 let _ = fs::remove_dir_all(&dir);
2260 fs::create_dir_all(&dir).unwrap();
2261
2262 let file_path = dir.join("repeat.txt");
2263 fs::write(&file_path, "foo bar foo bar foo").unwrap();
2264
2265 let args = serde_json::json!({
2266 "path": file_path,
2267 "old_text": "foo",
2268 "new_text": "baz"
2269 })
2270 .to_string();
2271
2272 let result = exec_edit_file(&args);
2273 assert!(result.contains("appears 3 times"), "got: {result}");
2274 // File should be unchanged.
2275 assert_eq!(
2276 fs::read_to_string(&file_path).unwrap(),
2277 "foo bar foo bar foo"
2278 );
2279
2280 let _ = fs::remove_dir_all(&dir);
2281 }
2282
2283 // ── delete_file tests ────────────────────────────────────────────────
2284
2285 #[test]
2286 fn test_delete_file_missing_path() {
2287 let result = exec_delete_file("{}");
2288 assert!(
2289 result.contains("missing required parameter"),
2290 "got: {result}"
2291 );
2292 }
2293
2294 #[test]
2295 fn test_delete_file_nonexistent() {
2296 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
2297 assert!(result.contains("does not exist"), "got: {result}");
2298 }
2299
2300 #[test]
2301 fn test_delete_file_success() {
2302 let dir = std::env::temp_dir().join("sigit_test_delete_file");
2303 let _ = fs::remove_dir_all(&dir);
2304 fs::create_dir_all(&dir).unwrap();
2305
2306 let file_path = dir.join("to_delete.txt");
2307 fs::write(&file_path, "bye").unwrap();
2308 assert!(file_path.exists());
2309
2310 let args = serde_json::json!({ "path": file_path }).to_string();
2311 let result = exec_delete_file(&args);
2312 assert!(result.contains("Deleted file"), "got: {result}");
2313 assert!(!file_path.exists());
2314
2315 let _ = fs::remove_dir_all(&dir);
2316 }
2317
2318 #[test]
2319 fn test_delete_empty_directory() {
2320 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
2321 let _ = fs::remove_dir_all(&dir);
2322 fs::create_dir_all(&dir).unwrap();
2323
2324 let args = serde_json::json!({ "path": dir }).to_string();
2325 let result = exec_delete_file(&args);
2326 assert!(result.contains("Deleted empty directory"), "got: {result}");
2327 assert!(!dir.exists());
2328 }
2329
2330 #[test]
2331 fn test_delete_nonempty_directory() {
2332 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
2333 let _ = fs::remove_dir_all(&dir);
2334 fs::create_dir_all(&dir).unwrap();
2335 fs::write(dir.join("child.txt"), "content").unwrap();
2336
2337 let args = serde_json::json!({ "path": dir }).to_string();
2338 let result = exec_delete_file(&args);
2339 assert!(result.contains("Error"), "got: {result}");
2340 assert!(dir.exists(), "directory should not have been deleted");
2341
2342 let _ = fs::remove_dir_all(&dir);
2343 }
2344
2345 // ── run_command tests ────────────────────────────────────────────────
2346
2347 #[test]
2348 fn test_run_command_missing_command() {
2349 let result = exec_run_command("{}");
2350 assert!(
2351 result.contains("missing required parameter"),
2352 "got: {result}"
2353 );
2354 }
2355
2356 #[test]
2357 fn test_run_command_success() {
2358 let result = exec_run_command(r#"{"command": "echo hello"}"#);
2359 assert!(result.contains("hello"), "got: {result}");
2360 assert!(result.contains("Exit code 0"), "got: {result}");
2361 }
2362
2363 #[test]
2364 fn test_run_command_failure() {
2365 #[cfg(unix)]
2366 let command = "false";
2367 #[cfg(windows)]
2368 let command = "exit /b 1";
2369
2370 let args = serde_json::json!({ "command": command }).to_string();
2371 let result = exec_run_command(&args);
2372 assert!(result.contains("failed"), "got: {result}");
2373 }
2374
2375 #[test]
2376 fn test_run_command_with_cwd() {
2377 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
2378 let _ = fs::remove_dir_all(&dir);
2379 fs::create_dir_all(&dir).unwrap();
2380
2381 #[cfg(unix)]
2382 let command = "pwd";
2383 #[cfg(windows)]
2384 let command = "cd";
2385
2386 let args = serde_json::json!({
2387 "command": command,
2388 "cwd": dir
2389 })
2390 .to_string();
2391 let result = exec_run_command(&args);
2392 // The output should contain the temp dir path.
2393 assert!(
2394 result.contains(&dir.to_string_lossy().to_string()),
2395 "got: {result}"
2396 );
2397
2398 let _ = fs::remove_dir_all(&dir);
2399 }
2400
2401 #[test]
2402 fn test_run_command_bad_cwd() {
2403 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
2404 let _ = fs::remove_dir_all(&missing_dir);
2405
2406 let args = serde_json::json!({
2407 "command": "echo hi",
2408 "cwd": missing_dir
2409 })
2410 .to_string();
2411 let result = exec_run_command(&args);
2412 assert!(result.contains("does not exist"), "got: {result}");
2413 }
2414
2415 #[test]
2416 fn test_run_command_captures_stderr() {
2417 #[cfg(unix)]
2418 let command = "echo err >&2";
2419 #[cfg(windows)]
2420 let command = "echo err 1>&2";
2421
2422 let args = serde_json::json!({ "command": command }).to_string();
2423 let result = exec_run_command(&args);
2424 assert!(result.contains("err"), "got: {result}");
2425 }
2426
2427 // ── task (subagent) tests ────────────────────────────────────────────
2428
2429 #[test]
2430 fn test_subagent_toolset_is_read_only() {
2431 let specs = subagent_tool_specs();
2432 let mut names: Vec<&str> = specs.iter().map(|spec| spec.name.as_str()).collect();
2433 names.sort_unstable();
2434 assert_eq!(
2435 names,
2436 [
2437 "glob",
2438 "list_directory",
2439 "read_file",
2440 "read_website",
2441 "search_files"
2442 ]
2443 );
2444
2445 // No recursion and no mutating tools, ever.
2446 assert!(!names.contains(&TASK_TOOL_NAME));
2447 for banned in [
2448 "create_file",
2449 "create_directory",
2450 "edit_file",
2451 "multi_edit",
2452 "delete_file",
2453 "run_command",
2454 "write_todos",
2455 "remember",
2456 ] {
2457 assert!(!names.contains(&banned), "{banned} leaked into subagent");
2458 }
2459
2460 // Every spec came from `all_tools()` (schemas intact).
2461 for spec in &specs {
2462 assert!(
2463 serde_json::from_str::<Value>(&spec.parameters_schema)
2464 .unwrap()
2465 .is_object()
2466 );
2467 }
2468 }
2469
2470 #[tokio::test]
2471 async fn test_task_reports_unavailable_without_factory() {
2472 let args = serde_json::json!({
2473 "description": "look around",
2474 "prompt": "What is in the current directory?"
2475 })
2476 .to_string();
2477 // `None` is exactly what `exec_task` passes before any surface has
2478 // registered the process-global factory.
2479 let result = exec_task_with(&args, None).await;
2480 assert!(
2481 result.contains("not available on-device yet"),
2482 "got: {result}"
2483 );
2484 }
2485
2486 #[tokio::test]
2487 async fn test_task_missing_prompt() {
2488 let result = exec_task_with(r#"{"description": "x"}"#, None).await;
2489 assert!(
2490 result.contains("missing required parameter \"prompt\""),
2491 "got: {result}"
2492 );
2493 }
2494
2495 // ── task (subagent) end-to-end against a scripted endpoint ──────────
2496
2497 /// A completion whose assistant message requests one tool call.
2498 fn completion_tool_call(id: &str, name: &str, arguments: &str) -> String {
2499 serde_json::json!({
2500 "choices": [{"message": {
2501 "role": "assistant",
2502 "content": serde_json::Value::Null,
2503 "tool_calls": [{
2504 "id": id,
2505 "type": "function",
2506 "function": {"name": name, "arguments": arguments},
2507 }],
2508 }}]
2509 })
2510 .to_string()
2511 }
2512
2513 /// A completion whose assistant message is a plain text answer.
2514 fn completion_text(text: &str) -> String {
2515 serde_json::json!({
2516 "choices": [{"message": {"role": "assistant", "content": text}}]
2517 })
2518 .to_string()
2519 }
2520
2521 /// Minimal scripted OpenAI-compatible endpoint (same pattern as
2522 /// `tests/acp_permissions.rs`): serves one canned chat-completion JSON body
2523 /// per request and records each request body. The subagent loop passes
2524 /// `sink: None`, so the backend takes the non-streaming path and expects
2525 /// plain JSON rather than SSE.
2526 fn start_scripted_endpoint(responses: Vec<String>) -> (u16, Arc<std::sync::Mutex<Vec<Value>>>) {
2527 use std::io::{BufRead, BufReader, Read, Write};
2528
2529 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind endpoint");
2530 let port = listener.local_addr().unwrap().port();
2531 let requests: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
2532 let recorded = Arc::clone(&requests);
2533 let queue = std::sync::Mutex::new(std::collections::VecDeque::from(responses));
2534
2535 std::thread::spawn(move || {
2536 // `connection: close` means one request per connection, matching
2537 // the backend's serial completion requests.
2538 for stream in listener.incoming() {
2539 let Ok(mut stream) = stream else { continue };
2540 let mut reader = BufReader::new(match stream.try_clone() {
2541 Ok(clone) => clone,
2542 Err(_) => continue,
2543 });
2544 let mut content_length = 0usize;
2545 loop {
2546 let mut line = String::new();
2547 if reader.read_line(&mut line).unwrap_or(0) == 0 {
2548 break;
2549 }
2550 let line = line.trim();
2551 if line.is_empty() {
2552 break;
2553 }
2554 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:")
2555 {
2556 content_length = length.trim().parse().unwrap_or(0);
2557 }
2558 }
2559 let mut body = vec![0u8; content_length];
2560 if reader.read_exact(&mut body).is_err() {
2561 continue;
2562 }
2563 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
2564 recorded.lock().unwrap().push(request);
2565 }
2566 let payload = queue
2567 .lock()
2568 .unwrap()
2569 .pop_front()
2570 .unwrap_or_else(|| completion_text("out of scripted responses"));
2571 let response = format!(
2572 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
2573 content-length: {}\r\nconnection: close\r\n\r\n{}",
2574 payload.len(),
2575 payload
2576 );
2577 let _ = stream.write_all(response.as_bytes());
2578 }
2579 });
2580
2581 (port, requests)
2582 }
2583
2584 #[tokio::test]
2585 async fn test_task_runs_subagent_end_to_end() {
2586 // A file only the subagent's read_file call can surface.
2587 let dir = std::env::temp_dir().join("sigit_test_subagent_e2e");
2588 let _ = fs::remove_dir_all(&dir);
2589 fs::create_dir_all(&dir).unwrap();
2590 let file = dir.join("notes.txt");
2591 fs::write(&file, "subagent secret: 4217").unwrap();
2592
2593 // Script: one read_file tool call, then a final text answer.
2594 let (port, requests) = start_scripted_endpoint(vec![
2595 completion_tool_call(
2596 "call_1",
2597 "read_file",
2598 &serde_json::json!({ "path": file }).to_string(),
2599 ),
2600 completion_text("The file contains the number 4217."),
2601 ]);
2602
2603 // Register the real process-global factory, pointing a fresh
2604 // OpenAiBackend at the scripted endpoint — exactly what the surfaces
2605 // do at startup. This is the only test that touches the OnceLock.
2606 set_subagent_factory(Box::new(move || {
2607 Some(Arc::new(crate::backend::OpenAiBackend::new(
2608 format!("http://127.0.0.1:{port}"),
2609 "test-key",
2610 "scripted-model",
2611 Some(SUBAGENT_SYSTEM_PROMPT.to_string()),
2612 )) as Arc<dyn InferenceBackend>)
2613 }));
2614 assert!(subagent_available());
2615
2616 let args = serde_json::json!({
2617 "description": "read the notes file",
2618 "prompt": format!("What number is recorded in {}?", file.display()),
2619 })
2620 .to_string();
2621 let result = execute_tool(TASK_TOOL_NAME, &args).await;
2622
2623 // Only the subagent's final text comes back.
2624 assert_eq!(result, "The file contains the number 4217.");
2625
2626 let recorded = requests.lock().unwrap();
2627 assert_eq!(recorded.len(), 2, "expected exactly two completions");
2628
2629 // The subagent conversation is fresh (subagent system prompt) and was
2630 // offered only the read-only toolset.
2631 let first = &recorded[0];
2632 assert_eq!(first["messages"][0]["role"], "system");
2633 assert!(
2634 first["messages"][0]["content"]
2635 .as_str()
2636 .unwrap()
2637 .contains("research subagent")
2638 );
2639 let offered: Vec<&str> = first["tools"]
2640 .as_array()
2641 .unwrap()
2642 .iter()
2643 .map(|tool| tool["function"]["name"].as_str().unwrap())
2644 .collect();
2645 for name in &offered {
2646 assert!(
2647 SUBAGENT_TOOL_NAMES.contains(name),
2648 "non-read-only tool offered: {name}"
2649 );
2650 }
2651 assert!(!offered.contains(&TASK_TOOL_NAME));
2652
2653 // The read-only tool actually executed: its output travelled back to
2654 // the endpoint as a tool result on the second request.
2655 let messages = recorded[1]["messages"].as_array().unwrap();
2656 let tool_message = messages
2657 .iter()
2658 .find(|message| message["role"] == "tool")
2659 .expect("no tool result in second request");
2660 assert_eq!(tool_message["tool_call_id"], "call_1");
2661 assert!(
2662 tool_message["content"]
2663 .as_str()
2664 .unwrap()
2665 .contains("subagent secret: 4217")
2666 );
2667
2668 let _ = fs::remove_dir_all(&dir);
2669 }
2670 }