Skip to main content

zeroclaw_providers/
kilocli.rs

1//! KiloCLI subprocess model_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 can be
10//! set via the typed alias's `binary_path` field.
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 model_provider.
32//!
33use crate::traits::{ChatRequest, ChatResponse, ModelProvider, TokenUsage};
34use async_trait::async_trait;
35use std::path::PathBuf;
36use tokio::io::AsyncWriteExt;
37use tokio::process::Command;
38use tokio::time::{Duration, timeout};
39
40/// Default `kilo` binary name (resolved via `PATH`).
41const DEFAULT_KILO_CLI_BINARY: &str = "kilo";
42
43/// Model name used to signal "use the model_provider's own default model".
44const DEFAULT_MODEL_MARKER: &str = "default";
45/// KiloCLI requests are bounded to avoid hung subprocesses.
46const KILO_CLI_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
47/// Avoid leaking oversized stderr payloads.
48const MAX_KILO_CLI_STDERR_CHARS: usize = 512;
49/// The CLI does not support sampling controls; allow only baseline defaults.
50const KILO_CLI_SUPPORTED_TEMPERATURES: [f64; 2] = [0.7, 1.0];
51const TEMP_EPSILON: f64 = 1e-9;
52
53/// ModelProvider that invokes the KiloCLI as a subprocess.
54///
55/// Each inference request spawns a fresh `kilo` process. This is the
56/// non-interactive approach: the process handles the prompt and exits.
57pub struct KiloCliModelProvider {
58    /// `[model_providers.<family>.<alias>]` config-key alias.
59    alias: String,
60    /// Path to the `kilo` binary.
61    binary_path: PathBuf,
62}
63
64impl KiloCliModelProvider {
65    /// Create a new `KiloCliModelProvider`. Pass `None` to use the default
66    /// `"kilo"` (PATH lookup); pass an explicit path to override.
67    pub fn new(alias: &str, binary_path: Option<&str>) -> Self {
68        let binary_path = binary_path
69            .map(str::trim)
70            .filter(|p| !p.is_empty())
71            .map(PathBuf::from)
72            .unwrap_or_else(|| PathBuf::from(DEFAULT_KILO_CLI_BINARY));
73        Self {
74            alias: alias.to_string(),
75            binary_path,
76        }
77    }
78    /// Returns true if the model argument should be forwarded to the CLI.
79    fn should_forward_model(model: &str) -> bool {
80        let trimmed = model.trim();
81        !trimmed.is_empty() && trimmed != DEFAULT_MODEL_MARKER
82    }
83
84    fn supports_temperature(temperature: f64) -> bool {
85        KILO_CLI_SUPPORTED_TEMPERATURES
86            .iter()
87            .any(|v| (temperature - v).abs() < TEMP_EPSILON)
88    }
89
90    fn validate_temperature(temperature: f64) -> anyhow::Result<()> {
91        if !temperature.is_finite() {
92            anyhow::bail!("KiloCLI model_provider received non-finite temperature value");
93        }
94        if !Self::supports_temperature(temperature) {
95            anyhow::bail!(
96                "temperature unsupported by KiloCLI: {temperature}. \
97                 Supported values: 0.7 or 1.0"
98            );
99        }
100        Ok(())
101    }
102
103    fn redact_stderr(stderr: &[u8]) -> String {
104        let text = String::from_utf8_lossy(stderr);
105        let trimmed = text.trim();
106        if trimmed.is_empty() {
107            return String::new();
108        }
109        if trimmed.chars().count() <= MAX_KILO_CLI_STDERR_CHARS {
110            return trimmed.to_string();
111        }
112        let clipped: String = trimmed.chars().take(MAX_KILO_CLI_STDERR_CHARS).collect();
113        format!("{clipped}...")
114    }
115
116    /// Invoke the kilo binary with the given prompt and optional model.
117    /// Returns the trimmed stdout output as the assistant response.
118    async fn invoke_cli(&self, message: &str, model: &str) -> anyhow::Result<String> {
119        let mut cmd = Command::new(&self.binary_path);
120        cmd.arg("--print");
121
122        if Self::should_forward_model(model) {
123            cmd.arg("--model").arg(model);
124        }
125
126        // Read prompt from stdin to avoid exposing sensitive content in process args.
127        cmd.arg("-");
128        cmd.kill_on_drop(true);
129        cmd.stdin(std::process::Stdio::piped());
130        cmd.stdout(std::process::Stdio::piped());
131        cmd.stderr(std::process::Stdio::piped());
132
133        let mut child = cmd.spawn().map_err(|err| {
134            ::zeroclaw_log::record!(
135                ERROR,
136                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
137                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
138                    .with_attrs(::serde_json::json!({
139                        "binary": self.binary_path.display().to_string(),
140                        "phase": "spawn",
141                        "error": format!("{}", err),
142                    })),
143                "kilocli: failed to spawn binary"
144            );
145            anyhow::Error::msg(format!(
146                "Failed to spawn KiloCLI binary at {}: {err}. \
147                 Ensure `kilo` is installed and in PATH, or set KILO_CLI_PATH.",
148                self.binary_path.display()
149            ))
150        })?;
151
152        if let Some(mut stdin) = child.stdin.take() {
153            stdin.write_all(message.as_bytes()).await.map_err(|err| {
154                ::zeroclaw_log::record!(
155                    ERROR,
156                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
157                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
158                        .with_attrs(::serde_json::json!({
159                            "phase": "stdin_write",
160                            "error": format!("{}", err),
161                        })),
162                    "kilocli: failed to write prompt to stdin"
163                );
164                anyhow::Error::msg(format!("Failed to write prompt to KiloCLI stdin: {err}"))
165            })?;
166            stdin.shutdown().await.map_err(|err| {
167                ::zeroclaw_log::record!(
168                    ERROR,
169                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
170                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
171                        .with_attrs(::serde_json::json!({
172                            "phase": "stdin_shutdown",
173                            "error": format!("{}", err),
174                        })),
175                    "kilocli: failed to finalize stdin stream"
176                );
177                anyhow::Error::msg(format!("Failed to finalize KiloCLI stdin stream: {err}"))
178            })?;
179        }
180
181        let output = timeout(KILO_CLI_REQUEST_TIMEOUT, child.wait_with_output())
182            .await
183            .map_err(|_| {
184                ::zeroclaw_log::record!(
185                    WARN,
186                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Timeout)
187                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
188                        .with_attrs(::serde_json::json!({
189                            "binary": self.binary_path.display().to_string(),
190                            "timeout": format!("{:?}", KILO_CLI_REQUEST_TIMEOUT),
191                        })),
192                    "kilocli: request timed out"
193                );
194                anyhow::Error::msg(format!(
195                    "KiloCLI request timed out after {:?} (binary: {})",
196                    KILO_CLI_REQUEST_TIMEOUT,
197                    self.binary_path.display()
198                ))
199            })?
200            .map_err(|err| {
201                ::zeroclaw_log::record!(
202                    ERROR,
203                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
204                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
205                        .with_attrs(::serde_json::json!({
206                            "phase": "process_wait",
207                            "error": format!("{}", err),
208                        })),
209                    "kilocli: process wait failed"
210                );
211                anyhow::Error::msg(format!("KiloCLI process failed: {err}"))
212            })?;
213
214        if !output.status.success() {
215            let code = output.status.code().unwrap_or(-1);
216            let stderr_excerpt = Self::redact_stderr(&output.stderr);
217            let stderr_note = if stderr_excerpt.is_empty() {
218                String::new()
219            } else {
220                format!(" Stderr: {stderr_excerpt}")
221            };
222            anyhow::bail!(
223                "KiloCLI exited with non-zero status {code}. \
224                 Check that KiloCLI is authenticated and the CLI is supported.{stderr_note}"
225            );
226        }
227
228        let text = String::from_utf8(output.stdout).map_err(|err| {
229            ::zeroclaw_log::record!(
230                ERROR,
231                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
232                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
233                    .with_attrs(::serde_json::json!({
234                        "phase": "utf8_decode",
235                        "error": format!("{}", err),
236                    })),
237                "kilocli: non-UTF-8 stdout"
238            );
239            anyhow::Error::msg(format!("KiloCLI produced non-UTF-8 output: {err}"))
240        })?;
241
242        Ok(text.trim().to_string())
243    }
244}
245
246#[async_trait]
247impl ModelProvider for KiloCliModelProvider {
248    async fn chat_with_system(
249        &self,
250        system_prompt: Option<&str>,
251        message: &str,
252        model: &str,
253        temperature: Option<f64>,
254    ) -> anyhow::Result<String> {
255        let temperature = temperature.unwrap_or(self.default_temperature());
256        Self::validate_temperature(temperature)?;
257
258        let full_message = match system_prompt {
259            Some(system) if !system.is_empty() => {
260                format!("{system}\n\n{message}")
261            }
262            _ => message.to_string(),
263        };
264
265        self.invoke_cli(&full_message, model).await
266    }
267
268    async fn chat(
269        &self,
270        request: ChatRequest<'_>,
271        model: &str,
272        temperature: Option<f64>,
273    ) -> anyhow::Result<ChatResponse> {
274        let text = self
275            .chat_with_history(request.messages, model, temperature)
276            .await?;
277
278        Ok(ChatResponse {
279            text: Some(text),
280            tool_calls: Vec::new(),
281            usage: Some(TokenUsage::default()),
282            reasoning_content: None,
283        })
284    }
285}
286
287impl ::zeroclaw_api::attribution::Attributable for KiloCliModelProvider {
288    fn role(&self) -> ::zeroclaw_api::attribution::Role {
289        ::zeroclaw_api::attribution::Role::Provider(
290            ::zeroclaw_api::attribution::ProviderKind::Model(
291                ::zeroclaw_api::attribution::ModelProviderKind::KiloCli,
292            ),
293        )
294    }
295    fn alias(&self) -> &str {
296        &self.alias
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn new_uses_explicit_binary_path() {
306        let p = KiloCliModelProvider::new("test", Some("/usr/local/bin/kilo"));
307        assert_eq!(p.binary_path, PathBuf::from("/usr/local/bin/kilo"));
308    }
309
310    #[test]
311    fn new_defaults_to_kilo() {
312        let p = KiloCliModelProvider::new("test", None);
313        assert_eq!(p.binary_path, PathBuf::from("kilo"));
314    }
315
316    #[test]
317    fn new_ignores_blank_binary_path() {
318        let p = KiloCliModelProvider::new("test", Some("   "));
319        assert_eq!(p.binary_path, PathBuf::from("kilo"));
320    }
321
322    #[test]
323    fn should_forward_model_standard() {
324        assert!(KiloCliModelProvider::should_forward_model("some-model"));
325        assert!(KiloCliModelProvider::should_forward_model("gpt-4o"));
326    }
327
328    #[test]
329    fn should_not_forward_default_model() {
330        assert!(!KiloCliModelProvider::should_forward_model(
331            DEFAULT_MODEL_MARKER
332        ));
333        assert!(!KiloCliModelProvider::should_forward_model(""));
334        assert!(!KiloCliModelProvider::should_forward_model("   "));
335    }
336
337    #[test]
338    fn validate_temperature_allows_defaults() {
339        assert!(KiloCliModelProvider::validate_temperature(0.7).is_ok());
340        assert!(KiloCliModelProvider::validate_temperature(1.0).is_ok());
341    }
342
343    #[test]
344    fn validate_temperature_rejects_custom_value() {
345        let err = KiloCliModelProvider::validate_temperature(0.2).unwrap_err();
346        assert!(
347            err.to_string()
348                .contains("temperature unsupported by KiloCLI")
349        );
350    }
351
352    #[tokio::test]
353    async fn invoke_missing_binary_returns_error() {
354        let model_provider = KiloCliModelProvider {
355            alias: "test".to_string(),
356            binary_path: PathBuf::from("/nonexistent/path/to/kilo"),
357        };
358        let result = model_provider.invoke_cli("hello", "default").await;
359        assert!(result.is_err());
360        let msg = result.unwrap_err().to_string();
361        assert!(
362            msg.contains("Failed to spawn KiloCLI binary"),
363            "unexpected error message: {msg}"
364        );
365    }
366}