Skip to main content

harness/
runner.rs

1use std::path::PathBuf;
2use std::pin::Pin;
3
4use async_trait::async_trait;
5use futures::Stream;
6use serde::{Deserialize, Serialize};
7use tokio_util::sync::CancellationToken;
8
9use crate::config::{AgentKind, TaskConfig};
10use crate::error::{Error, Result};
11use crate::event::Event;
12use crate::process::StreamHandle;
13
14/// A boxed, pinned event stream returned by agent runners.
15pub type EventStream = Pin<Box<dyn Stream<Item = Result<Event>> + Send>>;
16
17/// Check if any of the binary candidates for the given agent kind exist in PATH.
18pub fn find_binary(kind: AgentKind) -> Option<PathBuf> {
19    kind.binary_candidates()
20        .iter()
21        .find_map(|name| which::which(name).ok())
22}
23
24/// Check if any binary candidate is available on the system.
25pub fn is_any_binary_available(kind: AgentKind) -> bool {
26    find_binary(kind).is_some()
27}
28
29/// Resolve binary path: user override > PATH candidates > error.
30pub fn resolve_binary(kind: AgentKind, config: &TaskConfig) -> Result<PathBuf> {
31    if let Some(ref p) = config.binary_path {
32        return Ok(p.clone());
33    }
34    find_binary(kind).ok_or_else(|| Error::BinaryNotFound {
35        agent: kind.display_name().to_string(),
36        binary: kind.binary_candidates().join(" or "),
37    })
38}
39
40/// Describes what features an agent supports.
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct AgentCapabilities {
43    pub supports_system_prompt: bool,
44    pub supports_budget: bool,
45    pub supports_model: bool,
46    pub supports_max_turns: bool,
47    pub supports_append_system_prompt: bool,
48}
49
50/// A config validation warning.
51#[derive(Debug, Clone)]
52pub struct ConfigWarning {
53    pub message: String,
54}
55
56impl std::fmt::Display for ConfigWarning {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(f, "{}", self.message)
59    }
60}
61
62/// Trait implemented by each agent adapter.
63///
64/// The runner translates a unified `TaskConfig` into the agent-specific CLI
65/// invocation and converts the agent's streaming output into a unified
66/// `EventStream`.
67#[async_trait]
68pub trait AgentRunner: Send + Sync {
69    /// Human-readable name of this agent backend.
70    fn name(&self) -> &str;
71
72    /// Check whether the agent binary is available on the system.
73    fn is_available(&self) -> bool;
74
75    /// Resolve the binary path (user override or PATH lookup).
76    fn binary_path(&self, config: &TaskConfig) -> Result<std::path::PathBuf>;
77
78    /// Build the command-line arguments for the agent process.
79    fn build_args(&self, config: &TaskConfig) -> Vec<String>;
80
81    /// Build the environment variables for the agent process.
82    fn build_env(&self, config: &TaskConfig) -> Vec<(String, String)>;
83
84    /// Run the task and return a `StreamHandle` with event stream and cancel token.
85    async fn run(
86        &self,
87        config: &TaskConfig,
88        cancel_token: Option<CancellationToken>,
89    ) -> Result<StreamHandle>;
90
91    /// Get the version of the installed agent binary.
92    fn version(&self, config: &TaskConfig) -> Option<String> {
93        let binary = self.binary_path(config).ok()?;
94        let output = std::process::Command::new(&binary)
95            .arg("--version")
96            .stdout(std::process::Stdio::piped())
97            .stderr(std::process::Stdio::piped())
98            .output()
99            .ok()?;
100
101        if output.status.success() {
102            let version_str = String::from_utf8_lossy(&output.stdout);
103            Some(version_str.trim().to_string())
104        } else {
105            None
106        }
107    }
108
109    /// What features this agent supports.
110    fn capabilities(&self) -> AgentCapabilities {
111        // Default: conservative — subclasses override.
112        AgentCapabilities::default()
113    }
114
115    /// Validate config against this agent's capabilities.
116    fn validate_config(&self, config: &TaskConfig) -> Vec<ConfigWarning> {
117        let caps = self.capabilities();
118        let mut warnings = Vec::new();
119
120        if config.system_prompt.is_some() && !caps.supports_system_prompt {
121            warnings.push(ConfigWarning {
122                message: format!("{} does not support --system-prompt", self.name()),
123            });
124        }
125        if config.max_budget_usd.is_some() && !caps.supports_budget {
126            warnings.push(ConfigWarning {
127                message: format!("{} does not support --max-budget", self.name()),
128            });
129        }
130        if config.model.is_some() && !caps.supports_model {
131            warnings.push(ConfigWarning {
132                message: format!("{} does not support --model", self.name()),
133            });
134        }
135        if config.max_turns.is_some() && !caps.supports_max_turns {
136            warnings.push(ConfigWarning {
137                message: format!("{} does not support --max-turns", self.name()),
138            });
139        }
140        if config.append_system_prompt.is_some() && !caps.supports_append_system_prompt {
141            warnings.push(ConfigWarning {
142                message: format!("{} does not support --append-system-prompt", self.name()),
143            });
144        }
145
146        warnings
147    }
148}