Skip to main content

locode_packs/grok/
mod.rs

1//! The `grok` pack — a faithful port of Grok Build's `xai-grok-tools` toolset, trimmed
2//! to headless-minimal (ADR-0012), over `locode-host`. Task 13 renders its real system
3//! prompt; all five grok tools are wired here.
4
5mod grep;
6mod list_dir;
7pub mod prompt;
8mod read;
9mod search_replace;
10mod terminal;
11
12use std::sync::Arc;
13
14use locode_host::Host;
15use locode_protocol::{ContentBlock, Message, Role};
16use locode_tools::Registry;
17
18use crate::pack::{Pack, PackContext};
19use grep::GrokGrep;
20use list_dir::GrokListDir;
21use read::GrokReadFile;
22use search_replace::GrokSearchReplace;
23use terminal::GrokRunTerminalCmd;
24
25/// The grok harness pack (a zero-sized `&'static` singleton).
26#[derive(Debug, Default, Clone, Copy)]
27pub struct GrokPack;
28
29impl Pack for GrokPack {
30    fn name(&self) -> &'static str {
31        "grok"
32    }
33
34    fn register(&self, host: &Arc<Host>, registry: &mut Registry) {
35        registry.register(
36            "run_terminal_cmd",
37            GrokRunTerminalCmd::new(Arc::clone(host)),
38        );
39        registry.register("read_file", GrokReadFile::new(Arc::clone(host)));
40        registry.register("search_replace", GrokSearchReplace::new(Arc::clone(host)));
41        registry.register("grep", GrokGrep::new(Arc::clone(host)));
42        registry.register("list_dir", GrokListDir::new(Arc::clone(host)));
43    }
44
45    fn preamble(&self, ctx: &PackContext) -> Vec<Message> {
46        // grok's real split (Task 13, ADR-0013): the rendered base prompt is the
47        // System item; environment info (cwd/OS/shell/date) is NOT in grok's
48        // system prompt — it rides in the first user message as the minimal
49        // `<user_info>` prefix (grok's own headless variant). The actual user
50        // prompt is wrapped in `<user_query>` by the exec layer (Task 14) via
51        // `prompt::user_query`.
52        vec![
53            Message {
54                role: Role::System,
55                content: vec![ContentBlock::Text {
56                    text: prompt::render_base_prompt(ctx),
57                }],
58            },
59            Message {
60                role: Role::User,
61                content: vec![ContentBlock::Text {
62                    text: prompt::user_info_block(ctx),
63                }],
64            },
65        ]
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use locode_host::HostConfig;
73    use locode_protocol::ResultChunk;
74    use locode_tools::ToolCtx;
75    use serde_json::json;
76    use std::path::Path;
77    use tokio_util::sync::CancellationToken;
78
79    /// A grok registry over a fresh temp workspace; the shell runs `bash -c` (non-login)
80    /// so login-profile output can't pollute the captured streams. Returns the host's
81    /// **canonical** root — the caller must set `ToolCtx.cwd` to match the jail root
82    /// (they agree by construction in the engine).
83    fn setup() -> (tempfile::TempDir, Registry, std::path::PathBuf) {
84        let dir = tempfile::tempdir().unwrap();
85        let mut config = HostConfig::new(dir.path());
86        config.login_shell = false;
87        let host = Arc::new(Host::new(config).unwrap());
88        let root = host.workspace_root().to_path_buf();
89        let registry = GrokPack.build_registry(&host);
90        (dir, registry, root)
91    }
92
93    fn ctx(dir: &Path) -> ToolCtx {
94        ToolCtx::new(
95            dir.to_path_buf(),
96            "c1".into(),
97            dir.to_path_buf(),
98            CancellationToken::new(),
99        )
100    }
101
102    fn result_text(block: &ContentBlock) -> String {
103        match block {
104            ContentBlock::ToolResult { content, .. } => content
105                .iter()
106                .filter_map(|chunk| match chunk {
107                    ResultChunk::Text { text } => Some(text.clone()),
108                    ResultChunk::Image { .. } => None,
109                })
110                .collect(),
111            _ => panic!("expected a tool_result"),
112        }
113    }
114
115    fn is_error(block: &ContentBlock) -> bool {
116        matches!(block, ContentBlock::ToolResult { is_error: true, .. })
117    }
118
119    #[tokio::test]
120    async fn run_terminal_cmd_echo() {
121        let (_dir, registry, root) = setup();
122        let out = registry
123            .dispatch(
124                "run_terminal_cmd",
125                json!({ "command": "echo hi", "description": "say hi" }),
126                &ctx(&root),
127            )
128            .await;
129        assert!(out.record.ok);
130        assert_eq!(out.record.output["exit_code"], json!(0));
131        let text = result_text(&out.tool_result);
132        assert!(text.contains("exit: 0"), "{text}");
133        assert!(text.contains("hi"), "{text}");
134    }
135
136    #[tokio::test]
137    async fn run_terminal_cmd_nonzero_exit_is_soft() {
138        let (_dir, registry, root) = setup();
139        let out = registry
140            .dispatch(
141                "run_terminal_cmd",
142                json!({ "command": "exit 3", "description": "fail" }),
143                &ctx(&root),
144            )
145            .await;
146        // Spawn succeeded → ok; the non-zero exit is data, not an error (ADR-0004).
147        assert!(out.record.ok);
148        assert!(!is_error(&out.tool_result));
149        assert_eq!(out.record.output["exit_code"], json!(3));
150    }
151
152    #[tokio::test]
153    async fn read_file_numbers_lines() {
154        let (_dir, registry, root) = setup();
155        std::fs::write(root.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
156        let out = registry
157            .dispatch("read_file", json!({ "target_file": "f.txt" }), &ctx(&root))
158            .await;
159        assert!(out.record.ok);
160        assert_eq!(out.record.output["lines"], json!(3));
161        assert_eq!(out.record.output["truncated"], json!(false));
162        let text = result_text(&out.tool_result);
163        assert!(text.contains("1→alpha"), "{text}");
164        assert!(text.contains("3→gamma"), "{text}");
165    }
166
167    #[tokio::test]
168    async fn read_file_line_cap_truncates() {
169        use std::fmt::Write as _;
170        let (_dir, registry, root) = setup();
171        let mut big = String::new();
172        for n in 1..=1500 {
173            writeln!(big, "line {n}").unwrap();
174        }
175        std::fs::write(root.join("big.txt"), big).unwrap();
176        let out = registry
177            .dispatch(
178                "read_file",
179                json!({ "target_file": "big.txt" }),
180                &ctx(&root),
181            )
182            .await;
183        assert!(out.record.ok);
184        assert_eq!(out.record.output["lines"], json!(1500));
185        assert_eq!(out.record.output["truncated"], json!(true));
186        // Body holds the first 1000 numbered lines only.
187        let text = result_text(&out.tool_result);
188        assert!(text.contains("1000→line 1000"), "capped at 1000");
189        assert!(!text.contains("1001→"), "line 1001 excluded");
190    }
191
192    #[tokio::test]
193    async fn read_file_not_found_is_soft_error() {
194        let (_dir, registry, root) = setup();
195        let out = registry
196            .dispatch(
197                "read_file",
198                json!({ "target_file": "nope.txt" }),
199                &ctx(&root),
200            )
201            .await;
202        assert!(!out.record.ok);
203        assert!(is_error(&out.tool_result));
204    }
205
206    #[tokio::test]
207    async fn read_file_outside_jail_is_soft_error() {
208        let (_dir, registry, root) = setup();
209        let out = registry
210            .dispatch(
211                "read_file",
212                json!({ "target_file": "/etc/passwd" }),
213                &ctx(&root),
214            )
215            .await;
216        assert!(!out.record.ok);
217        assert!(is_error(&out.tool_result));
218    }
219
220    // ---- search_replace ----
221
222    async fn edit(
223        registry: &Registry,
224        root: &Path,
225        args: serde_json::Value,
226    ) -> locode_tools::Dispatched {
227        registry.dispatch("search_replace", args, &ctx(root)).await
228    }
229
230    #[tokio::test]
231    async fn search_replace_creates_file_on_empty_old_string() {
232        let (_dir, registry, root) = setup();
233        let out = edit(
234            &registry,
235            &root,
236            json!({ "file_path": "new.txt", "old_string": "", "new_string": "hello world" }),
237        )
238        .await;
239        assert!(out.record.ok);
240        assert_eq!(out.record.output["created"], json!(true));
241        assert_eq!(
242            std::fs::read_to_string(root.join("new.txt")).unwrap(),
243            "hello world"
244        );
245    }
246
247    #[tokio::test]
248    async fn search_replace_edits_unique_match() {
249        let (_dir, registry, root) = setup();
250        std::fs::write(root.join("f.txt"), "alpha beta gamma").unwrap();
251        let out = edit(
252            &registry,
253            &root,
254            json!({ "file_path": "f.txt", "old_string": "beta", "new_string": "BETA" }),
255        )
256        .await;
257        assert!(out.record.ok);
258        assert_eq!(out.record.output["replacements"], json!(1));
259        assert_eq!(
260            std::fs::read_to_string(root.join("f.txt")).unwrap(),
261            "alpha BETA gamma"
262        );
263    }
264
265    #[tokio::test]
266    async fn search_replace_no_op_is_soft_error() {
267        let (_dir, registry, root) = setup();
268        std::fs::write(root.join("f.txt"), "x").unwrap();
269        let out = edit(
270            &registry,
271            &root,
272            json!({ "file_path": "f.txt", "old_string": "x", "new_string": "x" }),
273        )
274        .await;
275        assert!(!out.record.ok);
276        assert!(result_text(&out.tool_result).contains("same"));
277    }
278
279    #[tokio::test]
280    async fn search_replace_not_found_is_soft_error() {
281        let (_dir, registry, root) = setup();
282        std::fs::write(root.join("f.txt"), "abc").unwrap();
283        let out = edit(
284            &registry,
285            &root,
286            json!({ "file_path": "f.txt", "old_string": "zzz", "new_string": "q" }),
287        )
288        .await;
289        assert!(!out.record.ok);
290        assert!(result_text(&out.tool_result).contains("not found"));
291    }
292
293    #[tokio::test]
294    async fn search_replace_multiple_matches_without_replace_all_is_soft_error() {
295        let (_dir, registry, root) = setup();
296        std::fs::write(root.join("f.txt"), "a a a").unwrap();
297        let out = edit(
298            &registry,
299            &root,
300            json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b" }),
301        )
302        .await;
303        assert!(!out.record.ok);
304        assert!(result_text(&out.tool_result).contains("multiple times"));
305    }
306
307    #[tokio::test]
308    async fn search_replace_replace_all() {
309        let (_dir, registry, root) = setup();
310        std::fs::write(root.join("f.txt"), "a a a").unwrap();
311        let out = edit(
312            &registry,
313            &root,
314            json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b", "replace_all": true }),
315        )
316        .await;
317        assert!(out.record.ok);
318        assert_eq!(out.record.output["replacements"], json!(3));
319        assert_eq!(
320            std::fs::read_to_string(root.join("f.txt")).unwrap(),
321            "b b b"
322        );
323    }
324
325    // ---- grep (needs `rg`; the happy path is gated on its presence) ----
326
327    fn rg_present() -> bool {
328        std::process::Command::new("rg")
329            .arg("--version")
330            .output()
331            .is_ok()
332    }
333
334    #[tokio::test]
335    async fn grep_finds_matches() {
336        if !rg_present() {
337            return;
338        }
339        let (_dir, registry, root) = setup();
340        std::fs::write(root.join("a.txt"), "needle here\nother line\n").unwrap();
341        std::fs::write(root.join("b.txt"), "nothing\n").unwrap();
342        let out = registry
343            .dispatch("grep", json!({ "pattern": "needle" }), &ctx(&root))
344            .await;
345        assert!(out.record.ok);
346        assert_eq!(out.record.output["matched"], json!(true));
347        let text = result_text(&out.tool_result);
348        assert!(text.contains("needle"), "{text}");
349        assert!(text.contains("a.txt"), "{text}");
350    }
351
352    #[tokio::test]
353    async fn grep_no_match_is_soft_ok() {
354        if !rg_present() {
355            return;
356        }
357        let (_dir, registry, root) = setup();
358        std::fs::write(root.join("a.txt"), "hello\n").unwrap();
359        let out = registry
360            .dispatch("grep", json!({ "pattern": "zzzznotfound" }), &ctx(&root))
361            .await;
362        assert!(out.record.ok);
363        assert_eq!(out.record.output["matched"], json!(false));
364        assert!(result_text(&out.tool_result).contains("No matches"));
365    }
366
367    // ---- list_dir (self-implemented walk; no external binary) ----
368
369    #[tokio::test]
370    async fn list_dir_walks_the_tree() {
371        let (_dir, registry, root) = setup();
372        std::fs::create_dir(root.join("src")).unwrap();
373        std::fs::write(root.join("src/main.rs"), "").unwrap();
374        std::fs::write(root.join("Cargo.toml"), "").unwrap();
375        let out = registry
376            .dispatch("list_dir", json!({ "target_directory": "." }), &ctx(&root))
377            .await;
378        assert!(out.record.ok);
379        let text = result_text(&out.tool_result);
380        assert!(text.contains("src/"), "{text}");
381        assert!(text.contains("main.rs"), "{text}");
382        assert!(text.contains("Cargo.toml"), "{text}");
383    }
384
385    #[tokio::test]
386    async fn list_dir_missing_is_soft_error() {
387        let (_dir, registry, root) = setup();
388        let out = registry
389            .dispatch(
390                "list_dir",
391                json!({ "target_directory": "nope" }),
392                &ctx(&root),
393            )
394            .await;
395        assert!(!out.record.ok);
396        assert!(is_error(&out.tool_result));
397    }
398}