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