Skip to main content

orch/engine/runner/agents/
grok.rs

1//! Grok agent runner.
2//!
3//! Headless Grok (`grok -p`) does not read the prompt from stdin. The task
4//! message is `--prompt-file`. The system prompt is passed with
5//! `--system-prompt-override`. Output is `streaming-messages-json`, the
6//! Messages API NDJSON shape, so parsing delegates to the Claude runner.
7//!
8//! ```bash
9//! grok --output-format streaming-messages-json \
10//!   --permission-mode bypassPermissions \
11//!   --sandbox off \
12//!   --deny 'Bash(rm *)' \
13//!   --system-prompt-override "$(cat "{sys_file}")" \
14//!   --prompt-file "{msg_file}"
15//! ```
16//!
17//! `--sandbox workspace` only writes the cwd. A worktree's git dir lives
18//! outside that cwd, and the CLI has no extra-dir flag, so task runs use
19//! `--sandbox off`. Orch still isolates the agent in the worktree and denies
20//! `rm` and `git push`. Control sessions (`deny_read_only`) use `read-only`.
21
22use super::claude::{self, ClaudeRunner};
23use super::{
24    AgentError, AgentRunner, ParsedResponse, PermissionRules, StreamEvent, shell_single_quote,
25};
26
27/// Runner for the `grok` CLI
28#[derive(Debug)]
29pub struct GrokRunner;
30
31impl GrokRunner {
32    fn parser() -> ClaudeRunner {
33        ClaudeRunner::new("grok")
34    }
35}
36
37impl AgentRunner for GrokRunner {
38    #[cfg(test)]
39    fn name(&self) -> &'static str {
40        "grok"
41    }
42
43    fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
44        claude::stream_events(event)
45    }
46
47    fn router_transcript(&self) -> super::RouterTranscript {
48        super::RouterTranscript::Messages
49    }
50
51    fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
52        Self::parser().extract_text(raw)
53    }
54
55    fn build_command(
56        &self,
57        model: Option<&str>,
58        timeout_cmd: &str,
59        sys_file: &str,
60        msg_file: &str,
61        permissions: &PermissionRules,
62    ) -> String {
63        let sq_sys = shell_single_quote(sys_file);
64        let sq_msg = shell_single_quote(msg_file);
65        let mut args = Vec::new();
66        if !timeout_cmd.is_empty() {
67            args.push(timeout_cmd.to_string());
68        }
69        args.push("grok".to_string());
70        args.push("--no-plan".to_string());
71        args.push("--output-format".to_string());
72        args.push("streaming-messages-json".to_string());
73        args.push("--permission-mode".to_string());
74        args.push(permission_mode(permissions).to_string());
75        args.push("--sandbox".to_string());
76        args.push(sandbox_profile(permissions).to_string());
77        if let Some(model) = model.filter(|m| !m.is_empty()) {
78            args.push("--model".to_string());
79            args.push(shell_single_quote(model));
80        }
81        args.extend(permission_args(permissions));
82        args.push("--system-prompt-override".to_string());
83        args.push(format!("\"$(cat {sq_sys})\""));
84        args.push("--prompt-file".to_string());
85        args.push(sq_msg);
86        args.join(" ")
87    }
88
89    fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
90        Self::parser().parse_response(raw)
91    }
92
93    fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
94        let extracted = startup_errors(stdout);
95        for message in &extracted {
96            if let Some(err) = classify_grok_message(message) {
97                return err;
98            }
99        }
100        let stderr = if extracted.is_empty() {
101            stderr.to_string()
102        } else {
103            format!("{stderr}\n{}", extracted.join("\n"))
104        };
105        Self::parser().classify_error(exit_code, stdout, &stderr)
106    }
107
108    fn router_command(
109        &self,
110        prompt: &str,
111        model: Option<&str>,
112    ) -> anyhow::Result<tokio::process::Command> {
113        // Classification only needs a JSON answer. Strip the tools so the
114        // call cannot edit the repo or browse the network while routing.
115        let mut cmd = tokio::process::Command::new("grok");
116        cmd.arg("--no-plan")
117            .arg("--no-subagents")
118            .arg("--disable-web-search")
119            .arg("--max-turns")
120            .arg("1")
121            .arg("--output-format")
122            .arg("streaming-messages-json")
123            .arg("--permission-mode")
124            .arg("dontAsk")
125            .arg("--disallowed-tools")
126            .arg("read_file,grep,list_dir,search_replace,write,run_terminal_cmd,web_search,web_fetch,task");
127        if let Some(model) = model.filter(|m| !m.is_empty()) {
128            cmd.arg("--model").arg(model);
129        }
130        cmd.arg("-p").arg(prompt);
131        Ok(cmd)
132    }
133}
134
135/// `streaming-messages-json` result, plus Grok's cost and stop reason
136pub fn find_grok_result(ndjson: &str) -> Option<super::AgentResult> {
137    let mut result = claude::find_claude_result(ndjson)?;
138    let Some((line, _)) = claude::find_ndjson_result_line(ndjson.trim()) else {
139        return Some(result);
140    };
141    let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
142        return Some(result);
143    };
144    if result.cost_usd.is_none() {
145        result.cost_usd = val
146            .get("total_cost_usd")
147            .and_then(serde_json::Value::as_f64);
148    }
149    if val.get("stop_reason").and_then(|v| v.as_str()) == Some("max_tokens") {
150        result.truncated_by_length = true;
151    }
152    Some(result)
153}
154
155fn permission_mode(permissions: &PermissionRules) -> &'static str {
156    if permissions.autonomous {
157        "bypassPermissions"
158    } else if permissions.deny_read_only {
159        "dontAsk"
160    } else {
161        "acceptEdits"
162    }
163}
164
165fn sandbox_profile(permissions: &PermissionRules) -> &'static str {
166    if permissions.deny_read_only {
167        "read-only"
168    } else {
169        "off"
170    }
171}
172
173/// Claude tool names that map to a Grok tool id for `--tools` / `--disallowed-tools`
174fn grok_tool_id(name: &str) -> Option<&'static str> {
175    Some(match name {
176        "Read" => "read_file",
177        "Grep" => "grep",
178        "Glob" => "list_dir",
179        "Edit" => "search_replace",
180        "Write" => "write",
181        "Bash" => "run_terminal_cmd",
182        "WebFetch" => "web_fetch",
183        "WebSearch" => "web_search",
184        "Task" => "task",
185        "TodoWrite" => "todo_write",
186        _ => return None,
187    })
188}
189
190fn permission_args(permissions: &PermissionRules) -> Vec<String> {
191    if !permissions.allowed_tools.is_empty() {
192        return allow_args(permissions);
193    }
194    deny_args(&permissions.disallowed_tools)
195}
196
197fn allow_args(permissions: &PermissionRules) -> Vec<String> {
198    let mut tools = Vec::new();
199    let mut allows = Vec::new();
200    for item in &permissions.allowed_tools {
201        if let Some(id) = grok_tool_id(item) {
202            if !tools.contains(&id) {
203                tools.push(id);
204            }
205            if (item == "Edit" || item == "Write") && !permissions.allowed_edit_paths.is_empty() {
206                for path in &permissions.allowed_edit_paths {
207                    allows.push(format!("{item}({}/**)", path.display()));
208                }
209            }
210        } else if item.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
211            // CLI command names become shell allow rules. PascalCase leftovers
212            // such as Skill are Claude-only tools, not shell commands
213            allows.push(format!("Bash({item} *)"));
214        }
215    }
216    // An allowlist does not express the rm / git-push bans. Keep those denies.
217    let mut args = Vec::new();
218    if !tools.is_empty() {
219        args.push("--tools".to_string());
220        args.push(shell_single_quote(&tools.join(",")));
221    }
222    for rule in allows {
223        args.push("--allow".to_string());
224        args.push(shell_single_quote(&rule));
225    }
226    args.extend(deny_args(&permissions.disallowed_tools));
227    args
228}
229
230fn deny_args(rules: &[String]) -> Vec<String> {
231    let mut remove = Vec::new();
232    let mut denies = Vec::new();
233    for rule in rules {
234        match rule.as_str() {
235            "Read" | "Grep" | "Glob" | "Edit" | "Write" | "Bash" => {
236                if let Some(id) = grok_tool_id(rule)
237                    && !remove.contains(&id)
238                {
239                    remove.push(id);
240                }
241            }
242            _ => denies.push(rule.as_str()),
243        }
244    }
245    let mut args = Vec::new();
246    if !remove.is_empty() {
247        args.push("--disallowed-tools".to_string());
248        args.push(shell_single_quote(&remove.join(",")));
249    }
250    for rule in denies {
251        args.push("--deny".to_string());
252        args.push(shell_single_quote(rule));
253    }
254    args
255}
256
257/// `type:error` messages. These are startup failures, not assistant prose.
258fn startup_errors(stdout: &str) -> Vec<String> {
259    let mut out = Vec::new();
260    for line in stdout.lines() {
261        let line = line.trim();
262        if line.is_empty() {
263            continue;
264        }
265        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
266            continue;
267        };
268        if val.get("type").and_then(|t| t.as_str()) != Some("error") {
269            continue;
270        }
271        if let Some(message) = val.get("message").and_then(|m| m.as_str())
272            && !message.is_empty()
273        {
274            out.push(message.to_string());
275        }
276    }
277    out
278}
279
280/// Grok phrases the shared classifier does not already name
281fn classify_grok_message(message: &str) -> Option<AgentError> {
282    let lower = message.to_lowercase();
283    if lower.contains("not logged in")
284        || lower.contains("please log in")
285        || lower.contains("grok login")
286        || lower.contains("xai_api_key")
287        || lower.contains("authentication failed")
288        || lower.contains("could not refresh")
289    {
290        return Some(AgentError::Auth {
291            message: message.to_string(),
292        });
293    }
294    if lower.contains("permission required") {
295        return Some(AgentError::PermissionDenied {
296            message: message.to_string(),
297        });
298    }
299    None
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::engine::runner::agents::SandboxLevel;
306
307    fn runner() -> GrokRunner {
308        GrokRunner
309    }
310
311    fn perms(autonomous: bool, disallowed: &[&str], allowed: &[&str]) -> PermissionRules {
312        PermissionRules {
313            autonomous,
314            sandbox: SandboxLevel::WorkspaceWrite,
315            disallowed_tools: disallowed.iter().map(|s| (*s).to_string()).collect(),
316            allowed_tools: allowed.iter().map(|s| (*s).to_string()).collect(),
317            allowed_edit_paths: vec![],
318            deny_read_only: false,
319            extra_writable_dirs: vec![],
320        }
321    }
322
323    #[test]
324    fn build_command_autonomous_denies_rm_and_passes_files() {
325        let cmd = runner().build_command(
326            Some("fast"),
327            "timeout 1800",
328            "/tmp/sys.txt",
329            "/tmp/msg.txt",
330            &perms(true, &["Bash(rm *)", "Bash(git push*)"], &[]),
331        );
332        assert!(cmd.starts_with("timeout 1800 grok "), "{cmd}");
333        assert!(cmd.contains("--model 'fast'"), "{cmd}");
334        assert!(
335            cmd.contains("--output-format streaming-messages-json"),
336            "{cmd}"
337        );
338        assert!(cmd.contains("--permission-mode bypassPermissions"), "{cmd}");
339        assert!(cmd.contains("--sandbox off"), "{cmd}");
340        assert!(cmd.contains("--no-plan"), "{cmd}");
341        assert!(cmd.contains("--deny 'Bash(rm *)'"), "{cmd}");
342        assert!(cmd.contains("--deny 'Bash(git push*)'"), "{cmd}");
343        assert!(cmd.contains("--prompt-file '/tmp/msg.txt'"), "{cmd}");
344        assert!(
345            cmd.contains("--system-prompt-override \"$(cat '/tmp/sys.txt')\""),
346            "{cmd}"
347        );
348        assert!(!cmd.contains("< "), "{cmd}");
349    }
350
351    #[test]
352    fn build_command_omits_empty_model() {
353        let cmd = runner().build_command(
354            Some(""),
355            "",
356            "/tmp/sys.txt",
357            "/tmp/msg.txt",
358            &perms(true, &[], &[]),
359        );
360        assert!(!cmd.contains("--model"), "{cmd}");
361    }
362
363    #[test]
364    fn build_command_supervised_accepts_edits() {
365        let cmd = runner().build_command(
366            None,
367            "",
368            "/tmp/sys.txt",
369            "/tmp/msg.txt",
370            &perms(false, &[], &[]),
371        );
372        assert!(cmd.contains("--permission-mode acceptEdits"), "{cmd}");
373    }
374
375    #[test]
376    fn build_command_read_only_removes_read_tools() {
377        let rules = PermissionRules::deny_read_only();
378        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &rules);
379        assert!(cmd.contains("--sandbox read-only"), "{cmd}");
380        assert!(cmd.contains("--permission-mode bypassPermissions"), "{cmd}");
381        assert!(cmd.contains("read_file"), "{cmd}");
382        assert!(cmd.contains("grep"), "{cmd}");
383        assert!(cmd.contains("list_dir"), "{cmd}");
384        assert!(cmd.contains("--deny 'Bash(cat *)'"), "{cmd}");
385    }
386
387    #[test]
388    fn build_command_allowlist_keeps_destructive_denies() {
389        let mut rules = perms(true, &["Bash(rm *)"], &["Edit", "Write", "Bash", "git"]);
390        rules.allowed_edit_paths = vec![std::path::PathBuf::from("/work/tree")];
391        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &rules);
392        assert!(
393            cmd.contains("--tools 'search_replace,write,run_terminal_cmd'"),
394            "{cmd}"
395        );
396        assert!(cmd.contains("--allow 'Edit(/work/tree/**)'"), "{cmd}");
397        assert!(cmd.contains("--allow 'Write(/work/tree/**)'"), "{cmd}");
398        assert!(cmd.contains("--allow 'Bash(git *)'"), "{cmd}");
399        assert!(cmd.contains("--deny 'Bash(rm *)'"), "{cmd}");
400    }
401
402    #[test]
403    fn parse_response_reads_messages_result() {
404        let raw = concat!(
405            r#"{"type":"system","subtype":"init","model":"test"}"#,
406            "\n",
407            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"shipped\",\"accomplished\":[\"a\"],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":8,"output_tokens":3},"total_cost_usd":0.02,"duration_ms":40,"stop_reason":"end_turn"}"#,
408            "\n",
409        );
410        let parsed = runner().parse_response(raw).expect("parse");
411        assert_eq!(parsed.response.status, "done");
412        assert_eq!(parsed.response.summary, "shipped");
413        assert_eq!(parsed.input_tokens, Some(8));
414        assert_eq!(parsed.output_tokens, Some(3));
415    }
416
417    #[test]
418    fn find_result_reads_cost_and_max_tokens() {
419        let raw = r#"{"type":"result","subtype":"success","is_error":false,"result":"partial","usage":{"input_tokens":1,"output_tokens":2},"total_cost_usd":1.5,"stop_reason":"max_tokens"}"#;
420        let result = find_grok_result(raw).expect("result");
421        assert_eq!(result.result_text, "partial");
422        assert_eq!(result.cost_usd, Some(1.5));
423        assert!(result.truncated_by_length);
424        assert_eq!(result.input_tokens, Some(1));
425    }
426
427    #[test]
428    fn classify_error_rate_limit_from_error_event() {
429        let stdout = r#"{"type":"error","message":"429 Too Many Requests"}"#;
430        let err = runner().classify_error(1, stdout, "");
431        assert!(matches!(err, AgentError::RateLimit { .. }), "{err}");
432    }
433
434    #[test]
435    fn classify_error_model_unavailable() {
436        let stdout = r#"{"type":"error","message":"The model `missing` is not supported"}"#;
437        let err = runner().classify_error(1, stdout, "");
438        match err {
439            AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "missing"),
440            other => panic!("expected model unavailable, got {other}"),
441        }
442    }
443
444    #[test]
445    fn classify_error_login_is_auth() {
446        let stdout = r#"{"type":"error","message":"Not logged in. Run grok login."}"#;
447        let err = runner().classify_error(1, stdout, "");
448        assert!(matches!(err, AgentError::Auth { .. }), "{err}");
449    }
450
451    #[test]
452    fn classify_error_permission_required() {
453        let stdout =
454            r#"{"type":"error","message":"permission required: Edit calc.py was not run"}"#;
455        let err = runner().classify_error(1, stdout, "");
456        assert!(matches!(err, AgentError::PermissionDenied { .. }), "{err}");
457    }
458}