1 //! Agent tools: schema definitions + execution for siGit Code.
2
3 use regex::Regex;
4 use serde_json::{Value, json};
5 use std::collections::HashMap;
6 use std::fs;
7 use std::path::{Path, PathBuf};
8 use std::process::Command;
9 use std::sync::atomic::{AtomicU64, Ordering};
10 use std::sync::{Arc, Mutex, OnceLock};
11
12 use crate::backend::{InferenceBackend, ToolResult, ToolSpec};
13
14 const WEBSITE_READ_CHAR_LIMIT: usize = 20_000;
15 const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
16 const WEBSITE_USER_AGENT: &str =
17 "siGit/0.1 (+https://github.com/getsigit/sigit; website-reading tool)";
18
19 const READ_FILE_CHAR_LIMIT: usize = 10_000;
20 const SEARCH_FILES_MATCH_LIMIT: usize = 50;
21 /// Upper bound on `max_results` for `search_files` and the number of paths
22 /// returned by `glob`, so a broad pattern can't flood the context window.
23 const SEARCH_RESULTS_HARD_CAP: usize = 1_000;
24
25 // ── Tool schemas ─────────────────────────────────────────────────────────────
26
27 pub struct AgentTool {
28 pub name: &'static str,
29 pub description: &'static str,
30 pub parameters_schema: Value,
31 }
32
33 pub fn all_tools() -> Vec<AgentTool> {
34 vec![
35 AgentTool {
36 name: "read_file",
37 description: "Read the contents of a file at the given path. \
38 Prefer an absolute path when possible. Use start_line and \
39 end_line to read a specific range instead of the whole file — \
40 strongly prefer this when you already know which lines matter. \
41 Output is truncated to 10 000 characters.",
42 parameters_schema: json!({
43 "type": "object",
44 "properties": {
45 "path": {
46 "type": "string",
47 "description": "Absolute or relative path to the file to read."
48 },
49 "start_line": {
50 "type": "integer",
51 "description": "First line to read (1-based, inclusive). Omit to start from the beginning."
52 },
53 "end_line": {
54 "type": "integer",
55 "description": "Last line to read (1-based, inclusive). Omit to read to the end."
56 }
57 },
58 "required": ["path"],
59 "additionalProperties": false
60 }),
61 },
62 AgentTool {
63 name: "create_directory",
64 description: "Create a directory at the given path. \
65 Prefer an absolute path when possible. Missing parent \
66 directories are created automatically. Use this before \
67 create_file when the parent path does not exist. Succeeds \
68 if the directory already exists.",
69 parameters_schema: json!({
70 "type": "object",
71 "properties": {
72 "path": {
73 "type": "string",
74 "description": "Absolute or relative path to the directory to create."
75 }
76 },
77 "required": ["path"],
78 "additionalProperties": false
79 }),
80 },
81 AgentTool {
82 name: "list_directory",
83 description: "List files and directories at the given path. \
84 Prefer an absolute path when possible. Each entry is \
85 prefixed with [DIR] or [FILE]. Directories are listed \
86 first, sorted alphabetically.",
87 parameters_schema: json!({
88 "type": "object",
89 "properties": {
90 "path": {
91 "type": "string",
92 "description": "Absolute or relative path to the directory to list."
93 }
94 },
95 "required": ["path"],
96 "additionalProperties": false
97 }),
98 },
99 AgentTool {
100 name: "search_files",
101 description: "Search for a regex pattern across files in a directory tree. \
102 Prefer an absolute root path when possible. Returns matching \
103 lines in `file:line_number: content` format. Skips binary \
104 files and hidden directories. Pass `file_glob` to restrict the \
105 search to files whose name matches a glob (e.g. \"*.rs\"), and \
106 `max_results` to raise or lower the default cap of 50 matches.",
107 parameters_schema: json!({
108 "type": "object",
109 "properties": {
110 "pattern": {
111 "type": "string",
112 "description": "Regular expression pattern to search for."
113 },
114 "path": {
115 "type": "string",
116 "description": "Root directory to search in. Defaults to \".\" (current directory)."
117 },
118 "file_glob": {
119 "type": "string",
120 "description": "Optional glob on the file name (not the full path), e.g. \"*.rs\" or \"*.{ts,tsx}\". Only matching files are searched."
121 },
122 "max_results": {
123 "type": "integer",
124 "description": "Maximum number of matching lines to return (default 50, capped at 1000)."
125 }
126 },
127 "required": ["pattern"],
128 "additionalProperties": false
129 }),
130 },
131 AgentTool {
132 name: "read_website",
133 description: "Fetch a web page and return readable text content. \
134 Use this when the user gives you a URL and asks you to read, \
135 summarize, inspect, or extract information from the page. \
136 Supports normal http and https URLs. Output is truncated if the \
137 page is very large.",
138 parameters_schema: json!({
139 "type": "object",
140 "properties": {
141 "url": {
142 "type": "string",
143 "description": "Absolute http or https URL to fetch."
144 }
145 },
146 "required": ["url"],
147 "additionalProperties": false
148 }),
149 },
150 AgentTool {
151 name: "create_file",
152 description: "Create a new file at the given path with the provided content. \
153 Prefer an absolute path when possible. Parent directories are \
154 created automatically if they do not exist. Fails if the file \
155 already exists — use edit_file to modify existing files.",
156 parameters_schema: json!({
157 "type": "object",
158 "properties": {
159 "path": {
160 "type": "string",
161 "description": "Absolute or relative path for the new file."
162 },
163 "content": {
164 "type": "string",
165 "description": "The full text content to write into the new file."
166 }
167 },
168 "required": ["path", "content"],
169 "additionalProperties": false
170 }),
171 },
172 AgentTool {
173 name: "edit_file",
174 description: "Edit an existing file by replacing an exact substring (old_text) with \
175 new text (new_text). Prefer an absolute path when possible. By \
176 default old_text must appear exactly once; set replace_all to true \
177 to replace every occurrence (useful for renaming a symbol). Use \
178 read_file first to see the current content and identify the exact \
179 text to replace. To append to a file, match the last few lines as \
180 old_text and include them plus the new content as new_text.",
181 parameters_schema: json!({
182 "type": "object",
183 "properties": {
184 "path": {
185 "type": "string",
186 "description": "Path to the existing file to edit."
187 },
188 "old_text": {
189 "type": "string",
190 "description": "The exact text span to find and replace. Must match exactly once unless replace_all is true."
191 },
192 "new_text": {
193 "type": "string",
194 "description": "The replacement text that will take the place of old_text."
195 },
196 "replace_all": {
197 "type": "boolean",
198 "description": "Replace every occurrence of old_text instead of requiring a unique match. Defaults to false."
199 }
200 },
201 "required": ["path", "old_text", "new_text"],
202 "additionalProperties": false
203 }),
204 },
205 AgentTool {
206 name: "delete_file",
207 description: "Delete a file or empty directory at the given path. \
208 Prefer an absolute path when possible. Refuses to delete \
209 non-empty directories to prevent accidental data loss. \
210 Use read_file or list_directory first to confirm the target.",
211 parameters_schema: json!({
212 "type": "object",
213 "properties": {
214 "path": {
215 "type": "string",
216 "description": "Absolute or relative path to the file or empty directory to delete."
217 }
218 },
219 "required": ["path"],
220 "additionalProperties": false
221 }),
222 },
223 AgentTool {
224 name: "run_command",
225 description: "Run a shell command and return its combined stdout and stderr output. \
226 The command runs in the given working directory (defaults to the \
227 user's home directory). Always use an absolute working directory \
228 path. Use this for build tools (cargo, npm, make), package managers, \
229 linters, test runners, and git commands, including git init, \
230 porcelain commands like status/add/commit/checkout, and plumbing \
231 commands like rev-parse, hash-object, update-ref, and cat-file. \
232 For `git clone`, always specify the full absolute destination path \
233 as the last argument (e.g. `git clone <url> /absolute/path/to/dir`) \
234 and set cwd to the parent directory. Never run `git clone` without \
235 an explicit destination. If the user asks for a new repo or scaffold, \
236 use this for `git clone`, `git init`, and normal repo setup steps. \
237 In smbCloud repos, prefer existing workspace commands, Rails \
238 conventions, and deploy flows over inventing new command sequences. \
239 Foreground commands are killed after 120 seconds — run servers, \
240 watchers, builds, test suites, and anything that may exceed two \
241 minutes with run_in_background set to true, then poll with \
242 command_output.",
243 parameters_schema: json!({
244 "type": "object",
245 "properties": {
246 "command": {
247 "type": "string",
248 "description": "The shell command to execute (e.g. \"cargo update\", \"git status\", \"git rev-parse HEAD\")."
249 },
250 "cwd": {
251 "type": "string",
252 "description": "Working directory for the command. Defaults to \".\" (current directory)."
253 },
254 "run_in_background": {
255 "type": "boolean",
256 "description": "Start the command as a background task and return a task id immediately instead of waiting for it to finish. Set this to true for servers, watchers, builds, test suites, and anything that may run longer than two minutes (foreground commands are killed after 120 seconds). Poll the task's output and status with command_output, and stop it with kill_command. Background tasks are killed when sigit exits. Defaults to false."
257 }
258 },
259 "required": ["command"],
260 "additionalProperties": false
261 }),
262 },
263 AgentTool {
264 name: "multi_edit",
265 description: "Apply several exact-substring edits to a single file in one call. \
266 Edits are applied in order, each to the result of the previous one, \
267 and the whole batch is atomic — if any edit fails to match, the file \
268 is left untouched and an error explains which edit failed. Prefer \
269 this over multiple edit_file calls when changing several spots in the \
270 same file. Each edit has old_text (must match exactly once, or every \
271 time when replace_all is true) and new_text.",
272 parameters_schema: json!({
273 "type": "object",
274 "properties": {
275 "path": {
276 "type": "string",
277 "description": "Path to the existing file to edit."
278 },
279 "edits": {
280 "type": "array",
281 "description": "Ordered list of edits to apply to the file.",
282 "items": {
283 "type": "object",
284 "properties": {
285 "old_text": {
286 "type": "string",
287 "description": "The exact text span to find and replace."
288 },
289 "new_text": {
290 "type": "string",
291 "description": "The replacement text."
292 },
293 "replace_all": {
294 "type": "boolean",
295 "description": "Replace every occurrence instead of requiring a unique match. Defaults to false."
296 }
297 },
298 "required": ["old_text", "new_text"],
299 "additionalProperties": false
300 }
301 }
302 },
303 "required": ["path", "edits"],
304 "additionalProperties": false
305 }),
306 },
307 AgentTool {
308 name: "glob",
309 description: "Find files by name using a glob pattern (e.g. \"**/*.rs\", \
310 \"src/**/*.{ts,tsx}\", \"Cargo.toml\"). Returns matching file paths, \
311 most-recently-modified first. Supports `*` (any run of non-separator \
312 characters), `**` (any number of directories), `?` (one character), \
313 and `{a,b}` alternation. Use this to locate files by name; use \
314 search_files to search file contents.",
315 parameters_schema: json!({
316 "type": "object",
317 "properties": {
318 "pattern": {
319 "type": "string",
320 "description": "Glob pattern matched against paths relative to the search root."
321 },
322 "path": {
323 "type": "string",
324 "description": "Root directory to search in. Defaults to \".\" (current directory)."
325 }
326 },
327 "required": ["pattern"],
328 "additionalProperties": false
329 }),
330 },
331 AgentTool {
332 name: "write_todos",
333 description: "Record or update a checklist of the steps for the current task. \
334 Use this for any multi-step task to plan the work and show progress: \
335 call it once up front with all the steps as `pending`, then call it \
336 again whenever a step's status changes. Mark exactly one step \
337 `in_progress` at a time and `completed` as soon as it is done. \
338 Keep the list short and outcome-focused.",
339 parameters_schema: json!({
340 "type": "object",
341 "properties": {
342 "todos": {
343 "type": "array",
344 "description": "The full, current checklist (replaces any previous list).",
345 "items": {
346 "type": "object",
347 "properties": {
348 "content": {
349 "type": "string",
350 "description": "Short imperative description of the step."
351 },
352 "status": {
353 "type": "string",
354 "enum": ["pending", "in_progress", "completed"],
355 "description": "Current status of the step."
356 }
357 },
358 "required": ["content", "status"],
359 "additionalProperties": false
360 }
361 }
362 },
363 "required": ["todos"],
364 "additionalProperties": false
365 }),
366 },
367 AgentTool {
368 name: "remember",
369 description: "Persist a durable note, preference, or convention by appending it to \
370 this project's instruction file (AGENTS.md / CLAUDE.md). Use this \
371 when the user asks you to remember something for next time, or states \
372 a lasting preference about how to work in this project. The note is \
373 written to the nearest existing instruction file, or a new CLAUDE.md \
374 at the repository root if none exists yet.",
375 parameters_schema: json!({
376 "type": "object",
377 "properties": {
378 "note": {
379 "type": "string",
380 "description": "The fact or preference to remember, phrased as a standalone instruction."
381 }
382 },
383 "required": ["note"],
384 "additionalProperties": false
385 }),
386 },
387 AgentTool {
388 name: "command_output",
389 description: "Get the output a background task (started with run_command's \
390 run_in_background) has produced since your last check, plus its \
391 status: still running, or exited with an exit code. Poll this \
392 periodically to follow builds, test suites, and servers. Between \
393 polls output is buffered up to 50 000 bytes per task; older \
394 output beyond that is dropped and the truncation is noted.",
395 parameters_schema: json!({
396 "type": "object",
397 "properties": {
398 "task_id": {
399 "type": "integer",
400 "description": "Id of the background task, as returned by run_command with run_in_background."
401 }
402 },
403 "required": ["task_id"],
404 "additionalProperties": false
405 }),
406 },
407 AgentTool {
408 name: "kill_command",
409 description: "Kill a background task started with run_command's run_in_background. \
410 Reports the tail of the task's unread output and confirms it was \
411 killed. Use this to stop servers or watchers you no longer need and \
412 runaway commands. Background tasks are also killed automatically \
413 when sigit exits.",
414 parameters_schema: json!({
415 "type": "object",
416 "properties": {
417 "task_id": {
418 "type": "integer",
419 "description": "Id of the background task, as returned by run_command with run_in_background."
420 }
421 },
422 "required": ["task_id"],
423 "additionalProperties": false
424 }),
425 },
426 ]
427 }
428
429 // ── Tool execution ───────────────────────────────────────────────────────────
430
431 pub async fn execute_tool(name: &str, arguments: &str) -> String {
432 match name {
433 "read_file" => exec_read_file(arguments),
434 "list_directory" => exec_list_directory(arguments),
435 "search_files" => exec_search_files(arguments),
436 "read_website" => {
437 // reqwest::blocking panics inside a tokio runtime, so run on the blocking pool.
438 let args = arguments.to_owned();
439 tokio::task::spawn_blocking(move || exec_read_website(&args))
440 .await
441 .unwrap_or_else(|err| format!("Error: read_website task failed: {err}"))
442 }
443 "create_directory" => exec_create_directory(arguments),
444 "create_file" => exec_create_file(arguments),
445 "edit_file" => exec_edit_file(arguments),
446 "multi_edit" => exec_multi_edit(arguments),
447 "glob" => exec_glob(arguments),
448 "write_todos" => exec_write_todos(arguments),
449 "remember" => exec_remember(arguments),
450 "delete_file" => exec_delete_file(arguments),
451 "run_command" => exec_run_command(arguments),
452 "command_output" => exec_command_output(arguments),
453 "kill_command" => exec_kill_command(arguments),
454 "skill" => crate::skills::activate_skill(arguments),
455 TASK_TOOL_NAME => exec_task(arguments).await,
456 // Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>`
457 // and forwarded to the owning server.
458 _ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await,
459 _ => format!("Unknown tool: {name}"),
460 }
461 }
462
463 // ── task (subagent) ──────────────────────────────────────────────────────────
464 //
465 // The `task` tool delegates a self-contained research question to a *nested*
466 // agent loop running in a fresh conversation, so the main thread receives only
467 // the final answer instead of every intermediate file read. The subagent's
468 // toolset is strictly read-only and never includes `task` itself (no
469 // recursion), so a delegated agent can research but not mutate state.
470 //
471 // This module is backend-agnostic and cannot construct a backend, so the
472 // surface that knows the active provider (`run_acp_server` / `run_interactive`
473 // in `main.rs`) registers a factory at startup. The factory returns `None`
474 // when inference runs on-device: onde has a single shared conversation
475 // history, so a second concurrent context is not possible yet.
476
477 pub const TASK_TOOL_NAME: &str = "task";
478
479 /// The tool names a subagent may call, filtered from [`all_tools`].
480 const SUBAGENT_TOOL_NAMES: &[&str] = &[
481 "read_file",
482 "list_directory",
483 "search_files",
484 "glob",
485 "read_website",
486 ];
487
488 /// System prompt seeding every subagent conversation.
489 pub const SUBAGENT_SYSTEM_PROMPT: &str = "You are a focused research subagent. \
490 You are given one self-contained task by a calling agent. Investigate it using \
491 the read-only tools available to you (read_file, list_directory, search_files, \
492 glob, read_website) and answer it thoroughly but concisely. You cannot modify \
493 files or run commands. Your final message is returned verbatim to the caller, \
494 so make it a complete, self-contained answer — include the concrete facts, \
495 paths, and code excerpts the caller needs, and nothing else.";
496
497 /// Rounds of tool calls a subagent may use before it is forced to answer.
498 const SUBAGENT_MAX_TOOL_ROUNDS: usize = 8;
499 /// Cap on the answer text returned to the caller.
500 const SUBAGENT_RESULT_CHAR_LIMIT: usize = 8_000;
501
502 /// Returned when `task` is called but no subagent backend can be built.
503 const SUBAGENT_UNAVAILABLE: &str = "The task tool is not available on-device \
504 yet: on-device inference has a single conversation context. Do the research \
505 yourself with the read-only tools.";
506
507 /// Builds a fresh backend for one subagent run, or `None` when the active
508 /// inference cannot host a second conversation (on-device).
509 pub type SubagentFactory = Box<dyn Fn() -> Option<Arc<dyn InferenceBackend>> + Send + Sync>;
510
511 static SUBAGENT_FACTORY: OnceLock<SubagentFactory> = OnceLock::new();
512
513 /// Register the process-wide subagent factory. Called once at startup by the
514 /// surface that resolved the inference provider; later calls are ignored.
515 pub fn set_subagent_factory(factory: SubagentFactory) {
516 let _ = SUBAGENT_FACTORY.set(factory);
517 }
518
519 /// Whether a `task` call could run right now: a factory is registered and it
520 /// can build a backend. The spec builders (`agent_tools_as_specs` /
521 /// `build_tool_specs`) use this to offer the tool only when it works, the same
522 /// conditional pattern as the `skill` tool.
523 pub fn subagent_available() -> bool {
524 SUBAGENT_FACTORY
525 .get()
526 .is_some_and(|factory| factory().is_some())
527 }
528
529 /// The read-only toolset offered to a subagent, filtered from [`all_tools`] by
530 /// name. Never contains `task` (recursion) or any mutating tool.
531 pub fn subagent_tool_specs() -> Vec<ToolSpec> {
532 all_tools()
533 .into_iter()
534 .filter(|tool| SUBAGENT_TOOL_NAMES.contains(&tool.name))
535 .map(|tool| ToolSpec {
536 name: tool.name.to_string(),
537 description: tool.description.to_string(),
538 parameters_schema: tool.parameters_schema.to_string(),
539 })
540 .collect()
541 }
542
543 /// Spec for the `task` tool. Lives in the `*_as_specs`/`build_tool_specs`
544 /// layer (like `skill` and MCP tools), not in [`all_tools`], because it is
545 /// only offered when [`subagent_available`] is true.
546 pub fn task_tool_spec() -> ToolSpec {
547 ToolSpec {
548 name: TASK_TOOL_NAME.to_string(),
549 description: "Delegate a self-contained research task to a subagent that \
550 runs in a fresh conversation and returns only its final answer. The \
551 subagent can read files, list directories, search, glob, and read \
552 websites, but cannot modify anything. Use this for exploratory \
553 questions whose intermediate file reads would otherwise clutter this \
554 conversation (e.g. \"where is X implemented and how does it work?\"). \
555 The subagent cannot see this conversation, so the prompt must be \
556 fully self-contained: include absolute paths, symbol names, and \
557 exactly what the answer should contain."
558 .to_string(),
559 parameters_schema: json!({
560 "type": "object",
561 "properties": {
562 "description": {
563 "type": "string",
564 "description": "A short (3-5 word) summary of the task, for progress display."
565 },
566 "prompt": {
567 "type": "string",
568 "description": "The full task for the subagent. Must be self-contained: the subagent sees nothing of this conversation."
569 }
570 },
571 "required": ["description", "prompt"],
572 "additionalProperties": false
573 })
574 .to_string(),
575 }
576 }
577
578 /// The `task` tool entry point used by [`execute_tool`].
579 async fn exec_task(arguments: &str) -> String {
580 exec_task_with(arguments, SUBAGENT_FACTORY.get()).await
581 }
582
583 /// Core of the `task` tool, parameterized on the factory so tests can exercise
584 /// the unavailable path without touching the process-global `OnceLock`.
585 async fn exec_task_with(arguments: &str, factory: Option<&SubagentFactory>) -> String {
586 let args: Value = match serde_json::from_str(arguments) {
587 Ok(v) => v,
588 Err(err) => return format!("Error: failed to parse arguments: {err}"),
589 };
590
591 let prompt = match args.get("prompt").and_then(Value::as_str) {
592 Some(p) if !p.trim().is_empty() => p,
593 _ => return "Error: missing required parameter \"prompt\"".to_string(),
594 };
595 let description = args
596 .get("description")
597 .and_then(Value::as_str)
598 .unwrap_or("(no description)");
599
600 let Some(backend) = factory.and_then(|build| build()) else {
601 return SUBAGENT_UNAVAILABLE.to_string();
602 };
603
604 log::info!("task: running subagent — {description}");
605 run_subagent(backend.as_ref(), prompt).await
606 }
607
608 /// The nested agent loop: a fresh conversation, read-only tools, a round cap,
609 /// and only the final text returned. Mirrors the main loops in `main.rs` /
610 /// `chat.rs`: offer tools each round, and pass `tools = None` on the last
611 /// round to force a text answer.
612 async fn run_subagent(backend: &dyn InferenceBackend, prompt: &str) -> String {
613 let specs = subagent_tool_specs();
614
615 let mut result = match backend.send_message_with_tools(prompt, &specs, None).await {
616 Ok(r) => r,
617 Err(err) => return format!("Error: subagent inference failed: {err}"),
618 };
619
620 let mut round = 0;
621 while !result.tool_calls.is_empty() && round < SUBAGENT_MAX_TOOL_ROUNDS {
622 round += 1;
623 log::info!(
624 "task: subagent tool round {round} — {} call(s)",
625 result.tool_calls.len()
626 );
627
628 let mut tool_results = Vec::with_capacity(result.tool_calls.len());
629 for call in &result.tool_calls {
630 // Hard gate, not just advertisement: even if the model asks for a
631 // tool outside the offered set, only read-only tools execute here.
632 let content = if SUBAGENT_TOOL_NAMES.contains(&call.name.as_str()) {
633 // Boxed to break the async cycle: execute_tool → task →
634 // run_subagent → execute_tool.
635 Box::pin(execute_tool(&call.name, &call.arguments)).await
636 } else {
637 format!(
638 "Error: `{}` is not available to a subagent. Only these \
639 read-only tools are: {}.",
640 call.name,
641 SUBAGENT_TOOL_NAMES.join(", ")
642 )
643 };
644 tool_results.push(ToolResult {
645 tool_call_id: call.id.clone(),
646 content,
647 });
648 }
649
650 // On the last round, offer no tools so the model must produce text.
651 let next_tools = if round < SUBAGENT_MAX_TOOL_ROUNDS {
652 Some(specs.as_slice())
653 } else {
654 None
655 };
656 result = match backend
657 .send_tool_results(tool_results, next_tools, None)
658 .await
659 {
660 Ok(r) => r,
661 Err(err) => return format!("Error: subagent inference failed: {err}"),
662 };
663 }
664
665 let text = result.text.trim();
666 if text.is_empty() {
667 return "The subagent finished without a text answer.".to_string();
668 }
669
670 let total = text.chars().count();
671 if total > SUBAGENT_RESULT_CHAR_LIMIT {
672 let truncated: String = text.chars().take(SUBAGENT_RESULT_CHAR_LIMIT).collect();
673 return format!(
674 "{truncated}\n\n--- truncated (showing {SUBAGENT_RESULT_CHAR_LIMIT} of {total} \
675 characters of the subagent's answer) ---"
676 );
677 }
678 text.to_string()
679 }
680
681 fn absolute_path(path: &Path) -> PathBuf {
682 if path.is_absolute() {
683 path.to_path_buf()
684 } else {
685 std::env::current_dir()
686 .unwrap_or_else(|_| PathBuf::from("."))
687 .join(path)
688 }
689 }
690
691 fn absolute_path_string(path: &Path) -> String {
692 absolute_path(path).display().to_string()
693 }
694
695 // ── read_file ────────────────────────────────────────────────────────────────
696
697 fn exec_read_file(arguments: &str) -> String {
698 let args: Value = match serde_json::from_str(arguments) {
699 Ok(v) => v,
700 Err(err) => return format!("Error: failed to parse arguments: {err}"),
701 };
702
703 let path_str = match args.get("path").and_then(Value::as_str) {
704 Some(p) => p,
705 None => return "Error: missing required parameter \"path\"".to_string(),
706 };
707
708 let start_line = args
709 .get("start_line")
710 .and_then(Value::as_u64)
711 .map(|n| n as usize);
712 let end_line = args
713 .get("end_line")
714 .and_then(Value::as_u64)
715 .map(|n| n as usize);
716
717 let path = Path::new(path_str);
718 let absolute_path = absolute_path(path);
719 let absolute_path_str = absolute_path.display().to_string();
720
721 if !absolute_path.exists() {
722 return format!("Error: path does not exist: {absolute_path_str}");
723 }
724
725 if !absolute_path.is_file() {
726 return format!("Error: path is not a file: {absolute_path_str}");
727 }
728
729 match fs::read_to_string(&absolute_path) {
730 Ok(contents) => {
731 if start_line.is_some() || end_line.is_some() {
732 let lines: Vec<&str> = contents.lines().collect();
733 let total = lines.len();
734 let start = start_line.unwrap_or(1).max(1);
735 let end = end_line.unwrap_or(total).min(total);
736
737 if start > total {
738 return format!(
739 "Error: start_line {start} is beyond end of file ({total} lines)"
740 );
741 }
742
743 let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
744 let range_text = selected.join("\n");
745 format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
746 } else if contents.len() > READ_FILE_CHAR_LIMIT {
747 let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
748 format!(
749 "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
750 contents.len()
751 )
752 } else {
753 contents
754 }
755 }
756 Err(err) => format!("Error: could not read file: {err}"),
757 }
758 }
759
760 // ── list_directory ───────────────────────────────────────────────────────────
761
762 fn exec_list_directory(arguments: &str) -> String {
763 let args: Value = match serde_json::from_str(arguments) {
764 Ok(v) => v,
765 Err(err) => return format!("Error: failed to parse arguments: {err}"),
766 };
767
768 let path_str = match args.get("path").and_then(Value::as_str) {
769 Some(p) => p,
770 None => return "Error: missing required parameter \"path\"".to_string(),
771 };
772
773 let path = Path::new(path_str);
774 let absolute_path = absolute_path(path);
775 let absolute_path_str = absolute_path.display().to_string();
776
777 if !absolute_path.exists() {
778 return format!("Error: path does not exist: {absolute_path_str}");
779 }
780
781 if !absolute_path.is_dir() {
782 return format!("Error: path is not a directory: {absolute_path_str}");
783 }
784
785 let entries = match fs::read_dir(&absolute_path) {
786 Ok(rd) => rd,
787 Err(err) => return format!("Error: could not read directory: {err}"),
788 };
789
790 let mut dirs: Vec<String> = Vec::new();
791 let mut files: Vec<String> = Vec::new();
792
793 for entry in entries {
794 let entry = match entry {
795 Ok(e) => e,
796 Err(err) => {
797 files.push(format!("[ERR] {err}"));
798 continue;
799 }
800 };
801
802 let name = entry.file_name().to_string_lossy().to_string();
803
804 let is_dir = match entry.file_type() {
805 Ok(ft) => ft.is_dir(),
806 Err(_) => false,
807 };
808
809 if is_dir {
810 dirs.push(format!("[DIR] {name}"));
811 } else {
812 files.push(format!("[FILE] {name}"));
813 }
814 }
815
816 dirs.sort();
817 files.sort();
818
819 dirs.extend(files);
820
821 if dirs.is_empty() {
822 return format!("(empty directory: {absolute_path_str})");
823 }
824
825 dirs.join("\n")
826 }
827
828 // ── search_files ─────────────────────────────────────────────────────────────
829
830 fn exec_search_files(arguments: &str) -> String {
831 let args: Value = match serde_json::from_str(arguments) {
832 Ok(v) => v,
833 Err(err) => return format!("Error: failed to parse arguments: {err}"),
834 };
835
836 let pattern_str = match args.get("pattern").and_then(Value::as_str) {
837 Some(p) => p,
838 None => return "Error: missing required parameter \"pattern\"".to_string(),
839 };
840
841 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
842
843 let re = match Regex::new(pattern_str) {
844 Ok(r) => r,
845 Err(err) => return format!("Error: invalid regex pattern: {err}"),
846 };
847
848 // Optional file-name filter compiled from a glob (e.g. "*.rs").
849 let name_filter = match args.get("file_glob").and_then(Value::as_str) {
850 Some(glob) => match Regex::new(&glob_to_regex(glob)) {
851 Ok(r) => Some(r),
852 Err(err) => return format!("Error: invalid file_glob: {err}"),
853 },
854 None => None,
855 };
856
857 let limit = args
858 .get("max_results")
859 .and_then(Value::as_u64)
860 .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
861 .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
862
863 let root = Path::new(root_str);
864 let absolute_root = absolute_path(root);
865 let absolute_root_str = absolute_root.display().to_string();
866
867 if !absolute_root.exists() {
868 return format!("Error: path does not exist: {absolute_root_str}");
869 }
870
871 if !absolute_root.is_dir() {
872 return format!("Error: path is not a directory: {absolute_root_str}");
873 }
874
875 let mut matches: Vec<String> = Vec::new();
876 walk_and_search(
877 &absolute_root,
878 &re,
879 name_filter.as_ref(),
880 limit,
881 &mut matches,
882 );
883
884 if matches.is_empty() {
885 return format!("No matches found for pattern: {pattern_str}");
886 }
887
888 let total = matches.len();
889 if total > limit {
890 matches.truncate(limit);
891 matches.push(format!(
892 "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
893 ));
894 }
895
896 matches.join("\n")
897 }
898
899 /// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
900 /// pattern can't walk an entire tree once enough hits are found.
901 fn walk_and_search(
902 dir: &Path,
903 re: &Regex,
904 name_filter: Option<&Regex>,
905 limit: usize,
906 matches: &mut Vec<String>,
907 ) {
908 let entries = match fs::read_dir(dir) {
909 Ok(rd) => rd,
910 Err(_) => return,
911 };
912
913 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
914 sorted.sort_by_key(|e| e.file_name());
915
916 for entry in sorted {
917 if matches.len() > limit {
918 return;
919 }
920
921 let path = entry.path();
922 let name = entry.file_name();
923 let name_str = name.to_string_lossy();
924
925 if name_str.starts_with('.') {
926 continue;
927 }
928
929 if path.is_dir() {
930 walk_and_search(&path, re, name_filter, limit, matches);
931 } else if path.is_file() {
932 if let Some(filter) = name_filter
933 && !filter.is_match(&name_str)
934 {
935 continue;
936 }
937 search_file(&path, re, matches);
938 }
939 }
940 }
941
942 /// skips non-UTF-8 files (probably binary).
943 fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
944 let contents = match fs::read_to_string(path) {
945 Ok(c) => c,
946 Err(_) => return,
947 };
948
949 let display_path = absolute_path_string(path);
950
951 for (line_idx, line) in contents.lines().enumerate() {
952 if re.is_match(line) {
953 let line_number = line_idx + 1;
954 matches.push(format!("{display_path}:{line_number}: {line}"));
955 }
956 }
957 }
958
959 // ── read_website ─────────────────────────────────────────────────────────────
960
961 fn exec_read_website(arguments: &str) -> String {
962 let args: Value = match serde_json::from_str(arguments) {
963 Ok(v) => v,
964 Err(err) => return format!("Error: failed to parse arguments: {err}"),
965 };
966
967 let url = match args.get("url").and_then(Value::as_str) {
968 Some(u) => u,
969 None => return "Error: missing required parameter \"url\"".to_string(),
970 };
971
972 if !(url.starts_with("http://") || url.starts_with("https://")) {
973 return format!("Error: url must start with http:// or https://: {url}");
974 }
975
976 let client = match reqwest::blocking::Client::builder()
977 .timeout(WEBSITE_READ_TIMEOUT)
978 .user_agent(WEBSITE_USER_AGENT)
979 .build()
980 {
981 Ok(client) => client,
982 Err(err) => return format!("Error: failed to build website client: {err}"),
983 };
984
985 let response = match client.get(url).send() {
986 Ok(r) => r,
987 Err(err) => return format!("Error: failed to fetch website: {err}"),
988 };
989
990 let final_url = response.url().to_string();
991 let status = response.status();
992 if !status.is_success() {
993 return format!("Error: website returned HTTP {status} for {final_url}");
994 }
995
996 let body = match response.text() {
997 Ok(text) => text,
998 Err(err) => return format!("Error: failed to read website body: {err}"),
999 };
1000
1001 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
1002 .unwrap()
1003 .captures(&body)
1004 .and_then(|captures| captures.get(1))
1005 .map(|m| {
1006 Regex::new(r"\s+")
1007 .unwrap()
1008 .replace_all(m.as_str(), " ")
1009 .trim()
1010 .to_string()
1011 })
1012 .filter(|title| !title.is_empty());
1013
1014 let with_block_breaks = Regex::new(
1015 r"(?is)</?(?:p|div|section|article|main|aside|header|footer|nav|li|ul|ol|h1|h2|h3|h4|h5|h6|br|tr|td|th)[^>]*>",
1016 )
1017 .unwrap()
1018 .replace_all(&body, "\n");
1019 let without_scripts = Regex::new(r"(?is)<script[^>]*>.*?</script>")
1020 .unwrap()
1021 .replace_all(&with_block_breaks, " ");
1022 let without_styles = Regex::new(r"(?is)<style[^>]*>.*?</style>")
1023 .unwrap()
1024 .replace_all(&without_scripts, " ");
1025 let without_tags = Regex::new(r"(?is)<[^>]+>")
1026 .unwrap()
1027 .replace_all(&without_styles, " ");
1028 let normalized_newlines = without_tags
1029 .replace("&nbsp;", " ")
1030 .replace("&amp;", "&")
1031 .replace("&lt;", "<")
1032 .replace("&gt;", ">")
1033 .replace("&quot;", "\"")
1034 .replace("&#39;", "'");
1035 let collapsed_lines = Regex::new(r"[ \t]+")
1036 .unwrap()
1037 .replace_all(&normalized_newlines, " ");
1038 let collapsed_breaks = Regex::new(r"\n\s*\n+")
1039 .unwrap()
1040 .replace_all(&collapsed_lines, "\n\n");
1041 let cleaned = collapsed_breaks
1042 .lines()
1043 .map(str::trim)
1044 .filter(|line| !line.is_empty())
1045 .collect::<Vec<_>>()
1046 .join("\n");
1047
1048 if cleaned.is_empty() {
1049 return format!("Fetched {url}, but no readable text content was found.");
1050 }
1051
1052 let mut metadata = vec![format!("URL: {final_url}")];
1053 if let Some(title) = &title {
1054 metadata.push(format!("Title: {title}"));
1055 }
1056
1057 let body_text = match title {
1058 Some(title) if !cleaned.starts_with(&title) => cleaned,
1059 _ => cleaned,
1060 };
1061
1062 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
1063
1064 if output.len() > WEBSITE_READ_CHAR_LIMIT {
1065 let truncated: String = output.chars().take(WEBSITE_READ_CHAR_LIMIT).collect();
1066 return format!(
1067 "{truncated}\n\n--- truncated (showing {WEBSITE_READ_CHAR_LIMIT} of {} characters) ---",
1068 output.len()
1069 );
1070 }
1071
1072 output
1073 }
1074
1075 // ── create_directory ─────────────────────────────────────────────────────────
1076
1077 fn exec_create_directory(arguments: &str) -> String {
1078 let args: Value = match serde_json::from_str(arguments) {
1079 Ok(v) => v,
1080 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1081 };
1082
1083 let path_str = match args.get("path").and_then(Value::as_str) {
1084 Some(p) => p,
1085 None => return "Error: missing required parameter \"path\"".to_string(),
1086 };
1087
1088 let path = Path::new(path_str);
1089 let absolute_path = absolute_path(path);
1090 let absolute_path_str = absolute_path.display().to_string();
1091
1092 if absolute_path.exists() {
1093 if absolute_path.is_dir() {
1094 return format!("Directory already exists: {absolute_path_str}");
1095 }
1096 return format!("Error: path exists and is not a directory: {absolute_path_str}");
1097 }
1098
1099 match fs::create_dir_all(&absolute_path) {
1100 Ok(()) => format!("Created directory: {absolute_path_str}"),
1101 Err(err) => format!("Error: could not create directory: {err}"),
1102 }
1103 }
1104
1105 /// fails if file exists so the LLM is forced to use `edit_file` for modifications.
1106 fn exec_create_file(arguments: &str) -> String {
1107 let args: Value = match serde_json::from_str(arguments) {
1108 Ok(v) => v,
1109 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1110 };
1111
1112 let path_str = match args.get("path").and_then(Value::as_str) {
1113 Some(p) => p,
1114 None => return "Error: missing required parameter \"path\"".to_string(),
1115 };
1116
1117 let content = match args.get("content").and_then(Value::as_str) {
1118 Some(c) => c,
1119 None => return "Error: missing required parameter \"content\"".to_string(),
1120 };
1121
1122 let path = Path::new(path_str);
1123 let absolute_path = absolute_path(path);
1124 let absolute_path_str = absolute_path.display().to_string();
1125
1126 if absolute_path.exists() {
1127 return format!(
1128 "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files"
1129 );
1130 }
1131
1132 if let Some(parent) = absolute_path.parent()
1133 && !parent.as_os_str().is_empty()
1134 && !parent.exists()
1135 && let Err(err) = fs::create_dir_all(parent)
1136 {
1137 return format!("Error: could not create parent directories: {err}");
1138 }
1139
1140 match fs::write(&absolute_path, content) {
1141 Ok(()) => format!(
1142 "Created file: {absolute_path_str} ({} bytes)",
1143 content.len()
1144 ),
1145 Err(err) => format!("Error: could not write file: {err}"),
1146 }
1147 }
1148
1149 // ── edit_file / multi_edit ─────────────────────────────────────────────────
1150
1151 /// Apply one exact-substring replacement to `contents`. Returns the updated
1152 /// string, or a human-readable explanation of why the match failed so the model
1153 /// can correct itself in a single follow-up instead of guessing blindly.
1154 fn apply_edit(
1155 contents: &str,
1156 old_text: &str,
1157 new_text: &str,
1158 replace_all: bool,
1159 ) -> Result<String, String> {
1160 if old_text.is_empty() {
1161 return Err("old_text is empty; nothing to match".to_string());
1162 }
1163 if old_text == new_text {
1164 return Err("old_text and new_text are identical; no change to make".to_string());
1165 }
1166
1167 let occurrences = contents.matches(old_text).count();
1168
1169 if occurrences == 0 {
1170 return Err(format!(
1171 "old_text not found. Use read_file to copy the exact text \
1172 (including whitespace and indentation) to replace.{}",
1173 nearest_line_hint(contents, old_text)
1174 ));
1175 }
1176
1177 if occurrences > 1 && !replace_all {
1178 return Err(format!(
1179 "old_text appears {occurrences} times; include more surrounding context so it \
1180 matches exactly once, or set replace_all to true to change every occurrence."
1181 ));
1182 }
1183
1184 if replace_all {
1185 Ok(contents.replace(old_text, new_text))
1186 } else {
1187 Ok(contents.replacen(old_text, new_text, 1))
1188 }
1189 }
1190
1191 /// When `old_text` doesn't match verbatim, point at the line whose trimmed text
1192 /// equals the first trimmed line of `old_text` — the usual culprit is a
1193 /// whitespace/indentation mismatch, and naming the line lets the model fix it.
1194 fn nearest_line_hint(contents: &str, old_text: &str) -> String {
1195 let first = old_text.lines().find(|l| !l.trim().is_empty());
1196 let Some(first) = first.map(str::trim) else {
1197 return String::new();
1198 };
1199 for (idx, line) in contents.lines().enumerate() {
1200 if line.trim() == first {
1201 return format!(
1202 " (the first line of old_text appears at line {}, so the difference is likely \
1203 whitespace or indentation)",
1204 idx + 1
1205 );
1206 }
1207 }
1208 String::new()
1209 }
1210
1211 fn exec_edit_file(arguments: &str) -> String {
1212 let args: Value = match serde_json::from_str(arguments) {
1213 Ok(v) => v,
1214 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1215 };
1216
1217 let path_str = match args.get("path").and_then(Value::as_str) {
1218 Some(p) => p,
1219 None => return "Error: missing required parameter \"path\"".to_string(),
1220 };
1221
1222 let old_text = match args.get("old_text").and_then(Value::as_str) {
1223 Some(t) => t,
1224 None => return "Error: missing required parameter \"old_text\"".to_string(),
1225 };
1226
1227 let new_text = match args.get("new_text").and_then(Value::as_str) {
1228 Some(t) => t,
1229 None => return "Error: missing required parameter \"new_text\"".to_string(),
1230 };
1231
1232 let replace_all = args
1233 .get("replace_all")
1234 .and_then(Value::as_bool)
1235 .unwrap_or(false);
1236
1237 let path = Path::new(path_str);
1238 let absolute_path = absolute_path(path);
1239 let absolute_path_str = absolute_path.display().to_string();
1240
1241 if !absolute_path.exists() {
1242 return format!(
1243 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1244 );
1245 }
1246
1247 if !absolute_path.is_file() {
1248 return format!("Error: path is not a file: {absolute_path_str}");
1249 }
1250
1251 let contents = match fs::read_to_string(&absolute_path) {
1252 Ok(c) => c,
1253 Err(err) => return format!("Error: could not read file: {err}"),
1254 };
1255
1256 let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
1257 Ok(updated) => updated,
1258 Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
1259 };
1260
1261 match fs::write(&absolute_path, &updated) {
1262 Ok(()) => format!(
1263 "Edited file: {absolute_path_str} ({} bytes written)",
1264 updated.len()
1265 ),
1266 Err(err) => format!("Error: could not write file: {err}"),
1267 }
1268 }
1269
1270 /// Apply a batch of edits to one file atomically: each edit is applied to the
1271 /// result of the previous one, and the file is only written if *every* edit
1272 /// matches. A failure leaves the file untouched.
1273 fn exec_multi_edit(arguments: &str) -> String {
1274 let args: Value = match serde_json::from_str(arguments) {
1275 Ok(v) => v,
1276 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1277 };
1278
1279 let path_str = match args.get("path").and_then(Value::as_str) {
1280 Some(p) => p,
1281 None => return "Error: missing required parameter \"path\"".to_string(),
1282 };
1283
1284 let edits = match args.get("edits").and_then(Value::as_array) {
1285 Some(e) if !e.is_empty() => e,
1286 Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1287 None => return "Error: missing required parameter \"edits\"".to_string(),
1288 };
1289
1290 let path = Path::new(path_str);
1291 let absolute_path = absolute_path(path);
1292 let absolute_path_str = absolute_path.display().to_string();
1293
1294 if !absolute_path.exists() {
1295 return format!(
1296 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1297 );
1298 }
1299
1300 if !absolute_path.is_file() {
1301 return format!("Error: path is not a file: {absolute_path_str}");
1302 }
1303
1304 let mut working = match fs::read_to_string(&absolute_path) {
1305 Ok(c) => c,
1306 Err(err) => return format!("Error: could not read file: {err}"),
1307 };
1308
1309 for (idx, edit) in edits.iter().enumerate() {
1310 let old_text = match edit.get("old_text").and_then(Value::as_str) {
1311 Some(t) => t,
1312 None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1313 };
1314 let new_text = match edit.get("new_text").and_then(Value::as_str) {
1315 Some(t) => t,
1316 None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1317 };
1318 let replace_all = edit
1319 .get("replace_all")
1320 .and_then(Value::as_bool)
1321 .unwrap_or(false);
1322
1323 match apply_edit(&working, old_text, new_text, replace_all) {
1324 Ok(updated) => working = updated,
1325 Err(why) => {
1326 return format!(
1327 "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1328 idx + 1
1329 );
1330 }
1331 }
1332 }
1333
1334 match fs::write(&absolute_path, &working) {
1335 Ok(()) => format!(
1336 "Applied {} edits to {absolute_path_str} ({} bytes written)",
1337 edits.len(),
1338 working.len()
1339 ),
1340 Err(err) => format!("Error: could not write file: {err}"),
1341 }
1342 }
1343
1344 // ── glob ─────────────────────────────────────────────────────────────────────
1345
1346 /// Translate a shell-style glob into an anchored regex. Supports `*`
1347 /// (non-separator run), `**` (any number of directories), `?` (one
1348 /// non-separator), and `{a,b}` alternation; everything else is matched
1349 /// literally. Used both by the `glob` tool (against relative paths) and by
1350 /// `search_files`' `file_glob` filter (against bare file names).
1351 fn glob_to_regex(glob: &str) -> String {
1352 let chars: Vec<char> = glob.chars().collect();
1353 let mut re = String::from("^");
1354 let mut brace_depth = 0usize;
1355 let mut i = 0;
1356
1357 while i < chars.len() {
1358 let c = chars[i];
1359 match c {
1360 '*' => {
1361 if i + 1 < chars.len() && chars[i + 1] == '*' {
1362 i += 1; // consume the second '*'
1363 if i + 1 < chars.len() && chars[i + 1] == '/' {
1364 // `**/` matches zero or more leading directories.
1365 re.push_str("(?:.*/)?");
1366 i += 1; // consume the '/'
1367 } else {
1368 re.push_str(".*");
1369 }
1370 } else {
1371 re.push_str("[^/]*");
1372 }
1373 }
1374 '?' => re.push_str("[^/]"),
1375 '{' => {
1376 brace_depth += 1;
1377 re.push_str("(?:");
1378 }
1379 '}' if brace_depth > 0 => {
1380 brace_depth -= 1;
1381 re.push(')');
1382 }
1383 ',' if brace_depth > 0 => re.push('|'),
1384 // Escape regex metacharacters so they match literally. (`{` is always
1385 // consumed by the brace arm above; an unmatched `}` lands here.)
1386 '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1387 re.push('\\');
1388 re.push(c);
1389 }
1390 other => re.push(other),
1391 }
1392 i += 1;
1393 }
1394
1395 re.push('$');
1396 re
1397 }
1398
1399 fn exec_glob(arguments: &str) -> String {
1400 let args: Value = match serde_json::from_str(arguments) {
1401 Ok(v) => v,
1402 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1403 };
1404
1405 let pattern = match args.get("pattern").and_then(Value::as_str) {
1406 Some(p) => p,
1407 None => return "Error: missing required parameter \"pattern\"".to_string(),
1408 };
1409
1410 let re = match Regex::new(&glob_to_regex(pattern)) {
1411 Ok(r) => r,
1412 Err(err) => return format!("Error: invalid glob pattern: {err}"),
1413 };
1414
1415 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1416 let absolute_root = absolute_path(Path::new(root_str));
1417 let absolute_root_str = absolute_root.display().to_string();
1418
1419 if !absolute_root.exists() {
1420 return format!("Error: path does not exist: {absolute_root_str}");
1421 }
1422 if !absolute_root.is_dir() {
1423 return format!("Error: path is not a directory: {absolute_root_str}");
1424 }
1425
1426 let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1427 glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1428
1429 if found.is_empty() {
1430 return format!("No files match glob: {pattern}");
1431 }
1432
1433 // Most-recently-modified first.
1434 found.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
1435
1436 let total = found.len();
1437 let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1438 if total > SEARCH_RESULTS_HARD_CAP {
1439 paths.truncate(SEARCH_RESULTS_HARD_CAP);
1440 paths.push(format!(
1441 "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1442 ));
1443 }
1444
1445 paths.join("\n")
1446 }
1447
1448 fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1449 if out.len() > SEARCH_RESULTS_HARD_CAP {
1450 return;
1451 }
1452
1453 let entries = match fs::read_dir(dir) {
1454 Ok(rd) => rd,
1455 Err(_) => return,
1456 };
1457
1458 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1459 sorted.sort_by_key(|e| e.file_name());
1460
1461 for entry in sorted {
1462 if out.len() > SEARCH_RESULTS_HARD_CAP {
1463 return;
1464 }
1465
1466 let path = entry.path();
1467 let name = entry.file_name();
1468 if name.to_string_lossy().starts_with('.') {
1469 continue;
1470 }
1471
1472 if path.is_dir() {
1473 glob_walk(root, &path, re, out);
1474 } else if path.is_file() {
1475 let relative = path
1476 .strip_prefix(root)
1477 .unwrap_or(&path)
1478 .to_string_lossy()
1479 .replace('\\', "/");
1480 if re.is_match(&relative) {
1481 let mtime = entry
1482 .metadata()
1483 .and_then(|m| m.modified())
1484 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1485 out.push((mtime, absolute_path_string(&path)));
1486 }
1487 }
1488 }
1489 }
1490
1491 // ── write_todos ──────────────────────────────────────────────────────────────
1492
1493 /// Renders the model's task checklist back as the tool result so the surface
1494 /// (TUI / ACP client) can show live progress. Pure presentation — the list is
1495 /// owned by the model, not persisted here.
1496 fn exec_write_todos(arguments: &str) -> String {
1497 let args: Value = match serde_json::from_str(arguments) {
1498 Ok(v) => v,
1499 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1500 };
1501
1502 let todos = match args.get("todos").and_then(Value::as_array) {
1503 Some(t) if !t.is_empty() => t,
1504 Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1505 None => return "Error: missing required parameter \"todos\"".to_string(),
1506 };
1507
1508 let mut lines = Vec::with_capacity(todos.len());
1509 let mut completed = 0usize;
1510
1511 for (idx, todo) in todos.iter().enumerate() {
1512 let content = match todo.get("content").and_then(Value::as_str) {
1513 Some(c) => c.trim(),
1514 None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1515 };
1516 let status = todo
1517 .get("status")
1518 .and_then(Value::as_str)
1519 .unwrap_or("pending");
1520
1521 let marker = match status {
1522 "completed" => {
1523 completed += 1;
1524 "[x]"
1525 }
1526 "in_progress" => "[~]",
1527 _ => "[ ]",
1528 };
1529 lines.push(format!("{marker} {content}"));
1530 }
1531
1532 format!(
1533 "Task list updated ({completed}/{} done):\n{}",
1534 todos.len(),
1535 lines.join("\n")
1536 )
1537 }
1538
1539 // ── remember ─────────────────────────────────────────────────────────────────
1540
1541 /// Appends a durable note to the project's instruction file so it persists
1542 /// across sessions (the always-on counterpart to a one-off chat message).
1543 fn exec_remember(arguments: &str) -> String {
1544 let args: Value = match serde_json::from_str(arguments) {
1545 Ok(v) => v,
1546 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1547 };
1548
1549 let note = match args.get("note").and_then(Value::as_str) {
1550 Some(n) if !n.trim().is_empty() => n.trim(),
1551 Some(_) => return "Error: \"note\" must not be empty".to_string(),
1552 None => return "Error: missing required parameter \"note\"".to_string(),
1553 };
1554
1555 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1556 remember_at(&cwd, note)
1557 }
1558
1559 /// Core of `remember`, parameterized on the working directory so it can be
1560 /// tested without mutating the process-global current directory.
1561 fn remember_at(cwd: &Path, note: &str) -> String {
1562 let target = crate::instructions::memory_file(cwd);
1563 let target_str = target.display().to_string();
1564
1565 let existed = target.exists();
1566 let mut body = if existed {
1567 match fs::read_to_string(&target) {
1568 Ok(c) => c,
1569 Err(err) => return format!("Error: could not read {target_str}: {err}"),
1570 }
1571 } else {
1572 if let Some(parent) = target.parent()
1573 && !parent.as_os_str().is_empty()
1574 && !parent.exists()
1575 && let Err(err) = fs::create_dir_all(parent)
1576 {
1577 return format!("Error: could not create parent directories: {err}");
1578 }
1579 String::new()
1580 };
1581
1582 // Keep remembered notes grouped under one heading so the file stays tidy.
1583 const HEADING: &str = "## Remembered notes";
1584 if !body.contains(HEADING) {
1585 if !body.is_empty() && !body.ends_with('\n') {
1586 body.push('\n');
1587 }
1588 if !body.is_empty() {
1589 body.push('\n');
1590 }
1591 body.push_str(HEADING);
1592 body.push('\n');
1593 }
1594 if !body.ends_with('\n') {
1595 body.push('\n');
1596 }
1597 body.push_str("- ");
1598 body.push_str(note);
1599 body.push('\n');
1600
1601 match fs::write(&target, &body) {
1602 Ok(()) => {
1603 let verb = if existed { "Appended to" } else { "Created" };
1604 format!("{verb} {target_str}: remembered \"{note}\"")
1605 }
1606 Err(err) => format!("Error: could not write {target_str}: {err}"),
1607 }
1608 }
1609
1610 // ── delete_file ──────────────────────────────────────────────────────────────
1611
1612 /// only removes files or *empty* directories — no recursive deletes.
1613 fn exec_delete_file(arguments: &str) -> String {
1614 let args: Value = match serde_json::from_str(arguments) {
1615 Ok(v) => v,
1616 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1617 };
1618
1619 let path_str = match args.get("path").and_then(Value::as_str) {
1620 Some(p) => p,
1621 None => return "Error: missing required parameter \"path\"".to_string(),
1622 };
1623
1624 let path = Path::new(path_str);
1625 let absolute_path = absolute_path(path);
1626 let absolute_path_str = absolute_path.display().to_string();
1627
1628 if !absolute_path.exists() {
1629 return format!("Error: path does not exist: {absolute_path_str}");
1630 }
1631
1632 if absolute_path.is_dir() {
1633 match fs::remove_dir(&absolute_path) {
1634 Ok(()) => format!("Deleted empty directory: {absolute_path_str}"),
1635 Err(err) => format!(
1636 "Error: could not delete directory: {err}. \
1637 Only empty directories can be deleted."
1638 ),
1639 }
1640 } else {
1641 match fs::remove_file(&absolute_path) {
1642 Ok(()) => format!("Deleted file: {absolute_path_str}"),
1643 Err(err) => format!("Error: could not delete file: {err}"),
1644 }
1645 }
1646 }
1647
1648 // ── run_command ──────────────────────────────────────────────────────────────
1649
1650 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1651 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1652
1653 /// Spawn `command_str` through the platform shell with piped stdout/stderr.
1654 /// Shared by the foreground and background paths of `run_command`.
1655 fn spawn_shell(command_str: &str, cwd_path: &Path) -> std::io::Result<std::process::Child> {
1656 #[cfg(unix)]
1657 let (shell, flag) = ("sh", "-c");
1658 #[cfg(windows)]
1659 let (shell, flag) = ("cmd", "/C");
1660
1661 Command::new(shell)
1662 .arg(flag)
1663 .arg(command_str)
1664 .current_dir(cwd_path)
1665 .stdout(std::process::Stdio::piped())
1666 .stderr(std::process::Stdio::piped())
1667 .spawn()
1668 }
1669
1670 /// Trailer identifying siGit Code as the co-author of commits it creates.
1671 /// GitHub detects `Co-authored-by:` trailers on the last lines of a commit
1672 /// message (separated from the body by a blank line) and lists the agent
1673 /// alongside the human author; `sigit@sigit.si` belongs to the
1674 /// <https://github.com/sigitc> account ("siGit Code"), so the co-author is
1675 /// rendered with that account's avatar and profile link. The system prompt
1676 /// asks the model to add this itself; [`ensure_commit_co_author`] is the
1677 /// safety net when it forgets.
1678 pub const COMMIT_CO_AUTHOR_TRAILER: &str = "Co-Authored-By: siGit Code <sigit@sigit.si>";
1679
1680 /// Run `git <args>` in `cwd`, returning trimmed stdout on success.
1681 fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
1682 let output = Command::new("git")
1683 .args(args)
1684 .current_dir(cwd)
1685 .output()
1686 .ok()?;
1687 output
1688 .status
1689 .success()
1690 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1691 }
1692
1693 fn git_head(cwd: &Path) -> Option<String> {
1694 git_stdout(cwd, &["rev-parse", "HEAD"])
1695 }
1696
1697 /// Deterministic co-author attribution: if the commit at HEAD lacks the
1698 /// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which
1699 /// places it after a blank line — the format GitHub detects). Never rewrites
1700 /// a commit that is already on a remote. Returns a note describing the amend
1701 /// so the model and user can see it happened.
1702 fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
1703 let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?;
1704 if message
1705 .to_lowercase()
1706 .contains("co-authored-by: sigit code")
1707 {
1708 return None;
1709 }
1710 // Amending changes the commit id; a commit that any remote ref already
1711 // contains must be left alone or the branch diverges from its upstream.
1712 match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) {
1713 Some(remotes) if remotes.is_empty() => {}
1714 _ => return None,
1715 }
1716 let amend = Command::new("git")
1717 .args(["commit", "--amend", "--no-edit", "--trailer"])
1718 .arg(COMMIT_CO_AUTHOR_TRAILER)
1719 .current_dir(cwd)
1720 .output()
1721 .ok()?;
1722 if amend.status.success() {
1723 log::info!(
1724 "appended co-author trailer to the new commit in {}",
1725 cwd.display()
1726 );
1727 Some(format!(
1728 "[siGit Code] The new commit was amended to append the co-author trailer \
1729 \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)."
1730 ))
1731 } else {
1732 log::warn!(
1733 "could not append co-author trailer in {}: {}",
1734 cwd.display(),
1735 String::from_utf8_lossy(&amend.stderr).trim()
1736 );
1737 None
1738 }
1739 }
1740
1741 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT unless
1742 /// `run_in_background` is set, in which case the child is registered as a
1743 /// background task and polled with `command_output` / stopped with
1744 /// `kill_command`.
1745 fn exec_run_command(arguments: &str) -> String {
1746 let args: Value = match serde_json::from_str(arguments) {
1747 Ok(v) => v,
1748 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1749 };
1750
1751 let command_str = match args.get("command").and_then(Value::as_str) {
1752 Some(c) => c,
1753 None => return "Error: missing required parameter \"command\"".to_string(),
1754 };
1755
1756 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1757 let cwd = args
1758 .get("cwd")
1759 .and_then(Value::as_str)
1760 .unwrap_or(&default_cwd);
1761 let cwd_path = absolute_path(Path::new(cwd));
1762 let cwd_str = cwd_path.display().to_string();
1763
1764 if !cwd_path.exists() {
1765 return format!("Error: working directory does not exist: {cwd_str}");
1766 }
1767
1768 let run_in_background = args
1769 .get("run_in_background")
1770 .and_then(Value::as_bool)
1771 .unwrap_or(false);
1772
1773 log::info!("run_command: `{command_str}` in `{cwd_str}` (background: {run_in_background})");
1774
1775 if run_in_background {
1776 return start_background_task(command_str, &cwd_path);
1777 }
1778
1779 // Co-author attribution: note where HEAD is before a command that looks
1780 // like it may commit, so a new commit can be detected afterwards. The
1781 // string check is only a cheap trigger — a false positive costs one
1782 // `git rev-parse` and nothing else. Background tasks skip the gate: their
1783 // commits finish after the tool returns, when there is nothing to amend
1784 // from.
1785 let may_commit = command_str.contains("git") && command_str.contains("commit");
1786 let head_before = if may_commit {
1787 git_head(&cwd_path)
1788 } else {
1789 None
1790 };
1791
1792 let mut child = match spawn_shell(command_str, &cwd_path) {
1793 Ok(c) => c,
1794 Err(err) => return format!("Error: failed to spawn command: {err}"),
1795 };
1796
1797 let start = std::time::Instant::now();
1798 loop {
1799 match child.try_wait() {
1800 Ok(Some(_status)) => break,
1801 Ok(None) => {
1802 if start.elapsed() >= COMMAND_TIMEOUT {
1803 let _ = child.kill();
1804 return format!(
1805 "Error: command timed out after {} seconds and was killed.",
1806 COMMAND_TIMEOUT.as_secs()
1807 );
1808 }
1809 std::thread::sleep(std::time::Duration::from_millis(100));
1810 }
1811 Err(err) => return format!("Error: failed to wait on command: {err}"),
1812 }
1813 }
1814
1815 let output = match child.wait_with_output() {
1816 Ok(o) => o,
1817 Err(err) => return format!("Error: failed to read command output: {err}"),
1818 };
1819
1820 let exit_code = output.status.code().unwrap_or(-1);
1821 let mut combined = String::new();
1822 combined.push_str(&String::from_utf8_lossy(&output.stdout));
1823 combined.push_str(&String::from_utf8_lossy(&output.stderr));
1824
1825 // A new commit appeared under this command: make sure it carries the
1826 // siGit co-author trailer (see `ensure_commit_co_author`).
1827 if may_commit {
1828 let head_after = git_head(&cwd_path);
1829 if head_after.is_some()
1830 && head_after != head_before
1831 && let Some(note) = ensure_commit_co_author(&cwd_path)
1832 {
1833 if !combined.is_empty() && !combined.ends_with('\n') {
1834 combined.push('\n');
1835 }
1836 combined.push_str(&note);
1837 }
1838 }
1839
1840 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
1841 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
1842 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
1843 } else {
1844 combined
1845 };
1846
1847 if output.status.success() {
1848 if truncated.is_empty() {
1849 format!("Command succeeded (exit code {exit_code}) with no output.")
1850 } else {
1851 format!("Exit code {exit_code}:\n{truncated}")
1852 }
1853 } else {
1854 format!("Command failed (exit code {exit_code}):\n{truncated}")
1855 }
1856 }
1857
1858 // ── background tasks (command_output / kill_command) ─────────────────────────
1859
1860 /// A command started with `run_in_background`. The child is never detached,
1861 /// so background tasks die with the sigit process.
1862 struct BackgroundTask {
1863 command: String,
1864 child: std::process::Child,
1865 /// Combined stdout+stderr drained by the reader threads, shared with them.
1866 output: Arc<Mutex<TaskOutput>>,
1867 /// Exit code cached once observed; `-1` when there is no code (killed by
1868 /// a signal). `None` while the task is still running.
1869 exit_code: Option<i32>,
1870 /// Set when the task was stopped via `kill_command`.
1871 killed: bool,
1872 }
1873
1874 /// Output accumulated for a background task since the last poll.
1875 #[derive(Default)]
1876 struct TaskOutput {
1877 buf: String,
1878 /// Whether the oldest output was dropped because `buf` hit the cap.
1879 dropped: bool,
1880 }
1881
1882 /// Process-global background task table (same pattern as `mcp::MCP`).
1883 fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> {
1884 static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new();
1885 TASKS.get_or_init(|| Mutex::new(HashMap::new()))
1886 }
1887
1888 fn lock_tasks() -> std::sync::MutexGuard<'static, HashMap<u64, BackgroundTask>> {
1889 tasks()
1890 .lock()
1891 .unwrap_or_else(|poisoned| poisoned.into_inner())
1892 }
1893
1894 fn next_task_id() -> u64 {
1895 static NEXT_ID: AtomicU64 = AtomicU64::new(1);
1896 NEXT_ID.fetch_add(1, Ordering::Relaxed)
1897 }
1898
1899 /// Drain a child's stream into the task's shared buffer on a plain std thread,
1900 /// capping the buffer at COMMAND_OUTPUT_LIMIT by dropping the oldest output.
1901 fn spawn_output_reader<R: std::io::Read + Send + 'static>(
1902 mut stream: R,
1903 output: Arc<Mutex<TaskOutput>>,
1904 ) {
1905 std::thread::spawn(move || {
1906 let mut chunk = [0u8; 8192];
1907 loop {
1908 match stream.read(&mut chunk) {
1909 Ok(0) | Err(_) => break,
1910 Ok(n) => {
1911 let text = String::from_utf8_lossy(&chunk[..n]).into_owned();
1912 let mut out = output
1913 .lock()
1914 .unwrap_or_else(|poisoned| poisoned.into_inner());
1915 out.buf.push_str(&text);
1916 if out.buf.len() > COMMAND_OUTPUT_LIMIT {
1917 let mut cut = out.buf.len() - COMMAND_OUTPUT_LIMIT;
1918 while !out.buf.is_char_boundary(cut) {
1919 cut += 1;
1920 }
1921 out.buf.drain(..cut);
1922 out.dropped = true;
1923 }
1924 }
1925 }
1926 }
1927 });
1928 }
1929
1930 /// Background branch of `run_command`: spawn, register, return immediately.
1931 fn start_background_task(command_str: &str, cwd_path: &Path) -> String {
1932 let mut child = match spawn_shell(command_str, cwd_path) {
1933 Ok(c) => c,
1934 Err(err) => return format!("Error: failed to spawn command: {err}"),
1935 };
1936
1937 let output = Arc::new(Mutex::new(TaskOutput::default()));
1938 if let Some(stdout) = child.stdout.take() {
1939 spawn_output_reader(stdout, Arc::clone(&output));
1940 }
1941 if let Some(stderr) = child.stderr.take() {
1942 spawn_output_reader(stderr, Arc::clone(&output));
1943 }
1944
1945 let task_id = next_task_id();
1946 lock_tasks().insert(
1947 task_id,
1948 BackgroundTask {
1949 command: command_str.to_string(),
1950 child,
1951 output,
1952 exit_code: None,
1953 killed: false,
1954 },
1955 );
1956
1957 format!(
1958 "Started background task {task_id}: `{command_str}`. Poll its output and status \
1959 with command_output (task_id: {task_id}); stop it with kill_command. The task is \
1960 killed when sigit exits."
1961 )
1962 }
1963
1964 /// Check (and cache) whether a task's child has exited. Returns the exit code,
1965 /// or `None` while it is still running.
1966 fn poll_exit_code(task: &mut BackgroundTask) -> Option<i32> {
1967 if task.exit_code.is_none()
1968 && let Ok(Some(status)) = task.child.try_wait()
1969 {
1970 task.exit_code = Some(status.code().unwrap_or(-1));
1971 }
1972 task.exit_code
1973 }
1974
1975 /// Take everything the task has printed since the last poll, plus whether
1976 /// older output was dropped at the buffer cap.
1977 fn drain_task_output(task: &BackgroundTask) -> (String, bool) {
1978 let mut out = task
1979 .output
1980 .lock()
1981 .unwrap_or_else(|poisoned| poisoned.into_inner());
1982 (
1983 std::mem::take(&mut out.buf),
1984 std::mem::take(&mut out.dropped),
1985 )
1986 }
1987
1988 fn parse_task_id(arguments: &str) -> Result<u64, String> {
1989 let args: Value = serde_json::from_str(arguments)
1990 .map_err(|err| format!("Error: failed to parse arguments: {err}"))?;
1991 args.get("task_id")
1992 .and_then(Value::as_u64)
1993 .ok_or_else(|| "Error: missing required parameter \"task_id\"".to_string())
1994 }
1995
1996 fn unknown_task(task_id: u64) -> String {
1997 format!(
1998 "Error: no background task with id {task_id}. Start one with run_command and \
1999 run_in_background set to true."
2000 )
2001 }
2002
2003 fn dropped_note(dropped: bool) -> &'static str {
2004 if dropped {
2005 "\n(note: earlier output was dropped after exceeding the 50000-byte buffer)"
2006 } else {
2007 ""
2008 }
2009 }
2010
2011 /// `command_output` tool: output since the last poll + running/exited status.
2012 fn exec_command_output(arguments: &str) -> String {
2013 let task_id = match parse_task_id(arguments) {
2014 Ok(id) => id,
2015 Err(err) => return err,
2016 };
2017
2018 let mut map = lock_tasks();
2019 let Some(task) = map.get_mut(&task_id) else {
2020 return unknown_task(task_id);
2021 };
2022
2023 let was_running = task.exit_code.is_none();
2024 let exit_code = poll_exit_code(task);
2025 if was_running && exit_code.is_some() {
2026 // The child just exited; give the reader threads a moment to flush
2027 // the final output through the pipes before draining the buffer.
2028 std::thread::sleep(std::time::Duration::from_millis(100));
2029 }
2030 let (new_output, dropped) = drain_task_output(task);
2031
2032 let command = &task.command;
2033 let status = match exit_code {
2034 None => format!("Task {task_id} (`{command}`) is still running."),
2035 Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."),
2036 Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."),
2037 };
2038
2039 if new_output.is_empty() {
2040 format!(
2041 "{status} No new output since the last check.{}",
2042 dropped_note(dropped)
2043 )
2044 } else {
2045 format!(
2046 "{status} New output since the last check:{}\n{new_output}",
2047 dropped_note(dropped)
2048 )
2049 }
2050 }
2051
2052 /// `kill_command` tool: stop a background task and report its output tail.
2053 fn exec_kill_command(arguments: &str) -> String {
2054 let task_id = match parse_task_id(arguments) {
2055 Ok(id) => id,
2056 Err(err) => return err,
2057 };
2058
2059 let mut map = lock_tasks();
2060 let Some(task) = map.get_mut(&task_id) else {
2061 return unknown_task(task_id);
2062 };
2063
2064 if let Some(code) = poll_exit_code(task) {
2065 let (new_output, dropped) = drain_task_output(task);
2066 let command = &task.command;
2067 let tail = if new_output.is_empty() {
2068 String::new()
2069 } else {
2070 format!(" Unread output:\n{new_output}")
2071 };
2072 return format!(
2073 "Task {task_id} (`{command}`) had already exited with code {code}; nothing to \
2074 kill.{}{tail}",
2075 dropped_note(dropped)
2076 );
2077 }
2078
2079 if let Err(err) = task.child.kill() {
2080 return format!("Error: failed to kill task {task_id}: {err}");
2081 }
2082 match task.child.wait() {
2083 Ok(status) => task.exit_code = Some(status.code().unwrap_or(-1)),
2084 Err(_) => task.exit_code = Some(-1),
2085 }
2086 task.killed = true;
2087
2088 // Let the reader threads flush whatever was in flight before reporting.
2089 std::thread::sleep(std::time::Duration::from_millis(100));
2090 let (new_output, dropped) = drain_task_output(task);
2091
2092 let command = &task.command;
2093 let tail = if new_output.is_empty() {
2094 " It produced no unread output.".to_string()
2095 } else {
2096 format!(" Last output:\n{new_output}")
2097 };
2098 format!(
2099 "Killed task {task_id} (`{command}`).{}{tail}",
2100 dropped_note(dropped)
2101 )
2102 }
2103
2104 #[cfg(test)]
2105 mod tests {
2106 use super::*;
2107 use std::fs;
2108
2109 #[tokio::test]
2110 async fn test_execute_unknown_tool() {
2111 let result = execute_tool("nonexistent", "{}").await;
2112 assert!(result.starts_with("Unknown tool:"));
2113 }
2114
2115 #[test]
2116 fn test_read_file_missing_path_param() {
2117 let result = exec_read_file("{}");
2118 assert!(result.contains("missing required parameter"));
2119 }
2120
2121 #[test]
2122 fn test_read_file_nonexistent() {
2123 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
2124 assert!(result.contains("does not exist"));
2125 }
2126
2127 #[test]
2128 fn test_read_file_success() {
2129 let dir = std::env::temp_dir().join("sigit_test_read_file");
2130 let _ = fs::create_dir_all(&dir);
2131 let file_path = dir.join("hello.txt");
2132 fs::write(&file_path, "hello world").unwrap();
2133
2134 let args = serde_json::json!({ "path": file_path }).to_string();
2135 let result = exec_read_file(&args);
2136 assert_eq!(result, "hello world");
2137
2138 let _ = fs::remove_dir_all(&dir);
2139 }
2140
2141 #[test]
2142 fn test_list_directory_missing_path_param() {
2143 let result = exec_list_directory("{}");
2144 assert!(result.contains("missing required parameter"));
2145 }
2146
2147 #[test]
2148 fn test_list_directory_success() {
2149 let dir = std::env::temp_dir().join("sigit_test_list_dir");
2150 let _ = fs::remove_dir_all(&dir);
2151 fs::create_dir_all(dir.join("subdir")).unwrap();
2152 fs::write(dir.join("aaa.txt"), "").unwrap();
2153 fs::write(dir.join("bbb.rs"), "").unwrap();
2154
2155 let args = serde_json::json!({ "path": dir }).to_string();
2156 let result = exec_list_directory(&args);
2157
2158 assert!(result.contains("[DIR] subdir"));
2159 assert!(result.contains("[FILE] aaa.txt"));
2160 assert!(result.contains("[FILE] bbb.rs"));
2161
2162 // Directories should appear before files.
2163 let dir_pos = result.find("[DIR]").unwrap();
2164 let file_pos = result.find("[FILE]").unwrap();
2165 assert!(dir_pos < file_pos);
2166
2167 let _ = fs::remove_dir_all(&dir);
2168 }
2169
2170 #[test]
2171 fn test_search_files_invalid_regex() {
2172 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
2173 assert!(result.contains("invalid regex"));
2174 }
2175
2176 #[test]
2177 fn test_search_files_success() {
2178 let dir = std::env::temp_dir().join("sigit_test_search");
2179 let _ = fs::remove_dir_all(&dir);
2180 fs::create_dir_all(&dir).unwrap();
2181 fs::write(
2182 dir.join("code.rs"),
2183 "fn main() {\n println!(\"hello\");\n}\n",
2184 )
2185 .unwrap();
2186 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
2187
2188 let args = serde_json::json!({
2189 "pattern": "println",
2190 "path": dir
2191 })
2192 .to_string();
2193 let result = exec_search_files(&args);
2194
2195 assert!(result.contains("code.rs:2:"));
2196 assert!(result.contains("println"));
2197 assert!(!result.contains("other.txt"));
2198
2199 let _ = fs::remove_dir_all(&dir);
2200 }
2201
2202 #[test]
2203 fn test_search_files_no_matches() {
2204 let dir = std::env::temp_dir().join("sigit_test_search_none");
2205 let _ = fs::remove_dir_all(&dir);
2206 fs::create_dir_all(&dir).unwrap();
2207 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
2208
2209 let args = serde_json::json!({
2210 "pattern": "zzz_will_not_match_42",
2211 "path": dir
2212 })
2213 .to_string();
2214 let result = exec_search_files(&args);
2215 assert!(result.contains("No matches found"));
2216
2217 let _ = fs::remove_dir_all(&dir);
2218 }
2219
2220 #[test]
2221 fn test_all_tools_count() {
2222 let tools = all_tools();
2223 assert_eq!(tools.len(), 15);
2224 assert_eq!(tools[0].name, "read_file");
2225 assert_eq!(tools[1].name, "create_directory");
2226 assert_eq!(tools[2].name, "list_directory");
2227 assert_eq!(tools[3].name, "search_files");
2228 assert_eq!(tools[4].name, "read_website");
2229 assert_eq!(tools[5].name, "create_file");
2230 assert_eq!(tools[6].name, "edit_file");
2231 assert_eq!(tools[7].name, "delete_file");
2232 assert_eq!(tools[8].name, "run_command");
2233 assert_eq!(tools[9].name, "multi_edit");
2234 assert_eq!(tools[10].name, "glob");
2235 assert_eq!(tools[11].name, "write_todos");
2236 assert_eq!(tools[12].name, "remember");
2237 assert_eq!(tools[13].name, "command_output");
2238 assert_eq!(tools[14].name, "kill_command");
2239 }
2240
2241 #[test]
2242 fn test_edit_file_replace_all() {
2243 let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
2244 let _ = fs::remove_dir_all(&dir);
2245 fs::create_dir_all(&dir).unwrap();
2246 let file = dir.join("f.txt");
2247 fs::write(&file, "foo foo foo").unwrap();
2248
2249 // Without replace_all an ambiguous match is rejected.
2250 let args =
2251 serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
2252 let result = exec_edit_file(&args);
2253 assert!(result.contains("appears 3 times"), "{result}");
2254
2255 // With replace_all every occurrence is changed.
2256 let args = serde_json::json!({
2257 "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
2258 })
2259 .to_string();
2260 let result = exec_edit_file(&args);
2261 assert!(result.starts_with("Edited file:"), "{result}");
2262 assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
2263
2264 let _ = fs::remove_dir_all(&dir);
2265 }
2266
2267 #[test]
2268 fn test_edit_file_whitespace_hint() {
2269 let dir = std::env::temp_dir().join("sigit_test_edit_hint");
2270 let _ = fs::remove_dir_all(&dir);
2271 fs::create_dir_all(&dir).unwrap();
2272 let file = dir.join("f.txt");
2273 fs::write(&file, "line one\n indented\nline three\n").unwrap();
2274
2275 // old_text has more indentation than the file, so it isn't a substring,
2276 // but its trimmed content still locates the intended line.
2277 let args = serde_json::json!({
2278 "path": &file, "old_text": " indented", "new_text": "x"
2279 })
2280 .to_string();
2281 let result = exec_edit_file(&args);
2282 assert!(result.contains("line 2"), "{result}");
2283 assert!(result.contains("whitespace"), "{result}");
2284
2285 let _ = fs::remove_dir_all(&dir);
2286 }
2287
2288 #[test]
2289 fn test_multi_edit_atomic_on_failure() {
2290 let dir = std::env::temp_dir().join("sigit_test_multi_edit");
2291 let _ = fs::remove_dir_all(&dir);
2292 fs::create_dir_all(&dir).unwrap();
2293 let file = dir.join("f.txt");
2294 fs::write(&file, "alpha beta gamma").unwrap();
2295
2296 // Second edit can't match -> nothing should be written.
2297 let args = serde_json::json!({
2298 "path": &file,
2299 "edits": [
2300 { "old_text": "alpha", "new_text": "ALPHA" },
2301 { "old_text": "nope", "new_text": "x" }
2302 ]
2303 })
2304 .to_string();
2305 let result = exec_multi_edit(&args);
2306 assert!(result.contains("edit #2 failed"), "{result}");
2307 assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
2308
2309 // All-matching batch applies in sequence.
2310 let args = serde_json::json!({
2311 "path": &file,
2312 "edits": [
2313 { "old_text": "alpha", "new_text": "ALPHA" },
2314 { "old_text": "gamma", "new_text": "GAMMA" }
2315 ]
2316 })
2317 .to_string();
2318 let result = exec_multi_edit(&args);
2319 assert!(result.contains("Applied 2 edits"), "{result}");
2320 assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
2321
2322 let _ = fs::remove_dir_all(&dir);
2323 }
2324
2325 #[test]
2326 fn test_glob_to_regex() {
2327 let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
2328 assert!(re.is_match("src/tools.rs"));
2329 assert!(re.is_match("main.rs")); // `**/` matches zero directories too
2330 assert!(!re.is_match("src/tools.txt"));
2331
2332 let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
2333 assert!(re.is_match("app.ts"));
2334 assert!(re.is_match("app.tsx"));
2335 assert!(!re.is_match("app.js"));
2336 }
2337
2338 #[test]
2339 fn test_glob_tool_success() {
2340 let dir = std::env::temp_dir().join("sigit_test_glob");
2341 let _ = fs::remove_dir_all(&dir);
2342 fs::create_dir_all(dir.join("src")).unwrap();
2343 fs::write(dir.join("Cargo.toml"), "").unwrap();
2344 fs::write(dir.join("src/main.rs"), "").unwrap();
2345 fs::write(dir.join("src/lib.rs"), "").unwrap();
2346
2347 let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
2348 let result = exec_glob(&args);
2349 assert!(result.contains("main.rs"), "{result}");
2350 assert!(result.contains("lib.rs"), "{result}");
2351 assert!(!result.contains("Cargo.toml"), "{result}");
2352
2353 let _ = fs::remove_dir_all(&dir);
2354 }
2355
2356 #[test]
2357 fn test_search_files_file_glob_filter() {
2358 let dir = std::env::temp_dir().join("sigit_test_search_glob");
2359 let _ = fs::remove_dir_all(&dir);
2360 fs::create_dir_all(&dir).unwrap();
2361 fs::write(dir.join("code.rs"), "needle here\n").unwrap();
2362 fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
2363
2364 let args = serde_json::json!({
2365 "pattern": "needle", "path": &dir, "file_glob": "*.rs"
2366 })
2367 .to_string();
2368 let result = exec_search_files(&args);
2369 assert!(result.contains("code.rs"), "{result}");
2370 assert!(!result.contains("notes.txt"), "{result}");
2371
2372 let _ = fs::remove_dir_all(&dir);
2373 }
2374
2375 #[test]
2376 fn test_write_todos_renders_checklist() {
2377 let args = serde_json::json!({
2378 "todos": [
2379 { "content": "Read code", "status": "completed" },
2380 { "content": "Make change", "status": "in_progress" },
2381 { "content": "Run tests", "status": "pending" }
2382 ]
2383 })
2384 .to_string();
2385 let result = exec_write_todos(&args);
2386 assert!(result.contains("1/3 done"), "{result}");
2387 assert!(result.contains("[x] Read code"), "{result}");
2388 assert!(result.contains("[~] Make change"), "{result}");
2389 assert!(result.contains("[ ] Run tests"), "{result}");
2390 }
2391
2392 #[test]
2393 fn test_remember_appends_to_instruction_file() {
2394 let dir = std::env::temp_dir().join("sigit_test_remember");
2395 let _ = fs::remove_dir_all(&dir);
2396 fs::create_dir_all(dir.join(".git")).unwrap();
2397 let claude_md = dir.join("CLAUDE.md");
2398 fs::write(&claude_md, "# Project\n").unwrap();
2399
2400 let target = crate::instructions::memory_file(&dir);
2401 // Should pick the existing CLAUDE.md at the repo root.
2402 assert_eq!(
2403 target.canonicalize().unwrap(),
2404 claude_md.canonicalize().unwrap()
2405 );
2406
2407 let result = remember_at(&dir, "remembered text");
2408 assert!(result.contains("remembered"), "{result}");
2409
2410 let updated = fs::read_to_string(&claude_md).unwrap();
2411 assert!(updated.contains("## Remembered notes"), "{updated}");
2412 assert!(updated.contains("- remembered text"), "{updated}");
2413
2414 let _ = fs::remove_dir_all(&dir);
2415 }
2416
2417 #[test]
2418 fn test_all_tools_schemas_are_valid_json_objects() {
2419 for tool in all_tools() {
2420 assert!(
2421 tool.parameters_schema.is_object(),
2422 "schema for {} is not an object",
2423 tool.name
2424 );
2425 let obj = tool.parameters_schema.as_object().unwrap();
2426 assert!(obj.contains_key("type"));
2427 assert!(obj.contains_key("properties"));
2428 assert!(obj.contains_key("required"));
2429 }
2430 }
2431
2432 // ── read_website tests ───────────────────────────────────────────────
2433
2434 #[test]
2435 fn test_read_website_missing_url() {
2436 let result = exec_read_website("{}");
2437 assert!(result.contains("missing required parameter"));
2438 }
2439
2440 #[test]
2441 fn test_read_website_invalid_scheme() {
2442 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
2443 assert!(result.contains("url must start with http:// or https://"));
2444 }
2445
2446 #[test]
2447 fn test_read_website_extracts_title_from_html() {
2448 let body = r#"
2449 <html>
2450 <head>
2451 <title>Qwen 3.6 27B</title>
2452 </head>
2453 <body>
2454 <h1>Model card</h1>
2455 <p>Large language model.</p>
2456 </body>
2457 </html>
2458 "#;
2459
2460 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
2461 .unwrap()
2462 .captures(body)
2463 .and_then(|captures| captures.get(1))
2464 .map(|m| {
2465 Regex::new(r"\s+")
2466 .unwrap()
2467 .replace_all(m.as_str(), " ")
2468 .trim()
2469 .to_string()
2470 })
2471 .filter(|title| !title.is_empty());
2472
2473 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
2474 }
2475
2476 #[test]
2477 fn test_read_website_metadata_includes_final_url_header() {
2478 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
2479 let title = Some("Qwen 3.6 27B".to_string());
2480 let cleaned = "Model card\nLarge language model.".to_string();
2481
2482 let mut metadata = vec![format!("URL: {final_url}")];
2483 if let Some(title) = &title {
2484 metadata.push(format!("Title: {title}"));
2485 }
2486
2487 let body_text = match title {
2488 Some(_) => cleaned,
2489 None => cleaned,
2490 };
2491
2492 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
2493
2494 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
2495 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
2496 }
2497
2498 // ── create_directory tests ───────────────────────────────────────────
2499
2500 #[test]
2501 fn test_create_directory_missing_path() {
2502 let result = exec_create_directory("{}");
2503 assert!(result.contains("missing required parameter"));
2504 }
2505
2506 #[test]
2507 fn test_create_directory_success() {
2508 let dir = std::env::temp_dir()
2509 .join("sigit_test_create_directory")
2510 .join("nested")
2511 .join("child");
2512 let _ = fs::remove_dir_all(dir.parent().unwrap());
2513
2514 let args = serde_json::json!({ "path": dir }).to_string();
2515 let result = exec_create_directory(&args);
2516 assert!(result.starts_with("Created directory:"), "got: {result}");
2517 assert!(dir.exists());
2518 assert!(dir.is_dir());
2519
2520 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
2521 }
2522
2523 #[test]
2524 fn test_create_directory_already_exists() {
2525 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
2526 let _ = fs::remove_dir_all(&dir);
2527 fs::create_dir_all(&dir).unwrap();
2528
2529 let args = serde_json::json!({ "path": dir }).to_string();
2530 let result = exec_create_directory(&args);
2531 assert!(result.contains("Directory already exists"), "got: {result}");
2532
2533 let _ = fs::remove_dir_all(&dir);
2534 }
2535
2536 // ── create_file tests ────────────────────────────────────────────────
2537
2538 #[test]
2539 fn test_create_file_missing_path() {
2540 let result = exec_create_file(r#"{"content": "hello"}"#);
2541 assert!(result.contains("missing required parameter"));
2542 }
2543
2544 #[test]
2545 fn test_create_file_missing_content() {
2546 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
2547 assert!(result.contains("missing required parameter"));
2548 }
2549
2550 #[test]
2551 fn test_create_file_success() {
2552 let dir = std::env::temp_dir().join("sigit_test_create_file");
2553 let _ = fs::remove_dir_all(&dir);
2554
2555 let file_path = dir.join("sub").join("new_file.txt");
2556 let args = serde_json::json!({
2557 "path": file_path,
2558 "content": "hello world"
2559 })
2560 .to_string();
2561
2562 let result = exec_create_file(&args);
2563 assert!(result.starts_with("Created file:"), "got: {result}");
2564 assert!(file_path.exists());
2565 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
2566
2567 let _ = fs::remove_dir_all(&dir);
2568 }
2569
2570 #[test]
2571 fn test_create_file_already_exists() {
2572 let dir = std::env::temp_dir().join("sigit_test_create_exists");
2573 let _ = fs::remove_dir_all(&dir);
2574 fs::create_dir_all(&dir).unwrap();
2575
2576 let file_path = dir.join("existing.txt");
2577 fs::write(&file_path, "original").unwrap();
2578
2579 let args = serde_json::json!({
2580 "path": file_path,
2581 "content": "overwrite attempt"
2582 })
2583 .to_string();
2584
2585 let result = exec_create_file(&args);
2586 assert!(result.contains("already exists"), "got: {result}");
2587 // Original content untouched.
2588 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2589
2590 let _ = fs::remove_dir_all(&dir);
2591 }
2592
2593 // ── edit_file tests ──────────────────────────────────────────────────
2594
2595 #[test]
2596 fn test_edit_file_missing_params() {
2597 let result = exec_edit_file(r#"{"path": "x"}"#);
2598 assert!(result.contains("missing required parameter"));
2599
2600 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
2601 assert!(result.contains("missing required parameter"));
2602 }
2603
2604 #[test]
2605 fn test_edit_file_nonexistent() {
2606 let result = exec_edit_file(
2607 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
2608 );
2609 assert!(result.contains("does not exist"));
2610 }
2611
2612 #[test]
2613 fn test_edit_file_success() {
2614 let dir = std::env::temp_dir().join("sigit_test_edit_file");
2615 let _ = fs::remove_dir_all(&dir);
2616 fs::create_dir_all(&dir).unwrap();
2617
2618 let file_path = dir.join("code.rs");
2619 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
2620
2621 let args = serde_json::json!({
2622 "path": file_path,
2623 "old_text": "println!(\"hello\")",
2624 "new_text": "println!(\"world\")"
2625 })
2626 .to_string();
2627
2628 let result = exec_edit_file(&args);
2629 assert!(result.starts_with("Edited file:"), "got: {result}");
2630
2631 let updated = fs::read_to_string(&file_path).unwrap();
2632 assert!(updated.contains("println!(\"world\")"));
2633 assert!(!updated.contains("println!(\"hello\")"));
2634
2635 let _ = fs::remove_dir_all(&dir);
2636 }
2637
2638 #[test]
2639 fn test_edit_file_old_text_not_found() {
2640 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
2641 let _ = fs::remove_dir_all(&dir);
2642 fs::create_dir_all(&dir).unwrap();
2643
2644 let file_path = dir.join("data.txt");
2645 fs::write(&file_path, "aaa bbb ccc").unwrap();
2646
2647 let args = serde_json::json!({
2648 "path": file_path,
2649 "old_text": "zzz",
2650 "new_text": "yyy"
2651 })
2652 .to_string();
2653
2654 let result = exec_edit_file(&args);
2655 assert!(result.contains("old_text not found"), "got: {result}");
2656
2657 let _ = fs::remove_dir_all(&dir);
2658 }
2659
2660 #[test]
2661 fn test_edit_file_ambiguous_match() {
2662 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
2663 let _ = fs::remove_dir_all(&dir);
2664 fs::create_dir_all(&dir).unwrap();
2665
2666 let file_path = dir.join("repeat.txt");
2667 fs::write(&file_path, "foo bar foo bar foo").unwrap();
2668
2669 let args = serde_json::json!({
2670 "path": file_path,
2671 "old_text": "foo",
2672 "new_text": "baz"
2673 })
2674 .to_string();
2675
2676 let result = exec_edit_file(&args);
2677 assert!(result.contains("appears 3 times"), "got: {result}");
2678 // File should be unchanged.
2679 assert_eq!(
2680 fs::read_to_string(&file_path).unwrap(),
2681 "foo bar foo bar foo"
2682 );
2683
2684 let _ = fs::remove_dir_all(&dir);
2685 }
2686
2687 // ── delete_file tests ────────────────────────────────────────────────
2688
2689 #[test]
2690 fn test_delete_file_missing_path() {
2691 let result = exec_delete_file("{}");
2692 assert!(
2693 result.contains("missing required parameter"),
2694 "got: {result}"
2695 );
2696 }
2697
2698 #[test]
2699 fn test_delete_file_nonexistent() {
2700 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
2701 assert!(result.contains("does not exist"), "got: {result}");
2702 }
2703
2704 #[test]
2705 fn test_delete_file_success() {
2706 let dir = std::env::temp_dir().join("sigit_test_delete_file");
2707 let _ = fs::remove_dir_all(&dir);
2708 fs::create_dir_all(&dir).unwrap();
2709
2710 let file_path = dir.join("to_delete.txt");
2711 fs::write(&file_path, "bye").unwrap();
2712 assert!(file_path.exists());
2713
2714 let args = serde_json::json!({ "path": file_path }).to_string();
2715 let result = exec_delete_file(&args);
2716 assert!(result.contains("Deleted file"), "got: {result}");
2717 assert!(!file_path.exists());
2718
2719 let _ = fs::remove_dir_all(&dir);
2720 }
2721
2722 #[test]
2723 fn test_delete_empty_directory() {
2724 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
2725 let _ = fs::remove_dir_all(&dir);
2726 fs::create_dir_all(&dir).unwrap();
2727
2728 let args = serde_json::json!({ "path": dir }).to_string();
2729 let result = exec_delete_file(&args);
2730 assert!(result.contains("Deleted empty directory"), "got: {result}");
2731 assert!(!dir.exists());
2732 }
2733
2734 #[test]
2735 fn test_delete_nonempty_directory() {
2736 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
2737 let _ = fs::remove_dir_all(&dir);
2738 fs::create_dir_all(&dir).unwrap();
2739 fs::write(dir.join("child.txt"), "content").unwrap();
2740
2741 let args = serde_json::json!({ "path": dir }).to_string();
2742 let result = exec_delete_file(&args);
2743 assert!(result.contains("Error"), "got: {result}");
2744 assert!(dir.exists(), "directory should not have been deleted");
2745
2746 let _ = fs::remove_dir_all(&dir);
2747 }
2748
2749 // ── run_command tests ────────────────────────────────────────────────
2750
2751 #[test]
2752 fn test_run_command_missing_command() {
2753 let result = exec_run_command("{}");
2754 assert!(
2755 result.contains("missing required parameter"),
2756 "got: {result}"
2757 );
2758 }
2759
2760 #[test]
2761 fn test_run_command_success() {
2762 let result = exec_run_command(r#"{"command": "echo hello"}"#);
2763 assert!(result.contains("hello"), "got: {result}");
2764 assert!(result.contains("Exit code 0"), "got: {result}");
2765 }
2766
2767 /// Fresh git repo with one commit, test identity, and signing off (the
2768 /// developer's global gpgsign must not leak into sandbox commits).
2769 fn init_test_repo(name: &str) -> std::path::PathBuf {
2770 let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id()));
2771 let _ = fs::remove_dir_all(&dir);
2772 fs::create_dir_all(&dir).unwrap();
2773 test_git(&dir, &["init", "-q", "-b", "main"]);
2774 test_git(&dir, &["config", "user.name", "Test User"]);
2775 test_git(&dir, &["config", "user.email", "test@example.com"]);
2776 test_git(&dir, &["config", "commit.gpgsign", "false"]);
2777 fs::write(dir.join("file.txt"), "one\n").unwrap();
2778 test_git(&dir, &["add", "file.txt"]);
2779 test_git(&dir, &["commit", "-q", "-m", "Initial"]);
2780 dir
2781 }
2782
2783 fn test_git(dir: &Path, args: &[&str]) {
2784 let out = Command::new("git")
2785 .args(args)
2786 .current_dir(dir)
2787 .output()
2788 .unwrap();
2789 assert!(
2790 out.status.success(),
2791 "git {args:?} failed: {}",
2792 String::from_utf8_lossy(&out.stderr)
2793 );
2794 }
2795
2796 #[test]
2797 fn run_command_appends_co_author_trailer_to_new_commits() {
2798 let dir = init_test_repo("coauthor_append");
2799 fs::write(dir.join("file.txt"), "two\n").unwrap();
2800 // Quote-free command: `cmd /C` does not strip double quotes the way
2801 // `sh -c` does, so quoted arguments would break on Windows.
2802 let args = serde_json::json!({
2803 "command": "git add file.txt && git commit -m Update",
2804 "cwd": dir.display().to_string(),
2805 })
2806 .to_string();
2807
2808 let result = exec_run_command(&args);
2809 assert!(result.contains("co-author trailer"), "got: {result}");
2810
2811 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2812 assert!(
2813 message.ends_with(COMMIT_CO_AUTHOR_TRAILER),
2814 "trailer must be the last line: {message:?}"
2815 );
2816 assert!(
2817 message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")),
2818 "trailer needs a blank line before it for GitHub to detect it: {message:?}"
2819 );
2820 let _ = fs::remove_dir_all(&dir);
2821 }
2822
2823 #[test]
2824 fn run_command_keeps_existing_co_author_trailer() {
2825 let dir = init_test_repo("coauthor_present");
2826 fs::write(dir.join("file.txt"), "two\n").unwrap();
2827 // The trailer contains spaces and angle brackets, which `cmd /C`
2828 // mis-tokenizes (`<` is redirection), so create the trailer-carrying
2829 // commit with direct git args and let run_command amend it without
2830 // editing: HEAD changes, the message already has the trailer, and the
2831 // gate must leave it alone.
2832 test_git(&dir, &["add", "file.txt"]);
2833 test_git(
2834 &dir,
2835 &[
2836 "commit",
2837 "-q",
2838 "-m",
2839 &format!("Update file\n\n{COMMIT_CO_AUTHOR_TRAILER}"),
2840 ],
2841 );
2842 let args = serde_json::json!({
2843 "command": "git commit --amend --no-edit",
2844 "cwd": dir.display().to_string(),
2845 })
2846 .to_string();
2847
2848 let result = exec_run_command(&args);
2849 assert!(
2850 !result.contains("[siGit Code]"),
2851 "no amend expected: {result}"
2852 );
2853
2854 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2855 assert_eq!(
2856 message.matches("Co-Authored-By: siGit Code").count(),
2857 1,
2858 "trailer must not be duplicated: {message:?}"
2859 );
2860 let _ = fs::remove_dir_all(&dir);
2861 }
2862
2863 #[test]
2864 fn run_command_never_amends_pushed_commits() {
2865 let dir = init_test_repo("coauthor_pushed");
2866 let remote = std::env::temp_dir().join(format!(
2867 "sigit_test_coauthor_remote_{}.git",
2868 std::process::id()
2869 ));
2870 let _ = fs::remove_dir_all(&remote);
2871 fs::create_dir_all(&remote).unwrap();
2872 test_git(&remote, &["init", "-q", "--bare"]);
2873 test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]);
2874
2875 fs::write(dir.join("file.txt"), "two\n").unwrap();
2876 let args = serde_json::json!({
2877 "command": "git add file.txt && git commit -m Update && git push -q origin main",
2878 "cwd": dir.display().to_string(),
2879 })
2880 .to_string();
2881
2882 let result = exec_run_command(&args);
2883 assert!(
2884 !result.contains("[siGit Code]"),
2885 "no amend expected: {result}"
2886 );
2887
2888 // Already on the remote when the gate ran, so it must be untouched.
2889 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2890 assert!(
2891 !message.contains("Co-Authored-By"),
2892 "pushed commit must not be rewritten: {message:?}"
2893 );
2894 let _ = fs::remove_dir_all(&dir);
2895 let _ = fs::remove_dir_all(&remote);
2896 }
2897
2898 #[test]
2899 fn test_run_command_failure() {
2900 #[cfg(unix)]
2901 let command = "false";
2902 #[cfg(windows)]
2903 let command = "exit /b 1";
2904
2905 let args = serde_json::json!({ "command": command }).to_string();
2906 let result = exec_run_command(&args);
2907 assert!(result.contains("failed"), "got: {result}");
2908 }
2909
2910 #[test]
2911 fn test_run_command_with_cwd() {
2912 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
2913 let _ = fs::remove_dir_all(&dir);
2914 fs::create_dir_all(&dir).unwrap();
2915
2916 #[cfg(unix)]
2917 let command = "pwd";
2918 #[cfg(windows)]
2919 let command = "cd";
2920
2921 let args = serde_json::json!({
2922 "command": command,
2923 "cwd": dir
2924 })
2925 .to_string();
2926 let result = exec_run_command(&args);
2927 // The output should contain the temp dir path.
2928 assert!(
2929 result.contains(&dir.to_string_lossy().to_string()),
2930 "got: {result}"
2931 );
2932
2933 let _ = fs::remove_dir_all(&dir);
2934 }
2935
2936 #[test]
2937 fn test_run_command_bad_cwd() {
2938 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
2939 let _ = fs::remove_dir_all(&missing_dir);
2940
2941 let args = serde_json::json!({
2942 "command": "echo hi",
2943 "cwd": missing_dir
2944 })
2945 .to_string();
2946 let result = exec_run_command(&args);
2947 assert!(result.contains("does not exist"), "got: {result}");
2948 }
2949
2950 #[test]
2951 fn test_run_command_captures_stderr() {
2952 #[cfg(unix)]
2953 let command = "echo err >&2";
2954 #[cfg(windows)]
2955 let command = "echo err 1>&2";
2956
2957 let args = serde_json::json!({ "command": command }).to_string();
2958 let result = exec_run_command(&args);
2959 assert!(result.contains("err"), "got: {result}");
2960 }
2961
2962 // ── background command tests ─────────────────────────────────────────
2963
2964 /// Extract the task id from a "Started background task {id}: ..." result.
2965 fn background_task_id(result: &str) -> u64 {
2966 let digits: String = result
2967 .strip_prefix("Started background task ")
2968 .unwrap_or_else(|| panic!("unexpected spawn result: {result}"))
2969 .chars()
2970 .take_while(char::is_ascii_digit)
2971 .collect();
2972 digits.parse().expect("task id")
2973 }
2974
2975 /// Whether the task's child has exited, without draining its output.
2976 fn background_task_exited(task_id: u64) -> bool {
2977 let mut map = lock_tasks();
2978 match map.get_mut(&task_id) {
2979 Some(task) => poll_exit_code(task).is_some(),
2980 None => true,
2981 }
2982 }
2983
2984 /// Wait (bounded) for a background task's child to exit.
2985 fn wait_for_background_exit(task_id: u64) {
2986 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
2987 while !background_task_exited(task_id) {
2988 assert!(
2989 std::time::Instant::now() < deadline,
2990 "task {task_id} did not exit in time"
2991 );
2992 std::thread::sleep(std::time::Duration::from_millis(50));
2993 }
2994 }
2995
2996 #[test]
2997 fn test_run_command_background_lifecycle() {
2998 #[cfg(unix)]
2999 let command = "echo start; sleep 2; echo done";
3000 #[cfg(windows)]
3001 let command = "echo start&& ping -n 3 127.0.0.1 > nul&& echo done";
3002
3003 let args = serde_json::json!({
3004 "command": command,
3005 "cwd": std::env::temp_dir(),
3006 "run_in_background": true
3007 })
3008 .to_string();
3009 let spawned = std::time::Instant::now();
3010 let result = exec_run_command(&args);
3011 // Spawning must return immediately, not wait the ~2s the command takes.
3012 assert!(
3013 spawned.elapsed() < std::time::Duration::from_secs(1),
3014 "background spawn blocked for {:?}",
3015 spawned.elapsed()
3016 );
3017 assert!(result.contains("command_output"), "got: {result}");
3018 let task_id = background_task_id(&result);
3019
3020 // Polling while the command is still sleeping reports it as running.
3021 let poll_args = serde_json::json!({ "task_id": task_id }).to_string();
3022 let poll = exec_command_output(&poll_args);
3023 assert!(poll.contains("still running"), "got: {poll}");
3024 let mut combined = poll;
3025
3026 wait_for_background_exit(task_id);
3027 // Grace period so the reader threads finish draining the pipes.
3028 std::thread::sleep(std::time::Duration::from_millis(300));
3029 let final_poll = exec_command_output(&poll_args);
3030 assert!(
3031 final_poll.contains("exited with code 0"),
3032 "got: {final_poll}"
3033 );
3034 combined.push_str(&final_poll);
3035
3036 // Across the polls, all output the command printed was delivered.
3037 assert!(combined.contains("start"), "got: {combined}");
3038 assert!(combined.contains("done"), "got: {combined}");
3039 }
3040
3041 #[test]
3042 fn test_kill_command_stops_background_task() {
3043 #[cfg(unix)]
3044 let command = "sleep 30";
3045 #[cfg(windows)]
3046 let command = "ping -n 31 127.0.0.1 > nul";
3047
3048 let args = serde_json::json!({
3049 "command": command,
3050 "cwd": std::env::temp_dir(),
3051 "run_in_background": true
3052 })
3053 .to_string();
3054 let result = exec_run_command(&args);
3055 let task_id = background_task_id(&result);
3056
3057 let kill_args = serde_json::json!({ "task_id": task_id }).to_string();
3058 let killed_at = std::time::Instant::now();
3059 let kill_result = exec_kill_command(&kill_args);
3060 assert!(
3061 kill_result.contains(&format!("Killed task {task_id}")),
3062 "got: {kill_result}"
3063 );
3064 // The kill must not wait out the 30s sleep.
3065 assert!(
3066 killed_at.elapsed() < std::time::Duration::from_secs(5),
3067 "kill blocked for {:?}",
3068 killed_at.elapsed()
3069 );
3070
3071 // A later poll reports the task as killed, not still running.
3072 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3073 assert!(poll.contains("was killed"), "got: {poll}");
3074 }
3075
3076 #[test]
3077 fn test_command_output_unknown_task() {
3078 let result = exec_command_output(r#"{"task_id": 9999999}"#);
3079 assert!(
3080 result.contains("no background task with id"),
3081 "got: {result}"
3082 );
3083 }
3084
3085 // ── task (subagent) tests ────────────────────────────────────────────
3086
3087 #[test]
3088 fn test_subagent_toolset_is_read_only() {
3089 let specs = subagent_tool_specs();
3090 let mut names: Vec<&str> = specs.iter().map(|spec| spec.name.as_str()).collect();
3091 names.sort_unstable();
3092 assert_eq!(
3093 names,
3094 [
3095 "glob",
3096 "list_directory",
3097 "read_file",
3098 "read_website",
3099 "search_files"
3100 ]
3101 );
3102
3103 // No recursion and no mutating tools, ever.
3104 assert!(!names.contains(&TASK_TOOL_NAME));
3105 for banned in [
3106 "create_file",
3107 "create_directory",
3108 "edit_file",
3109 "multi_edit",
3110 "delete_file",
3111 "run_command",
3112 "write_todos",
3113 "remember",
3114 ] {
3115 assert!(!names.contains(&banned), "{banned} leaked into subagent");
3116 }
3117
3118 // Every spec came from `all_tools()` (schemas intact).
3119 for spec in &specs {
3120 assert!(
3121 serde_json::from_str::<Value>(&spec.parameters_schema)
3122 .unwrap()
3123 .is_object()
3124 );
3125 }
3126 }
3127
3128 #[tokio::test]
3129 async fn test_task_reports_unavailable_without_factory() {
3130 let args = serde_json::json!({
3131 "description": "look around",
3132 "prompt": "What is in the current directory?"
3133 })
3134 .to_string();
3135 // `None` is exactly what `exec_task` passes before any surface has
3136 // registered the process-global factory.
3137 let result = exec_task_with(&args, None).await;
3138 assert!(
3139 result.contains("not available on-device yet"),
3140 "got: {result}"
3141 );
3142 }
3143
3144 #[test]
3145 fn test_kill_command_unknown_task() {
3146 let result = exec_kill_command(r#"{"task_id": 9999999}"#);
3147 assert!(
3148 result.contains("no background task with id"),
3149 "got: {result}"
3150 );
3151 }
3152
3153 #[tokio::test]
3154 async fn test_task_missing_prompt() {
3155 let result = exec_task_with(r#"{"description": "x"}"#, None).await;
3156 assert!(
3157 result.contains("missing required parameter \"prompt\""),
3158 "got: {result}"
3159 );
3160 }
3161
3162 #[test]
3163 fn test_command_output_missing_task_id() {
3164 let result = exec_command_output("{}");
3165 assert!(
3166 result.contains("missing required parameter"),
3167 "got: {result}"
3168 );
3169 }
3170
3171 #[test]
3172 fn test_background_output_is_capped() {
3173 // Print well over COMMAND_OUTPUT_LIMIT (50 000) bytes without polling,
3174 // so the buffer must drop the oldest output and note the truncation.
3175 #[cfg(unix)]
3176 let command = r#"i=0; while [ $i -lt 200 ]; do printf 'line-%04d %s\n' "$i" \
3177 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3178 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3179 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3180 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3181 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; \
3182 i=$((i+1)); done"#;
3183 // Quote-free on purpose: `cmd /C` does not strip double quotes the
3184 // way `sh -c` does, so a quoted PowerShell one-liner gets mangled.
3185 // A cmd-native for /L loop needs no quoting at all.
3186 #[cfg(windows)]
3187 let command = &format!(
3188 "for /L %i in (1,1,200) do @echo line-%i-end {}",
3189 "a".repeat(330)
3190 );
3191
3192 let args = serde_json::json!({
3193 "command": command,
3194 "cwd": std::env::temp_dir(),
3195 "run_in_background": true
3196 })
3197 .to_string();
3198 let result = exec_run_command(&args);
3199 let task_id = background_task_id(&result);
3200
3201 wait_for_background_exit(task_id);
3202 // Grace period so the reader threads finish draining the pipes.
3203 std::thread::sleep(std::time::Duration::from_millis(300));
3204
3205 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3206 assert!(poll.contains("exited with code 0"), "got: {poll}");
3207 assert!(
3208 poll.contains("earlier output was dropped"),
3209 "expected truncation note, got: {poll}"
3210 );
3211 // The oldest lines were dropped; the newest survived. The two
3212 // platforms number lines differently (printf %04d vs cmd's %i).
3213 #[cfg(unix)]
3214 let (oldest, newest) = ("line-0000", "line-0199");
3215 #[cfg(windows)]
3216 let (oldest, newest) = ("line-1-end", "line-200-end");
3217 assert!(!poll.contains(oldest), "oldest output not dropped");
3218 assert!(poll.contains(newest), "newest output missing");
3219 // Buffer stayed within the cap, plus the framing: the status line
3220 // (which quotes the command) and the truncation note.
3221 assert!(
3222 poll.len() <= COMMAND_OUTPUT_LIMIT + command.len() + 500,
3223 "poll result too large: {} bytes",
3224 poll.len()
3225 );
3226 }
3227
3228 // ── task (subagent) end-to-end against a scripted endpoint ──────────
3229
3230 /// A completion whose assistant message requests one tool call.
3231 fn completion_tool_call(id: &str, name: &str, arguments: &str) -> String {
3232 serde_json::json!({
3233 "choices": [{"message": {
3234 "role": "assistant",
3235 "content": serde_json::Value::Null,
3236 "tool_calls": [{
3237 "id": id,
3238 "type": "function",
3239 "function": {"name": name, "arguments": arguments},
3240 }],
3241 }}]
3242 })
3243 .to_string()
3244 }
3245
3246 /// A completion whose assistant message is a plain text answer.
3247 fn completion_text(text: &str) -> String {
3248 serde_json::json!({
3249 "choices": [{"message": {"role": "assistant", "content": text}}]
3250 })
3251 .to_string()
3252 }
3253
3254 /// Minimal scripted OpenAI-compatible endpoint (same pattern as
3255 /// `tests/acp_permissions.rs`): serves one canned chat-completion JSON body
3256 /// per request and records each request body. The subagent loop passes
3257 /// `sink: None`, so the backend takes the non-streaming path and expects
3258 /// plain JSON rather than SSE.
3259 fn start_scripted_endpoint(responses: Vec<String>) -> (u16, Arc<std::sync::Mutex<Vec<Value>>>) {
3260 use std::io::{BufRead, BufReader, Read, Write};
3261
3262 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind endpoint");
3263 let port = listener.local_addr().unwrap().port();
3264 let requests: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
3265 let recorded = Arc::clone(&requests);
3266 let queue = std::sync::Mutex::new(std::collections::VecDeque::from(responses));
3267
3268 std::thread::spawn(move || {
3269 // `connection: close` means one request per connection, matching
3270 // the backend's serial completion requests.
3271 for stream in listener.incoming() {
3272 let Ok(mut stream) = stream else { continue };
3273 let mut reader = BufReader::new(match stream.try_clone() {
3274 Ok(clone) => clone,
3275 Err(_) => continue,
3276 });
3277 let mut content_length = 0usize;
3278 loop {
3279 let mut line = String::new();
3280 if reader.read_line(&mut line).unwrap_or(0) == 0 {
3281 break;
3282 }
3283 let line = line.trim();
3284 if line.is_empty() {
3285 break;
3286 }
3287 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:")
3288 {
3289 content_length = length.trim().parse().unwrap_or(0);
3290 }
3291 }
3292 let mut body = vec![0u8; content_length];
3293 if reader.read_exact(&mut body).is_err() {
3294 continue;
3295 }
3296 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
3297 recorded.lock().unwrap().push(request);
3298 }
3299 let payload = queue
3300 .lock()
3301 .unwrap()
3302 .pop_front()
3303 .unwrap_or_else(|| completion_text("out of scripted responses"));
3304 let response = format!(
3305 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
3306 content-length: {}\r\nconnection: close\r\n\r\n{}",
3307 payload.len(),
3308 payload
3309 );
3310 let _ = stream.write_all(response.as_bytes());
3311 }
3312 });
3313
3314 (port, requests)
3315 }
3316
3317 #[tokio::test]
3318 async fn test_task_runs_subagent_end_to_end() {
3319 // A file only the subagent's read_file call can surface.
3320 let dir = std::env::temp_dir().join("sigit_test_subagent_e2e");
3321 let _ = fs::remove_dir_all(&dir);
3322 fs::create_dir_all(&dir).unwrap();
3323 let file = dir.join("notes.txt");
3324 fs::write(&file, "subagent secret: 4217").unwrap();
3325
3326 // Script: one read_file tool call, then a final text answer.
3327 let (port, requests) = start_scripted_endpoint(vec![
3328 completion_tool_call(
3329 "call_1",
3330 "read_file",
3331 &serde_json::json!({ "path": file }).to_string(),
3332 ),
3333 completion_text("The file contains the number 4217."),
3334 ]);
3335
3336 // Register the real process-global factory, pointing a fresh
3337 // OpenAiBackend at the scripted endpoint — exactly what the surfaces
3338 // do at startup. This is the only test that touches the OnceLock.
3339 set_subagent_factory(Box::new(move || {
3340 Some(Arc::new(crate::backend::OpenAiBackend::new(
3341 format!("http://127.0.0.1:{port}"),
3342 "test-key",
3343 "scripted-model",
3344 Some(SUBAGENT_SYSTEM_PROMPT.to_string()),
3345 )) as Arc<dyn InferenceBackend>)
3346 }));
3347 assert!(subagent_available());
3348
3349 let args = serde_json::json!({
3350 "description": "read the notes file",
3351 "prompt": format!("What number is recorded in {}?", file.display()),
3352 })
3353 .to_string();
3354 let result = execute_tool(TASK_TOOL_NAME, &args).await;
3355
3356 // Only the subagent's final text comes back.
3357 assert_eq!(result, "The file contains the number 4217.");
3358
3359 let recorded = requests.lock().unwrap();
3360 assert_eq!(recorded.len(), 2, "expected exactly two completions");
3361
3362 // The subagent conversation is fresh (subagent system prompt) and was
3363 // offered only the read-only toolset.
3364 let first = &recorded[0];
3365 assert_eq!(first["messages"][0]["role"], "system");
3366 assert!(
3367 first["messages"][0]["content"]
3368 .as_str()
3369 .unwrap()
3370 .contains("research subagent")
3371 );
3372 let offered: Vec<&str> = first["tools"]
3373 .as_array()
3374 .unwrap()
3375 .iter()
3376 .map(|tool| tool["function"]["name"].as_str().unwrap())
3377 .collect();
3378 for name in &offered {
3379 assert!(
3380 SUBAGENT_TOOL_NAMES.contains(name),
3381 "non-read-only tool offered: {name}"
3382 );
3383 }
3384 assert!(!offered.contains(&TASK_TOOL_NAME));
3385
3386 // The read-only tool actually executed: its output travelled back to
3387 // the endpoint as a tool result on the second request.
3388 let messages = recorded[1]["messages"].as_array().unwrap();
3389 let tool_message = messages
3390 .iter()
3391 .find(|message| message["role"] == "tool")
3392 .expect("no tool result in second request");
3393 assert_eq!(tool_message["tool_call_id"], "call_1");
3394 assert!(
3395 tool_message["content"]
3396 .as_str()
3397 .unwrap()
3398 .contains("subagent secret: 4217")
3399 );
3400
3401 let _ = fs::remove_dir_all(&dir);
3402 }
3403 }