zeph_memory/semantic/
trajectory.rs1use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16use tokio::time::timeout;
17use zeph_common::llm_response::extract_json_array_slice;
18use zeph_llm::any::AnyProvider;
19use zeph_llm::provider::{LlmProvider as _, Message, Role};
20
21use crate::error::MemoryError;
22
23const EXTRACTION_SYSTEM_PROMPT: &str = "\
24You are a trajectory memory extractor. Given messages from an agent turn that included tool \
25calls, extract reusable patterns and notable events.
26
27Rules:
281. Classify each entry as 'procedural' (a reusable how-to pattern — general technique) or \
29 'episodic' (a one-off event — specific occurrence).
302. Focus on the intent (goal), outcome (result), and tools used.
313. Confidence: 0.8-1.0 for clear outcomes, 0.4-0.7 for ambiguous ones.
324. Keep intent and outcome concise (one sentence each).
335. Return empty array if no meaningful entries can be extracted.
34
35Output JSON array:
36[
37 {
38 \"kind\": \"procedural|episodic\",
39 \"intent\": \"what the agent was trying to accomplish\",
40 \"outcome\": \"what actually happened\",
41 \"tools_used\": [\"tool_name\", ...],
42 \"confidence\": 0.0-1.0
43 }
44]";
45
46#[derive(Debug, Clone)]
48pub struct TrajectoryEntry {
49 pub kind: String,
50 pub intent: String,
51 pub outcome: String,
52 pub tools_used: Vec<String>,
53 pub confidence: f64,
54}
55
56pub struct TrajectoryExtractionConfig {
58 pub enabled: bool,
59 pub max_messages: usize,
61 pub extraction_timeout_secs: u64,
63}
64
65#[derive(Debug, Deserialize, Serialize)]
66struct RawEntry {
67 kind: String,
68 intent: String,
69 outcome: String,
70 #[serde(default)]
71 tools_used: Vec<String>,
72 confidence: f64,
73}
74
75pub async fn extract_trajectory_entries(
83 provider: &AnyProvider,
84 messages: &[Message],
85 config: &TrajectoryExtractionConfig,
86) -> Result<Vec<TrajectoryEntry>, MemoryError> {
87 if !config.enabled || messages.is_empty() {
88 return Ok(Vec::new());
89 }
90
91 let messages_to_send: Vec<&Message> = messages
92 .iter()
93 .rev()
94 .take(config.max_messages)
95 .collect::<Vec<_>>()
96 .into_iter()
97 .rev()
98 .collect();
99
100 let user_prompt = build_extraction_prompt(&messages_to_send);
101
102 let llm_messages = [
103 Message::from_legacy(Role::System, EXTRACTION_SYSTEM_PROMPT),
104 Message::from_legacy(Role::User, user_prompt),
105 ];
106
107 let extraction_timeout = Duration::from_secs(config.extraction_timeout_secs);
108 let response = match timeout(extraction_timeout, provider.chat(&llm_messages)).await {
109 Ok(Ok(text)) => text,
110 Ok(Err(e)) => return Err(MemoryError::Llm(e)),
111 Err(_) => {
112 tracing::warn!(
113 "trajectory extraction timed out after {}s",
114 config.extraction_timeout_secs
115 );
116 return Ok(Vec::new());
117 }
118 };
119
120 let entries = parse_extraction_response(&response);
121 Ok(entries)
122}
123
124fn build_extraction_prompt(messages: &[&Message]) -> String {
125 let mut prompt = String::from("Agent turn messages:\n");
126 for (i, msg) in messages.iter().enumerate() {
127 use std::fmt::Write as _;
128 let role = format!("{:?}", msg.role);
129 let _ = writeln!(prompt, "[{}] {}: {}", i + 1, role, msg.content);
130 }
131 prompt.push_str("\nExtract trajectory entries as JSON array.");
132 prompt
133}
134
135fn parse_extraction_response(response: &str) -> Vec<TrajectoryEntry> {
136 let raw: Vec<RawEntry> = if let Ok(v) = serde_json::from_str(response) {
137 v
138 } else if let Some(slice) = extract_json_array_slice(response) {
139 serde_json::from_str(slice).unwrap_or_default()
140 } else {
141 tracing::warn!(
142 "trajectory extraction: failed to parse response (len={}): {:.200}",
143 response.len(),
144 response
145 );
146 return Vec::new();
147 };
148
149 raw.into_iter()
150 .filter(|e| !e.intent.is_empty() && !e.outcome.is_empty())
151 .filter(|e| matches!(e.kind.as_str(), "procedural" | "episodic"))
152 .map(|e| TrajectoryEntry {
153 kind: e.kind,
154 intent: e.intent,
155 outcome: e.outcome,
156 tools_used: e.tools_used,
157 confidence: e.confidence.clamp(0.0, 1.0),
158 })
159 .collect()
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn parse_direct_json_array() {
168 let json = r#"[{"kind":"procedural","intent":"read a file","outcome":"file read ok","tools_used":["read_file"],"confidence":0.9}]"#;
169 let entries = parse_extraction_response(json);
170 assert_eq!(entries.len(), 1);
171 assert_eq!(entries[0].kind, "procedural");
172 assert_eq!(entries[0].intent, "read a file");
173 assert_eq!(entries[0].tools_used, vec!["read_file"]);
174 assert!((entries[0].confidence - 0.9).abs() < 1e-9);
175 }
176
177 #[test]
178 fn parse_json_embedded_in_prose() {
179 let response = "Here you go:\n[{\"kind\":\"episodic\",\"intent\":\"fixed a bug\",\"outcome\":\"patch applied\",\"tools_used\":[],\"confidence\":0.8}]\nDone.";
180 let entries = parse_extraction_response(response);
181 assert_eq!(entries.len(), 1);
182 assert_eq!(entries[0].kind, "episodic");
183 }
184
185 #[test]
186 fn parse_empty_array() {
187 let entries = parse_extraction_response("[]");
188 assert!(entries.is_empty());
189 }
190
191 #[test]
192 fn parse_invalid_json_returns_empty() {
193 let entries = parse_extraction_response("not json");
194 assert!(entries.is_empty());
195 }
196
197 #[test]
198 fn parse_filters_unknown_kind() {
199 let json =
200 r#"[{"kind":"unknown","intent":"x","outcome":"y","tools_used":[],"confidence":0.5}]"#;
201 let entries = parse_extraction_response(json);
202 assert!(entries.is_empty(), "unknown kind must be filtered out");
203 }
204
205 #[test]
206 fn parse_clamps_confidence() {
207 let json = r#"[{"kind":"procedural","intent":"x","outcome":"y","tools_used":[],"confidence":1.5}]"#;
208 let entries = parse_extraction_response(json);
209 assert_eq!(entries.len(), 1);
210 assert!(
211 (entries[0].confidence - 1.0).abs() < 1e-9,
212 "confidence must be clamped to 1.0"
213 );
214 }
215
216 #[test]
217 fn parse_filters_empty_intent_or_outcome() {
218 let json =
219 r#"[{"kind":"procedural","intent":"","outcome":"y","tools_used":[],"confidence":0.8}]"#;
220 let entries = parse_extraction_response(json);
221 assert!(entries.is_empty(), "empty intent must be filtered");
222 }
223}