Skip to main content

construct/providers/
kilocli.rs

1//! KiloCLI subprocess provider.
2//!
3//! Integrates with the KiloCLI tool, spawning the `kilo` binary
4//! as a subprocess for each inference request. This allows using KiloCLI's AI
5//! models without an interactive UI session.
6//!
7//! # Usage
8//!
9//! The `kilo` binary must be available in `PATH`, or its location must be
10//! set via the `KILO_CLI_PATH` environment variable.
11//!
12//! KiloCLI is invoked as:
13//! ```text
14//! kilo --print -
15//! ```
16//! with prompt content written to stdin.
17//!
18//! # Limitations
19//!
20//! - **Conversation history**: Only the system prompt (if present) and the last
21//!   user message are forwarded. Full multi-turn history is not preserved because
22//!   the CLI accepts a single prompt per invocation.
23//! - **System prompt**: The system prompt is prepended to the user message with a
24//!   blank-line separator, as the CLI does not provide a dedicated system-prompt flag.
25//! - **Temperature**: The CLI does not expose a temperature parameter.
26//!   Only default values are accepted; custom values return an explicit error.
27//!
28//! # Authentication
29//!
30//! Authentication is handled by KiloCLI itself (its own credential store).
31//! No explicit API key is required by this provider.
32//!
33//! # Environment variables
34//!
35//! - `KILO_CLI_PATH` — override the path to the `kilo` binary (default: `"kilo"`)
36
37use crate::providers::traits::{ChatRequest, ChatResponse, Provider, TokenUsage};
38use async_trait::async_trait;
39use std::path::PathBuf;
40use tokio::io::AsyncWriteExt;
41use tokio::process::Command;
42use tokio::time::{Duration, timeout};
43
44/// Environment variable for overriding the path to the `kilo` binary.
45pub const KILO_CLI_PATH_ENV: &str = "KILO_CLI_PATH";
46
47/// Default `kilo` binary name (resolved via `PATH`).
48const DEFAULT_KILO_CLI_BINARY: &str = "kilo";
49
50/// Model name used to signal "use the provider's own default model".
51const DEFAULT_MODEL_MARKER: &str = "default";
52/// KiloCLI requests are bounded to avoid hung subprocesses.
53const KILO_CLI_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
54/// Avoid leaking oversized stderr payloads.
55const MAX_KILO_CLI_STDERR_CHARS: usize = 512;
56/// The CLI does not support sampling controls; allow only baseline defaults.
57const KILO_CLI_SUPPORTED_TEMPERATURES: [f64; 2] = [0.7, 1.0];
58const TEMP_EPSILON: f64 = 1e-9;
59
60/// Provider that invokes the KiloCLI as a subprocess.
61///
62/// Each inference request spawns a fresh `kilo` process. This is the
63/// non-interactive approach: the process handles the prompt and exits.
64pub struct KiloCliProvider {
65    /// Path to the `kilo` binary.
66    binary_path: PathBuf,
67}
68
69impl KiloCliProvider {
70    /// Create a new `KiloCliProvider`.
71    ///
72    /// The binary path is resolved from `KILO_CLI_PATH` env var if set,
73    /// otherwise defaults to `"kilo"` (found via `PATH`).
74    pub fn new() -> Self {
75        let binary_path = std::env::var(KILO_CLI_PATH_ENV)
76            .ok()
77            .filter(|path| !path.trim().is_empty())
78            .map(PathBuf::from)
79            .unwrap_or_else(|| PathBuf::from(DEFAULT_KILO_CLI_BINARY));
80
81        Self { binary_path }
82    }
83
84    /// Returns true if the model argument should be forwarded to the CLI.
85    fn should_forward_model(model: &str) -> bool {
86        let trimmed = model.trim();
87        !trimmed.is_empty() && trimmed != DEFAULT_MODEL_MARKER
88    }
89
90    fn supports_temperature(temperature: f64) -> bool {
91        KILO_CLI_SUPPORTED_TEMPERATURES
92            .iter()
93            .any(|v| (temperature - v).abs() < TEMP_EPSILON)
94    }
95
96    fn validate_temperature(temperature: f64) -> anyhow::Result<()> {
97        if !temperature.is_finite() {
98            anyhow::bail!("KiloCLI provider received non-finite temperature value");
99        }
100        if !Self::supports_temperature(temperature) {
101            anyhow::bail!(
102                "temperature unsupported by KiloCLI: {temperature}. \
103                 Supported values: 0.7 or 1.0"
104            );
105        }
106        Ok(())
107    }
108
109    fn redact_stderr(stderr: &[u8]) -> String {
110        let text = String::from_utf8_lossy(stderr);
111        let trimmed = text.trim();
112        if trimmed.is_empty() {
113            return String::new();
114        }
115        if trimmed.chars().count() <= MAX_KILO_CLI_STDERR_CHARS {
116            return trimmed.to_string();
117        }
118        let clipped: String = trimmed.chars().take(MAX_KILO_CLI_STDERR_CHARS).collect();
119        format!("{clipped}...")
120    }
121
122    /// Invoke the kilo binary with the given prompt and optional model.
123    /// Returns the trimmed stdout output as the assistant response.
124    async fn invoke_cli(&self, message: &str, model: &str) -> anyhow::Result<String> {
125        let mut cmd = Command::new(&self.binary_path);
126        cmd.arg("--print");
127
128        if Self::should_forward_model(model) {
129            cmd.arg("--model").arg(model);
130        }
131
132        // Read prompt from stdin to avoid exposing sensitive content in process args.
133        cmd.arg("-");
134        cmd.kill_on_drop(true);
135        cmd.stdin(std::process::Stdio::piped());
136        cmd.stdout(std::process::Stdio::piped());
137        cmd.stderr(std::process::Stdio::piped());
138
139        let mut child = cmd.spawn().map_err(|err| {
140            anyhow::anyhow!(
141                "Failed to spawn KiloCLI binary at {}: {err}. \
142                 Ensure `kilo` is installed and in PATH, or set KILO_CLI_PATH.",
143                self.binary_path.display()
144            )
145        })?;
146
147        if let Some(mut stdin) = child.stdin.take() {
148            stdin
149                .write_all(message.as_bytes())
150                .await
151                .map_err(|err| anyhow::anyhow!("Failed to write prompt to KiloCLI stdin: {err}"))?;
152            stdin
153                .shutdown()
154                .await
155                .map_err(|err| anyhow::anyhow!("Failed to finalize KiloCLI stdin stream: {err}"))?;
156        }
157
158        let output = timeout(KILO_CLI_REQUEST_TIMEOUT, child.wait_with_output())
159            .await
160            .map_err(|_| {
161                anyhow::anyhow!(
162                    "KiloCLI request timed out after {:?} (binary: {})",
163                    KILO_CLI_REQUEST_TIMEOUT,
164                    self.binary_path.display()
165                )
166            })?
167            .map_err(|err| anyhow::anyhow!("KiloCLI process failed: {err}"))?;
168
169        if !output.status.success() {
170            let code = output.status.code().unwrap_or(-1);
171            let stderr_excerpt = Self::redact_stderr(&output.stderr);
172            let stderr_note = if stderr_excerpt.is_empty() {
173                String::new()
174            } else {
175                format!(" Stderr: {stderr_excerpt}")
176            };
177            anyhow::bail!(
178                "KiloCLI exited with non-zero status {code}. \
179                 Check that KiloCLI is authenticated and the CLI is supported.{stderr_note}"
180            );
181        }
182
183        let text = String::from_utf8(output.stdout)
184            .map_err(|err| anyhow::anyhow!("KiloCLI produced non-UTF-8 output: {err}"))?;
185
186        Ok(text.trim().to_string())
187    }
188}
189
190impl Default for KiloCliProvider {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196#[async_trait]
197impl Provider for KiloCliProvider {
198    async fn chat_with_system(
199        &self,
200        system_prompt: Option<&str>,
201        message: &str,
202        model: &str,
203        temperature: f64,
204    ) -> anyhow::Result<String> {
205        Self::validate_temperature(temperature)?;
206
207        let full_message = match system_prompt {
208            Some(system) if !system.is_empty() => {
209                format!("{system}\n\n{message}")
210            }
211            _ => message.to_string(),
212        };
213
214        self.invoke_cli(&full_message, model).await
215    }
216
217    async fn chat(
218        &self,
219        request: ChatRequest<'_>,
220        model: &str,
221        temperature: f64,
222    ) -> anyhow::Result<ChatResponse> {
223        let text = self
224            .chat_with_history(request.messages, model, temperature)
225            .await?;
226
227        Ok(ChatResponse {
228            text: Some(text),
229            tool_calls: Vec::new(),
230            usage: Some(TokenUsage::default()),
231            reasoning_content: None,
232        })
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::sync::{Mutex, OnceLock};
240
241    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
242        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
243        LOCK.get_or_init(|| Mutex::new(()))
244            .lock()
245            .expect("env lock poisoned")
246    }
247
248    #[test]
249    fn new_uses_env_override() {
250        let _guard = env_lock();
251        let orig = std::env::var(KILO_CLI_PATH_ENV).ok();
252        // SAFETY: test-only, single-threaded test runner.
253        unsafe { std::env::set_var(KILO_CLI_PATH_ENV, "/usr/local/bin/kilo") };
254        let provider = KiloCliProvider::new();
255        assert_eq!(provider.binary_path, PathBuf::from("/usr/local/bin/kilo"));
256        match orig {
257            // SAFETY: test-only, single-threaded test runner.
258            Some(v) => unsafe { std::env::set_var(KILO_CLI_PATH_ENV, v) },
259            // SAFETY: test-only, single-threaded test runner.
260            None => unsafe { std::env::remove_var(KILO_CLI_PATH_ENV) },
261        }
262    }
263
264    #[test]
265    fn new_defaults_to_kilo() {
266        let _guard = env_lock();
267        let orig = std::env::var(KILO_CLI_PATH_ENV).ok();
268        // SAFETY: test-only, single-threaded test runner.
269        unsafe { std::env::remove_var(KILO_CLI_PATH_ENV) };
270        let provider = KiloCliProvider::new();
271        assert_eq!(provider.binary_path, PathBuf::from("kilo"));
272        if let Some(v) = orig {
273            // SAFETY: test-only, single-threaded test runner.
274            unsafe { std::env::set_var(KILO_CLI_PATH_ENV, v) };
275        }
276    }
277
278    #[test]
279    fn new_ignores_blank_env_override() {
280        let _guard = env_lock();
281        let orig = std::env::var(KILO_CLI_PATH_ENV).ok();
282        // SAFETY: test-only, single-threaded test runner.
283        unsafe { std::env::set_var(KILO_CLI_PATH_ENV, "   ") };
284        let provider = KiloCliProvider::new();
285        assert_eq!(provider.binary_path, PathBuf::from("kilo"));
286        match orig {
287            // SAFETY: test-only, single-threaded test runner.
288            Some(v) => unsafe { std::env::set_var(KILO_CLI_PATH_ENV, v) },
289            // SAFETY: test-only, single-threaded test runner.
290            None => unsafe { std::env::remove_var(KILO_CLI_PATH_ENV) },
291        }
292    }
293
294    #[test]
295    fn should_forward_model_standard() {
296        assert!(KiloCliProvider::should_forward_model("some-model"));
297        assert!(KiloCliProvider::should_forward_model("gpt-4o"));
298    }
299
300    #[test]
301    fn should_not_forward_default_model() {
302        assert!(!KiloCliProvider::should_forward_model(DEFAULT_MODEL_MARKER));
303        assert!(!KiloCliProvider::should_forward_model(""));
304        assert!(!KiloCliProvider::should_forward_model("   "));
305    }
306
307    #[test]
308    fn validate_temperature_allows_defaults() {
309        assert!(KiloCliProvider::validate_temperature(0.7).is_ok());
310        assert!(KiloCliProvider::validate_temperature(1.0).is_ok());
311    }
312
313    #[test]
314    fn validate_temperature_rejects_custom_value() {
315        let err = KiloCliProvider::validate_temperature(0.2).unwrap_err();
316        assert!(
317            err.to_string()
318                .contains("temperature unsupported by KiloCLI")
319        );
320    }
321
322    #[tokio::test]
323    async fn invoke_missing_binary_returns_error() {
324        let provider = KiloCliProvider {
325            binary_path: PathBuf::from("/nonexistent/path/to/kilo"),
326        };
327        let result = provider.invoke_cli("hello", "default").await;
328        assert!(result.is_err());
329        let msg = result.unwrap_err().to_string();
330        assert!(
331            msg.contains("Failed to spawn KiloCLI binary"),
332            "unexpected error message: {msg}"
333        );
334    }
335}