1use crate::backend::{
16 AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
17};
18#[cfg(unix)]
19use crate::backend_claude::kill_group;
20#[cfg(windows)]
21use crate::backend_claude::win_job;
22use crate::cost;
23use crate::error::{EngineError, Result};
24use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
25use crate::types::TokenUsage;
26use serde_json::{json, Value};
27use std::collections::VecDeque;
28use std::path::{Path, PathBuf};
29use std::process::Stdio;
30use std::sync::{Arc, Mutex};
31use tokio::process::{Child, ChildStdout};
32use tokio::task::JoinHandle;
33
34const STDERR_TAIL_CHARS: usize = 500;
36
37pub fn discover_droid_binary(configured: Option<&str>) -> Result<PathBuf> {
54 if let Some(env_bin) = std::env::var_os("KRANZ_DROID_BIN") {
55 if !env_bin.is_empty() {
56 let candidate = PathBuf::from(env_bin);
57 return match probe_version(&candidate) {
58 Ok(_version) => Ok(candidate),
59 Err(why) => Err(EngineError::Config(format!(
60 "KRANZ_DROID_BIN points at {} which did not work: {why}",
61 candidate.display()
62 ))),
63 };
64 }
65 }
66
67 let mut candidates: Vec<PathBuf> = Vec::new();
68 if let Some(configured) = configured {
69 candidates.push(PathBuf::from(configured));
70 }
71 candidates.push(PathBuf::from("droid"));
74 #[cfg(windows)]
75 {
76 candidates.push(PathBuf::from("droid.cmd"));
77 candidates.push(PathBuf::from("droid.exe"));
78 }
79 candidates.extend(fallback_candidates());
80
81 let mut deduped: Vec<PathBuf> = Vec::new();
83 for candidate in candidates {
84 if !deduped.contains(&candidate) {
85 deduped.push(candidate);
86 }
87 }
88
89 let mut attempts: Vec<String> = Vec::new();
90 for candidate in deduped {
91 match probe_version(&candidate) {
92 Ok(_version) => return Ok(candidate),
93 Err(why) => attempts.push(format!("{} ({why})", candidate.display())),
94 }
95 }
96 Err(EngineError::Config(format!(
97 "no working droid binary found; tried: {}. Install Factory droid \
98 or point kranz at it via the validatorScrutiny.droidBinary config \
99 field or the KRANZ_DROID_BIN environment variable.",
100 attempts.join(", ")
101 )))
102}
103
104#[cfg(not(windows))]
106fn fallback_candidates() -> Vec<PathBuf> {
107 let home = std::env::var_os("HOME").map(PathBuf::from);
108 let mut out = Vec::new();
109 if let Some(home) = &home {
110 out.push(home.join(".factory").join("bin").join("droid"));
111 out.push(home.join(".local").join("bin").join("droid"));
112 }
113 out.push(PathBuf::from("/opt/homebrew/bin/droid"));
114 out.push(PathBuf::from("/usr/local/bin/droid"));
115 if let Some(home) = &home {
116 out.push(home.join(".npm-global").join("bin").join("droid"));
117 }
118 out
119}
120
121#[cfg(windows)]
123fn fallback_candidates() -> Vec<PathBuf> {
124 let mut out = Vec::new();
125 if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
126 for dir in [
127 profile.join(".factory").join("bin"),
128 profile.join(".local").join("bin"),
129 profile.join("AppData").join("Roaming").join("npm"),
130 profile.join(".npm-global").join("bin"),
131 ] {
132 for name in ["droid.cmd", "droid.exe", "droid"] {
133 out.push(dir.join(name));
134 }
135 }
136 }
137 out
138}
139
140const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
144
145fn probe_version(binary: &Path) -> std::result::Result<String, String> {
148 crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
149}
150
151fn effective_prompt(spec: &SessionSpec) -> String {
160 let prompt_text = match &spec.prompt {
161 PromptMode::SingleShot(text) => text.as_str(),
162 PromptMode::Streaming(text) => text.as_str(),
163 };
164 match &spec.append_system_prompt {
165 Some(system) if !system.is_empty() => format!("{system}\n\n{prompt_text}"),
166 _ => prompt_text.to_string(),
167 }
168}
169
170pub fn build_args(spec: &SessionSpec) -> Vec<String> {
177 let auto = if spec.writable { "high" } else { "low" };
178 vec![
179 "exec".into(),
180 "-o".into(),
181 "json".into(),
182 "--auto".into(),
183 auto.into(),
184 "-m".into(),
185 spec.model.clone(),
186 effective_prompt(spec),
187 ]
188}
189
190pub fn parse_droid_result(line: &str, model: &str) -> Vec<AgentEvent> {
203 match serde_json::from_str::<Value>(line) {
204 Ok(value) => parse_droid_value(value, model),
205 Err(_) => vec![AgentEvent::Other {
206 raw: json!({ "unparsed": line }),
207 }],
208 }
209}
210
211fn parse_droid_value(value: Value, model: &str) -> Vec<AgentEvent> {
214 let line_type = value.get("type").and_then(Value::as_str).unwrap_or("");
215 if line_type != "result" {
216 return vec![AgentEvent::Other { raw: value }];
217 }
218
219 let session_id = str_field(&value, "session_id");
220 let init = AgentEvent::Init {
221 session_id,
222 model: model.to_string(),
223 raw: value.clone(),
224 };
225
226 let usage_field = |key: &str| {
227 value
228 .pointer(&format!("/usage/{key}"))
229 .and_then(Value::as_u64)
230 .unwrap_or(0)
231 };
232 let usage = TokenUsage {
233 input: usage_field("input_tokens"),
234 output: usage_field("output_tokens"),
235 cache_read: usage_field("cache_read_input_tokens"),
236 cache_write: usage_field("cache_creation_input_tokens"),
237 };
238 let cost_usd = value
239 .get("cost_usd")
240 .and_then(Value::as_f64)
241 .or_else(|| Some(cost::usage_cost_usd(&usage, model)));
242 let result_text = value
243 .get("result")
244 .and_then(Value::as_str)
245 .unwrap_or("")
246 .to_string();
247 let num_turns = value
248 .get("num_turns")
249 .and_then(Value::as_u64)
250 .map(|n| n as u32);
251
252 let terminal = AgentEvent::Result {
253 text: result_text,
254 is_error: value
255 .get("is_error")
256 .and_then(Value::as_bool)
257 .unwrap_or(false),
258 usage,
259 cost_usd,
260 num_turns,
261 raw: value,
262 };
263
264 vec![init, terminal]
265}
266
267fn str_field(value: &Value, key: &str) -> String {
268 value
269 .get(key)
270 .and_then(Value::as_str)
271 .unwrap_or_default()
272 .to_string()
273}
274
275fn last_chars(text: &str, max: usize) -> String {
277 let chars: Vec<char> = text.chars().collect();
278 let start = chars.len().saturating_sub(max);
279 chars[start..].iter().collect()
280}
281
282#[derive(Debug, Clone)]
289pub struct DroidBackend {
290 binary: PathBuf,
291}
292
293impl DroidBackend {
294 pub fn new(binary: impl Into<PathBuf>) -> Self {
296 DroidBackend {
297 binary: binary.into(),
298 }
299 }
300
301 pub fn discover(configured: Option<&str>) -> Result<Self> {
303 Ok(DroidBackend {
304 binary: discover_droid_binary(configured)?,
305 })
306 }
307
308 pub fn binary(&self) -> &Path {
310 &self.binary
311 }
312}
313
314#[async_trait::async_trait]
315impl AgentBackend for DroidBackend {
316 async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
317 if spec.resume.is_some() {
318 return Err(EngineError::Backend(
319 "droid backend is single-shot only; resume is unsupported".to_string(),
320 ));
321 }
322 let model = spec.model.clone();
323 let args = build_args(&spec);
324
325 let mut command = tokio::process::Command::new(&self.binary);
326 command
327 .args(&args)
328 .current_dir(&spec.cwd)
329 .env_clear()
333 .envs(crate::agent_env::agent_session_env(
334 &spec.env,
335 &spec.session_id,
336 Some("FACTORY_API_KEY"),
337 ))
338 .stdin(Stdio::null())
339 .stdout(Stdio::piped())
340 .stderr(Stdio::piped())
341 .kill_on_drop(true);
342 #[cfg(unix)]
345 command.process_group(0);
346
347 let mut child = command.spawn().map_err(|e| {
348 EngineError::Backend(format!("failed to spawn {}: {e}", self.binary.display()))
349 })?;
350
351 #[cfg(windows)]
353 let job = match child.raw_handle() {
354 Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
355 Ok(job) => Some(job),
356 Err(e) => {
357 tracing::warn!(error = %e, "failed to create Job Object for droid child; \
358 tree-kill on abort will be unavailable");
359 None
360 }
361 },
362 None => None,
363 };
364
365 let stdout = child
366 .stdout
367 .take()
368 .ok_or_else(|| EngineError::Backend("droid child has no stdout pipe".to_string()))?;
369 let stderr = child
370 .stderr
371 .take()
372 .ok_or_else(|| EngineError::Backend("droid child has no stderr pipe".to_string()))?;
373
374 let stderr_buf = Arc::new(Mutex::new(String::new()));
379 let stderr_task = {
380 let buf = Arc::clone(&stderr_buf);
381 tokio::spawn(async move {
382 let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
383 *buf.lock().expect("stderr buffer lock") = tail;
384 })
385 };
386
387 Ok(Box::new(DroidSession {
388 session_id: spec.session_id.clone(),
389 model,
390 child,
391 #[cfg(windows)]
392 job,
393 lines: BoundedLines::new(stdout),
394 stderr_buf,
395 stderr_task: Some(stderr_task),
396 queue: VecDeque::new(),
397 saw_result: false,
398 saw_success_result: false,
399 exit: None,
400 }))
401 }
402}
403
404pub struct DroidSession {
415 session_id: String,
416 model: String,
417 child: Child,
418 #[cfg(windows)]
419 job: Option<win_job::JobHandle>,
420 lines: BoundedLines<ChildStdout>,
421 stderr_buf: Arc<Mutex<String>>,
422 stderr_task: Option<JoinHandle<()>>,
423 queue: VecDeque<AgentEvent>,
426 saw_result: bool,
427 saw_success_result: bool,
428 exit: Option<SessionExit>,
429}
430
431#[cfg(unix)]
432impl Drop for DroidSession {
433 fn drop(&mut self) {
434 crate::backend_claude::kill_unreaped_group(&self.child);
435 }
436}
437
438impl DroidSession {
439 fn observe(&mut self, event: &AgentEvent) {
440 match event {
441 AgentEvent::Init { session_id, .. } => {
442 self.session_id = session_id.clone();
443 }
444 AgentEvent::Result { is_error, .. } => {
445 self.saw_result = true;
446 if !is_error {
447 self.saw_success_result = true;
448 }
449 }
450 _ => {}
451 }
452 }
453
454 async fn kill_child(&mut self) {
459 #[cfg(unix)]
460 {
461 let pgid = self
462 .child
463 .id()
464 .and_then(|pid| i32::try_from(pid).ok())
465 .filter(|pid| *pid > 0);
466 let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
467 if !group_killed {
468 let _ = self.child.start_kill();
469 }
470 let _ = self.child.wait().await;
471 if group_killed {
472 if let Some(pgid) = pgid {
473 let _ = kill_group(pgid);
474 }
475 }
476 }
477 #[cfg(windows)]
478 {
479 match &self.job {
480 Some(job) => job.kill(),
481 None => {
482 let _ = self.child.start_kill();
483 }
484 }
485 let _ = self.child.wait().await;
486 }
487 #[cfg(all(not(unix), not(windows)))]
488 {
489 let _ = self.child.start_kill();
490 let _ = self.child.wait().await;
491 }
492 if let Some(task) = self.stderr_task.take() {
493 let _ = task.await;
494 }
495 }
496
497 async fn finish_at_eof(&mut self) {
498 let status = self.child.wait().await;
499 if let Some(task) = self.stderr_task.take() {
500 let _ = task.await;
501 }
502 let exit = match status {
503 Ok(status) if status.success() && self.saw_result => SessionExit::Completed,
504 Ok(status) => SessionExit::Failed(format!(
505 "droid exited with {status}{}; stderr tail: {}",
506 if self.saw_result {
507 ""
508 } else {
509 " without emitting a terminal event"
510 },
511 self.stderr_tail(),
512 )),
513 Err(e) => SessionExit::Failed(format!(
514 "failed to reap droid process: {e}; stderr tail: {}",
515 self.stderr_tail(),
516 )),
517 };
518 self.exit = Some(exit);
519 }
520
521 fn stderr_tail(&self) -> String {
522 let captured = self
523 .stderr_buf
524 .lock()
525 .map(|guard| guard.clone())
526 .unwrap_or_default();
527 last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
528 }
529}
530
531#[async_trait::async_trait]
532impl AgentSession for DroidSession {
533 fn session_id(&self) -> String {
534 self.session_id.clone()
535 }
536
537 async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
538 loop {
539 if let Some(event) = self.queue.pop_front() {
540 return Ok(Some(event));
541 }
542 if self.exit.is_some() {
543 return Ok(None);
544 }
545 let line = match self.lines.next_line().await {
546 Ok(Some(line)) => line,
547 Ok(None) => {
548 self.finish_at_eof().await;
549 return Ok(None);
550 }
551 Err(e) => {
552 self.kill_child().await;
553 self.exit = Some(SessionExit::Failed(format!(
554 "error reading droid stdout: {e}; stderr tail: {}",
555 self.stderr_tail(),
556 )));
557 return Ok(None);
558 }
559 };
560 if line.trim().is_empty() {
561 continue;
562 }
563 let events = parse_droid_result(&line, &self.model);
564 for event in &events {
565 self.observe(event);
566 }
567 self.queue.extend(events);
568 }
569 }
570
571 async fn send_user_message(&mut self, _text: &str) -> Result<()> {
572 Err(EngineError::Backend(
573 "droid backend is single-shot only; send_user_message is unsupported".to_string(),
574 ))
575 }
576
577 async fn abort(&mut self) -> Result<()> {
578 let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
579 self.kill_child().await;
580 if self.saw_success_result && already_exited {
581 self.exit = Some(SessionExit::Completed);
582 } else {
583 self.exit = Some(SessionExit::Aborted);
584 }
585 Ok(())
586 }
587
588 fn exit_status(&self) -> Option<SessionExit> {
589 self.exit.clone()
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 const TEST_MODEL: &str = "accounts/fireworks/models/glm-5p2";
598
599 #[test]
600 #[cfg(unix)]
601 fn probe_version_kills_a_hung_binary_within_the_deadline() {
602 use std::os::unix::fs::PermissionsExt;
603 let dir = tempfile::tempdir().unwrap();
604 let stub = dir.path().join("hung-droid");
605 std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
606 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
607
608 let start = std::time::Instant::now();
609 let result = probe_version(&stub);
610
611 let error = result.expect_err("a hung probe must be reported as broken");
612 assert!(error.contains("did not exit"), "{error}");
613 assert!(
614 start.elapsed() < std::time::Duration::from_secs(10),
615 "probe returned within the deadline, not after the stub's sleep"
616 );
617 }
618
619 fn fixture(name: &str) -> String {
620 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
621 .join("tests")
622 .join("fixtures")
623 .join(name);
624 std::fs::read_to_string(path).expect("read fixture")
625 }
626
627 #[test]
628 fn backend_droid_parse_fixture() {
629 let line = fixture("droid_exec_scrutiny.json");
630 let events = parse_droid_result(line.trim(), TEST_MODEL);
631
632 assert!(
633 events.iter().any(
634 |e| matches!(e, AgentEvent::Init { session_id, .. } if !session_id.is_empty())
635 ),
636 "expected an Init event with a non-empty session id"
637 );
638
639 let terminal = events
640 .iter()
641 .find_map(|e| match e {
642 AgentEvent::Result {
643 text,
644 usage,
645 cost_usd,
646 num_turns,
647 ..
648 } => Some((text, usage, cost_usd, num_turns)),
649 _ => None,
650 })
651 .expect("expected a terminal Result event");
652 let (text, usage, cost_usd, num_turns) = terminal;
653 assert!(!text.is_empty(), "expected non-empty terminal text");
654 assert!(
655 usage.input > 0 || usage.output > 0 || usage.cache_read > 0,
656 "expected non-zero usage on the terminal Result"
657 );
658 assert!(cost_usd.is_some(), "expected cost_usd to be Some");
659 assert!(num_turns.is_some(), "expected num_turns to be Some");
660 }
661
662 #[test]
663 fn backend_droid_terminal_text_parses_report() {
664 let line = fixture("droid_exec_scrutiny.json");
665 let events = parse_droid_result(line.trim(), TEST_MODEL);
666
667 let terminal_text = events
668 .iter()
669 .find_map(|e| match e {
670 AgentEvent::Result { text, .. } => Some(text.clone()),
671 _ => None,
672 })
673 .expect("expected a terminal Result event");
674
675 let report = crate::runner::parse_validator_report(&terminal_text)
676 .expect("terminal text should parse as a ValidatorReport");
677 assert!(
678 !report.findings.is_empty(),
679 "expected the fixture's ValidatorReport to have findings"
680 );
681 }
682
683 #[test]
684 fn build_args_droid_read_only() {
685 let spec = SessionSpec {
686 cwd: PathBuf::from("."),
687 prompt: PromptMode::SingleShot("do the thing".to_string()),
688 append_system_prompt: None,
689 model: TEST_MODEL.to_string(),
690 effort: "high".to_string(),
691 session_id: "sess-1".to_string(),
692 resume: None,
693 permission_mode: Some("acceptEdits".to_string()),
694 allowed_tools: vec!["Bash(npm test*)".to_string()],
695 disallowed_tools: vec!["Bash(git push*)".to_string()],
696 tools: vec!["Bash".to_string()],
697 writable: false,
698 settings_json: Some(json!({"hooks": {}})),
699 json_schema: Some(json!({"type": "object"})),
700 max_budget_usd: Some(5.0),
701 max_turns: Some(10),
702 env: Default::default(),
703 sandbox: None,
704 hook_status: None,
705 };
706 let args = build_args(&spec);
707 assert_eq!(
708 args,
709 vec![
710 "exec".to_string(),
711 "-o".to_string(),
712 "json".to_string(),
713 "--auto".to_string(),
714 "low".to_string(),
715 "-m".to_string(),
716 TEST_MODEL.to_string(),
717 "do the thing".to_string(),
718 ]
719 );
720 }
721
722 #[test]
723 fn build_args_droid_writable_uses_high_auto() {
724 let spec = SessionSpec {
725 cwd: PathBuf::from("."),
726 prompt: PromptMode::SingleShot("do the thing".to_string()),
727 append_system_prompt: Some("be terse".to_string()),
728 model: TEST_MODEL.to_string(),
729 effort: "high".to_string(),
730 session_id: "sess-1".to_string(),
731 resume: None,
732 permission_mode: None,
733 allowed_tools: vec![],
734 disallowed_tools: vec![],
735 tools: vec![],
736 writable: true,
737 settings_json: None,
738 json_schema: None,
739 max_budget_usd: None,
740 max_turns: None,
741 env: Default::default(),
742 sandbox: None,
743 hook_status: None,
744 };
745 let args = build_args(&spec);
746 assert_eq!(
747 args,
748 vec![
749 "exec".to_string(),
750 "-o".to_string(),
751 "json".to_string(),
752 "--auto".to_string(),
753 "high".to_string(),
754 "-m".to_string(),
755 TEST_MODEL.to_string(),
756 "be terse\n\ndo the thing".to_string(),
757 ]
758 );
759 }
760
761 #[test]
762 fn build_args_droid_folds_append_system_prompt() {
763 let spec = SessionSpec {
764 cwd: PathBuf::from("."),
765 prompt: PromptMode::SingleShot("do the thing".to_string()),
766 append_system_prompt: Some("be terse".to_string()),
767 model: TEST_MODEL.to_string(),
768 effort: "high".to_string(),
769 session_id: "sess-1".to_string(),
770 resume: None,
771 permission_mode: None,
772 allowed_tools: vec![],
773 disallowed_tools: vec![],
774 tools: vec![],
775 writable: false,
776 settings_json: None,
777 json_schema: None,
778 max_budget_usd: None,
779 max_turns: None,
780 env: Default::default(),
781 sandbox: None,
782 hook_status: None,
783 };
784 let args = build_args(&spec);
785 assert_eq!(args.last().unwrap(), "be terse\n\ndo the thing");
786 }
787
788 #[test]
789 fn droid_backend_rejects_resumed_spec() {
790 use crate::backend::AgentBackend;
791 let backend = DroidBackend::new("droid");
792 let spec = SessionSpec {
793 cwd: PathBuf::from("."),
794 prompt: PromptMode::SingleShot("do the thing".to_string()),
795 append_system_prompt: None,
796 model: TEST_MODEL.to_string(),
797 effort: "high".to_string(),
798 session_id: "sess-1".to_string(),
799 resume: Some("sess-0".to_string()),
800 permission_mode: None,
801 allowed_tools: vec![],
802 disallowed_tools: vec![],
803 tools: vec![],
804 writable: false,
805 settings_json: None,
806 json_schema: None,
807 max_budget_usd: None,
808 max_turns: None,
809 env: Default::default(),
810 sandbox: None,
811 hook_status: None,
812 };
813 let result = tokio::runtime::Builder::new_current_thread()
814 .enable_all()
815 .build()
816 .unwrap()
817 .block_on(backend.start(spec));
818 assert!(result.is_err(), "expected resume to be rejected");
819 }
820}