1use crate::engine::runner::agents::{
7 get_runner, shell_single_quote, supports_session, AgentError, PermissionRules,
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 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
159pub 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
196fn 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
253pub(crate) fn extract_from_envelope(raw: &str) -> (String, Option<u64>, Option<u64>) {
258 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}