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 by the `glob` tool (against relative paths), by
1350 /// `search_files`' `file_glob` filter (against bare file names), and by
1351 /// `crate::permissions` rule patterns (which re-anchor the result).
1352 pub(crate) fn glob_to_regex(glob: &str) -> String {
1353 let chars: Vec<char> = glob.chars().collect();
1354 let mut re = String::from("^");
1355 let mut brace_depth = 0usize;
1356 let mut i = 0;
1357
1358 while i < chars.len() {
1359 let c = chars[i];
1360 match c {
1361 '*' => {
1362 if i + 1 < chars.len() && chars[i + 1] == '*' {
1363 i += 1; // consume the second '*'
1364 if i + 1 < chars.len() && chars[i + 1] == '/' {
1365 // `**/` matches zero or more leading directories.
1366 re.push_str("(?:.*/)?");
1367 i += 1; // consume the '/'
1368 } else {
1369 re.push_str(".*");
1370 }
1371 } else {
1372 re.push_str("[^/]*");
1373 }
1374 }
1375 '?' => re.push_str("[^/]"),
1376 '{' => {
1377 brace_depth += 1;
1378 re.push_str("(?:");
1379 }
1380 '}' if brace_depth > 0 => {
1381 brace_depth -= 1;
1382 re.push(')');
1383 }
1384 ',' if brace_depth > 0 => re.push('|'),
1385 // Escape regex metacharacters so they match literally. (`{` is always
1386 // consumed by the brace arm above; an unmatched `}` lands here.)
1387 '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1388 re.push('\\');
1389 re.push(c);
1390 }
1391 other => re.push(other),
1392 }
1393 i += 1;
1394 }
1395
1396 re.push('$');
1397 re
1398 }
1399
1400 fn exec_glob(arguments: &str) -> String {
1401 let args: Value = match serde_json::from_str(arguments) {
1402 Ok(v) => v,
1403 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1404 };
1405
1406 let pattern = match args.get("pattern").and_then(Value::as_str) {
1407 Some(p) => p,
1408 None => return "Error: missing required parameter \"pattern\"".to_string(),
1409 };
1410
1411 let re = match Regex::new(&glob_to_regex(pattern)) {
1412 Ok(r) => r,
1413 Err(err) => return format!("Error: invalid glob pattern: {err}"),
1414 };
1415
1416 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1417 let absolute_root = absolute_path(Path::new(root_str));
1418 let absolute_root_str = absolute_root.display().to_string();
1419
1420 if !absolute_root.exists() {
1421 return format!("Error: path does not exist: {absolute_root_str}");
1422 }
1423 if !absolute_root.is_dir() {
1424 return format!("Error: path is not a directory: {absolute_root_str}");
1425 }
1426
1427 let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1428 glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1429
1430 if found.is_empty() {
1431 return format!("No files match glob: {pattern}");
1432 }
1433
1434 // Most-recently-modified first.
1435 found.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
1436
1437 let total = found.len();
1438 let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1439 if total > SEARCH_RESULTS_HARD_CAP {
1440 paths.truncate(SEARCH_RESULTS_HARD_CAP);
1441 paths.push(format!(
1442 "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1443 ));
1444 }
1445
1446 paths.join("\n")
1447 }
1448
1449 fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1450 if out.len() > SEARCH_RESULTS_HARD_CAP {
1451 return;
1452 }
1453
1454 let entries = match fs::read_dir(dir) {
1455 Ok(rd) => rd,
1456 Err(_) => return,
1457 };
1458
1459 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1460 sorted.sort_by_key(|e| e.file_name());
1461
1462 for entry in sorted {
1463 if out.len() > SEARCH_RESULTS_HARD_CAP {
1464 return;
1465 }
1466
1467 let path = entry.path();
1468 let name = entry.file_name();
1469 if name.to_string_lossy().starts_with('.') {
1470 continue;
1471 }
1472
1473 if path.is_dir() {
1474 glob_walk(root, &path, re, out);
1475 } else if path.is_file() {
1476 let relative = path
1477 .strip_prefix(root)
1478 .unwrap_or(&path)
1479 .to_string_lossy()
1480 .replace('\\', "/");
1481 if re.is_match(&relative) {
1482 let mtime = entry
1483 .metadata()
1484 .and_then(|m| m.modified())
1485 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1486 out.push((mtime, absolute_path_string(&path)));
1487 }
1488 }
1489 }
1490 }
1491
1492 // ── write_todos ──────────────────────────────────────────────────────────────
1493
1494 /// Renders the model's task checklist back as the tool result so the surface
1495 /// (TUI / ACP client) can show live progress. Pure presentation — the list is
1496 /// owned by the model, not persisted here.
1497 fn exec_write_todos(arguments: &str) -> String {
1498 let args: Value = match serde_json::from_str(arguments) {
1499 Ok(v) => v,
1500 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1501 };
1502
1503 let todos = match args.get("todos").and_then(Value::as_array) {
1504 Some(t) if !t.is_empty() => t,
1505 Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1506 None => return "Error: missing required parameter \"todos\"".to_string(),
1507 };
1508
1509 let mut lines = Vec::with_capacity(todos.len());
1510 let mut completed = 0usize;
1511
1512 for (idx, todo) in todos.iter().enumerate() {
1513 let content = match todo.get("content").and_then(Value::as_str) {
1514 Some(c) => c.trim(),
1515 None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1516 };
1517 let status = todo
1518 .get("status")
1519 .and_then(Value::as_str)
1520 .unwrap_or("pending");
1521
1522 let marker = match status {
1523 "completed" => {
1524 completed += 1;
1525 "[x]"
1526 }
1527 "in_progress" => "[~]",
1528 _ => "[ ]",
1529 };
1530 lines.push(format!("{marker} {content}"));
1531 }
1532
1533 format!(
1534 "Task list updated ({completed}/{} done):\n{}",
1535 todos.len(),
1536 lines.join("\n")
1537 )
1538 }
1539
1540 // ── remember ─────────────────────────────────────────────────────────────────
1541
1542 /// Appends a durable note to the project's instruction file so it persists
1543 /// across sessions (the always-on counterpart to a one-off chat message).
1544 fn exec_remember(arguments: &str) -> String {
1545 let args: Value = match serde_json::from_str(arguments) {
1546 Ok(v) => v,
1547 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1548 };
1549
1550 let note = match args.get("note").and_then(Value::as_str) {
1551 Some(n) if !n.trim().is_empty() => n.trim(),
1552 Some(_) => return "Error: \"note\" must not be empty".to_string(),
1553 None => return "Error: missing required parameter \"note\"".to_string(),
1554 };
1555
1556 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1557 remember_at(&cwd, note)
1558 }
1559
1560 /// Core of `remember`, parameterized on the working directory so it can be
1561 /// tested without mutating the process-global current directory.
1562 fn remember_at(cwd: &Path, note: &str) -> String {
1563 let target = crate::instructions::memory_file(cwd);
1564 let target_str = target.display().to_string();
1565
1566 let existed = target.exists();
1567 let mut body = if existed {
1568 match fs::read_to_string(&target) {
1569 Ok(c) => c,
1570 Err(err) => return format!("Error: could not read {target_str}: {err}"),
1571 }
1572 } else {
1573 if let Some(parent) = target.parent()
1574 && !parent.as_os_str().is_empty()
1575 && !parent.exists()
1576 && let Err(err) = fs::create_dir_all(parent)
1577 {
1578 return format!("Error: could not create parent directories: {err}");
1579 }
1580 String::new()
1581 };
1582
1583 // Keep remembered notes grouped under one heading so the file stays tidy.
1584 const HEADING: &str = "## Remembered notes";
1585 if !body.contains(HEADING) {
1586 if !body.is_empty() && !body.ends_with('\n') {
1587 body.push('\n');
1588 }
1589 if !body.is_empty() {
1590 body.push('\n');
1591 }
1592 body.push_str(HEADING);
1593 body.push('\n');
1594 }
1595 if !body.ends_with('\n') {
1596 body.push('\n');
1597 }
1598 body.push_str("- ");
1599 body.push_str(note);
1600 body.push('\n');
1601
1602 match fs::write(&target, &body) {
1603 Ok(()) => {
1604 let verb = if existed { "Appended to" } else { "Created" };
1605 format!("{verb} {target_str}: remembered \"{note}\"")
1606 }
1607 Err(err) => format!("Error: could not write {target_str}: {err}"),
1608 }
1609 }
1610
1611 // ── delete_file ──────────────────────────────────────────────────────────────
1612
1613 /// only removes files or *empty* directories — no recursive deletes.
1614 fn exec_delete_file(arguments: &str) -> String {
1615 let args: Value = match serde_json::from_str(arguments) {
1616 Ok(v) => v,
1617 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1618 };
1619
1620 let path_str = match args.get("path").and_then(Value::as_str) {
1621 Some(p) => p,
1622 None => return "Error: missing required parameter \"path\"".to_string(),
1623 };
1624
1625 let path = Path::new(path_str);
1626 let absolute_path = absolute_path(path);
1627 let absolute_path_str = absolute_path.display().to_string();
1628
1629 if !absolute_path.exists() {
1630 return format!("Error: path does not exist: {absolute_path_str}");
1631 }
1632
1633 if absolute_path.is_dir() {
1634 match fs::remove_dir(&absolute_path) {
1635 Ok(()) => format!("Deleted empty directory: {absolute_path_str}"),
1636 Err(err) => format!(
1637 "Error: could not delete directory: {err}. \
1638 Only empty directories can be deleted."
1639 ),
1640 }
1641 } else {
1642 match fs::remove_file(&absolute_path) {
1643 Ok(()) => format!("Deleted file: {absolute_path_str}"),
1644 Err(err) => format!("Error: could not delete file: {err}"),
1645 }
1646 }
1647 }
1648
1649 // ── run_command ──────────────────────────────────────────────────────────────
1650
1651 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1652 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1653
1654 /// Spawn `command_str` through the platform shell with piped stdout/stderr.
1655 /// Shared by the foreground and background paths of `run_command`.
1656 fn spawn_shell(command_str: &str, cwd_path: &Path) -> std::io::Result<std::process::Child> {
1657 #[cfg(unix)]
1658 let (shell, flag) = ("sh", "-c");
1659 #[cfg(windows)]
1660 let (shell, flag) = ("cmd", "/C");
1661
1662 Command::new(shell)
1663 .arg(flag)
1664 .arg(command_str)
1665 .current_dir(cwd_path)
1666 .stdout(std::process::Stdio::piped())
1667 .stderr(std::process::Stdio::piped())
1668 .spawn()
1669 }
1670
1671 /// Trailer identifying siGit Code as the co-author of commits it creates.
1672 /// GitHub detects `Co-authored-by:` trailers on the last lines of a commit
1673 /// message (separated from the body by a blank line) and lists the agent
1674 /// alongside the human author; `sigit@sigit.si` belongs to the
1675 /// <https://github.com/sigitc> account ("siGit Code"), so the co-author is
1676 /// rendered with that account's avatar and profile link. The system prompt
1677 /// asks the model to add this itself; [`ensure_commit_co_author`] is the
1678 /// safety net when it forgets.
1679 pub const COMMIT_CO_AUTHOR_TRAILER: &str = "Co-Authored-By: siGit Code <sigit@sigit.si>";
1680
1681 /// Run `git <args>` in `cwd`, returning trimmed stdout on success.
1682 fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
1683 let output = Command::new("git")
1684 .args(args)
1685 .current_dir(cwd)
1686 .output()
1687 .ok()?;
1688 output
1689 .status
1690 .success()
1691 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1692 }
1693
1694 fn git_head(cwd: &Path) -> Option<String> {
1695 git_stdout(cwd, &["rev-parse", "HEAD"])
1696 }
1697
1698 /// Deterministic co-author attribution: if the commit at HEAD lacks the
1699 /// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which
1700 /// places it after a blank line — the format GitHub detects). Never rewrites
1701 /// a commit that is already on a remote. Returns a note describing the amend
1702 /// so the model and user can see it happened.
1703 fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
1704 let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?;
1705 if message
1706 .to_lowercase()
1707 .contains("co-authored-by: sigit code")
1708 {
1709 return None;
1710 }
1711 // Amending changes the commit id; a commit that any remote ref already
1712 // contains must be left alone or the branch diverges from its upstream.
1713 match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) {
1714 Some(remotes) if remotes.is_empty() => {}
1715 _ => return None,
1716 }
1717 let amend = Command::new("git")
1718 .args(["commit", "--amend", "--no-edit", "--trailer"])
1719 .arg(COMMIT_CO_AUTHOR_TRAILER)
1720 .current_dir(cwd)
1721 .output()
1722 .ok()?;
1723 if amend.status.success() {
1724 log::info!(
1725 "appended co-author trailer to the new commit in {}",
1726 cwd.display()
1727 );
1728 Some(format!(
1729 "[siGit Code] The new commit was amended to append the co-author trailer \
1730 \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)."
1731 ))
1732 } else {
1733 log::warn!(
1734 "could not append co-author trailer in {}: {}",
1735 cwd.display(),
1736 String::from_utf8_lossy(&amend.stderr).trim()
1737 );
1738 None
1739 }
1740 }
1741
1742 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT unless
1743 /// `run_in_background` is set, in which case the child is registered as a
1744 /// background task and polled with `command_output` / stopped with
1745 /// `kill_command`.
1746 fn exec_run_command(arguments: &str) -> String {
1747 let args: Value = match serde_json::from_str(arguments) {
1748 Ok(v) => v,
1749 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1750 };
1751
1752 let command_str = match args.get("command").and_then(Value::as_str) {
1753 Some(c) => c,
1754 None => return "Error: missing required parameter \"command\"".to_string(),
1755 };
1756
1757 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1758 let cwd = args
1759 .get("cwd")
1760 .and_then(Value::as_str)
1761 .unwrap_or(&default_cwd);
1762 let cwd_path = absolute_path(Path::new(cwd));
1763 let cwd_str = cwd_path.display().to_string();
1764
1765 if !cwd_path.exists() {
1766 return format!("Error: working directory does not exist: {cwd_str}");
1767 }
1768
1769 let run_in_background = args
1770 .get("run_in_background")
1771 .and_then(Value::as_bool)
1772 .unwrap_or(false);
1773
1774 log::info!("run_command: `{command_str}` in `{cwd_str}` (background: {run_in_background})");
1775
1776 if run_in_background {
1777 return start_background_task(command_str, &cwd_path);
1778 }
1779
1780 // Co-author attribution: note where HEAD is before a command that looks
1781 // like it may commit, so a new commit can be detected afterwards. The
1782 // string check is only a cheap trigger — a false positive costs one
1783 // `git rev-parse` and nothing else. Background tasks skip the gate: their
1784 // commits finish after the tool returns, when there is nothing to amend
1785 // from.
1786 let may_commit = command_str.contains("git") && command_str.contains("commit");
1787 let head_before = if may_commit {
1788 git_head(&cwd_path)
1789 } else {
1790 None
1791 };
1792
1793 let mut child = match spawn_shell(command_str, &cwd_path) {
1794 Ok(c) => c,
1795 Err(err) => return format!("Error: failed to spawn command: {err}"),
1796 };
1797
1798 let start = std::time::Instant::now();
1799 loop {
1800 match child.try_wait() {
1801 Ok(Some(_status)) => break,
1802 Ok(None) => {
1803 if start.elapsed() >= COMMAND_TIMEOUT {
1804 let _ = child.kill();
1805 return format!(
1806 "Error: command timed out after {} seconds and was killed.",
1807 COMMAND_TIMEOUT.as_secs()
1808 );
1809 }
1810 std::thread::sleep(std::time::Duration::from_millis(100));
1811 }
1812 Err(err) => return format!("Error: failed to wait on command: {err}"),
1813 }
1814 }
1815
1816 let output = match child.wait_with_output() {
1817 Ok(o) => o,
1818 Err(err) => return format!("Error: failed to read command output: {err}"),
1819 };
1820
1821 let exit_code = output.status.code().unwrap_or(-1);
1822 let mut combined = String::new();
1823 combined.push_str(&String::from_utf8_lossy(&output.stdout));
1824 combined.push_str(&String::from_utf8_lossy(&output.stderr));
1825
1826 // A new commit appeared under this command: make sure it carries the
1827 // siGit co-author trailer (see `ensure_commit_co_author`).
1828 if may_commit {
1829 let head_after = git_head(&cwd_path);
1830 if head_after.is_some()
1831 && head_after != head_before
1832 && let Some(note) = ensure_commit_co_author(&cwd_path)
1833 {
1834 if !combined.is_empty() && !combined.ends_with('\n') {
1835 combined.push('\n');
1836 }
1837 combined.push_str(&note);
1838 }
1839 }
1840
1841 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
1842 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
1843 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
1844 } else {
1845 combined
1846 };
1847
1848 if output.status.success() {
1849 if truncated.is_empty() {
1850 format!("Command succeeded (exit code {exit_code}) with no output.")
1851 } else {
1852 format!("Exit code {exit_code}:\n{truncated}")
1853 }
1854 } else {
1855 format!("Command failed (exit code {exit_code}):\n{truncated}")
1856 }
1857 }
1858
1859 // ── background tasks (command_output / kill_command) ─────────────────────────
1860
1861 /// A command started with `run_in_background`. The child is never detached,
1862 /// so background tasks die with the sigit process.
1863 struct BackgroundTask {
1864 command: String,
1865 child: std::process::Child,
1866 /// Combined stdout+stderr drained by the reader threads, shared with them.
1867 output: Arc<Mutex<TaskOutput>>,
1868 /// Exit code cached once observed; `-1` when there is no code (killed by
1869 /// a signal). `None` while the task is still running.
1870 exit_code: Option<i32>,
1871 /// Set when the task was stopped via `kill_command`.
1872 killed: bool,
1873 }
1874
1875 /// Output accumulated for a background task since the last poll.
1876 #[derive(Default)]
1877 struct TaskOutput {
1878 buf: String,
1879 /// Whether the oldest output was dropped because `buf` hit the cap.
1880 dropped: bool,
1881 }
1882
1883 /// Process-global background task table (same pattern as `mcp::MCP`).
1884 fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> {
1885 static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new();
1886 TASKS.get_or_init(|| Mutex::new(HashMap::new()))
1887 }
1888
1889 fn lock_tasks() -> std::sync::MutexGuard<'static, HashMap<u64, BackgroundTask>> {
1890 tasks()
1891 .lock()
1892 .unwrap_or_else(|poisoned| poisoned.into_inner())
1893 }
1894
1895 fn next_task_id() -> u64 {
1896 static NEXT_ID: AtomicU64 = AtomicU64::new(1);
1897 NEXT_ID.fetch_add(1, Ordering::Relaxed)
1898 }
1899
1900 /// Drain a child's stream into the task's shared buffer on a plain std thread,
1901 /// capping the buffer at COMMAND_OUTPUT_LIMIT by dropping the oldest output.
1902 fn spawn_output_reader<R: std::io::Read + Send + 'static>(
1903 mut stream: R,
1904 output: Arc<Mutex<TaskOutput>>,
1905 ) {
1906 std::thread::spawn(move || {
1907 let mut chunk = [0u8; 8192];
1908 loop {
1909 match stream.read(&mut chunk) {
1910 Ok(0) | Err(_) => break,
1911 Ok(n) => {
1912 let text = String::from_utf8_lossy(&chunk[..n]).into_owned();
1913 let mut out = output
1914 .lock()
1915 .unwrap_or_else(|poisoned| poisoned.into_inner());
1916 out.buf.push_str(&text);
1917 if out.buf.len() > COMMAND_OUTPUT_LIMIT {
1918 let mut cut = out.buf.len() - COMMAND_OUTPUT_LIMIT;
1919 while !out.buf.is_char_boundary(cut) {
1920 cut += 1;
1921 }
1922 out.buf.drain(..cut);
1923 out.dropped = true;
1924 }
1925 }
1926 }
1927 }
1928 });
1929 }
1930
1931 /// Background branch of `run_command`: spawn, register, return immediately.
1932 fn start_background_task(command_str: &str, cwd_path: &Path) -> String {
1933 let mut child = match spawn_shell(command_str, cwd_path) {
1934 Ok(c) => c,
1935 Err(err) => return format!("Error: failed to spawn command: {err}"),
1936 };
1937
1938 let output = Arc::new(Mutex::new(TaskOutput::default()));
1939 if let Some(stdout) = child.stdout.take() {
1940 spawn_output_reader(stdout, Arc::clone(&output));
1941 }
1942 if let Some(stderr) = child.stderr.take() {
1943 spawn_output_reader(stderr, Arc::clone(&output));
1944 }
1945
1946 let task_id = next_task_id();
1947 lock_tasks().insert(
1948 task_id,
1949 BackgroundTask {
1950 command: command_str.to_string(),
1951 child,
1952 output,
1953 exit_code: None,
1954 killed: false,
1955 },
1956 );
1957
1958 format!(
1959 "Started background task {task_id}: `{command_str}`. Poll its output and status \
1960 with command_output (task_id: {task_id}); stop it with kill_command. The task is \
1961 killed when sigit exits."
1962 )
1963 }
1964
1965 /// Check (and cache) whether a task's child has exited. Returns the exit code,
1966 /// or `None` while it is still running.
1967 fn poll_exit_code(task: &mut BackgroundTask) -> Option<i32> {
1968 if task.exit_code.is_none()
1969 && let Ok(Some(status)) = task.child.try_wait()
1970 {
1971 task.exit_code = Some(status.code().unwrap_or(-1));
1972 }
1973 task.exit_code
1974 }
1975
1976 /// Take everything the task has printed since the last poll, plus whether
1977 /// older output was dropped at the buffer cap.
1978 fn drain_task_output(task: &BackgroundTask) -> (String, bool) {
1979 let mut out = task
1980 .output
1981 .lock()
1982 .unwrap_or_else(|poisoned| poisoned.into_inner());
1983 (
1984 std::mem::take(&mut out.buf),
1985 std::mem::take(&mut out.dropped),
1986 )
1987 }
1988
1989 fn parse_task_id(arguments: &str) -> Result<u64, String> {
1990 let args: Value = serde_json::from_str(arguments)
1991 .map_err(|err| format!("Error: failed to parse arguments: {err}"))?;
1992 args.get("task_id")
1993 .and_then(Value::as_u64)
1994 .ok_or_else(|| "Error: missing required parameter \"task_id\"".to_string())
1995 }
1996
1997 fn unknown_task(task_id: u64) -> String {
1998 format!(
1999 "Error: no background task with id {task_id}. Start one with run_command and \
2000 run_in_background set to true."
2001 )
2002 }
2003
2004 fn dropped_note(dropped: bool) -> &'static str {
2005 if dropped {
2006 "\n(note: earlier output was dropped after exceeding the 50000-byte buffer)"
2007 } else {
2008 ""
2009 }
2010 }
2011
2012 /// `command_output` tool: output since the last poll + running/exited status.
2013 fn exec_command_output(arguments: &str) -> String {
2014 let task_id = match parse_task_id(arguments) {
2015 Ok(id) => id,
2016 Err(err) => return err,
2017 };
2018
2019 let mut map = lock_tasks();
2020 let Some(task) = map.get_mut(&task_id) else {
2021 return unknown_task(task_id);
2022 };
2023
2024 let was_running = task.exit_code.is_none();
2025 let exit_code = poll_exit_code(task);
2026 if was_running && exit_code.is_some() {
2027 // The child just exited; give the reader threads a moment to flush
2028 // the final output through the pipes before draining the buffer.
2029 std::thread::sleep(std::time::Duration::from_millis(100));
2030 }
2031 let (new_output, dropped) = drain_task_output(task);
2032
2033 let command = &task.command;
2034 let status = match exit_code {
2035 None => format!("Task {task_id} (`{command}`) is still running."),
2036 Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."),
2037 Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."),
2038 };
2039
2040 if new_output.is_empty() {
2041 format!(
2042 "{status} No new output since the last check.{}",
2043 dropped_note(dropped)
2044 )
2045 } else {
2046 format!(
2047 "{status} New output since the last check:{}\n{new_output}",
2048 dropped_note(dropped)
2049 )
2050 }
2051 }
2052
2053 /// `kill_command` tool: stop a background task and report its output tail.
2054 fn exec_kill_command(arguments: &str) -> String {
2055 let task_id = match parse_task_id(arguments) {
2056 Ok(id) => id,
2057 Err(err) => return err,
2058 };
2059
2060 let mut map = lock_tasks();
2061 let Some(task) = map.get_mut(&task_id) else {
2062 return unknown_task(task_id);
2063 };
2064
2065 if let Some(code) = poll_exit_code(task) {
2066 let (new_output, dropped) = drain_task_output(task);
2067 let command = &task.command;
2068 let tail = if new_output.is_empty() {
2069 String::new()
2070 } else {
2071 format!(" Unread output:\n{new_output}")
2072 };
2073 return format!(
2074 "Task {task_id} (`{command}`) had already exited with code {code}; nothing to \
2075 kill.{}{tail}",
2076 dropped_note(dropped)
2077 );
2078 }
2079
2080 if let Err(err) = task.child.kill() {
2081 return format!("Error: failed to kill task {task_id}: {err}");
2082 }
2083 match task.child.wait() {
2084 Ok(status) => task.exit_code = Some(status.code().unwrap_or(-1)),
2085 Err(_) => task.exit_code = Some(-1),
2086 }
2087 task.killed = true;
2088
2089 // Let the reader threads flush whatever was in flight before reporting.
2090 std::thread::sleep(std::time::Duration::from_millis(100));
2091 let (new_output, dropped) = drain_task_output(task);
2092
2093 let command = &task.command;
2094 let tail = if new_output.is_empty() {
2095 " It produced no unread output.".to_string()
2096 } else {
2097 format!(" Last output:\n{new_output}")
2098 };
2099 format!(
2100 "Killed task {task_id} (`{command}`).{}{tail}",
2101 dropped_note(dropped)
2102 )
2103 }
2104
2105 #[cfg(test)]
2106 mod tests {
2107 use super::*;
2108 use std::fs;
2109
2110 #[tokio::test]
2111 async fn test_execute_unknown_tool() {
2112 let result = execute_tool("nonexistent", "{}").await;
2113 assert!(result.starts_with("Unknown tool:"));
2114 }
2115
2116 #[test]
2117 fn test_read_file_missing_path_param() {
2118 let result = exec_read_file("{}");
2119 assert!(result.contains("missing required parameter"));
2120 }
2121
2122 #[test]
2123 fn test_read_file_nonexistent() {
2124 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
2125 assert!(result.contains("does not exist"));
2126 }
2127
2128 #[test]
2129 fn test_read_file_success() {
2130 let dir = std::env::temp_dir().join("sigit_test_read_file");
2131 let _ = fs::create_dir_all(&dir);
2132 let file_path = dir.join("hello.txt");
2133 fs::write(&file_path, "hello world").unwrap();
2134
2135 let args = serde_json::json!({ "path": file_path }).to_string();
2136 let result = exec_read_file(&args);
2137 assert_eq!(result, "hello world");
2138
2139 let _ = fs::remove_dir_all(&dir);
2140 }
2141
2142 #[test]
2143 fn test_list_directory_missing_path_param() {
2144 let result = exec_list_directory("{}");
2145 assert!(result.contains("missing required parameter"));
2146 }
2147
2148 #[test]
2149 fn test_list_directory_success() {
2150 let dir = std::env::temp_dir().join("sigit_test_list_dir");
2151 let _ = fs::remove_dir_all(&dir);
2152 fs::create_dir_all(dir.join("subdir")).unwrap();
2153 fs::write(dir.join("aaa.txt"), "").unwrap();
2154 fs::write(dir.join("bbb.rs"), "").unwrap();
2155
2156 let args = serde_json::json!({ "path": dir }).to_string();
2157 let result = exec_list_directory(&args);
2158
2159 assert!(result.contains("[DIR] subdir"));
2160 assert!(result.contains("[FILE] aaa.txt"));
2161 assert!(result.contains("[FILE] bbb.rs"));
2162
2163 // Directories should appear before files.
2164 let dir_pos = result.find("[DIR]").unwrap();
2165 let file_pos = result.find("[FILE]").unwrap();
2166 assert!(dir_pos < file_pos);
2167
2168 let _ = fs::remove_dir_all(&dir);
2169 }
2170
2171 #[test]
2172 fn test_search_files_invalid_regex() {
2173 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
2174 assert!(result.contains("invalid regex"));
2175 }
2176
2177 #[test]
2178 fn test_search_files_success() {
2179 let dir = std::env::temp_dir().join("sigit_test_search");
2180 let _ = fs::remove_dir_all(&dir);
2181 fs::create_dir_all(&dir).unwrap();
2182 fs::write(
2183 dir.join("code.rs"),
2184 "fn main() {\n println!(\"hello\");\n}\n",
2185 )
2186 .unwrap();
2187 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
2188
2189 let args = serde_json::json!({
2190 "pattern": "println",
2191 "path": dir
2192 })
2193 .to_string();
2194 let result = exec_search_files(&args);
2195
2196 assert!(result.contains("code.rs:2:"));
2197 assert!(result.contains("println"));
2198 assert!(!result.contains("other.txt"));
2199
2200 let _ = fs::remove_dir_all(&dir);
2201 }
2202
2203 #[test]
2204 fn test_search_files_no_matches() {
2205 let dir = std::env::temp_dir().join("sigit_test_search_none");
2206 let _ = fs::remove_dir_all(&dir);
2207 fs::create_dir_all(&dir).unwrap();
2208 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
2209
2210 let args = serde_json::json!({
2211 "pattern": "zzz_will_not_match_42",
2212 "path": dir
2213 })
2214 .to_string();
2215 let result = exec_search_files(&args);
2216 assert!(result.contains("No matches found"));
2217
2218 let _ = fs::remove_dir_all(&dir);
2219 }
2220
2221 #[test]
2222 fn test_all_tools_count() {
2223 let tools = all_tools();
2224 assert_eq!(tools.len(), 15);
2225 assert_eq!(tools[0].name, "read_file");
2226 assert_eq!(tools[1].name, "create_directory");
2227 assert_eq!(tools[2].name, "list_directory");
2228 assert_eq!(tools[3].name, "search_files");
2229 assert_eq!(tools[4].name, "read_website");
2230 assert_eq!(tools[5].name, "create_file");
2231 assert_eq!(tools[6].name, "edit_file");
2232 assert_eq!(tools[7].name, "delete_file");
2233 assert_eq!(tools[8].name, "run_command");
2234 assert_eq!(tools[9].name, "multi_edit");
2235 assert_eq!(tools[10].name, "glob");
2236 assert_eq!(tools[11].name, "write_todos");
2237 assert_eq!(tools[12].name, "remember");
2238 assert_eq!(tools[13].name, "command_output");
2239 assert_eq!(tools[14].name, "kill_command");
2240 }
2241
2242 #[test]
2243 fn test_edit_file_replace_all() {
2244 let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
2245 let _ = fs::remove_dir_all(&dir);
2246 fs::create_dir_all(&dir).unwrap();
2247 let file = dir.join("f.txt");
2248 fs::write(&file, "foo foo foo").unwrap();
2249
2250 // Without replace_all an ambiguous match is rejected.
2251 let args =
2252 serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
2253 let result = exec_edit_file(&args);
2254 assert!(result.contains("appears 3 times"), "{result}");
2255
2256 // With replace_all every occurrence is changed.
2257 let args = serde_json::json!({
2258 "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
2259 })
2260 .to_string();
2261 let result = exec_edit_file(&args);
2262 assert!(result.starts_with("Edited file:"), "{result}");
2263 assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
2264
2265 let _ = fs::remove_dir_all(&dir);
2266 }
2267
2268 #[test]
2269 fn test_edit_file_whitespace_hint() {
2270 let dir = std::env::temp_dir().join("sigit_test_edit_hint");
2271 let _ = fs::remove_dir_all(&dir);
2272 fs::create_dir_all(&dir).unwrap();
2273 let file = dir.join("f.txt");
2274 fs::write(&file, "line one\n indented\nline three\n").unwrap();
2275
2276 // old_text has more indentation than the file, so it isn't a substring,
2277 // but its trimmed content still locates the intended line.
2278 let args = serde_json::json!({
2279 "path": &file, "old_text": " indented", "new_text": "x"
2280 })
2281 .to_string();
2282 let result = exec_edit_file(&args);
2283 assert!(result.contains("line 2"), "{result}");
2284 assert!(result.contains("whitespace"), "{result}");
2285
2286 let _ = fs::remove_dir_all(&dir);
2287 }
2288
2289 #[test]
2290 fn test_multi_edit_atomic_on_failure() {
2291 let dir = std::env::temp_dir().join("sigit_test_multi_edit");
2292 let _ = fs::remove_dir_all(&dir);
2293 fs::create_dir_all(&dir).unwrap();
2294 let file = dir.join("f.txt");
2295 fs::write(&file, "alpha beta gamma").unwrap();
2296
2297 // Second edit can't match -> nothing should be written.
2298 let args = serde_json::json!({
2299 "path": &file,
2300 "edits": [
2301 { "old_text": "alpha", "new_text": "ALPHA" },
2302 { "old_text": "nope", "new_text": "x" }
2303 ]
2304 })
2305 .to_string();
2306 let result = exec_multi_edit(&args);
2307 assert!(result.contains("edit #2 failed"), "{result}");
2308 assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
2309
2310 // All-matching batch applies in sequence.
2311 let args = serde_json::json!({
2312 "path": &file,
2313 "edits": [
2314 { "old_text": "alpha", "new_text": "ALPHA" },
2315 { "old_text": "gamma", "new_text": "GAMMA" }
2316 ]
2317 })
2318 .to_string();
2319 let result = exec_multi_edit(&args);
2320 assert!(result.contains("Applied 2 edits"), "{result}");
2321 assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
2322
2323 let _ = fs::remove_dir_all(&dir);
2324 }
2325
2326 #[test]
2327 fn test_glob_to_regex() {
2328 let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
2329 assert!(re.is_match("src/tools.rs"));
2330 assert!(re.is_match("main.rs")); // `**/` matches zero directories too
2331 assert!(!re.is_match("src/tools.txt"));
2332
2333 let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
2334 assert!(re.is_match("app.ts"));
2335 assert!(re.is_match("app.tsx"));
2336 assert!(!re.is_match("app.js"));
2337 }
2338
2339 #[test]
2340 fn test_glob_tool_success() {
2341 let dir = std::env::temp_dir().join("sigit_test_glob");
2342 let _ = fs::remove_dir_all(&dir);
2343 fs::create_dir_all(dir.join("src")).unwrap();
2344 fs::write(dir.join("Cargo.toml"), "").unwrap();
2345 fs::write(dir.join("src/main.rs"), "").unwrap();
2346 fs::write(dir.join("src/lib.rs"), "").unwrap();
2347
2348 let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
2349 let result = exec_glob(&args);
2350 assert!(result.contains("main.rs"), "{result}");
2351 assert!(result.contains("lib.rs"), "{result}");
2352 assert!(!result.contains("Cargo.toml"), "{result}");
2353
2354 let _ = fs::remove_dir_all(&dir);
2355 }
2356
2357 #[test]
2358 fn test_search_files_file_glob_filter() {
2359 let dir = std::env::temp_dir().join("sigit_test_search_glob");
2360 let _ = fs::remove_dir_all(&dir);
2361 fs::create_dir_all(&dir).unwrap();
2362 fs::write(dir.join("code.rs"), "needle here\n").unwrap();
2363 fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
2364
2365 let args = serde_json::json!({
2366 "pattern": "needle", "path": &dir, "file_glob": "*.rs"
2367 })
2368 .to_string();
2369 let result = exec_search_files(&args);
2370 assert!(result.contains("code.rs"), "{result}");
2371 assert!(!result.contains("notes.txt"), "{result}");
2372
2373 let _ = fs::remove_dir_all(&dir);
2374 }
2375
2376 #[test]
2377 fn test_write_todos_renders_checklist() {
2378 let args = serde_json::json!({
2379 "todos": [
2380 { "content": "Read code", "status": "completed" },
2381 { "content": "Make change", "status": "in_progress" },
2382 { "content": "Run tests", "status": "pending" }
2383 ]
2384 })
2385 .to_string();
2386 let result = exec_write_todos(&args);
2387 assert!(result.contains("1/3 done"), "{result}");
2388 assert!(result.contains("[x] Read code"), "{result}");
2389 assert!(result.contains("[~] Make change"), "{result}");
2390 assert!(result.contains("[ ] Run tests"), "{result}");
2391 }
2392
2393 #[test]
2394 fn test_remember_appends_to_instruction_file() {
2395 let dir = std::env::temp_dir().join("sigit_test_remember");
2396 let _ = fs::remove_dir_all(&dir);
2397 fs::create_dir_all(dir.join(".git")).unwrap();
2398 let claude_md = dir.join("CLAUDE.md");
2399 fs::write(&claude_md, "# Project\n").unwrap();
2400
2401 let target = crate::instructions::memory_file(&dir);
2402 // Should pick the existing CLAUDE.md at the repo root.
2403 assert_eq!(
2404 target.canonicalize().unwrap(),
2405 claude_md.canonicalize().unwrap()
2406 );
2407
2408 let result = remember_at(&dir, "remembered text");
2409 assert!(result.contains("remembered"), "{result}");
2410
2411 let updated = fs::read_to_string(&claude_md).unwrap();
2412 assert!(updated.contains("## Remembered notes"), "{updated}");
2413 assert!(updated.contains("- remembered text"), "{updated}");
2414
2415 let _ = fs::remove_dir_all(&dir);
2416 }
2417
2418 #[test]
2419 fn test_all_tools_schemas_are_valid_json_objects() {
2420 for tool in all_tools() {
2421 assert!(
2422 tool.parameters_schema.is_object(),
2423 "schema for {} is not an object",
2424 tool.name
2425 );
2426 let obj = tool.parameters_schema.as_object().unwrap();
2427 assert!(obj.contains_key("type"));
2428 assert!(obj.contains_key("properties"));
2429 assert!(obj.contains_key("required"));
2430 }
2431 }
2432
2433 // ── read_website tests ───────────────────────────────────────────────
2434
2435 #[test]
2436 fn test_read_website_missing_url() {
2437 let result = exec_read_website("{}");
2438 assert!(result.contains("missing required parameter"));
2439 }
2440
2441 #[test]
2442 fn test_read_website_invalid_scheme() {
2443 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
2444 assert!(result.contains("url must start with http:// or https://"));
2445 }
2446
2447 #[test]
2448 fn test_read_website_extracts_title_from_html() {
2449 let body = r#"
2450 <html>
2451 <head>
2452 <title>Qwen 3.6 27B</title>
2453 </head>
2454 <body>
2455 <h1>Model card</h1>
2456 <p>Large language model.</p>
2457 </body>
2458 </html>
2459 "#;
2460
2461 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
2462 .unwrap()
2463 .captures(body)
2464 .and_then(|captures| captures.get(1))
2465 .map(|m| {
2466 Regex::new(r"\s+")
2467 .unwrap()
2468 .replace_all(m.as_str(), " ")
2469 .trim()
2470 .to_string()
2471 })
2472 .filter(|title| !title.is_empty());
2473
2474 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
2475 }
2476
2477 #[test]
2478 fn test_read_website_metadata_includes_final_url_header() {
2479 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
2480 let title = Some("Qwen 3.6 27B".to_string());
2481 let cleaned = "Model card\nLarge language model.".to_string();
2482
2483 let mut metadata = vec![format!("URL: {final_url}")];
2484 if let Some(title) = &title {
2485 metadata.push(format!("Title: {title}"));
2486 }
2487
2488 let body_text = match title {
2489 Some(_) => cleaned,
2490 None => cleaned,
2491 };
2492
2493 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
2494
2495 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
2496 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
2497 }
2498
2499 // ── create_directory tests ───────────────────────────────────────────
2500
2501 #[test]
2502 fn test_create_directory_missing_path() {
2503 let result = exec_create_directory("{}");
2504 assert!(result.contains("missing required parameter"));
2505 }
2506
2507 #[test]
2508 fn test_create_directory_success() {
2509 let dir = std::env::temp_dir()
2510 .join("sigit_test_create_directory")
2511 .join("nested")
2512 .join("child");
2513 let _ = fs::remove_dir_all(dir.parent().unwrap());
2514
2515 let args = serde_json::json!({ "path": dir }).to_string();
2516 let result = exec_create_directory(&args);
2517 assert!(result.starts_with("Created directory:"), "got: {result}");
2518 assert!(dir.exists());
2519 assert!(dir.is_dir());
2520
2521 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
2522 }
2523
2524 #[test]
2525 fn test_create_directory_already_exists() {
2526 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
2527 let _ = fs::remove_dir_all(&dir);
2528 fs::create_dir_all(&dir).unwrap();
2529
2530 let args = serde_json::json!({ "path": dir }).to_string();
2531 let result = exec_create_directory(&args);
2532 assert!(result.contains("Directory already exists"), "got: {result}");
2533
2534 let _ = fs::remove_dir_all(&dir);
2535 }
2536
2537 // ── create_file tests ────────────────────────────────────────────────
2538
2539 #[test]
2540 fn test_create_file_missing_path() {
2541 let result = exec_create_file(r#"{"content": "hello"}"#);
2542 assert!(result.contains("missing required parameter"));
2543 }
2544
2545 #[test]
2546 fn test_create_file_missing_content() {
2547 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
2548 assert!(result.contains("missing required parameter"));
2549 }
2550
2551 #[test]
2552 fn test_create_file_success() {
2553 let dir = std::env::temp_dir().join("sigit_test_create_file");
2554 let _ = fs::remove_dir_all(&dir);
2555
2556 let file_path = dir.join("sub").join("new_file.txt");
2557 let args = serde_json::json!({
2558 "path": file_path,
2559 "content": "hello world"
2560 })
2561 .to_string();
2562
2563 let result = exec_create_file(&args);
2564 assert!(result.starts_with("Created file:"), "got: {result}");
2565 assert!(file_path.exists());
2566 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
2567
2568 let _ = fs::remove_dir_all(&dir);
2569 }
2570
2571 #[test]
2572 fn test_create_file_already_exists() {
2573 let dir = std::env::temp_dir().join("sigit_test_create_exists");
2574 let _ = fs::remove_dir_all(&dir);
2575 fs::create_dir_all(&dir).unwrap();
2576
2577 let file_path = dir.join("existing.txt");
2578 fs::write(&file_path, "original").unwrap();
2579
2580 let args = serde_json::json!({
2581 "path": file_path,
2582 "content": "overwrite attempt"
2583 })
2584 .to_string();
2585
2586 let result = exec_create_file(&args);
2587 assert!(result.contains("already exists"), "got: {result}");
2588 // Original content untouched.
2589 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2590
2591 let _ = fs::remove_dir_all(&dir);
2592 }
2593
2594 // ── edit_file tests ──────────────────────────────────────────────────
2595
2596 #[test]
2597 fn test_edit_file_missing_params() {
2598 let result = exec_edit_file(r#"{"path": "x"}"#);
2599 assert!(result.contains("missing required parameter"));
2600
2601 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
2602 assert!(result.contains("missing required parameter"));
2603 }
2604
2605 #[test]
2606 fn test_edit_file_nonexistent() {
2607 let result = exec_edit_file(
2608 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
2609 );
2610 assert!(result.contains("does not exist"));
2611 }
2612
2613 #[test]
2614 fn test_edit_file_success() {
2615 let dir = std::env::temp_dir().join("sigit_test_edit_file");
2616 let _ = fs::remove_dir_all(&dir);
2617 fs::create_dir_all(&dir).unwrap();
2618
2619 let file_path = dir.join("code.rs");
2620 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
2621
2622 let args = serde_json::json!({
2623 "path": file_path,
2624 "old_text": "println!(\"hello\")",
2625 "new_text": "println!(\"world\")"
2626 })
2627 .to_string();
2628
2629 let result = exec_edit_file(&args);
2630 assert!(result.starts_with("Edited file:"), "got: {result}");
2631
2632 let updated = fs::read_to_string(&file_path).unwrap();
2633 assert!(updated.contains("println!(\"world\")"));
2634 assert!(!updated.contains("println!(\"hello\")"));
2635
2636 let _ = fs::remove_dir_all(&dir);
2637 }
2638
2639 #[test]
2640 fn test_edit_file_old_text_not_found() {
2641 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
2642 let _ = fs::remove_dir_all(&dir);
2643 fs::create_dir_all(&dir).unwrap();
2644
2645 let file_path = dir.join("data.txt");
2646 fs::write(&file_path, "aaa bbb ccc").unwrap();
2647
2648 let args = serde_json::json!({
2649 "path": file_path,
2650 "old_text": "zzz",
2651 "new_text": "yyy"
2652 })
2653 .to_string();
2654
2655 let result = exec_edit_file(&args);
2656 assert!(result.contains("old_text not found"), "got: {result}");
2657
2658 let _ = fs::remove_dir_all(&dir);
2659 }
2660
2661 #[test]
2662 fn test_edit_file_ambiguous_match() {
2663 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
2664 let _ = fs::remove_dir_all(&dir);
2665 fs::create_dir_all(&dir).unwrap();
2666
2667 let file_path = dir.join("repeat.txt");
2668 fs::write(&file_path, "foo bar foo bar foo").unwrap();
2669
2670 let args = serde_json::json!({
2671 "path": file_path,
2672 "old_text": "foo",
2673 "new_text": "baz"
2674 })
2675 .to_string();
2676
2677 let result = exec_edit_file(&args);
2678 assert!(result.contains("appears 3 times"), "got: {result}");
2679 // File should be unchanged.
2680 assert_eq!(
2681 fs::read_to_string(&file_path).unwrap(),
2682 "foo bar foo bar foo"
2683 );
2684
2685 let _ = fs::remove_dir_all(&dir);
2686 }
2687
2688 // ── delete_file tests ────────────────────────────────────────────────
2689
2690 #[test]
2691 fn test_delete_file_missing_path() {
2692 let result = exec_delete_file("{}");
2693 assert!(
2694 result.contains("missing required parameter"),
2695 "got: {result}"
2696 );
2697 }
2698
2699 #[test]
2700 fn test_delete_file_nonexistent() {
2701 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
2702 assert!(result.contains("does not exist"), "got: {result}");
2703 }
2704
2705 #[test]
2706 fn test_delete_file_success() {
2707 let dir = std::env::temp_dir().join("sigit_test_delete_file");
2708 let _ = fs::remove_dir_all(&dir);
2709 fs::create_dir_all(&dir).unwrap();
2710
2711 let file_path = dir.join("to_delete.txt");
2712 fs::write(&file_path, "bye").unwrap();
2713 assert!(file_path.exists());
2714
2715 let args = serde_json::json!({ "path": file_path }).to_string();
2716 let result = exec_delete_file(&args);
2717 assert!(result.contains("Deleted file"), "got: {result}");
2718 assert!(!file_path.exists());
2719
2720 let _ = fs::remove_dir_all(&dir);
2721 }
2722
2723 #[test]
2724 fn test_delete_empty_directory() {
2725 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
2726 let _ = fs::remove_dir_all(&dir);
2727 fs::create_dir_all(&dir).unwrap();
2728
2729 let args = serde_json::json!({ "path": dir }).to_string();
2730 let result = exec_delete_file(&args);
2731 assert!(result.contains("Deleted empty directory"), "got: {result}");
2732 assert!(!dir.exists());
2733 }
2734
2735 #[test]
2736 fn test_delete_nonempty_directory() {
2737 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
2738 let _ = fs::remove_dir_all(&dir);
2739 fs::create_dir_all(&dir).unwrap();
2740 fs::write(dir.join("child.txt"), "content").unwrap();
2741
2742 let args = serde_json::json!({ "path": dir }).to_string();
2743 let result = exec_delete_file(&args);
2744 assert!(result.contains("Error"), "got: {result}");
2745 assert!(dir.exists(), "directory should not have been deleted");
2746
2747 let _ = fs::remove_dir_all(&dir);
2748 }
2749
2750 // ── run_command tests ────────────────────────────────────────────────
2751
2752 #[test]
2753 fn test_run_command_missing_command() {
2754 let result = exec_run_command("{}");
2755 assert!(
2756 result.contains("missing required parameter"),
2757 "got: {result}"
2758 );
2759 }
2760
2761 #[test]
2762 fn test_run_command_success() {
2763 let result = exec_run_command(r#"{"command": "echo hello"}"#);
2764 assert!(result.contains("hello"), "got: {result}");
2765 assert!(result.contains("Exit code 0"), "got: {result}");
2766 }
2767
2768 /// Fresh git repo with one commit, test identity, and signing off (the
2769 /// developer's global gpgsign must not leak into sandbox commits).
2770 fn init_test_repo(name: &str) -> std::path::PathBuf {
2771 let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id()));
2772 let _ = fs::remove_dir_all(&dir);
2773 fs::create_dir_all(&dir).unwrap();
2774 test_git(&dir, &["init", "-q", "-b", "main"]);
2775 test_git(&dir, &["config", "user.name", "Test User"]);
2776 test_git(&dir, &["config", "user.email", "test@example.com"]);
2777 test_git(&dir, &["config", "commit.gpgsign", "false"]);
2778 fs::write(dir.join("file.txt"), "one\n").unwrap();
2779 test_git(&dir, &["add", "file.txt"]);
2780 test_git(&dir, &["commit", "-q", "-m", "Initial"]);
2781 dir
2782 }
2783
2784 fn test_git(dir: &Path, args: &[&str]) {
2785 let out = Command::new("git")
2786 .args(args)
2787 .current_dir(dir)
2788 .output()
2789 .unwrap();
2790 assert!(
2791 out.status.success(),
2792 "git {args:?} failed: {}",
2793 String::from_utf8_lossy(&out.stderr)
2794 );
2795 }
2796
2797 #[test]
2798 fn run_command_appends_co_author_trailer_to_new_commits() {
2799 let dir = init_test_repo("coauthor_append");
2800 fs::write(dir.join("file.txt"), "two\n").unwrap();
2801 // Quote-free command: `cmd /C` does not strip double quotes the way
2802 // `sh -c` does, so quoted arguments would break on Windows.
2803 let args = serde_json::json!({
2804 "command": "git add file.txt && git commit -m Update",
2805 "cwd": dir.display().to_string(),
2806 })
2807 .to_string();
2808
2809 let result = exec_run_command(&args);
2810 assert!(result.contains("co-author trailer"), "got: {result}");
2811
2812 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2813 assert!(
2814 message.ends_with(COMMIT_CO_AUTHOR_TRAILER),
2815 "trailer must be the last line: {message:?}"
2816 );
2817 assert!(
2818 message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")),
2819 "trailer needs a blank line before it for GitHub to detect it: {message:?}"
2820 );
2821 let _ = fs::remove_dir_all(&dir);
2822 }
2823
2824 #[test]
2825 fn run_command_keeps_existing_co_author_trailer() {
2826 let dir = init_test_repo("coauthor_present");
2827 fs::write(dir.join("file.txt"), "two\n").unwrap();
2828 // The trailer contains spaces and angle brackets, which `cmd /C`
2829 // mis-tokenizes (`<` is redirection), so create the trailer-carrying
2830 // commit with direct git args and let run_command amend it without
2831 // editing: HEAD changes, the message already has the trailer, and the
2832 // gate must leave it alone.
2833 test_git(&dir, &["add", "file.txt"]);
2834 test_git(
2835 &dir,
2836 &[
2837 "commit",
2838 "-q",
2839 "-m",
2840 &format!("Update file\n\n{COMMIT_CO_AUTHOR_TRAILER}"),
2841 ],
2842 );
2843 let args = serde_json::json!({
2844 "command": "git commit --amend --no-edit",
2845 "cwd": dir.display().to_string(),
2846 })
2847 .to_string();
2848
2849 let result = exec_run_command(&args);
2850 assert!(
2851 !result.contains("[siGit Code]"),
2852 "no amend expected: {result}"
2853 );
2854
2855 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2856 assert_eq!(
2857 message.matches("Co-Authored-By: siGit Code").count(),
2858 1,
2859 "trailer must not be duplicated: {message:?}"
2860 );
2861 let _ = fs::remove_dir_all(&dir);
2862 }
2863
2864 #[test]
2865 fn run_command_never_amends_pushed_commits() {
2866 let dir = init_test_repo("coauthor_pushed");
2867 let remote = std::env::temp_dir().join(format!(
2868 "sigit_test_coauthor_remote_{}.git",
2869 std::process::id()
2870 ));
2871 let _ = fs::remove_dir_all(&remote);
2872 fs::create_dir_all(&remote).unwrap();
2873 test_git(&remote, &["init", "-q", "--bare"]);
2874 test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]);
2875
2876 fs::write(dir.join("file.txt"), "two\n").unwrap();
2877 let args = serde_json::json!({
2878 "command": "git add file.txt && git commit -m Update && git push -q origin main",
2879 "cwd": dir.display().to_string(),
2880 })
2881 .to_string();
2882
2883 let result = exec_run_command(&args);
2884 assert!(
2885 !result.contains("[siGit Code]"),
2886 "no amend expected: {result}"
2887 );
2888
2889 // Already on the remote when the gate ran, so it must be untouched.
2890 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2891 assert!(
2892 !message.contains("Co-Authored-By"),
2893 "pushed commit must not be rewritten: {message:?}"
2894 );
2895 let _ = fs::remove_dir_all(&dir);
2896 let _ = fs::remove_dir_all(&remote);
2897 }
2898
2899 #[test]
2900 fn test_run_command_failure() {
2901 #[cfg(unix)]
2902 let command = "false";
2903 #[cfg(windows)]
2904 let command = "exit /b 1";
2905
2906 let args = serde_json::json!({ "command": command }).to_string();
2907 let result = exec_run_command(&args);
2908 assert!(result.contains("failed"), "got: {result}");
2909 }
2910
2911 #[test]
2912 fn test_run_command_with_cwd() {
2913 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
2914 let _ = fs::remove_dir_all(&dir);
2915 fs::create_dir_all(&dir).unwrap();
2916
2917 #[cfg(unix)]
2918 let command = "pwd";
2919 #[cfg(windows)]
2920 let command = "cd";
2921
2922 let args = serde_json::json!({
2923 "command": command,
2924 "cwd": dir
2925 })
2926 .to_string();
2927 let result = exec_run_command(&args);
2928 // The output should contain the temp dir path.
2929 assert!(
2930 result.contains(&dir.to_string_lossy().to_string()),
2931 "got: {result}"
2932 );
2933
2934 let _ = fs::remove_dir_all(&dir);
2935 }
2936
2937 #[test]
2938 fn test_run_command_bad_cwd() {
2939 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
2940 let _ = fs::remove_dir_all(&missing_dir);
2941
2942 let args = serde_json::json!({
2943 "command": "echo hi",
2944 "cwd": missing_dir
2945 })
2946 .to_string();
2947 let result = exec_run_command(&args);
2948 assert!(result.contains("does not exist"), "got: {result}");
2949 }
2950
2951 #[test]
2952 fn test_run_command_captures_stderr() {
2953 #[cfg(unix)]
2954 let command = "echo err >&2";
2955 #[cfg(windows)]
2956 let command = "echo err 1>&2";
2957
2958 let args = serde_json::json!({ "command": command }).to_string();
2959 let result = exec_run_command(&args);
2960 assert!(result.contains("err"), "got: {result}");
2961 }
2962
2963 // ── background command tests ─────────────────────────────────────────
2964
2965 /// Extract the task id from a "Started background task {id}: ..." result.
2966 fn background_task_id(result: &str) -> u64 {
2967 let digits: String = result
2968 .strip_prefix("Started background task ")
2969 .unwrap_or_else(|| panic!("unexpected spawn result: {result}"))
2970 .chars()
2971 .take_while(char::is_ascii_digit)
2972 .collect();
2973 digits.parse().expect("task id")
2974 }
2975
2976 /// Whether the task's child has exited, without draining its output.
2977 fn background_task_exited(task_id: u64) -> bool {
2978 let mut map = lock_tasks();
2979 match map.get_mut(&task_id) {
2980 Some(task) => poll_exit_code(task).is_some(),
2981 None => true,
2982 }
2983 }
2984
2985 /// Wait (bounded) for a background task's child to exit.
2986 fn wait_for_background_exit(task_id: u64) {
2987 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
2988 while !background_task_exited(task_id) {
2989 assert!(
2990 std::time::Instant::now() < deadline,
2991 "task {task_id} did not exit in time"
2992 );
2993 std::thread::sleep(std::time::Duration::from_millis(50));
2994 }
2995 }
2996
2997 #[test]
2998 fn test_run_command_background_lifecycle() {
2999 #[cfg(unix)]
3000 let command = "echo start; sleep 2; echo done";
3001 #[cfg(windows)]
3002 let command = "echo start&& ping -n 3 127.0.0.1 > nul&& echo done";
3003
3004 let args = serde_json::json!({
3005 "command": command,
3006 "cwd": std::env::temp_dir(),
3007 "run_in_background": true
3008 })
3009 .to_string();
3010 let spawned = std::time::Instant::now();
3011 let result = exec_run_command(&args);
3012 // Spawning must return immediately, not wait the ~2s the command takes.
3013 assert!(
3014 spawned.elapsed() < std::time::Duration::from_secs(1),
3015 "background spawn blocked for {:?}",
3016 spawned.elapsed()
3017 );
3018 assert!(result.contains("command_output"), "got: {result}");
3019 let task_id = background_task_id(&result);
3020
3021 // Polling while the command is still sleeping reports it as running.
3022 let poll_args = serde_json::json!({ "task_id": task_id }).to_string();
3023 let poll = exec_command_output(&poll_args);
3024 assert!(poll.contains("still running"), "got: {poll}");
3025 let mut combined = poll;
3026
3027 wait_for_background_exit(task_id);
3028 // Grace period so the reader threads finish draining the pipes.
3029 std::thread::sleep(std::time::Duration::from_millis(300));
3030 let final_poll = exec_command_output(&poll_args);
3031 assert!(
3032 final_poll.contains("exited with code 0"),
3033 "got: {final_poll}"
3034 );
3035 combined.push_str(&final_poll);
3036
3037 // Across the polls, all output the command printed was delivered.
3038 assert!(combined.contains("start"), "got: {combined}");
3039 assert!(combined.contains("done"), "got: {combined}");
3040 }
3041
3042 #[test]
3043 fn test_kill_command_stops_background_task() {
3044 #[cfg(unix)]
3045 let command = "sleep 30";
3046 #[cfg(windows)]
3047 let command = "ping -n 31 127.0.0.1 > nul";
3048
3049 let args = serde_json::json!({
3050 "command": command,
3051 "cwd": std::env::temp_dir(),
3052 "run_in_background": true
3053 })
3054 .to_string();
3055 let result = exec_run_command(&args);
3056 let task_id = background_task_id(&result);
3057
3058 let kill_args = serde_json::json!({ "task_id": task_id }).to_string();
3059 let killed_at = std::time::Instant::now();
3060 let kill_result = exec_kill_command(&kill_args);
3061 assert!(
3062 kill_result.contains(&format!("Killed task {task_id}")),
3063 "got: {kill_result}"
3064 );
3065 // The kill must not wait out the 30s sleep.
3066 assert!(
3067 killed_at.elapsed() < std::time::Duration::from_secs(5),
3068 "kill blocked for {:?}",
3069 killed_at.elapsed()
3070 );
3071
3072 // A later poll reports the task as killed, not still running.
3073 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3074 assert!(poll.contains("was killed"), "got: {poll}");
3075 }
3076
3077 #[test]
3078 fn test_command_output_unknown_task() {
3079 let result = exec_command_output(r#"{"task_id": 9999999}"#);
3080 assert!(
3081 result.contains("no background task with id"),
3082 "got: {result}"
3083 );
3084 }
3085
3086 // ── task (subagent) tests ────────────────────────────────────────────
3087
3088 #[test]
3089 fn test_subagent_toolset_is_read_only() {
3090 let specs = subagent_tool_specs();
3091 let mut names: Vec<&str> = specs.iter().map(|spec| spec.name.as_str()).collect();
3092 names.sort_unstable();
3093 assert_eq!(
3094 names,
3095 [
3096 "glob",
3097 "list_directory",
3098 "read_file",
3099 "read_website",
3100 "search_files"
3101 ]
3102 );
3103
3104 // No recursion and no mutating tools, ever.
3105 assert!(!names.contains(&TASK_TOOL_NAME));
3106 for banned in [
3107 "create_file",
3108 "create_directory",
3109 "edit_file",
3110 "multi_edit",
3111 "delete_file",
3112 "run_command",
3113 "write_todos",
3114 "remember",
3115 ] {
3116 assert!(!names.contains(&banned), "{banned} leaked into subagent");
3117 }
3118
3119 // Every spec came from `all_tools()` (schemas intact).
3120 for spec in &specs {
3121 assert!(
3122 serde_json::from_str::<Value>(&spec.parameters_schema)
3123 .unwrap()
3124 .is_object()
3125 );
3126 }
3127 }
3128
3129 #[tokio::test]
3130 async fn test_task_reports_unavailable_without_factory() {
3131 let args = serde_json::json!({
3132 "description": "look around",
3133 "prompt": "What is in the current directory?"
3134 })
3135 .to_string();
3136 // `None` is exactly what `exec_task` passes before any surface has
3137 // registered the process-global factory.
3138 let result = exec_task_with(&args, None).await;
3139 assert!(
3140 result.contains("not available on-device yet"),
3141 "got: {result}"
3142 );
3143 }
3144
3145 #[test]
3146 fn test_kill_command_unknown_task() {
3147 let result = exec_kill_command(r#"{"task_id": 9999999}"#);
3148 assert!(
3149 result.contains("no background task with id"),
3150 "got: {result}"
3151 );
3152 }
3153
3154 #[tokio::test]
3155 async fn test_task_missing_prompt() {
3156 let result = exec_task_with(r#"{"description": "x"}"#, None).await;
3157 assert!(
3158 result.contains("missing required parameter \"prompt\""),
3159 "got: {result}"
3160 );
3161 }
3162
3163 #[test]
3164 fn test_command_output_missing_task_id() {
3165 let result = exec_command_output("{}");
3166 assert!(
3167 result.contains("missing required parameter"),
3168 "got: {result}"
3169 );
3170 }
3171
3172 #[test]
3173 fn test_background_output_is_capped() {
3174 // Print well over COMMAND_OUTPUT_LIMIT (50 000) bytes without polling,
3175 // so the buffer must drop the oldest output and note the truncation.
3176 #[cfg(unix)]
3177 let command = r#"i=0; while [ $i -lt 200 ]; do printf 'line-%04d %s\n' "$i" \
3178 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3179 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3180 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3181 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
3182 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; \
3183 i=$((i+1)); done"#;
3184 // Quote-free on purpose: `cmd /C` does not strip double quotes the
3185 // way `sh -c` does, so a quoted PowerShell one-liner gets mangled.
3186 // A cmd-native for /L loop needs no quoting at all.
3187 #[cfg(windows)]
3188 let command = &format!(
3189 "for /L %i in (1,1,200) do @echo line-%i-end {}",
3190 "a".repeat(330)
3191 );
3192
3193 let args = serde_json::json!({
3194 "command": command,
3195 "cwd": std::env::temp_dir(),
3196 "run_in_background": true
3197 })
3198 .to_string();
3199 let result = exec_run_command(&args);
3200 let task_id = background_task_id(&result);
3201
3202 wait_for_background_exit(task_id);
3203 // Grace period so the reader threads finish draining the pipes.
3204 std::thread::sleep(std::time::Duration::from_millis(300));
3205
3206 let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string());
3207 assert!(poll.contains("exited with code 0"), "got: {poll}");
3208 assert!(
3209 poll.contains("earlier output was dropped"),
3210 "expected truncation note, got: {poll}"
3211 );
3212 // The oldest lines were dropped; the newest survived. The two
3213 // platforms number lines differently (printf %04d vs cmd's %i).
3214 #[cfg(unix)]
3215 let (oldest, newest) = ("line-0000", "line-0199");
3216 #[cfg(windows)]
3217 let (oldest, newest) = ("line-1-end", "line-200-end");
3218 assert!(!poll.contains(oldest), "oldest output not dropped");
3219 assert!(poll.contains(newest), "newest output missing");
3220 // Buffer stayed within the cap, plus the framing: the status line
3221 // (which quotes the command) and the truncation note.
3222 assert!(
3223 poll.len() <= COMMAND_OUTPUT_LIMIT + command.len() + 500,
3224 "poll result too large: {} bytes",
3225 poll.len()
3226 );
3227 }
3228
3229 // ── task (subagent) end-to-end against a scripted endpoint ──────────
3230
3231 /// A completion whose assistant message requests one tool call.
3232 fn completion_tool_call(id: &str, name: &str, arguments: &str) -> String {
3233 serde_json::json!({
3234 "choices": [{"message": {
3235 "role": "assistant",
3236 "content": serde_json::Value::Null,
3237 "tool_calls": [{
3238 "id": id,
3239 "type": "function",
3240 "function": {"name": name, "arguments": arguments},
3241 }],
3242 }}]
3243 })
3244 .to_string()
3245 }
3246
3247 /// A completion whose assistant message is a plain text answer.
3248 fn completion_text(text: &str) -> String {
3249 serde_json::json!({
3250 "choices": [{"message": {"role": "assistant", "content": text}}]
3251 })
3252 .to_string()
3253 }
3254
3255 /// Minimal scripted OpenAI-compatible endpoint (same pattern as
3256 /// `tests/acp_permissions.rs`): serves one canned chat-completion JSON body
3257 /// per request and records each request body. The subagent loop passes
3258 /// `sink: None`, so the backend takes the non-streaming path and expects
3259 /// plain JSON rather than SSE.
3260 fn start_scripted_endpoint(responses: Vec<String>) -> (u16, Arc<std::sync::Mutex<Vec<Value>>>) {
3261 use std::io::{BufRead, BufReader, Read, Write};
3262
3263 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind endpoint");
3264 let port = listener.local_addr().unwrap().port();
3265 let requests: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
3266 let recorded = Arc::clone(&requests);
3267 let queue = std::sync::Mutex::new(std::collections::VecDeque::from(responses));
3268
3269 std::thread::spawn(move || {
3270 // `connection: close` means one request per connection, matching
3271 // the backend's serial completion requests.
3272 for stream in listener.incoming() {
3273 let Ok(mut stream) = stream else { continue };
3274 let mut reader = BufReader::new(match stream.try_clone() {
3275 Ok(clone) => clone,
3276 Err(_) => continue,
3277 });
3278 let mut content_length = 0usize;
3279 loop {
3280 let mut line = String::new();
3281 if reader.read_line(&mut line).unwrap_or(0) == 0 {
3282 break;
3283 }
3284 let line = line.trim();
3285 if line.is_empty() {
3286 break;
3287 }
3288 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:")
3289 {
3290 content_length = length.trim().parse().unwrap_or(0);
3291 }
3292 }
3293 let mut body = vec![0u8; content_length];
3294 if reader.read_exact(&mut body).is_err() {
3295 continue;
3296 }
3297 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
3298 recorded.lock().unwrap().push(request);
3299 }
3300 let payload = queue
3301 .lock()
3302 .unwrap()
3303 .pop_front()
3304 .unwrap_or_else(|| completion_text("out of scripted responses"));
3305 let response = format!(
3306 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
3307 content-length: {}\r\nconnection: close\r\n\r\n{}",
3308 payload.len(),
3309 payload
3310 );
3311 let _ = stream.write_all(response.as_bytes());
3312 }
3313 });
3314
3315 (port, requests)
3316 }
3317
3318 #[tokio::test]
3319 async fn test_task_runs_subagent_end_to_end() {
3320 // A file only the subagent's read_file call can surface.
3321 let dir = std::env::temp_dir().join("sigit_test_subagent_e2e");
3322 let _ = fs::remove_dir_all(&dir);
3323 fs::create_dir_all(&dir).unwrap();
3324 let file = dir.join("notes.txt");
3325 fs::write(&file, "subagent secret: 4217").unwrap();
3326
3327 // Script: one read_file tool call, then a final text answer.
3328 let (port, requests) = start_scripted_endpoint(vec![
3329 completion_tool_call(
3330 "call_1",
3331 "read_file",
3332 &serde_json::json!({ "path": file }).to_string(),
3333 ),
3334 completion_text("The file contains the number 4217."),
3335 ]);
3336
3337 // Register the real process-global factory, pointing a fresh
3338 // OpenAiBackend at the scripted endpoint — exactly what the surfaces
3339 // do at startup. This is the only test that touches the OnceLock.
3340 set_subagent_factory(Box::new(move || {
3341 Some(Arc::new(crate::backend::OpenAiBackend::new(
3342 format!("http://127.0.0.1:{port}"),
3343 "test-key",
3344 "scripted-model",
3345 Some(SUBAGENT_SYSTEM_PROMPT.to_string()),
3346 )) as Arc<dyn InferenceBackend>)
3347 }));
3348 assert!(subagent_available());
3349
3350 let args = serde_json::json!({
3351 "description": "read the notes file",
3352 "prompt": format!("What number is recorded in {}?", file.display()),
3353 })
3354 .to_string();
3355 let result = execute_tool(TASK_TOOL_NAME, &args).await;
3356
3357 // Only the subagent's final text comes back.
3358 assert_eq!(result, "The file contains the number 4217.");
3359
3360 let recorded = requests.lock().unwrap();
3361 assert_eq!(recorded.len(), 2, "expected exactly two completions");
3362
3363 // The subagent conversation is fresh (subagent system prompt) and was
3364 // offered only the read-only toolset.
3365 let first = &recorded[0];
3366 assert_eq!(first["messages"][0]["role"], "system");
3367 assert!(
3368 first["messages"][0]["content"]
3369 .as_str()
3370 .unwrap()
3371 .contains("research subagent")
3372 );
3373 let offered: Vec<&str> = first["tools"]
3374 .as_array()
3375 .unwrap()
3376 .iter()
3377 .map(|tool| tool["function"]["name"].as_str().unwrap())
3378 .collect();
3379 for name in &offered {
3380 assert!(
3381 SUBAGENT_TOOL_NAMES.contains(name),
3382 "non-read-only tool offered: {name}"
3383 );
3384 }
3385 assert!(!offered.contains(&TASK_TOOL_NAME));
3386
3387 // The read-only tool actually executed: its output travelled back to
3388 // the endpoint as a tool result on the second request.
3389 let messages = recorded[1]["messages"].as_array().unwrap();
3390 let tool_message = messages
3391 .iter()
3392 .find(|message| message["role"] == "tool")
3393 .expect("no tool result in second request");
3394 assert_eq!(tool_message["tool_call_id"], "call_1");
3395 assert!(
3396 tool_message["content"]
3397 .as_str()
3398 .unwrap()
3399 .contains("subagent secret: 4217")
3400 );
3401
3402 let _ = fs::remove_dir_all(&dir);
3403 }
3404 }