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