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
63pub 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
102pub struct Llm {
108 backend: Box<dyn TraceInsightsBackend>,
109 pub backend_label: String,
110}
111
112pub 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 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 pub fn normalize_model_alias(alias: &str) -> String {
152 super::providers::ollama::normalize_model_alias(alias)
153 }
154
155 pub fn is_reachable(syn: &SynthesisConfig) -> bool {
157 super::providers::ollama::is_reachable(syn)
158 }
159
160 pub fn is_model_pulled(syn: &SynthesisConfig) -> AnyhowResult<bool> {
162 super::providers::ollama::is_model_pulled(syn)
163 }
164
165 pub fn pull_model(syn: &SynthesisConfig, model: &str) -> AnyhowResult<()> {
167 super::providers::ollama::pull_model(syn, model)
168 }
169
170 pub fn ensure_ready(merged: &MergedConfig) -> anyhow::Result<EnsureReport> {
173 super::providers::ollama::ensure_ready(&merged.synthesis)
174 }
175
176 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}