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 tokio::process::Command;
23
24use super::claude::{self, ClaudeRunner};
25use super::{
26    AgentError, AgentRunner, ParsedResponse, PermissionRules, StreamEvent, shell_single_quote,
27};
28
29/// Runner for the `grok` CLI
30#[derive(Debug)]
31pub struct GrokRunner;
32
33impl GrokRunner {
34    fn parser() -> ClaudeRunner {
35        ClaudeRunner::new("grok")
36    }
37}
38
39impl AgentRunner for GrokRunner {
40    #[cfg(test)]
41    fn name(&self) -> &'static str {
42        "grok"
43    }
44
45    fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
46        claude::stream_events(event)
47    }
48
49    fn router_transcript(&self) -> super::RouterTranscript {
50        super::RouterTranscript::Messages
51    }
52
53    fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
54        Self::parser().extract_text(raw)
55    }
56
57    fn build_command(
58        &self,
59        model: Option<&str>,
60        timeout_cmd: &str,
61        sys_file: &str,
62        msg_file: &str,
63        permissions: &PermissionRules,
64    ) -> String {
65        let sq_sys = shell_single_quote(sys_file);
66        let sq_msg = shell_single_quote(msg_file);
67        let mut args = Vec::new();
68        if !timeout_cmd.is_empty() {
69            args.push(timeout_cmd.to_string());
70        }
71        args.push("grok".to_string());
72        args.push("--no-plan".to_string());
73        args.push("--output-format".to_string());
74        args.push("streaming-messages-json".to_string());
75        args.push("--permission-mode".to_string());
76        args.push(permission_mode(permissions).to_string());
77        args.push("--sandbox".to_string());
78        args.push(sandbox_profile(permissions).to_string());
79        if let Some(model) = model.filter(|m| !m.is_empty()) {
80            args.push("--model".to_string());
81            args.push(shell_single_quote(model));
82        }
83        args.extend(permission_args(permissions));
84        args.push("--system-prompt-override".to_string());
85        args.push(format!("\"$(cat {sq_sys})\""));
86        args.push("--prompt-file".to_string());
87        args.push(sq_msg);
88        args.join(" ")
89    }
90
91    fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
92        Self::parser().parse_response(raw)
93    }
94
95    fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
96        let extracted = startup_errors(stdout);
97        for message in &extracted {
98            if let Some(err) = classify_grok_message(message) {
99                return err;
100            }
101        }
102        let stderr = if extracted.is_empty() {
103            stderr.to_string()
104        } else {
105            format!("{stderr}\n{}", extracted.join("\n"))
106        };
107        Self::parser().classify_error(exit_code, stdout, &stderr)
108    }
109
110    fn router_command(&self, prompt: &str, model: Option<&str>) -> anyhow::Result<Command> {
111        // Classification only needs a JSON answer. Strip the tools so the
112        // call cannot edit the repo or browse the network while routing.
113        let mut cmd = Command::new("grok");
114        cmd.arg("--no-plan")
115            .arg("--no-subagents")
116            .arg("--disable-web-search")
117            .arg("--max-turns")
118            .arg("1")
119            .arg("--output-format")
120            .arg("streaming-messages-json")
121            .arg("--permission-mode")
122            .arg("dontAsk")
123            .arg("--disallowed-tools")
124            .arg("read_file,grep,list_dir,search_replace,write,run_terminal_cmd,web_search,web_fetch,task");
125        if let Some(model) = model.filter(|m| !m.is_empty()) {
126            cmd.arg("--model").arg(model);
127        }
128        cmd.arg("-p").arg(prompt);
129        Ok(cmd)
130    }
131}
132
133/// `streaming-messages-json` result, plus Grok's cost and stop reason
134pub fn find_grok_result(ndjson: &str) -> Option<super::AgentResult> {
135    let mut result = claude::find_claude_result(ndjson)?;
136    let Some((line, _)) = claude::find_ndjson_result_line(ndjson.trim()) else {
137        return Some(result);
138    };
139    let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
140        return Some(result);
141    };
142    if result.cost_usd.is_none() {
143        result.cost_usd = val
144            .get("total_cost_usd")
145            .and_then(serde_json::Value::as_f64);
146    }
147    if val.get("stop_reason").and_then(|v| v.as_str()) == Some("max_tokens") {
148        result.truncated_by_length = true;
149    }
150    Some(result)
151}
152
153fn permission_mode(permissions: &PermissionRules) -> &'static str {
154    if permissions.autonomous {
155        "bypassPermissions"
156    } else if permissions.deny_read_only {
157        "dontAsk"
158    } else {
159        "acceptEdits"
160    }
161}
162
163fn sandbox_profile(permissions: &PermissionRules) -> &'static str {
164    if permissions.deny_read_only {
165        "read-only"
166    } else {
167        "off"
168    }
169}
170
171/// Claude tool names that map to a Grok tool id for `--tools` / `--disallowed-tools`
172fn grok_tool_id(name: &str) -> Option<&'static str> {
173    Some(match name {
174        "Read" => "read_file",
175        "Grep" => "grep",
176        "Glob" => "list_dir",
177        "Edit" => "search_replace",
178        "Write" => "write",
179        "Bash" => "run_terminal_cmd",
180        "WebFetch" => "web_fetch",
181        "WebSearch" => "web_search",
182        "Task" => "task",
183        "TodoWrite" => "todo_write",
184        _ => return None,
185    })
186}
187
188fn permission_args(permissions: &PermissionRules) -> Vec<String> {
189    if !permissions.allowed_tools.is_empty() {
190        return allow_args(permissions);
191    }
192    deny_args(&permissions.disallowed_tools)
193}
194
195fn allow_args(permissions: &PermissionRules) -> Vec<String> {
196    let mut tools = Vec::new();
197    let mut allows = Vec::new();
198    for item in &permissions.allowed_tools {
199        if let Some(id) = grok_tool_id(item) {
200            if !tools.contains(&id) {
201                tools.push(id);
202            }
203            if (item == "Edit" || item == "Write") && !permissions.allowed_edit_paths.is_empty() {
204                for path in &permissions.allowed_edit_paths {
205                    allows.push(format!("{item}({}/**)", path.display()));
206                }
207            }
208        } else if item.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
209            // CLI command names become shell allow rules. PascalCase leftovers
210            // such as Skill are Claude-only tools, not shell commands
211            allows.push(format!("Bash({item} *)"));
212        }
213    }
214    // An allowlist does not express the rm / git-push bans. Keep those denies.
215    let mut args = Vec::new();
216    if !tools.is_empty() {
217        args.push("--tools".to_string());
218        args.push(shell_single_quote(&tools.join(",")));
219    }
220    for rule in allows {
221        args.push("--allow".to_string());
222        args.push(shell_single_quote(&rule));
223    }
224    args.extend(deny_args(&permissions.disallowed_tools));
225    args
226}
227
228fn deny_args(rules: &[String]) -> Vec<String> {
229    let mut remove = Vec::new();
230    let mut denies = Vec::new();
231    for rule in rules {
232        match rule.as_str() {
233            "Read" | "Grep" | "Glob" | "Edit" | "Write" | "Bash" => {
234                if let Some(id) = grok_tool_id(rule)
235                    && !remove.contains(&id)
236                {
237                    remove.push(id);
238                }
239            }
240            _ => denies.push(rule.as_str()),
241        }
242    }
243    let mut args = Vec::new();
244    if !remove.is_empty() {
245        args.push("--disallowed-tools".to_string());
246        args.push(shell_single_quote(&remove.join(",")));
247    }
248    for rule in denies {
249        args.push("--deny".to_string());
250        args.push(shell_single_quote(rule));
251    }
252    args
253}
254
255/// `type:error` messages. These are startup failures, not assistant prose.
256fn startup_errors(stdout: &str) -> Vec<String> {
257    let mut out = Vec::new();
258    for line in stdout.lines() {
259        let line = line.trim();
260        if line.is_empty() {
261            continue;
262        }
263        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
264            continue;
265        };
266        if val.get("type").and_then(|t| t.as_str()) != Some("error") {
267            continue;
268        }
269        if let Some(message) = val.get("message").and_then(|m| m.as_str())
270            && !message.is_empty()
271        {
272            out.push(message.to_string());
273        }
274    }
275    out
276}
277
278/// Grok phrases the shared classifier does not already name
279fn classify_grok_message(message: &str) -> Option<AgentError> {
280    let lower = message.to_lowercase();
281    if lower.contains("not logged in")
282        || lower.contains("please log in")
283        || lower.contains("grok login")
284        || lower.contains("xai_api_key")
285        || lower.contains("authentication failed")
286        || lower.contains("could not refresh")
287    {
288        return Some(AgentError::Auth {
289            message: message.to_string(),
290        });
291    }
292    if lower.contains("permission required") {
293        return Some(AgentError::PermissionDenied {
294            message: message.to_string(),
295        });
296    }
297    None
298}
299
300#[cfg(test)]
301mod tests {
302    use std::path::PathBuf;
303
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![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}