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