1use 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#[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#[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
67pub 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#[derive(Debug, Clone, Copy)]
96pub struct SessionContinuation<'a> {
97 pub session_id: &'a str,
98 pub resume: bool,
99}
100
101pub 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
135fn 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
169pub 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
206fn 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
263pub(crate) fn extract_from_envelope(raw: &str) -> (String, Option<u64>, Option<u64>) {
268 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}