Skip to main content

orch/engine/runner/
direct.rs

1//! One-shot agent invocation without tmux.
2//!
3//! Used by the control session and the router LLM to run agents
4//! in a fire-and-forget mode without a persistent tmux session.
5
6use crate::engine::runner::agents::{
7    AgentError, PermissionRules, get_runner, shell_single_quote, supports_session,
8};
9use anyhow::Result;
10use std::time::Duration;
11
12/// Result of a one-shot agent invocation.
13#[derive(Debug, Clone)]
14pub struct DirectResult {
15    pub text: String,
16    pub input_tokens: Option<u64>,
17    pub output_tokens: Option<u64>,
18}
19
20/// Error type for `run_direct_command_raw()` — carries stdout on non-zero exit
21/// so callers can inspect it for rate-limit detection.
22#[derive(Debug)]
23pub enum DirectCommandError {
24    Timeout {
25        secs: u64,
26    },
27    Spawn {
28        message: String,
29    },
30    NonZeroExit {
31        exit_code: i32,
32        stdout: String,
33        stderr: String,
34    },
35    EmptyResponse {
36        stderr: String,
37    },
38}
39
40impl std::fmt::Display for DirectCommandError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Timeout { secs } => write!(f, "agent timed out after {secs}s"),
44            Self::Spawn { message } => write!(f, "spawning agent: {message}"),
45            Self::NonZeroExit {
46                exit_code, stderr, ..
47            } => {
48                write!(f, "agent failed (exit {exit_code}): {stderr}")
49            }
50            Self::EmptyResponse { .. } => write!(f, "agent returned empty response"),
51        }
52    }
53}
54
55impl std::error::Error for DirectCommandError {}
56
57/// Run an agent one-shot without tmux.
58///
59/// Writes temp files to `work_dir`, builds the CLI command via
60/// `get_runner(agent).build_command()`, executes with `timeout`,
61/// then parses with `parse_response()`.
62///
63/// `work_dir` should be an isolated directory (e.g., `~/.orch/state/control/{pid}/`).
64pub async fn run_direct(
65    agent: &str,
66    model: Option<&str>,
67    system_prompt: &str,
68    message: &str,
69    timeout: Duration,
70    work_dir: &str,
71) -> Result<DirectResult> {
72    run_direct_with_session(
73        agent,
74        model,
75        system_prompt,
76        message,
77        timeout,
78        work_dir,
79        None,
80    )
81    .await
82}
83
84/// Configuration for continuing an agent session.
85#[derive(Debug, Clone, Copy)]
86pub struct SessionContinuation<'a> {
87    pub session_id: &'a str,
88    pub resume: bool,
89}
90
91/// Run an agent one-shot with an optional `--session-id` or `--resume` for conversation continuity.
92///
93/// For agents that support sessions (claude, kimi, minimax, grok), appends
94/// `--session-id <uuid>` when starting a new session, or `--resume <uuid>` when
95/// continuing one. Other agents ignore the parameter.
96pub async fn run_direct_with_session(
97    agent: &str,
98    model: Option<&str>,
99    system_prompt: &str,
100    message: &str,
101    timeout: Duration,
102    work_dir: &str,
103    session: Option<SessionContinuation<'_>>,
104) -> Result<DirectResult> {
105    let sys_file = format!("{work_dir}/system.md");
106    let msg_file = format!("{work_dir}/message.txt");
107
108    tokio::fs::write(&sys_file, system_prompt).await?;
109    tokio::fs::write(&msg_file, message).await?;
110
111    let runner = get_runner(agent);
112    let permissions = PermissionRules::deny_read_only();
113    let timeout_cmd = format!("timeout {}", timeout.as_secs());
114
115    let mut shell_cmd =
116        runner.build_command(model, &timeout_cmd, &sys_file, &msg_file, &permissions);
117
118    if let Some(s) = session {
119        shell_cmd = insert_session_flag(agent, shell_cmd, s);
120    }
121
122    run_shell_command(&shell_cmd, work_dir, timeout, agent).await
123}
124
125/// Append `--session-id` or `--resume` for agents that keep a conversation.
126///
127/// Claude-family commands redirect the prompt with `< "file"`, so the flag is
128/// inserted before that redirect. Grok takes `--prompt-file` and has no
129/// redirect, so the flag is appended.
130fn insert_session_flag(
131    agent: &str,
132    mut shell_cmd: String,
133    session: SessionContinuation<'_>,
134) -> String {
135    use std::fmt::Write as _;
136    if !supports_session(agent) {
137        return shell_cmd;
138    }
139    let flag = if session.resume {
140        "--resume"
141    } else {
142        "--session-id"
143    };
144    let quoted = shell_single_quote(session.session_id);
145    if let Some(pos) = shell_cmd.rfind("< \"") {
146        shell_cmd.insert_str(pos, &format!("{flag} {quoted} \\\n  "));
147    } else if agent == "grok" {
148        let _ = write!(shell_cmd, " \\\n  {flag} {quoted}");
149    } else {
150        tracing::warn!(
151            agent,
152            flag,
153            session_id = session.session_id,
154            "could not insert session flag: '< \"' pattern not found in shell command"
155        );
156    }
157    shell_cmd
158}
159
160/// Execute an already-built `tokio::process::Command` one-shot, returning raw stdout.
161///
162/// Unlike `run_direct_command()`, this does NOT call `parse_response()`.
163/// Use this when the caller needs the raw stdout to parse itself (e.g., router).
164/// Returns `Err(DirectCommandError::NonZeroExit { stdout, .. })` so callers can
165/// inspect stdout for rate-limit messages even on failure.
166pub async fn run_direct_command_raw(
167    cmd: &mut tokio::process::Command,
168    timeout: Duration,
169) -> std::result::Result<String, DirectCommandError> {
170    cmd.kill_on_drop(true);
171    let output = tokio::time::timeout(timeout + Duration::from_secs(5), cmd.output())
172        .await
173        .map_err(|_| DirectCommandError::Timeout {
174            secs: timeout.as_secs(),
175        })?
176        .map_err(|e| DirectCommandError::Spawn {
177            message: e.to_string(),
178        })?;
179
180    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
181    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
182    let exit_code = output.status.code().unwrap_or(-1);
183
184    if !output.status.success() {
185        return Err(DirectCommandError::NonZeroExit {
186            exit_code,
187            stdout,
188            stderr,
189        });
190    }
191    if stdout.is_empty() {
192        return Err(DirectCommandError::EmptyResponse { stderr });
193    }
194    Ok(stdout)
195}
196
197/// Shared output-handling logic: parse stdout, classify errors.
198fn finish_output(output: &std::process::Output, agent: &str) -> Result<DirectResult> {
199    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
200    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
201    let exit_code = output.status.code().unwrap_or(-1);
202
203    let runner = get_runner(agent);
204
205    if !output.status.success() {
206        let err = runner.classify_error(exit_code, &stdout, &stderr);
207        anyhow::bail!("{err}");
208    }
209
210    match runner.parse_response(&stdout) {
211        Ok(parsed) => {
212            let text = if parsed.response.summary.is_empty() {
213                stdout.clone()
214            } else {
215                parsed.response.summary.clone()
216            };
217            Ok(DirectResult {
218                text,
219                input_tokens: parsed.input_tokens,
220                output_tokens: parsed.output_tokens,
221            })
222        }
223        Err(AgentError::InvalidResponse { .. }) => {
224            let (text, input_tokens, output_tokens) = extract_from_envelope(&stdout);
225            Ok(DirectResult {
226                text,
227                input_tokens,
228                output_tokens,
229            })
230        }
231        Err(err) => anyhow::bail!("{err}"),
232    }
233}
234
235async fn run_shell_command(
236    shell_cmd: &str,
237    work_dir: &str,
238    timeout: Duration,
239    agent: &str,
240) -> Result<DirectResult> {
241    let mut cmd = tokio::process::Command::new("bash");
242    cmd.arg("-c")
243        .arg(shell_cmd)
244        .current_dir(work_dir)
245        .kill_on_drop(true);
246    let output = tokio::time::timeout(timeout + Duration::from_secs(5), cmd.output())
247        .await
248        .map_err(|_| anyhow::anyhow!("agent timed out after {}s", timeout.as_secs()))?
249        .map_err(|e| anyhow::anyhow!("spawning agent: {e}"))?;
250
251    finish_output(&output, agent)
252}
253
254/// Extract text and token usage from a Claude JSON envelope or raw output.
255///
256/// Handles both single JSON blobs (`--output-format json`) and NDJSON streams
257/// (`--output-format stream-json`): finds the last line with `"type":"result"`.
258pub(crate) fn extract_from_envelope(raw: &str) -> (String, Option<u64>, Option<u64>) {
259    // Find the result line: last NDJSON line with "type":"result", or the full string
260    let candidate = raw
261        .lines()
262        .filter(|l| !l.trim().is_empty())
263        .rev()
264        .find(|line| {
265            serde_json::from_str::<serde_json::Value>(line)
266                .ok()
267                .and_then(|v| {
268                    v.get("type")
269                        .and_then(|t| t.as_str())
270                        .map(|t| t == "result")
271                })
272                .unwrap_or(false)
273        })
274        .unwrap_or(raw);
275
276    if let Ok(value) = serde_json::from_str::<serde_json::Value>(candidate)
277        && let Some(obj) = value.as_object()
278        && obj.get("type").and_then(|v| v.as_str()) == Some("result")
279    {
280        let text = obj
281            .get("result")
282            .and_then(|v| v.as_str())
283            .unwrap_or("")
284            .to_string();
285        let input_tokens = obj
286            .get("usage")
287            .and_then(|u| u.get("input_tokens"))
288            .and_then(serde_json::Value::as_u64);
289        let output_tokens = obj
290            .get("usage")
291            .and_then(|u| u.get("output_tokens"))
292            .and_then(serde_json::Value::as_u64);
293        return (text, input_tokens, output_tokens);
294    }
295    (raw.to_string(), None, None)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn session_flag_appends_for_grok_prompt_file() {
304        let cmd = "grok --prompt-file '/tmp/msg.txt'".to_string();
305        let updated = insert_session_flag(
306            "grok",
307            cmd,
308            SessionContinuation {
309                session_id: "abc",
310                resume: false,
311            },
312        );
313        assert!(updated.contains("--session-id 'abc'"), "{updated}");
314        assert!(updated.contains("--prompt-file"), "{updated}");
315    }
316
317    #[test]
318    fn session_flag_inserts_before_stdin_redirect() {
319        let cmd = "claude -p < \"/tmp/msg.txt\"".to_string();
320        let updated = insert_session_flag(
321            "claude",
322            cmd,
323            SessionContinuation {
324                session_id: "abc",
325                resume: true,
326            },
327        );
328        assert!(updated.contains("--resume 'abc'"), "{updated}");
329        let resume = updated.find("--resume").unwrap();
330        let redirect = updated.find("< \"").unwrap();
331        assert!(resume < redirect, "{updated}");
332    }
333
334    #[test]
335    fn session_flag_ignored_for_codex() {
336        let cmd = "codex exec".to_string();
337        let updated = insert_session_flag(
338            "codex",
339            cmd.clone(),
340            SessionContinuation {
341                session_id: "abc",
342                resume: false,
343            },
344        );
345        assert_eq!(updated, cmd);
346    }
347
348    #[test]
349    fn test_extract_from_envelope_claude_json() {
350        let raw = r#"{"type":"result","result":"Hello world","usage":{"input_tokens":10,"output_tokens":5}}"#;
351        let (text, input, output) = extract_from_envelope(raw);
352        assert_eq!(text, "Hello world");
353        assert_eq!(input, Some(10));
354        assert_eq!(output, Some(5));
355    }
356
357    #[test]
358    fn test_extract_from_envelope_ndjson_stream() {
359        let raw = concat!(
360            r#"{"type":"system","subtype":"init"}"#,
361            "\n",
362            r#"{"type":"assistant","message":{}}"#,
363            "\n",
364            r#"{"type":"result","result":"streamed result","usage":{"input_tokens":20,"output_tokens":8}}"#,
365            "\n"
366        );
367        let (text, input, output) = extract_from_envelope(raw);
368        assert_eq!(text, "streamed result");
369        assert_eq!(input, Some(20));
370        assert_eq!(output, Some(8));
371    }
372
373    #[test]
374    fn test_extract_from_envelope_not_envelope() {
375        let raw = "plain text response";
376        let (text, input, output) = extract_from_envelope(raw);
377        assert_eq!(text, "plain text response");
378        assert_eq!(input, None);
379        assert_eq!(output, None);
380    }
381
382    #[test]
383    fn test_extract_from_envelope_wrong_type() {
384        let raw = r#"{"type":"error","message":"something"}"#;
385        let (text, input, output) = extract_from_envelope(raw);
386        assert_eq!(text, raw);
387        assert_eq!(input, None);
388        assert_eq!(output, None);
389    }
390
391    #[test]
392    fn test_direct_result_debug() {
393        let r = DirectResult {
394            text: "hi".to_string(),
395            input_tokens: Some(1),
396            output_tokens: Some(2),
397        };
398        assert!(format!("{r:?}").contains("hi"));
399    }
400
401    #[test]
402    fn test_direct_command_error_display() {
403        assert_eq!(
404            DirectCommandError::Timeout { secs: 30 }.to_string(),
405            "agent timed out after 30s"
406        );
407        assert_eq!(
408            DirectCommandError::Spawn {
409                message: "no binary".to_string()
410            }
411            .to_string(),
412            "spawning agent: no binary"
413        );
414        assert_eq!(
415            DirectCommandError::NonZeroExit {
416                exit_code: 1,
417                stdout: "out".to_string(),
418                stderr: "err".to_string()
419            }
420            .to_string(),
421            "agent failed (exit 1): err"
422        );
423        assert_eq!(
424            DirectCommandError::EmptyResponse {
425                stderr: String::new()
426            }
427            .to_string(),
428            "agent returned empty response"
429        );
430    }
431}