Skip to main content

agent_trace/llm/
trace_insights.rs

1use super::backend::TraceInsightsBackend;
2use super::providers::ollama::EnsureReport;
3use super::providers::{resolve, ResolvedBackendInfo};
4use super::synthesis_engine::SynthesisEngine;
5use crate::config::{CredentialsStore, MergedConfig, SynthesisConfig};
6use crate::runtime::allow_degraded_mode;
7use crate::types::DocType;
8use anyhow::Result as AnyhowResult;
9use std::path::Path;
10use thiserror::Error;
11
12#[derive(Debug, Clone)]
13pub struct TraceDocument {
14    pub path: String,
15    pub doc_type: DocType,
16    pub content_snippet: String,
17}
18
19#[derive(Debug, Clone)]
20pub enum TraceInsightsRequest {
21    SummarizeChange {
22        path: String,
23        doc_type: DocType,
24        diff: String,
25    },
26    SynthesizeContext {
27        documents: Vec<TraceDocument>,
28        updates: Vec<String>,
29    },
30    SummarizeSession {
31        session_id: String,
32        events: Vec<String>,
33    },
34    UpdateRunningSummary {
35        previous_summary: String,
36        new_events: String,
37        plan_snippet: String,
38    },
39    SummarizeEventHistory {
40        events: String,
41    },
42}
43
44#[derive(Debug, Clone)]
45pub enum TraceInsightsResponse {
46    ChangeSummary(String),
47    ContextDocument(String),
48    SessionSummary(String),
49    RunningSummary(String),
50    EventHistorySummary(String),
51}
52
53#[derive(Debug, Error)]
54pub enum LlmError {
55    #[error("model unavailable: {0}")]
56    ModelUnavailable(String),
57    #[error("invalid output: {0}")]
58    InvalidOutput(String),
59    #[error("backend failure: {0}")]
60    BackendFailure(String),
61}
62
63/// Backward-compat alias for `LlmError` (deprecated — use `LlmError`).
64pub type TraceInsightsError = LlmError;
65
66struct EngineAdapter {
67    inner: Box<dyn SynthesisEngine>,
68}
69
70impl TraceInsightsBackend for EngineAdapter {
71    fn summarize_change(&self, path: &str, doc_type: &str, diff: &str) -> Result<String, String> {
72        self.inner.summarize_change(path, doc_type, diff)
73    }
74
75    fn synthesize_context(
76        &self,
77        documents: &[TraceDocument],
78        updates: &[String],
79    ) -> Result<String, String> {
80        self.inner.synthesize_context(documents, updates)
81    }
82
83    fn summarize_session(&self, session_id: &str, events: &[String]) -> Result<String, String> {
84        self.inner.summarize_session(session_id, events)
85    }
86
87    fn summarize_event_history(&self, events: &str) -> Result<String, String> {
88        self.inner.summarize_event_history(events)
89    }
90
91    fn update_running_summary(
92        &self,
93        previous_summary: &str,
94        new_events: &str,
95        plan_snippet: &str,
96    ) -> Result<String, String> {
97        self.inner
98            .update_running_summary(previous_summary, new_events, plan_snippet)
99    }
100}
101
102/// Unified LLM facade — the single public entry point for all inference.
103///
104/// Construct with `Llm::from_store_root` or `Llm::from_merged_config`.
105/// All synthesis operations are routed through this type; callers never
106/// import from `providers::` directly.
107pub struct Llm {
108    backend: Box<dyn TraceInsightsBackend>,
109    pub backend_label: String,
110}
111
112/// Backward-compat alias for `Llm` (deprecated — use `Llm`).
113pub type TraceInsightsFacade = Llm;
114
115impl Llm {
116    pub fn from_merged_config(merged: &MergedConfig) -> Result<Self, LlmError> {
117        let creds = CredentialsStore::load().unwrap_or_default();
118        let resolved = resolve(merged, &creds);
119        let info = resolved.info();
120        if info.degraded && !allow_degraded_mode() {
121            return Err(LlmError::ModelUnavailable(
122                "Synthesis backend unavailable. Run: agent-trace model ensure".into(),
123            ));
124        }
125        let label = info.label.clone();
126        Ok(Self {
127            backend: Box::new(EngineAdapter {
128                inner: resolved.into_engine(),
129            }),
130            backend_label: label,
131        })
132    }
133
134    pub fn from_store_root(store_root: &Path) -> Result<Self, LlmError> {
135        let merged = MergedConfig::load(store_root)
136            .map_err(|e| LlmError::ModelUnavailable(format!("config load failed: {e}")))?;
137        Self::from_merged_config(&merged)
138    }
139
140    pub fn is_degraded(&self) -> bool {
141        self.backend_label == "degraded"
142    }
143
144    /// Return backend info for status/TUI display (from already-loaded config).
145    pub fn backend_info_from_config(merged: &MergedConfig) -> ResolvedBackendInfo {
146        let creds = CredentialsStore::load().unwrap_or_default();
147        resolve(merged, &creds).info()
148    }
149
150    /// Normalize short Ollama model aliases (e.g. `1.5b` → `qwen2.5:1.5b`).
151    pub fn normalize_model_alias(alias: &str) -> String {
152        super::providers::ollama::normalize_model_alias(alias)
153    }
154
155    /// Whether the configured Ollama endpoint responds to a health check.
156    pub fn is_reachable(syn: &SynthesisConfig) -> bool {
157        super::providers::ollama::is_reachable(syn)
158    }
159
160    /// Whether the configured model tag is listed on the Ollama daemon.
161    pub fn is_model_pulled(syn: &SynthesisConfig) -> AnyhowResult<bool> {
162        super::providers::ollama::is_model_pulled(syn)
163    }
164
165    /// Pull a model tag via the configured Ollama native API.
166    pub fn pull_model(syn: &SynthesisConfig, model: &str) -> AnyhowResult<()> {
167        super::providers::ollama::pull_model(syn, model)
168    }
169
170    /// Ensure Ollama daemon is running and the configured model is pulled.
171    /// Returns `Ok(EnsureReport)` on success, or an error with an actionable message.
172    pub fn ensure_ready(merged: &MergedConfig) -> anyhow::Result<EnsureReport> {
173        super::providers::ollama::ensure_ready(&merged.synthesis)
174    }
175
176    /// Gate check: return backend info or bail if degraded and not allowed.
177    pub fn require_backend(store_root: Option<&Path>) -> anyhow::Result<ResolvedBackendInfo> {
178        use crate::config::{GlobalConfig, PollingConfig, StoreConfig, StoreInfo};
179        let merged = match store_root {
180            Some(root) if root.join(".agent-trace").exists() => {
181                MergedConfig::load(root).map_err(|e| anyhow::anyhow!("config load: {e}"))?
182            }
183            _ => {
184                let global = GlobalConfig::load()?;
185                MergedConfig::merge(
186                    global,
187                    StoreConfig {
188                        store: StoreInfo::new("gate".into()),
189                        llm: None,
190                        synthesis: None,
191                        polling: PollingConfig::default(),
192                    },
193                )
194            }
195        };
196        let creds = CredentialsStore::load().unwrap_or_default();
197        if super::providers::ollama::lifecycle::needs_ollama_for_resolve(&merged.synthesis, &creds)
198            && !allow_degraded_mode()
199        {
200            Self::ensure_ready(&merged)?;
201        }
202        let info = resolve(&merged, &creds).info();
203        if info.degraded && !allow_degraded_mode() {
204            anyhow::bail!("Synthesis backend unavailable. Run: agent-trace model ensure");
205        }
206        Ok(info)
207    }
208
209    pub fn execute(
210        &self,
211        request: TraceInsightsRequest,
212    ) -> Result<TraceInsightsResponse, LlmError> {
213        match request {
214            TraceInsightsRequest::SummarizeChange {
215                path,
216                doc_type,
217                diff,
218            } => {
219                let text = self
220                    .backend
221                    .summarize_change(&path, &doc_type.to_string(), &diff)
222                    .map_err(LlmError::BackendFailure)?;
223                validate_non_empty(&text)?;
224                Ok(TraceInsightsResponse::ChangeSummary(text))
225            }
226            TraceInsightsRequest::SynthesizeContext { documents, updates } => {
227                let text = self
228                    .backend
229                    .synthesize_context(&documents, &updates)
230                    .map_err(LlmError::BackendFailure)?;
231                validate_non_empty(&text)?;
232                Ok(TraceInsightsResponse::ContextDocument(text))
233            }
234            TraceInsightsRequest::SummarizeSession { session_id, events } => {
235                let text = self
236                    .backend
237                    .summarize_session(&session_id, &events)
238                    .map_err(LlmError::BackendFailure)?;
239                validate_non_empty(&text)?;
240                Ok(TraceInsightsResponse::SessionSummary(text))
241            }
242            TraceInsightsRequest::UpdateRunningSummary {
243                previous_summary,
244                new_events,
245                plan_snippet,
246            } => {
247                let text = self
248                    .backend
249                    .update_running_summary(&previous_summary, &new_events, &plan_snippet)
250                    .map_err(LlmError::BackendFailure)?;
251                validate_non_empty(&text)?;
252                Ok(TraceInsightsResponse::RunningSummary(text))
253            }
254            TraceInsightsRequest::SummarizeEventHistory { events } => {
255                let text = self
256                    .backend
257                    .summarize_event_history(&events)
258                    .map_err(LlmError::BackendFailure)?;
259                validate_non_empty(&text)?;
260                Ok(TraceInsightsResponse::EventHistorySummary(text))
261            }
262        }
263    }
264
265    pub fn summarize_change(
266        &self,
267        path: &Path,
268        doc_type: &DocType,
269        diff: &str,
270    ) -> Result<String, LlmError> {
271        let request = TraceInsightsRequest::SummarizeChange {
272            path: path.display().to_string(),
273            doc_type: doc_type.clone(),
274            diff: diff.to_string(),
275        };
276        match self.execute(request)? {
277            TraceInsightsResponse::ChangeSummary(v) => Ok(v),
278            _ => Err(LlmError::InvalidOutput(
279                "expected ChangeSummary response".into(),
280            )),
281        }
282    }
283
284    pub fn synthesize_context(
285        &self,
286        documents: &[TraceDocument],
287        updates: &[String],
288    ) -> Result<String, LlmError> {
289        let request = TraceInsightsRequest::SynthesizeContext {
290            documents: documents.to_vec(),
291            updates: updates.to_vec(),
292        };
293        match self.execute(request)? {
294            TraceInsightsResponse::ContextDocument(v) => Ok(v),
295            _ => Err(LlmError::InvalidOutput(
296                "expected ContextDocument response".into(),
297            )),
298        }
299    }
300
301    pub fn summarize_session(
302        &self,
303        session_id: &str,
304        events: &[String],
305    ) -> Result<String, LlmError> {
306        let request = TraceInsightsRequest::SummarizeSession {
307            session_id: session_id.to_string(),
308            events: events.to_vec(),
309        };
310        match self.execute(request)? {
311            TraceInsightsResponse::SessionSummary(v) => Ok(v),
312            _ => Err(LlmError::InvalidOutput(
313                "expected SessionSummary response".into(),
314            )),
315        }
316    }
317
318    pub fn update_running_summary(
319        &self,
320        previous_summary: &str,
321        new_events: &str,
322        plan_snippet: &str,
323    ) -> Result<String, LlmError> {
324        let request = TraceInsightsRequest::UpdateRunningSummary {
325            previous_summary: previous_summary.to_string(),
326            new_events: new_events.to_string(),
327            plan_snippet: plan_snippet.to_string(),
328        };
329        match self.execute(request)? {
330            TraceInsightsResponse::RunningSummary(v) => Ok(v),
331            _ => Err(LlmError::InvalidOutput(
332                "expected RunningSummary response".into(),
333            )),
334        }
335    }
336
337    pub fn summarize_event_history(&self, events: &str) -> Result<String, LlmError> {
338        let request = TraceInsightsRequest::SummarizeEventHistory {
339            events: events.to_string(),
340        };
341        match self.execute(request)? {
342            TraceInsightsResponse::EventHistorySummary(v) => Ok(v),
343            _ => Err(LlmError::InvalidOutput(
344                "expected EventHistorySummary response".into(),
345            )),
346        }
347    }
348}
349
350fn validate_non_empty(text: &str) -> Result<(), LlmError> {
351    if text.trim().is_empty() {
352        return Err(LlmError::InvalidOutput(
353            "backend returned empty output".into(),
354        ));
355    }
356    Ok(())
357}