diff --git a/logicshell-llm/examples/phase8.rs b/logicshell-llm/examples/phase8.rs new file mode 100644 index 0000000..dcb15dd --- /dev/null +++ b/logicshell-llm/examples/phase8.rs @@ -0,0 +1,83 @@ +// Phase 8 demo: LLM context + prompt composer +// +// Usage: +// cargo run --example phase8 --package logicshell-llm +// +// No network calls; demonstrates SystemContextProvider, PromptComposer, +// and the LlmClient trait without a live Ollama daemon. + +use logicshell_core::config::LlmConfig; +use logicshell_llm::{ + LlmClient, LlmError, LlmRequest, LlmResponse, PromptComposer, SystemContextProvider, +}; + +/// Stub LlmClient that returns a hard-coded suggestion. +struct StubClient; + +impl LlmClient for StubClient { + async fn complete(&self, req: LlmRequest) -> Result { + Ok(LlmResponse { + text: "ls -lhS".into(), + model: req.model, + }) + } +} + +#[tokio::main] +async fn main() { + println!("[Phase 8: SystemContextProvider]"); + let provider = SystemContextProvider::new(); + let snap = provider.snapshot(); + assert!(!snap.os_family.is_empty(), "os_family must be set"); + assert!(!snap.arch.is_empty(), "arch must be set"); + assert!(!snap.cwd.is_empty(), "cwd must be set"); + println!(" os_family = {:?}", snap.os_family); + println!(" arch = {:?}", snap.arch); + println!(" cwd = {:?}", snap.cwd); + println!(" PATH[0..] = {:?}", snap.path_dirs); + + println!("[Phase 8: PromptComposer — NL to command]"); + let cfg = LlmConfig { + enabled: true, + model: Some("llama3".into()), + ..LlmConfig::default() + }; + let composer = PromptComposer::from_config(&cfg).expect("composer from config"); + let req = composer + .compose_nl_to_command("list files sorted by size", &snap) + .expect("compose_nl_to_command"); + + assert_eq!(req.model, "llama3"); + assert!(req.prompt.contains("list files sorted by size")); + println!(" model = {:?}", req.model); + println!( + " prompt (first 80 chars) = {:?}", + &req.prompt[..80.min(req.prompt.len())] + ); + + println!("[Phase 8: PromptComposer — assist on exit 127]"); + let req2 = composer + .compose_assist_on_127(&["gti", "status"], &snap) + .expect("compose_assist_on_127"); + assert!(req2.prompt.contains("gti status")); + println!(" failed_cmd = \"gti status\" embedded in prompt: OK"); + + println!("[Phase 8: LlmClient trait — stub round-trip]"); + let client = StubClient; + let resp = client.complete(req).await.expect("complete"); + assert_eq!(resp.model, "llama3"); + assert!(!resp.text.is_empty()); + println!(" response = {:?}", resp.text); + + println!("[Phase 8: ContextTooLarge error]"); + let tight_composer = PromptComposer::new("m", 10); + let snap2 = snap.clone(); + match tight_composer.compose_nl_to_command("ls", &snap2) { + Err(logicshell_llm::LlmError::ContextTooLarge { size, max }) => { + println!(" ContextTooLarge: size={size} > max={max} ✓"); + } + other => panic!("expected ContextTooLarge, got: {other:?}"), + } + + println!("\n✓ Phase 8 features verified OK"); +} diff --git a/logicshell-llm/src/client.rs b/logicshell-llm/src/client.rs new file mode 100644 index 0000000..1eb8480 --- /dev/null +++ b/logicshell-llm/src/client.rs @@ -0,0 +1,179 @@ +// LLM client trait + request/response types — LLM Module PRD §5.1 +// +// `LlmClient` is async because inference is I/O-bound (NFR-05). +// Implementations must not read `std::env` or discover cwd — all context is +// supplied via `SystemContextSnapshot` assembled upstream (FR-10, FR-11). + +use crate::error::LlmError; + +/// Input to an LLM inference call. +#[derive(Debug, Clone, PartialEq)] +pub struct LlmRequest { + /// Model identifier (e.g. `"llama3"`). + pub model: String, + /// Fully-composed prompt text. + pub prompt: String, +} + +/// Output from an LLM inference call. +#[derive(Debug, Clone, PartialEq)] +pub struct LlmResponse { + /// Generated text returned by the model. + pub text: String, + /// Model that produced the response. + pub model: String, +} + +/// Async inference provider boundary — FR-21, LLM Module PRD §5.1. +/// +/// Implementations supply the HTTP transport (e.g. `OllamaLlmClient` in Phase 9). +/// Unit tests use concrete mock structs; `#[automock]` generates `MockLlmClient` +/// in test builds. +/// +/// The `async_fn_in_trait` lint is suppressed here intentionally: this trait is +/// used only within this crate for now and `Send` bounds are added in Phase 10 +/// when object-safe boxing is introduced. +#[allow(async_fn_in_trait)] +#[cfg_attr(test, mockall::automock)] +pub trait LlmClient: Send + Sync { + /// Submit a prompt and return the model's response. + async fn complete(&self, request: LlmRequest) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── LlmRequest ───────────────────────────────────────────────────────────── + + #[test] + fn request_fields_accessible() { + let req = LlmRequest { + model: "llama3".into(), + prompt: "list files".into(), + }; + assert_eq!(req.model, "llama3"); + assert_eq!(req.prompt, "list files"); + } + + #[test] + fn request_clone_eq() { + let req = LlmRequest { + model: "m".into(), + prompt: "p".into(), + }; + assert_eq!(req.clone(), req); + } + + #[test] + fn request_debug() { + let req = LlmRequest { + model: "m".into(), + prompt: "p".into(), + }; + assert!(!format!("{req:?}").contains("llama3")); + assert!(format!("{req:?}").contains("LlmRequest")); + } + + #[test] + fn request_partial_eq() { + let a = LlmRequest { + model: "m".into(), + prompt: "p".into(), + }; + let b = LlmRequest { + model: "m".into(), + prompt: "p".into(), + }; + let c = LlmRequest { + model: "other".into(), + prompt: "p".into(), + }; + assert_eq!(a, b); + assert_ne!(a, c); + } + + // ── LlmResponse ─────────────────────────────────────────────────────────── + + #[test] + fn response_fields_accessible() { + let resp = LlmResponse { + text: "ls -la".into(), + model: "llama3".into(), + }; + assert_eq!(resp.text, "ls -la"); + assert_eq!(resp.model, "llama3"); + } + + #[test] + fn response_clone_eq() { + let resp = LlmResponse { + text: "t".into(), + model: "m".into(), + }; + assert_eq!(resp.clone(), resp); + } + + #[test] + fn response_debug() { + let resp = LlmResponse { + text: "t".into(), + model: "m".into(), + }; + assert!(format!("{resp:?}").contains("LlmResponse")); + } + + #[test] + fn response_partial_eq() { + let a = LlmResponse { + text: "t".into(), + model: "m".into(), + }; + let b = LlmResponse { + text: "t".into(), + model: "m".into(), + }; + let c = LlmResponse { + text: "x".into(), + model: "m".into(), + }; + assert_eq!(a, b); + assert_ne!(a, c); + } + + // ── MockLlmClient (generated by mockall) ────────────────────────────────── + + #[tokio::test] + async fn mock_client_returns_configured_response() { + let mut mock = MockLlmClient::new(); + mock.expect_complete().returning(|req| { + Ok(LlmResponse { + text: format!("echo {}", req.prompt.lines().last().unwrap_or("")), + model: req.model, + }) + }); + + let req = LlmRequest { + model: "llama3".into(), + prompt: "list files".into(), + }; + let resp = mock.complete(req).await.unwrap(); + assert_eq!(resp.model, "llama3"); + assert!(!resp.text.is_empty()); + } + + #[tokio::test] + async fn mock_client_can_return_error() { + let mut mock = MockLlmClient::new(); + mock.expect_complete() + .returning(|_| Err(LlmError::Http("connection refused".into()))); + + let req = LlmRequest { + model: "m".into(), + prompt: "p".into(), + }; + let result = mock.complete(req).await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), LlmError::Http(_))); + } +} diff --git a/logicshell-llm/src/context.rs b/logicshell-llm/src/context.rs new file mode 100644 index 0000000..562bd74 --- /dev/null +++ b/logicshell-llm/src/context.rs @@ -0,0 +1,228 @@ +// System context capture — FR-10, FR-11, LLM Module PRD §5.2 +// +// `SystemContextProvider` is the ONLY place in this crate that reads `std::env` +// or the filesystem. Everything downstream (`PromptComposer`, `LlmClient`) +// receives a `SystemContextSnapshot` instead of reading the environment directly. + +/// A point-in-time snapshot of OS and shell context used to construct LLM prompts. +#[derive(Debug, Clone, PartialEq)] +pub struct SystemContextSnapshot { + /// OS family string, e.g. `"linux"`, `"macos"`, `"windows"`. + pub os_family: String, + /// CPU architecture, e.g. `"x86_64"`, `"aarch64"`. + pub arch: String, + /// Absolute path of the current working directory at snapshot time. + pub cwd: String, + /// First up to `MAX_PATH_DIRS` entries from the `PATH` environment variable. + pub path_dirs: Vec, +} + +/// Maximum number of PATH directories included in the context snapshot. +const MAX_PATH_DIRS: usize = 10; + +/// Reads OS state to produce a [`SystemContextSnapshot`]. +/// +/// Callers obtain a snapshot once and pass it to [`PromptComposer`]; the +/// composer itself never accesses `std::env` (FR-11). +/// +/// [`PromptComposer`]: crate::prompt::PromptComposer +#[derive(Debug, Default)] +pub struct SystemContextProvider; + +impl SystemContextProvider { + /// Create a new provider. + pub fn new() -> Self { + Self + } + + /// Capture a snapshot of the current OS and shell context. + pub fn snapshot(&self) -> SystemContextSnapshot { + SystemContextSnapshot { + os_family: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + cwd: std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "?".to_string()), + path_dirs: Self::abbreviated_path(), + } + } + + /// Returns at most `MAX_PATH_DIRS` non-empty entries from `$PATH`. + fn abbreviated_path() -> Vec { + let raw = std::env::var("PATH").unwrap_or_default(); + split_path_str(&raw) + } +} + +/// Pure helper: split a colon-separated PATH string into at most `MAX_PATH_DIRS` +/// non-empty entries. Extracted for deterministic unit testing without env mutation. +pub(crate) fn split_path_str(path_str: &str) -> Vec { + path_str + .split(':') + .filter(|s| !s.is_empty()) + .take(MAX_PATH_DIRS) + .map(|s| s.to_string()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── SystemContextSnapshot ───────────────────────────────────────────────── + + #[test] + fn snapshot_fields_accessible() { + let s = SystemContextSnapshot { + os_family: "linux".into(), + arch: "x86_64".into(), + cwd: "/home/user".into(), + path_dirs: vec!["/usr/bin".into(), "/bin".into()], + }; + assert_eq!(s.os_family, "linux"); + assert_eq!(s.arch, "x86_64"); + assert_eq!(s.cwd, "/home/user"); + assert_eq!(s.path_dirs.len(), 2); + } + + #[test] + fn snapshot_clone_eq() { + let s = SystemContextSnapshot { + os_family: "linux".into(), + arch: "x86_64".into(), + cwd: "/tmp".into(), + path_dirs: vec![], + }; + assert_eq!(s.clone(), s); + } + + #[test] + fn snapshot_debug() { + let s = SystemContextSnapshot { + os_family: "linux".into(), + arch: "x86_64".into(), + cwd: "/tmp".into(), + path_dirs: vec![], + }; + let d = format!("{s:?}"); + assert!(d.contains("SystemContextSnapshot")); + assert!(d.contains("linux")); + } + + #[test] + fn snapshot_partial_eq() { + let a = SystemContextSnapshot { + os_family: "linux".into(), + arch: "x86_64".into(), + cwd: "/tmp".into(), + path_dirs: vec![], + }; + let b = a.clone(); + let c = SystemContextSnapshot { + os_family: "macos".into(), + ..a.clone() + }; + assert_eq!(a, b); + assert_ne!(a, c); + } + + // ── SystemContextProvider ───────────────────────────────────────────────── + + #[test] + fn provider_default_works() { + let _ = SystemContextProvider::default(); + } + + #[test] + fn provider_new_works() { + let _ = SystemContextProvider::new(); + } + + #[test] + fn snapshot_os_family_non_empty() { + let snap = SystemContextProvider::new().snapshot(); + assert!(!snap.os_family.is_empty(), "os_family must not be empty"); + } + + #[test] + fn snapshot_arch_non_empty() { + let snap = SystemContextProvider::new().snapshot(); + assert!(!snap.arch.is_empty(), "arch must not be empty"); + } + + #[test] + fn snapshot_cwd_non_empty() { + let snap = SystemContextProvider::new().snapshot(); + assert!(!snap.cwd.is_empty(), "cwd must not be empty"); + } + + #[test] + fn snapshot_path_dirs_at_most_max() { + let snap = SystemContextProvider::new().snapshot(); + assert!( + snap.path_dirs.len() <= MAX_PATH_DIRS, + "path_dirs exceeds MAX_PATH_DIRS: got {}", + snap.path_dirs.len() + ); + } + + #[test] + fn snapshot_path_dirs_no_empty_entries() { + let snap = SystemContextProvider::new().snapshot(); + for dir in &snap.path_dirs { + assert!( + !dir.is_empty(), + "path_dirs should not contain empty strings" + ); + } + } + + // ── split_path_str (pure, no env mutation needed) ──────────────────────── + + #[test] + fn split_path_str_limits_to_max_dirs() { + let many: String = (0..=20) + .map(|i| format!("/dir{i}")) + .collect::>() + .join(":"); + let dirs = split_path_str(&many); + assert_eq!(dirs.len(), MAX_PATH_DIRS); + } + + #[test] + fn split_path_str_with_empty_string_returns_empty() { + let dirs = split_path_str(""); + assert!(dirs.is_empty()); + } + + #[test] + fn split_path_str_filters_empty_segments() { + let dirs = split_path_str("/usr/bin::/bin"); + // The empty segment between two colons should be dropped + assert!(dirs.iter().all(|d| !d.is_empty())); + assert!(dirs.contains(&"/usr/bin".to_string())); + assert!(dirs.contains(&"/bin".to_string())); + } + + #[test] + fn split_path_str_single_entry() { + let dirs = split_path_str("/usr/bin"); + assert_eq!(dirs, vec!["/usr/bin"]); + } + + #[test] + fn split_path_str_exactly_max_entries() { + let exact: String = (0..MAX_PATH_DIRS) + .map(|i| format!("/dir{i}")) + .collect::>() + .join(":"); + let dirs = split_path_str(&exact); + assert_eq!(dirs.len(), MAX_PATH_DIRS); + } + + #[test] + fn provider_debug() { + let p = SystemContextProvider::new(); + assert!(format!("{p:?}").contains("SystemContextProvider")); + } +} diff --git a/logicshell-llm/src/error.rs b/logicshell-llm/src/error.rs new file mode 100644 index 0000000..723261e --- /dev/null +++ b/logicshell-llm/src/error.rs @@ -0,0 +1,112 @@ +// LLM-specific error type — LLM Module PRD §5.x + +use thiserror::Error; + +/// Errors that can originate from the LLM bridge subsystem. +#[derive(Debug, Error, PartialEq)] +pub enum LlmError { + /// LLM is disabled in configuration (`llm.enabled = false`). + #[error("LLM is disabled by configuration")] + Disabled, + + /// `llm.enabled = true` but `llm.model` is absent — caught at composition time. + #[error("llm.model is required when llm.enabled = true")] + ModelNotSpecified, + + /// Composed prompt exceeds the configured `max_context_chars` limit. + #[error("prompt length {size} chars exceeds max_context_chars limit {max}")] + ContextTooLarge { size: usize, max: usize }, + + /// LLM response could not be parsed into a usable form. + #[error("LLM response parse error: {0}")] + Parse(String), + + /// HTTP or transport error from the Ollama client (Phase 9+). + #[error("LLM HTTP error: {0}")] + Http(String), + + /// Catch-all for unforeseen LLM subsystem errors. + #[error("LLM error: {0}")] + Other(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_display() { + let e = LlmError::Disabled; + assert_eq!(e.to_string(), "LLM is disabled by configuration"); + } + + #[test] + fn model_not_specified_display() { + let e = LlmError::ModelNotSpecified; + assert!(e.to_string().contains("llm.model is required")); + } + + #[test] + fn context_too_large_display() { + let e = LlmError::ContextTooLarge { + size: 9000, + max: 8000, + }; + let s = e.to_string(); + assert!(s.contains("9000"), "size in message: {s}"); + assert!(s.contains("8000"), "max in message: {s}"); + } + + #[test] + fn parse_error_display() { + let e = LlmError::Parse("unexpected EOF".into()); + assert!(e.to_string().contains("parse error")); + assert!(e.to_string().contains("unexpected EOF")); + } + + #[test] + fn http_error_display() { + let e = LlmError::Http("connection refused".into()); + assert!(e.to_string().contains("HTTP error")); + assert!(e.to_string().contains("connection refused")); + } + + #[test] + fn other_error_display() { + let e = LlmError::Other("something went wrong".into()); + assert!(e.to_string().contains("something went wrong")); + } + + #[test] + fn errors_are_debug() { + let e = LlmError::Disabled; + assert!(!format!("{e:?}").is_empty()); + } + + #[test] + fn errors_are_partial_eq() { + assert_eq!(LlmError::Disabled, LlmError::Disabled); + assert_eq!(LlmError::ModelNotSpecified, LlmError::ModelNotSpecified); + assert_eq!( + LlmError::ContextTooLarge { size: 1, max: 0 }, + LlmError::ContextTooLarge { size: 1, max: 0 } + ); + assert_ne!(LlmError::Disabled, LlmError::ModelNotSpecified); + } + + #[test] + fn parse_error_partial_eq() { + assert_eq!(LlmError::Parse("x".into()), LlmError::Parse("x".into())); + assert_ne!(LlmError::Parse("a".into()), LlmError::Parse("b".into())); + } + + #[test] + fn http_error_partial_eq() { + assert_eq!(LlmError::Http("e".into()), LlmError::Http("e".into())); + } + + #[test] + fn other_error_partial_eq() { + assert_eq!(LlmError::Other("x".into()), LlmError::Other("x".into())); + } +} diff --git a/logicshell-llm/src/lib.rs b/logicshell-llm/src/lib.rs index f627168..2795feb 100644 --- a/logicshell-llm/src/lib.rs +++ b/logicshell-llm/src/lib.rs @@ -1,9 +1,12 @@ // logicshell-llm: LLM bridge, context, composer, Ollama client -// Enable HTTP backend with feature `ollama` +// Enable HTTP backend with the `ollama` feature flag. -#[cfg(test)] -mod tests { - /// Phase 1 smoke: LLM crate builds — NFR-09, NFR-10 - #[test] - fn workspace_compiles() {} -} +pub mod client; +pub mod context; +pub mod error; +pub mod prompt; + +pub use client::{LlmClient, LlmRequest, LlmResponse}; +pub use context::{SystemContextProvider, SystemContextSnapshot}; +pub use error::LlmError; +pub use prompt::PromptComposer; diff --git a/logicshell-llm/src/prompt.rs b/logicshell-llm/src/prompt.rs new file mode 100644 index 0000000..3a93cc9 --- /dev/null +++ b/logicshell-llm/src/prompt.rs @@ -0,0 +1,395 @@ +// Prompt composer — LLM Module PRD §5.3 +// +// Pure, sync, deterministic. All environment access is delegated to +// `SystemContextProvider`; this module never reads `std::env` directly (FR-11). +// Templates are embedded at compile time via `include_str!`. + +use logicshell_core::config::LlmConfig; + +use crate::{client::LlmRequest, context::SystemContextSnapshot, error::LlmError}; + +const NL_TO_COMMAND_TEMPLATE: &str = include_str!("templates/nl_to_command.txt"); +const ASSIST_ON_127_TEMPLATE: &str = include_str!("templates/assist_on_127.txt"); + +/// Builds LLM prompts from templates, enforcing the `max_context_chars` cap. +/// +/// `PromptComposer` is pure and sync — no I/O, no side effects. Construct once +/// from a [`LlmConfig`] or directly with [`PromptComposer::new`]. +#[derive(Debug)] +pub struct PromptComposer { + model: String, + max_context_chars: usize, +} + +impl PromptComposer { + /// Construct directly with a model name and character cap. + pub fn new(model: impl Into, max_context_chars: usize) -> Self { + Self { + model: model.into(), + max_context_chars, + } + } + + /// Construct from an [`LlmConfig`], returning errors for disabled / missing model. + pub fn from_config(config: &LlmConfig) -> Result { + if !config.enabled { + return Err(LlmError::Disabled); + } + let model = config.model.clone().ok_or(LlmError::ModelNotSpecified)?; + Ok(Self { + model, + max_context_chars: config.invocation.max_context_chars as usize, + }) + } + + /// Model name this composer will embed in every [`LlmRequest`]. + pub fn model(&self) -> &str { + &self.model + } + + /// Character cap applied to every composed prompt. + pub fn max_context_chars(&self) -> usize { + self.max_context_chars + } + + /// Compose a natural-language-to-command prompt. + /// + /// Returns `Err(LlmError::ContextTooLarge)` if the rendered prompt exceeds + /// `max_context_chars`. + pub fn compose_nl_to_command( + &self, + nl_input: &str, + ctx: &SystemContextSnapshot, + ) -> Result { + let prompt = NL_TO_COMMAND_TEMPLATE + .replace("{os_family}", &ctx.os_family) + .replace("{arch}", &ctx.arch) + .replace("{cwd}", &ctx.cwd) + .replace("{path_dirs}", &ctx.path_dirs.join(":")) + .replace("{nl_input}", nl_input); + + self.check_length(&prompt)?; + + Ok(LlmRequest { + model: self.model.clone(), + prompt, + }) + } + + /// Compose an exit-code-127 assist prompt. + /// + /// `failed_argv` is the argv that returned 127; the composer joins it with + /// spaces to embed in the template. Returns `Err(LlmError::ContextTooLarge)` + /// if the prompt is too long. + pub fn compose_assist_on_127( + &self, + failed_argv: &[&str], + ctx: &SystemContextSnapshot, + ) -> Result { + let failed_cmd = failed_argv.join(" "); + let prompt = ASSIST_ON_127_TEMPLATE + .replace("{os_family}", &ctx.os_family) + .replace("{arch}", &ctx.arch) + .replace("{cwd}", &ctx.cwd) + .replace("{path_dirs}", &ctx.path_dirs.join(":")) + .replace("{failed_cmd}", &failed_cmd); + + self.check_length(&prompt)?; + + Ok(LlmRequest { + model: self.model.clone(), + prompt, + }) + } + + /// Return `Err(ContextTooLarge)` if `prompt` exceeds the configured cap. + fn check_length(&self, prompt: &str) -> Result<(), LlmError> { + let size = prompt.chars().count(); + if size > self.max_context_chars { + return Err(LlmError::ContextTooLarge { + size, + max: self.max_context_chars, + }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use logicshell_core::config::LlmConfig; + + fn ctx() -> SystemContextSnapshot { + SystemContextSnapshot { + os_family: "linux".into(), + arch: "x86_64".into(), + cwd: "/home/user/project".into(), + path_dirs: vec!["/usr/bin".into(), "/bin".into()], + } + } + + fn ctx_empty_path() -> SystemContextSnapshot { + SystemContextSnapshot { + path_dirs: vec![], + ..ctx() + } + } + + // ── PromptComposer::new ─────────────────────────────────────────────────── + + #[test] + fn new_stores_model_and_cap() { + let c = PromptComposer::new("llama3", 8_000); + assert_eq!(c.model(), "llama3"); + assert_eq!(c.max_context_chars(), 8_000); + } + + #[test] + fn new_with_zero_cap() { + let c = PromptComposer::new("m", 0); + assert_eq!(c.max_context_chars(), 0); + } + + // ── PromptComposer::from_config ─────────────────────────────────────────── + + #[test] + fn from_config_disabled_llm_returns_disabled_error() { + let cfg = LlmConfig { + enabled: false, + ..LlmConfig::default() + }; + let result = PromptComposer::from_config(&cfg); + assert_eq!(result.unwrap_err(), LlmError::Disabled); + } + + #[test] + fn from_config_enabled_no_model_returns_model_not_specified() { + let cfg = LlmConfig { + enabled: true, + model: None, + ..LlmConfig::default() + }; + let result = PromptComposer::from_config(&cfg); + assert_eq!(result.unwrap_err(), LlmError::ModelNotSpecified); + } + + #[test] + fn from_config_enabled_with_model_succeeds() { + let cfg = LlmConfig { + enabled: true, + model: Some("llama3".into()), + ..LlmConfig::default() + }; + let c = PromptComposer::from_config(&cfg).unwrap(); + assert_eq!(c.model(), "llama3"); + assert_eq!(c.max_context_chars(), 8_000); // default max_context_chars + } + + #[test] + fn from_config_respects_max_context_chars() { + let mut cfg = LlmConfig { + enabled: true, + model: Some("m".into()), + ..LlmConfig::default() + }; + cfg.invocation.max_context_chars = 4_000; + let c = PromptComposer::from_config(&cfg).unwrap(); + assert_eq!(c.max_context_chars(), 4_000); + } + + // ── compose_nl_to_command ───────────────────────────────────────────────── + + #[test] + fn nl_to_command_includes_nl_input() { + let c = PromptComposer::new("llama3", 8_000); + let req = c + .compose_nl_to_command("list files sorted by size", &ctx()) + .unwrap(); + assert!( + req.prompt.contains("list files sorted by size"), + "prompt must contain nl_input" + ); + } + + #[test] + fn nl_to_command_model_propagated() { + let c = PromptComposer::new("mistral", 8_000); + let req = c.compose_nl_to_command("show disk usage", &ctx()).unwrap(); + assert_eq!(req.model, "mistral"); + } + + #[test] + fn nl_to_command_contains_os_family() { + let c = PromptComposer::new("llama3", 8_000); + let req = c.compose_nl_to_command("ls", &ctx()).unwrap(); + assert!( + req.prompt.contains("linux"), + "prompt must contain os_family" + ); + } + + #[test] + fn nl_to_command_contains_arch() { + let c = PromptComposer::new("llama3", 8_000); + let req = c.compose_nl_to_command("ls", &ctx()).unwrap(); + assert!(req.prompt.contains("x86_64"), "prompt must contain arch"); + } + + #[test] + fn nl_to_command_contains_cwd() { + let c = PromptComposer::new("llama3", 8_000); + let req = c.compose_nl_to_command("ls", &ctx()).unwrap(); + assert!( + req.prompt.contains("/home/user/project"), + "prompt must contain cwd" + ); + } + + #[test] + fn nl_to_command_contains_path_dirs() { + let c = PromptComposer::new("llama3", 8_000); + let req = c.compose_nl_to_command("ls", &ctx()).unwrap(); + assert!( + req.prompt.contains("/usr/bin"), + "prompt must contain PATH entries" + ); + } + + #[test] + fn nl_to_command_empty_path_dirs() { + let c = PromptComposer::new("llama3", 8_000); + let req = c.compose_nl_to_command("ls", &ctx_empty_path()).unwrap(); + assert!(!req.prompt.is_empty()); + } + + #[test] + fn nl_to_command_context_too_large() { + let c = PromptComposer::new("m", 10); // tiny cap + let result = c.compose_nl_to_command("ls", &ctx()); + assert!( + matches!(result, Err(LlmError::ContextTooLarge { .. })), + "should error when prompt > cap" + ); + } + + #[test] + fn nl_to_command_zero_cap_always_errors() { + let c = PromptComposer::new("m", 0); + let result = c.compose_nl_to_command("ls", &ctx()); + assert!(matches!(result, Err(LlmError::ContextTooLarge { .. }))); + } + + #[test] + fn nl_to_command_context_too_large_reports_sizes() { + let c = PromptComposer::new("m", 5); + match c.compose_nl_to_command("ls", &ctx()).unwrap_err() { + LlmError::ContextTooLarge { size, max } => { + assert!(size > 5, "size should be > 5, got {size}"); + assert_eq!(max, 5); + } + other => panic!("expected ContextTooLarge, got {other:?}"), + } + } + + // ── compose_assist_on_127 ───────────────────────────────────────────────── + + #[test] + fn assist_127_includes_failed_command() { + let c = PromptComposer::new("llama3", 8_000); + let req = c.compose_assist_on_127(&["lss", "-la"], &ctx()).unwrap(); + assert!( + req.prompt.contains("lss -la"), + "prompt must contain failed command" + ); + } + + #[test] + fn assist_127_model_propagated() { + let c = PromptComposer::new("codellama", 8_000); + let req = c.compose_assist_on_127(&["gti", "status"], &ctx()).unwrap(); + assert_eq!(req.model, "codellama"); + } + + #[test] + fn assist_127_contains_os_context() { + let c = PromptComposer::new("m", 8_000); + let req = c.compose_assist_on_127(&["cmd"], &ctx()).unwrap(); + assert!(req.prompt.contains("linux")); + assert!(req.prompt.contains("x86_64")); + } + + #[test] + fn assist_127_single_argv_element() { + let c = PromptComposer::new("m", 8_000); + let req = c.compose_assist_on_127(&["dockerr"], &ctx()).unwrap(); + assert!(req.prompt.contains("dockerr")); + } + + #[test] + fn assist_127_empty_argv_works() { + let c = PromptComposer::new("m", 8_000); + let req = c.compose_assist_on_127(&[], &ctx()).unwrap(); + // Empty argv → empty failed_cmd substituted; prompt still valid + assert!(!req.prompt.is_empty()); + } + + #[test] + fn assist_127_context_too_large() { + let c = PromptComposer::new("m", 10); + let result = c.compose_assist_on_127(&["cmd"], &ctx()); + assert!(matches!(result, Err(LlmError::ContextTooLarge { .. }))); + } + + #[test] + fn assist_127_zero_cap_always_errors() { + let c = PromptComposer::new("m", 0); + let result = c.compose_assist_on_127(&["cmd"], &ctx()); + assert!(matches!(result, Err(LlmError::ContextTooLarge { .. }))); + } + + // ── check_length boundary ───────────────────────────────────────────────── + + #[test] + fn exact_cap_length_succeeds() { + // Build a prompt that is exactly at the limit + // Use a very large cap so the template passes + let c = PromptComposer::new("m", usize::MAX); + let req = c.compose_nl_to_command("ls", &ctx()).unwrap(); + let len = req.prompt.chars().count(); + + // Create a composer with exactly the prompt length as cap + let c2 = PromptComposer::new("m", len); + let result = c2.compose_nl_to_command("ls", &ctx()); + assert!(result.is_ok(), "prompt at exactly cap length must succeed"); + } + + #[test] + fn one_over_cap_fails() { + let c = PromptComposer::new("m", usize::MAX); + let req = c.compose_nl_to_command("ls", &ctx()).unwrap(); + let len = req.prompt.chars().count(); + + // Cap is one less than the prompt length + let c2 = PromptComposer::new("m", len - 1); + let result = c2.compose_nl_to_command("ls", &ctx()); + assert!( + matches!(result, Err(LlmError::ContextTooLarge { .. })), + "one char over cap must fail" + ); + } + + // ── Templates are embedded (non-empty) ─────────────────────────────────── + + #[test] + fn nl_to_command_template_is_non_empty() { + assert!(!NL_TO_COMMAND_TEMPLATE.is_empty()); + assert!(NL_TO_COMMAND_TEMPLATE.contains("{nl_input}")); + } + + #[test] + fn assist_on_127_template_is_non_empty() { + assert!(!ASSIST_ON_127_TEMPLATE.is_empty()); + assert!(ASSIST_ON_127_TEMPLATE.contains("{failed_cmd}")); + } +} diff --git a/logicshell-llm/src/templates/assist_on_127.txt b/logicshell-llm/src/templates/assist_on_127.txt new file mode 100644 index 0000000..e81f647 --- /dev/null +++ b/logicshell-llm/src/templates/assist_on_127.txt @@ -0,0 +1,10 @@ +Command not found on {os_family} ({arch}) (exit code 127): + {failed_cmd} + +Suggest the correct command or the package needed to install it. +Respond with ONLY the corrected command, no explanation, no code fences. + +Working directory: {cwd} +PATH entries: {path_dirs} + +Correction: \ No newline at end of file diff --git a/logicshell-llm/src/templates/nl_to_command.txt b/logicshell-llm/src/templates/nl_to_command.txt new file mode 100644 index 0000000..22d53c7 --- /dev/null +++ b/logicshell-llm/src/templates/nl_to_command.txt @@ -0,0 +1,10 @@ +You are a command-line assistant on {os_family} ({arch}). +Convert the user's natural language description into a single shell command. +Respond with ONLY the command, no explanation, no code fences. + +Working directory: {cwd} +PATH entries: {path_dirs} + +Request: {nl_input} + +Command: \ No newline at end of file diff --git a/logicshell-llm/tests/phase8_integration.rs b/logicshell-llm/tests/phase8_integration.rs new file mode 100644 index 0000000..437fca6 --- /dev/null +++ b/logicshell-llm/tests/phase8_integration.rs @@ -0,0 +1,259 @@ +// Phase 8 integration tests — LLM context + prompt composer end-to-end +// +// These tests exercise the full Phase 8 pipeline: +// SystemContextProvider → SystemContextSnapshot → PromptComposer → LlmRequest +// +// No network calls are made; the LlmClient trait is exercised via concrete stubs. +// Traces: FR-10, FR-11, FR-21, NFR-05, LLM Module PRD §5.1–5.3 + +use logicshell_core::config::LlmConfig; +use logicshell_llm::{ + LlmClient, LlmError, LlmRequest, LlmResponse, PromptComposer, SystemContextProvider, + SystemContextSnapshot, +}; + +// ── Stub LlmClient ──────────────────────────────────────────────────────────── + +/// Stub that echoes the request back in the response. +struct EchoClient; + +impl LlmClient for EchoClient { + async fn complete(&self, req: LlmRequest) -> Result { + Ok(LlmResponse { + text: format!("echo: {}", req.prompt.lines().last().unwrap_or("").trim()), + model: req.model.clone(), + }) + } +} + +/// Stub that always returns a fixed command string. +struct FixedClient { + response: String, +} + +impl LlmClient for FixedClient { + async fn complete(&self, req: LlmRequest) -> Result { + Ok(LlmResponse { + text: self.response.clone(), + model: req.model, + }) + } +} + +/// Stub that always errors. +struct ErrorClient; + +impl LlmClient for ErrorClient { + async fn complete(&self, _req: LlmRequest) -> Result { + Err(LlmError::Http("daemon not running".into())) + } +} + +// ── Helper context ──────────────────────────────────────────────────────────── + +fn test_ctx() -> SystemContextSnapshot { + SystemContextSnapshot { + os_family: "linux".into(), + arch: "x86_64".into(), + cwd: "/home/user/project".into(), + path_dirs: vec!["/usr/bin".into(), "/bin".into(), "/usr/local/bin".into()], + } +} + +// ── SystemContextProvider integration ──────────────────────────────────────── + +/// FR-10: SystemContextProvider returns a consistent snapshot. +#[test] +fn provider_snapshot_is_consistent() { + let provider = SystemContextProvider::new(); + let snap1 = provider.snapshot(); + let snap2 = provider.snapshot(); + + // OS and arch never change within a run + assert_eq!(snap1.os_family, snap2.os_family); + assert_eq!(snap1.arch, snap2.arch); +} + +/// FR-11: snapshot contains required fields on Linux CI. +#[test] +fn provider_snapshot_has_required_fields() { + let snap = SystemContextProvider::new().snapshot(); + assert!(!snap.os_family.is_empty(), "os_family must be present"); + assert!(!snap.arch.is_empty(), "arch must be present"); + assert!(!snap.cwd.is_empty(), "cwd must be present"); +} + +// ── PromptComposer from LlmConfig integration ───────────────────────────────── + +/// from_config with all fields set produces a working composer. +#[test] +fn composer_from_config_full_pipeline() { + let cfg = LlmConfig { + enabled: true, + model: Some("llama3".into()), + ..LlmConfig::default() + }; + let composer = PromptComposer::from_config(&cfg).unwrap(); + + let ctx = test_ctx(); + let req = composer + .compose_nl_to_command("list files sorted by modification time", &ctx) + .unwrap(); + + assert_eq!(req.model, "llama3"); + assert!(req + .prompt + .contains("list files sorted by modification time")); + assert!(req.prompt.contains("linux")); + assert!(req.prompt.contains("x86_64")); +} + +/// PromptComposer::from_config respects invocation.max_context_chars. +#[test] +fn composer_from_config_respects_context_cap() { + let mut cfg = LlmConfig { + enabled: true, + model: Some("m".into()), + ..LlmConfig::default() + }; + cfg.invocation.max_context_chars = 50; // very tight + + let composer = PromptComposer::from_config(&cfg).unwrap(); + let result = composer.compose_nl_to_command("ls", &test_ctx()); + assert!( + matches!(result, Err(LlmError::ContextTooLarge { .. })), + "tight cap must trigger ContextTooLarge" + ); +} + +// ── Full pipeline: context → composer → client ──────────────────────────────── + +/// FR-21: context → composer → echo-client round-trip. +#[tokio::test] +async fn full_nl_to_command_pipeline() { + let ctx = SystemContextProvider::new().snapshot(); + let composer = PromptComposer::new("llama3", 8_000); + + let req = composer + .compose_nl_to_command("show current directory contents", &ctx) + .unwrap(); + assert_eq!(req.model, "llama3"); + assert!(req.prompt.contains("show current directory contents")); + + let client = EchoClient; + let resp = client.complete(req).await.unwrap(); + assert_eq!(resp.model, "llama3"); + assert!(!resp.text.is_empty()); +} + +/// FR-21: context → composer → fixed-client round-trip for assist-on-127. +#[tokio::test] +async fn full_assist_127_pipeline() { + let ctx = SystemContextProvider::new().snapshot(); + let composer = PromptComposer::new("codellama", 8_000); + + let req = composer + .compose_assist_on_127(&["gti", "status"], &ctx) + .unwrap(); + assert!(req.prompt.contains("gti status")); + assert_eq!(req.model, "codellama"); + + let client = FixedClient { + response: "git status".into(), + }; + let resp = client.complete(req).await.unwrap(); + assert_eq!(resp.text, "git status"); + assert_eq!(resp.model, "codellama"); +} + +/// FR-24: graceful degradation when LlmClient returns an error. +#[tokio::test] +async fn client_error_is_propagated_gracefully() { + let ctx = test_ctx(); + let composer = PromptComposer::new("llama3", 8_000); + let req = composer.compose_nl_to_command("ls", &ctx).unwrap(); + + let client = ErrorClient; + let result = client.complete(req).await; + + assert!(result.is_err(), "error must propagate"); + assert!(matches!(result.unwrap_err(), LlmError::Http(_))); +} + +// ── LlmRequest / LlmResponse round-trip ────────────────────────────────────── + +#[test] +fn request_response_fields_stable() { + let req = LlmRequest { + model: "llama3".into(), + prompt: "ls -la".into(), + }; + assert_eq!(req.model, "llama3"); + assert_eq!(req.prompt, "ls -la"); + + let resp = LlmResponse { + text: "ls -la /home".into(), + model: "llama3".into(), + }; + assert_eq!(resp.text, "ls -la /home"); + assert_eq!(resp.model, "llama3"); +} + +// ── LlmError propagation ────────────────────────────────────────────────────── + +#[test] +fn llm_error_variants_all_display() { + let errors = [ + LlmError::Disabled, + LlmError::ModelNotSpecified, + LlmError::ContextTooLarge { + size: 9000, + max: 8000, + }, + LlmError::Parse("bad json".into()), + LlmError::Http("timeout".into()), + LlmError::Other("unknown".into()), + ]; + for e in &errors { + assert!(!e.to_string().is_empty(), "error must have display: {e:?}"); + } +} + +// ── Context isolation: composer does not read env ───────────────────────────── + +/// FR-11: PromptComposer uses the provided snapshot, not live env vars. +#[test] +fn composer_uses_snapshot_not_live_env() { + let artificial_ctx = SystemContextSnapshot { + os_family: "fake_os".into(), + arch: "fake_arch".into(), + cwd: "/fake/cwd".into(), + path_dirs: vec!["/fake/path".into()], + }; + + let composer = PromptComposer::new("m", 8_000); + let req = composer + .compose_nl_to_command("ls", &artificial_ctx) + .unwrap(); + + // The prompt must reflect the artificial snapshot, not the real env + assert!( + req.prompt.contains("fake_os"), + "must use snapshot os_family" + ); + assert!(req.prompt.contains("fake_arch"), "must use snapshot arch"); + assert!(req.prompt.contains("/fake/cwd"), "must use snapshot cwd"); +} + +// ── Multiple composer invocations are idempotent ────────────────────────────── + +#[test] +fn composer_is_idempotent() { + let composer = PromptComposer::new("llama3", 8_000); + let ctx = test_ctx(); + + let req1 = composer.compose_nl_to_command("ls", &ctx).unwrap(); + let req2 = composer.compose_nl_to_command("ls", &ctx).unwrap(); + + assert_eq!(req1, req2, "same inputs must produce identical LlmRequest"); +}