codetether_agent/session/
relevance.rs1use serde::{Deserialize, Serialize};
54
55use crate::provider::{ContentPart, Message};
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct RelevanceMeta {
65 #[serde(default)]
67 pub files: Vec<String>,
68 #[serde(default)]
70 pub tools: Vec<String>,
71 #[serde(default)]
74 pub error_classes: Vec<String>,
75 #[serde(default)]
78 pub explicit_refs: Vec<usize>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum Difficulty {
85 Easy,
86 Medium,
87 Hard,
88}
89
90impl Difficulty {
91 pub const fn as_str(self) -> &'static str {
94 match self {
95 Difficulty::Easy => "easy",
96 Difficulty::Medium => "medium",
97 Difficulty::Hard => "hard",
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum Dependency {
106 Isolated,
108 Chained,
110}
111
112impl Dependency {
113 pub const fn as_str(self) -> &'static str {
115 match self {
116 Dependency::Isolated => "isolated",
117 Dependency::Chained => "chained",
118 }
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum ToolUse {
126 No,
127 Yes,
128}
129
130impl ToolUse {
131 pub const fn as_str(self) -> &'static str {
133 match self {
134 ToolUse::No => "no",
135 ToolUse::Yes => "yes",
136 }
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Bucket {
146 pub difficulty: Difficulty,
147 pub dependency: Dependency,
148 pub tool_use: ToolUse,
149}
150
151impl RelevanceMeta {
152 pub fn project_bucket(&self) -> Bucket {
181 let tool_use = if self.tools.is_empty() {
182 ToolUse::No
183 } else {
184 ToolUse::Yes
185 };
186 let dependency = if self.files.len() >= 2 || self.files.iter().any(|f| f.contains('/')) {
187 Dependency::Chained
188 } else {
189 Dependency::Isolated
190 };
191 let difficulty = match self.error_classes.len() {
192 0 => Difficulty::Easy,
193 1 | 2 => Difficulty::Medium,
194 _ => Difficulty::Hard,
195 };
196 Bucket {
197 difficulty,
198 dependency,
199 tool_use,
200 }
201 }
202}
203
204const ERROR_MARKERS: &[&str] = &[
209 "error:",
210 "error[e",
211 "failed",
212 "panicked",
213 "traceback",
214 "stack trace",
215];
216
217pub fn extract(msg: &Message) -> RelevanceMeta {
222 let mut meta = RelevanceMeta::default();
223 for part in &msg.content {
224 match part {
225 ContentPart::Text { text } => {
226 append_files(text, &mut meta.files);
227 append_error_classes(text, &mut meta.error_classes);
228 }
229 ContentPart::ToolCall { name, .. } => {
230 if !meta.tools.contains(name) {
231 meta.tools.push(name.clone());
232 }
233 }
234 ContentPart::ToolResult { content, .. } => {
235 append_error_classes(content, &mut meta.error_classes);
236 }
237 _ => {}
238 }
239 }
240 dedupe_preserving_order(&mut meta.files);
241 dedupe_preserving_order(&mut meta.error_classes);
242 meta
243}
244
245pub fn bucket_for_messages(messages: &[Message]) -> Bucket {
251 let start = messages.len().saturating_sub(8);
252 let mut merged = RelevanceMeta::default();
253 for msg in &messages[start..] {
254 let next = extract(msg);
255 merged.files.extend(next.files);
256 merged.tools.extend(next.tools);
257 merged.error_classes.extend(next.error_classes);
258 merged.explicit_refs.extend(next.explicit_refs);
259 }
260 dedupe_preserving_order(&mut merged.files);
261 dedupe_preserving_order(&mut merged.tools);
262 dedupe_preserving_order(&mut merged.error_classes);
263 merged.project_bucket()
264}
265
266fn append_files(text: &str, out: &mut Vec<String>) {
274 for raw in text.split(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | '(' | ')' | '`')) {
275 let trimmed = raw.trim_matches(|c: char| matches!(c, '"' | '\'' | '.'));
276 if trimmed.is_empty() || trimmed.len() < 3 {
277 continue;
278 }
279 let looks_like_path =
280 (trimmed.contains('/') && !trimmed.contains("://") && trimmed.len() > 3)
281 || ends_with_source_ext(trimmed);
282 if looks_like_path && !out.contains(&trimmed.to_string()) {
283 out.push(trimmed.to_string());
284 }
285 }
286}
287
288fn ends_with_source_ext(s: &str) -> bool {
289 [
290 ".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".md", ".json", ".toml", ".yaml",
291 ".yml", ".html", ".css", ".c", ".cpp", ".h", ".hpp",
292 ]
293 .iter()
294 .any(|ext| s.ends_with(ext))
295}
296
297fn append_error_classes(text: &str, out: &mut Vec<String>) {
298 let lower = text.to_lowercase();
299 for marker in ERROR_MARKERS {
300 if lower.contains(marker) {
301 let tag = marker.trim_end_matches(':').to_string();
302 if !out.contains(&tag) {
303 out.push(tag);
304 }
305 }
306 }
307}
308
309fn dedupe_preserving_order(items: &mut Vec<String>) {
310 let mut seen: std::collections::HashSet<String> =
311 std::collections::HashSet::with_capacity(items.len());
312 items.retain(|item| seen.insert(item.clone()));
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::provider::{ContentPart, Role};
319
320 fn text(s: &str) -> Message {
321 Message {
322 role: Role::Assistant,
323 content: vec![ContentPart::Text {
324 text: s.to_string(),
325 }],
326 }
327 }
328
329 fn tool_call(name: &str) -> Message {
330 Message {
331 role: Role::Assistant,
332 content: vec![ContentPart::ToolCall {
333 id: "call-1".to_string(),
334 name: name.to_string(),
335 arguments: "{}".to_string(),
336 thought_signature: None,
337 }],
338 }
339 }
340
341 fn tool_result(body: &str) -> Message {
342 Message {
343 role: Role::Tool,
344 content: vec![ContentPart::ToolResult {
345 tool_call_id: "call-1".to_string(),
346 content: body.to_string(),
347 }],
348 }
349 }
350
351 #[test]
352 fn extract_picks_up_paths_and_dedupes() {
353 let meta = extract(&text(
354 "Edited src/lib.rs and src/lib.rs again, plus tests/a.rs",
355 ));
356 assert_eq!(meta.files.len(), 2);
357 assert!(meta.files.contains(&"src/lib.rs".to_string()));
358 assert!(meta.files.contains(&"tests/a.rs".to_string()));
359 }
360
361 #[test]
362 fn extract_recognises_source_extensions_without_slash() {
363 let meta = extract(&text("check lib.rs and index.tsx"));
364 assert!(meta.files.iter().any(|f| f == "lib.rs"));
365 assert!(meta.files.iter().any(|f| f == "index.tsx"));
366 }
367
368 #[test]
369 fn extract_captures_tool_names_from_tool_calls() {
370 let meta = extract(&tool_call("Shell"));
371 assert_eq!(meta.tools, vec!["Shell".to_string()]);
372 }
373
374 #[test]
375 fn extract_tags_error_markers_from_tool_results() {
376 let meta = extract(&tool_result(
377 "Error: file not found\n panicked at main.rs:12",
378 ));
379 assert!(meta.error_classes.contains(&"error".to_string()));
380 assert!(meta.error_classes.contains(&"panicked".to_string()));
381 }
382
383 #[test]
384 fn project_bucket_maps_axes_correctly() {
385 let meta = RelevanceMeta {
386 files: vec!["src/a.rs".into()],
387 tools: Vec::new(),
388 error_classes: Vec::new(),
389 explicit_refs: Vec::new(),
390 };
391 let bucket = meta.project_bucket();
392 assert_eq!(bucket.tool_use, ToolUse::No);
393 assert_eq!(bucket.dependency, Dependency::Chained); assert_eq!(bucket.difficulty, Difficulty::Easy);
395 }
396
397 #[test]
398 fn project_bucket_escalates_difficulty_with_error_count() {
399 let meta = RelevanceMeta {
400 error_classes: vec!["error".into(), "failed".into(), "panicked".into()],
401 ..Default::default()
402 };
403 assert_eq!(meta.project_bucket().difficulty, Difficulty::Hard);
404 }
405
406 #[test]
407 fn bucket_for_messages_merges_recent_window() {
408 let bucket = bucket_for_messages(&[
409 text("edited src/lib.rs"),
410 tool_call("Shell"),
411 tool_result("Error: broken"),
412 ]);
413 assert_eq!(bucket.tool_use, ToolUse::Yes);
414 assert_eq!(bucket.dependency, Dependency::Chained);
415 assert_eq!(bucket.difficulty, Difficulty::Medium);
416 }
417}