1use 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#[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#[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
69pub 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#[derive(Debug, Clone, Copy)]
98pub struct SessionContinuation<'a> {
99 pub session_id: &'a str,
100 pub resume: bool,
101}
102
103pub 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
137fn 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
171pub 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
208fn 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
267pub(crate) fn extract_from_envelope(raw: &str) -> (String, Option<u64>, Option<u64>) {
272 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}