1use std::sync::Arc;
39use std::time::Duration;
40
41use async_trait::async_trait;
42use lunaris_core::LunarisError;
43use lunaris_llm::{GenOpts, LlmBackend, SchemaConstraint};
44use serde::{Deserialize, Serialize};
45use ulid::Ulid;
46
47#[derive(Clone, Debug, Default, Serialize)]
52pub struct ReflectInput {
53 pub turn_id: Option<Ulid>,
56 pub turn_summary: String,
59 pub recent_fact_ids: Vec<Ulid>,
63 pub recent_chunk_ids: Vec<Ulid>,
66}
67
68#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
72pub struct ReflectOutput {
73 #[serde(default)]
75 pub invalidate: Vec<Ulid>,
76 #[serde(default)]
78 pub boost: Vec<Ulid>,
79 #[serde(default)]
82 pub pre_warm_query: Option<String>,
83}
84
85#[derive(Clone, Debug)]
87pub struct ReflectOpts {
88 pub timeout_ms: u64,
91 pub max_tokens: u32,
94 pub temperature: f32,
98}
99
100impl Default for ReflectOpts {
101 fn default() -> Self {
102 Self { timeout_ms: 500, max_tokens: 384, temperature: 0.2 }
103 }
104}
105
106#[async_trait]
108pub trait ReflectSupervisor: Send + Sync + 'static {
109 async fn reflect(&self, input: ReflectInput) -> Result<ReflectOutput, LunarisError>;
110
111 fn applies(&self) -> bool {
114 true
115 }
116}
117
118#[derive(Clone, Copy, Debug, Default)]
122pub struct NoopReflectSupervisor;
123
124#[async_trait]
125impl ReflectSupervisor for NoopReflectSupervisor {
126 async fn reflect(&self, _input: ReflectInput) -> Result<ReflectOutput, LunarisError> {
127 Ok(ReflectOutput::default())
128 }
129 fn applies(&self) -> bool {
130 false
131 }
132}
133
134#[derive(Clone)]
139pub struct LlmReflectSupervisor {
140 backend: Arc<dyn LlmBackend>,
141 opts: ReflectOpts,
142}
143
144impl std::fmt::Debug for LlmReflectSupervisor {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.debug_struct("LlmReflectSupervisor")
147 .field("model_id", &self.backend.model_id())
148 .field("opts", &self.opts)
149 .finish()
150 }
151}
152
153impl LlmReflectSupervisor {
154 pub fn new(backend: Arc<dyn LlmBackend>) -> Self {
155 Self { backend, opts: ReflectOpts::default() }
156 }
157
158 pub fn with_opts(backend: Arc<dyn LlmBackend>, opts: ReflectOpts) -> Self {
159 Self { backend, opts }
160 }
161}
162
163#[async_trait]
164impl ReflectSupervisor for LlmReflectSupervisor {
165 async fn reflect(&self, input: ReflectInput) -> Result<ReflectOutput, LunarisError> {
166 let prompt = build_prompt(&input);
167 let schema = output_schema();
168 let gen_opts = GenOpts {
169 max_tokens: self.opts.max_tokens,
170 temperature: self.opts.temperature,
171 timeout: Duration::from_millis(self.opts.timeout_ms),
172 };
173 match self.backend.generate(&prompt, SchemaConstraint::JsonSchema(&schema), gen_opts).await
174 {
175 Ok(decoded) => Ok(parse_reflect_output(&decoded)),
176 Err(e) => {
177 tracing::warn!(
178 err = %e,
179 model_id = self.backend.model_id(),
180 turn_id = ?input.turn_id,
181 "LlmReflectSupervisor generate failed; emitting empty reflection"
182 );
183 Ok(ReflectOutput::default())
184 }
185 }
186 }
187
188 fn applies(&self) -> bool {
189 self.backend.applies()
190 }
191}
192
193fn build_prompt(input: &ReflectInput) -> String {
194 let facts = input.recent_fact_ids.iter().map(|u| u.to_string()).collect::<Vec<_>>().join(", ");
199 let chunks =
200 input.recent_chunk_ids.iter().map(|u| u.to_string()).collect::<Vec<_>>().join(", ");
201 format!(
202 "You are calibrating an agent memory store after a turn finished.\n\
203 Turn summary:\n{summary}\n\n\
204 Recent fact ulids: [{facts}]\n\
205 Recent chunk ulids: [{chunks}]\n\n\
206 Respond with a JSON object:\n\
207 {{\"invalidate\": [<fact ulids to invalidate>],\
208 \"boost\": [<chunk ulids to boost>],\
209 \"pre_warm_query\": <string or null>}}\n\
210 If nothing should change, emit empty arrays and a null query.",
211 summary = input.turn_summary
212 )
213}
214
215fn output_schema() -> serde_json::Value {
216 serde_json::json!({
217 "type": "object",
218 "properties": {
219 "invalidate": {"type": "array", "items": {"type": "string"}},
220 "boost": {"type": "array", "items": {"type": "string"}},
221 "pre_warm_query": {"type": ["string", "null"]}
222 },
223 "required": ["invalidate", "boost", "pre_warm_query"]
224 })
225}
226
227fn parse_reflect_output(decoded: &str) -> ReflectOutput {
228 let Some(start) = decoded.find('{') else {
229 return ReflectOutput::default();
230 };
231 let bytes = decoded.as_bytes();
232 let mut depth = 0_i32;
233 let mut end_excl = start;
234 let mut in_string = false;
235 let mut escaped = false;
236 for (i, &b) in bytes.iter().enumerate().skip(start) {
237 if in_string {
238 if escaped {
239 escaped = false;
240 } else if b == b'\\' {
241 escaped = true;
242 } else if b == b'"' {
243 in_string = false;
244 }
245 continue;
246 }
247 match b {
248 b'"' => in_string = true,
249 b'{' => depth += 1,
250 b'}' => {
251 depth -= 1;
252 if depth == 0 {
253 end_excl = i + 1;
254 break;
255 }
256 }
257 _ => {}
258 }
259 }
260 if end_excl == start {
261 return ReflectOutput::default();
262 }
263 let json_slice = &decoded[start..end_excl];
264
265 #[derive(Deserialize)]
266 struct Wire {
267 #[serde(default)]
268 invalidate: Vec<String>,
269 #[serde(default)]
270 boost: Vec<String>,
271 #[serde(default)]
272 pre_warm_query: Option<String>,
273 }
274 match serde_json::from_str::<Wire>(json_slice) {
275 Ok(w) => ReflectOutput {
276 invalidate: w
277 .invalidate
278 .into_iter()
279 .filter_map(|s| Ulid::from_string(&s).ok())
280 .collect(),
281 boost: w.boost.into_iter().filter_map(|s| Ulid::from_string(&s).ok()).collect(),
282 pre_warm_query: w.pre_warm_query.filter(|s| !s.is_empty()),
283 },
284 Err(e) => {
285 tracing::warn!(err = %e, "LlmReflectSupervisor JSON parse failed; emitting empty");
286 ReflectOutput::default()
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 struct StubBackend {
296 out: String,
297 }
298
299 #[async_trait]
300 impl LlmBackend for StubBackend {
301 async fn generate(
302 &self,
303 _prompt: &str,
304 _constraint: SchemaConstraint<'_>,
305 _opts: GenOpts,
306 ) -> Result<String, LunarisError> {
307 Ok(self.out.clone())
308 }
309 fn model_id(&self) -> &str {
310 "stub://reflect"
311 }
312 }
313
314 #[tokio::test]
315 async fn noop_supervisor_returns_empty() {
316 let s = NoopReflectSupervisor;
317 let out = s.reflect(ReflectInput::default()).await.unwrap();
318 assert_eq!(out, ReflectOutput::default());
319 assert!(!s.applies());
320 }
321
322 #[tokio::test]
323 async fn parses_valid_reflect_json() {
324 let fact = Ulid::new();
325 let chunk = Ulid::new();
326 let out_json = format!(
327 r#"{{"invalidate":["{fact}"],"boost":["{chunk}"],"pre_warm_query":"who is Alice?"}}"#
328 );
329 let backend: Arc<dyn LlmBackend> = Arc::new(StubBackend { out: out_json });
330 let supervisor = LlmReflectSupervisor::new(backend);
331 let out = supervisor
332 .reflect(ReflectInput {
333 turn_summary: "agent answered question".into(),
334 ..ReflectInput::default()
335 })
336 .await
337 .unwrap();
338 assert_eq!(out.invalidate, vec![fact]);
339 assert_eq!(out.boost, vec![chunk]);
340 assert_eq!(out.pre_warm_query.as_deref(), Some("who is Alice?"));
341 }
342
343 #[tokio::test]
344 async fn malformed_output_emits_empty() {
345 let backend: Arc<dyn LlmBackend> =
346 Arc::new(StubBackend { out: "definitely not json".into() });
347 let supervisor = LlmReflectSupervisor::new(backend);
348 let out = supervisor.reflect(ReflectInput::default()).await.unwrap();
349 assert_eq!(out, ReflectOutput::default());
350 }
351
352 #[tokio::test]
353 async fn invalid_ulids_in_output_are_dropped() {
354 let valid = Ulid::new();
355 let out_json = format!(
356 r#"{{"invalidate":["{valid}","not-a-ulid"],"boost":[],"pre_warm_query":null}}"#
357 );
358 let backend: Arc<dyn LlmBackend> = Arc::new(StubBackend { out: out_json });
359 let supervisor = LlmReflectSupervisor::new(backend);
360 let out = supervisor.reflect(ReflectInput::default()).await.unwrap();
361 assert_eq!(out.invalidate, vec![valid]);
362 assert!(out.boost.is_empty());
363 assert!(out.pre_warm_query.is_none());
364 }
365
366 #[test]
367 fn output_schema_has_required_fields() {
368 let schema = output_schema();
369 let required = schema["required"].as_array().unwrap();
370 let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
371 assert!(names.contains(&"invalidate"));
372 assert!(names.contains(&"boost"));
373 assert!(names.contains(&"pre_warm_query"));
374 }
375}