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