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    get_runner, shell_single_quote, supports_session, AgentError, PermissionRules,
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    if !supports_session(agent) {
136        return shell_cmd;
137    }
138    let flag = if session.resume {
139        "--resume"
140    } else {
141        "--session-id"
142    };
143    let quoted = shell_single_quote(session.session_id);
144    if let Some(pos) = shell_cmd.rfind("< \"") {
145        shell_cmd.insert_str(pos, &format!("{flag} {quoted} \\\n  "));
146    } else if agent == "grok" {
147        shell_cmd.push_str(&format!(" \\\n  {flag} {quoted}"));
148    } else {
149        tracing::warn!(
150            agent,
151            flag,
152            session_id = session.session_id,
153            "could not insert session flag: '< \"' pattern not found in shell command"
154        );
155    }
156    shell_cmd
157}
158
159/// Execute an already-built `tokio::process::Command` one-shot, returning raw stdout.
160///
161/// Unlike `run_direct_command()`, this does NOT call `parse_response()`.
162/// Use this when the caller needs the raw stdout to parse itself (e.g., router).
163/// Returns `Err(DirectCommandError::NonZeroExit { stdout, .. })` so callers can
164/// inspect stdout for rate-limit messages even on failure.
165pub async fn run_direct_command_raw(
166    cmd: &mut tokio::process::Command,
167    timeout: Duration,
168) -> std::result::Result<String, DirectCommandError> {
169    cmd.kill_on_drop(true);
170    let output = tokio::time::timeout(timeout + Duration::from_secs(5), cmd.output())
171        .await
172        .map_err(|_| DirectCommandError::Timeout {
173            secs: timeout.as_secs(),
174        })?
175        .map_err(|e| DirectCommandError::Spawn {
176            message: e.to_string(),
177        })?;
178
179    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
180    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
181    let exit_code = output.status.code().unwrap_or(-1);
182
183    if !output.status.success() {
184        return Err(DirectCommandError::NonZeroExit {
185            exit_code,
186            stdout,
187            stderr,
188        });
189    }
190    if stdout.is_empty() {
191        return Err(DirectCommandError::EmptyResponse { stderr });
192    }
193    Ok(stdout)
194}
195
196/// Shared output-handling logic: parse stdout, classify errors.
197fn finish_output(output: std::process::Output, agent: &str) -> Result<DirectResult> {
198    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
199    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
200    let exit_code = output.status.code().unwrap_or(-1);
201
202    let runner = get_runner(agent);
203
204    if !output.status.success() {
205        let err = runner.classify_error(exit_code, &stdout, &stderr);
206        anyhow::bail!("{err}");
207    }
208
209    match runner.parse_response(&stdout) {
210        Ok(parsed) => {
211            let text = if !parsed.response.summary.is_empty() {
212                parsed.response.summary.clone()
213            } else {
214                stdout.clone()
215            };
216            Ok(DirectResult {
217                text,
218                input_tokens: parsed.input_tokens,
219                output_tokens: parsed.output_tokens,
220            })
221        }
222        Err(AgentError::InvalidResponse { .. }) => {
223            let (text, input_tokens, output_tokens) = extract_from_envelope(&stdout);
224            Ok(DirectResult {
225                text,
226                input_tokens,
227                output_tokens,
228            })
229        }
230        Err(err) => anyhow::bail!("{err}"),
231    }
232}
233
234async fn run_shell_command(
235    shell_cmd: &str,
236    work_dir: &str,
237    timeout: Duration,
238    agent: &str,
239) -> Result<DirectResult> {
240    let mut cmd = tokio::process::Command::new("bash");
241    cmd.arg("-c")
242        .arg(shell_cmd)
243        .current_dir(work_dir)
244        .kill_on_drop(true);
245    let output = tokio::time::timeout(timeout + Duration::from_secs(5), cmd.output())
246        .await
247        .map_err(|_| anyhow::anyhow!("agent timed out after {}s", timeout.as_secs()))?
248        .map_err(|e| anyhow::anyhow!("spawning agent: {e}"))?;
249
250    finish_output(output, agent)
251}
252
253/// Extract text and token usage from a Claude JSON envelope or raw output.
254///
255/// Handles both single JSON blobs (`--output-format json`) and NDJSON streams
256/// (`--output-format stream-json`): finds the last line with `"type":"result"`.
257pub(crate) fn extract_from_envelope(raw: &str) -> (String, Option<u64>, Option<u64>) {
258    // Find the result line: last NDJSON line with "type":"result", or the full string
259    let candidate = raw
260        .lines()
261        .filter(|l| !l.trim().is_empty())
262        .rev()
263        .find(|line| {
264            serde_json::from_str::<serde_json::Value>(line)
265                .ok()
266                .and_then(|v| {
267                    v.get("type")
268                        .and_then(|t| t.as_str())
269                        .map(|t| t == "result")
270                })
271                .unwrap_or(false)
272        })
273        .unwrap_or(raw);
274
275    if let Ok(value) = serde_json::from_str::<serde_json::Value>(candidate) {
276        if let Some(obj) = value.as_object() {
277            if obj.get("type").and_then(|v| v.as_str()) == Some("result") {
278                let text = obj
279                    .get("result")
280                    .and_then(|v| v.as_str())
281                    .unwrap_or("")
282                    .to_string();
283                let input_tokens = obj
284                    .get("usage")
285                    .and_then(|u| u.get("input_tokens"))
286                    .and_then(|v| v.as_u64());
287                let output_tokens = obj
288                    .get("usage")
289                    .and_then(|u| u.get("output_tokens"))
290                    .and_then(|v| v.as_u64());
291                return (text, input_tokens, output_tokens);
292            }
293        }
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: "".to_string()
426            }
427            .to_string(),
428            "agent returned empty response"
429        );
430    }
431}