1use crate::engine::runner::agents::{
7 AgentError, PermissionRules, get_runner, shell_single_quote, supports_session,
8};
9use anyhow::Result;
10use std::time::Duration;
11
12#[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#[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
57pub 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#[derive(Debug, Clone, Copy)]
86pub struct SessionContinuation<'a> {
87 pub session_id: &'a str,
88 pub resume: bool,
89}
90
91pub 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
125fn insert_session_flag(
131 agent: &str,
132 mut shell_cmd: String,
133 session: SessionContinuation<'_>,
134) -> String {
135 use std::fmt::Write as _;
136 if !supports_session(agent) {
137 return shell_cmd;
138 }
139 let flag = if session.resume {
140 "--resume"
141 } else {
142 "--session-id"
143 };
144 let quoted = shell_single_quote(session.session_id);
145 if let Some(pos) = shell_cmd.rfind("< \"") {
146 shell_cmd.insert_str(pos, &format!("{flag} {quoted} \\\n "));
147 } else if agent == "grok" {
148 let _ = write!(shell_cmd, " \\\n {flag} {quoted}");
149 } else {
150 tracing::warn!(
151 agent,
152 flag,
153 session_id = session.session_id,
154 "could not insert session flag: '< \"' pattern not found in shell command"
155 );
156 }
157 shell_cmd
158}
159
160pub async fn run_direct_command_raw(
167 cmd: &mut tokio::process::Command,
168 timeout: Duration,
169) -> std::result::Result<String, DirectCommandError> {
170 cmd.kill_on_drop(true);
171 let output = tokio::time::timeout(timeout + Duration::from_secs(5), cmd.output())
172 .await
173 .map_err(|_| DirectCommandError::Timeout {
174 secs: timeout.as_secs(),
175 })?
176 .map_err(|e| DirectCommandError::Spawn {
177 message: e.to_string(),
178 })?;
179
180 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
181 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
182 let exit_code = output.status.code().unwrap_or(-1);
183
184 if !output.status.success() {
185 return Err(DirectCommandError::NonZeroExit {
186 exit_code,
187 stdout,
188 stderr,
189 });
190 }
191 if stdout.is_empty() {
192 return Err(DirectCommandError::EmptyResponse { stderr });
193 }
194 Ok(stdout)
195}
196
197fn finish_output(output: &std::process::Output, agent: &str) -> Result<DirectResult> {
199 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
200 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
201 let exit_code = output.status.code().unwrap_or(-1);
202
203 let runner = get_runner(agent);
204
205 if !output.status.success() {
206 let err = runner.classify_error(exit_code, &stdout, &stderr);
207 anyhow::bail!("{err}");
208 }
209
210 match runner.parse_response(&stdout) {
211 Ok(parsed) => {
212 let text = if parsed.response.summary.is_empty() {
213 stdout.clone()
214 } else {
215 parsed.response.summary.clone()
216 };
217 Ok(DirectResult {
218 text,
219 input_tokens: parsed.input_tokens,
220 output_tokens: parsed.output_tokens,
221 })
222 }
223 Err(AgentError::InvalidResponse { .. }) => {
224 let (text, input_tokens, output_tokens) = extract_from_envelope(&stdout);
225 Ok(DirectResult {
226 text,
227 input_tokens,
228 output_tokens,
229 })
230 }
231 Err(err) => anyhow::bail!("{err}"),
232 }
233}
234
235async fn run_shell_command(
236 shell_cmd: &str,
237 work_dir: &str,
238 timeout: Duration,
239 agent: &str,
240) -> Result<DirectResult> {
241 let mut cmd = tokio::process::Command::new("bash");
242 cmd.arg("-c")
243 .arg(shell_cmd)
244 .current_dir(work_dir)
245 .kill_on_drop(true);
246 let output = tokio::time::timeout(timeout + Duration::from_secs(5), cmd.output())
247 .await
248 .map_err(|_| anyhow::anyhow!("agent timed out after {}s", timeout.as_secs()))?
249 .map_err(|e| anyhow::anyhow!("spawning agent: {e}"))?;
250
251 finish_output(&output, agent)
252}
253
254pub(crate) fn extract_from_envelope(raw: &str) -> (String, Option<u64>, Option<u64>) {
259 let candidate = raw
261 .lines()
262 .filter(|l| !l.trim().is_empty())
263 .rev()
264 .find(|line| {
265 serde_json::from_str::<serde_json::Value>(line)
266 .ok()
267 .and_then(|v| {
268 v.get("type")
269 .and_then(|t| t.as_str())
270 .map(|t| t == "result")
271 })
272 .unwrap_or(false)
273 })
274 .unwrap_or(raw);
275
276 if let Ok(value) = serde_json::from_str::<serde_json::Value>(candidate)
277 && let Some(obj) = value.as_object()
278 && obj.get("type").and_then(|v| v.as_str()) == Some("result")
279 {
280 let text = obj
281 .get("result")
282 .and_then(|v| v.as_str())
283 .unwrap_or("")
284 .to_string();
285 let input_tokens = obj
286 .get("usage")
287 .and_then(|u| u.get("input_tokens"))
288 .and_then(serde_json::Value::as_u64);
289 let output_tokens = obj
290 .get("usage")
291 .and_then(|u| u.get("output_tokens"))
292 .and_then(serde_json::Value::as_u64);
293 return (text, input_tokens, output_tokens);
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: String::new()
426 }
427 .to_string(),
428 "agent returned empty response"
429 );
430 }
431}