zeroclaw_providers/
kilocli.rs1use 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
40const DEFAULT_KILO_CLI_BINARY: &str = "kilo";
42
43const DEFAULT_MODEL_MARKER: &str = "default";
45const KILO_CLI_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
47const MAX_KILO_CLI_STDERR_CHARS: usize = 512;
49const KILO_CLI_SUPPORTED_TEMPERATURES: [f64; 2] = [0.7, 1.0];
51const TEMP_EPSILON: f64 = 1e-9;
52
53pub struct KiloCliModelProvider {
58 alias: String,
60 binary_path: PathBuf,
62}
63
64impl KiloCliModelProvider {
65 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 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 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 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}