Skip to main content

codetether_agent/session/
relevance.rs

1//! Per-entry relevance metadata and CADMAS-CTX bucket projection.
2//!
3//! ## Phase B foundation
4//!
5//! The Liu et al. paper (arXiv:2512.22087) calls for a per-entry
6//! sidecar of relevance signals that an incremental derivation policy
7//! can score against the current task. The CADMAS-CTX paper
8//! (arXiv:2604.17950) independently needs a coarse **context bucket**
9//! `z = (difficulty, dependency, tool_use)` to key its per-(agent,
10//! skill, bucket) posteriors.
11//!
12//! Both consumers share ~80 % of the extraction work — file paths,
13//! tool names, error-class markers — so this module emits a single
14//! [`RelevanceMeta`] that projects down to a [`Bucket`] via
15//! [`RelevanceMeta::project_bucket`]. Phase B's `DerivePolicy::Incremental`
16//! and Phase C's `DelegationState` both read from the same sidecar.
17//!
18//! ## Scope in Phase B step 15
19//!
20//! Extraction is **pure and syntactic** — no LLM calls, no IO. Heuristics:
21//!
22//! * `files`: regex over text parts for path-like tokens.
23//! * `tools`: names of `ToolCall` / `ToolResult` content parts.
24//! * `error_classes`: leading tokens of common error markers
25//!   (`Error:`, `error[E`, `failed`, `panicked`, `traceback`).
26//! * `explicit_refs`: left for future turns-N-reference extraction
27//!   (empty in this first cut).
28//!
29//! Keeping it syntactic means the extractor can run in the append hot
30//! path (`Session::add_message`) without blocking.
31//!
32//! ## Examples
33//!
34//! ```rust
35//! use codetether_agent::provider::{ContentPart, Message, Role};
36//! use codetether_agent::session::relevance::{Bucket, Dependency, Difficulty, RelevanceMeta, ToolUse, extract};
37//!
38//! let msg = Message {
39//!     role: Role::Assistant,
40//!     content: vec![ContentPart::Text {
41//!         text: "Edited src/lib.rs and tests/smoke.rs".to_string(),
42//!     }],
43//! };
44//! let meta: RelevanceMeta = extract(&msg);
45//! assert_eq!(meta.files.len(), 2);
46//!
47//! let bucket: Bucket = meta.project_bucket();
48//! assert_eq!(bucket.tool_use, ToolUse::No);
49//! assert_eq!(bucket.dependency, Dependency::Chained);
50//! assert_eq!(bucket.difficulty, Difficulty::Easy);
51//! ```
52
53use serde::{Deserialize, Serialize};
54
55use crate::provider::{ContentPart, Message};
56
57/// Per-entry syntactic relevance signals.
58///
59/// Parallel-array friendly: one [`RelevanceMeta`] per entry in
60/// [`Session::messages`](crate::session::Session). Lives in a
61/// `<session-id>.relevance.jsonl` sidecar once wired into
62/// `Session::save` (a future commit).
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct RelevanceMeta {
65    /// Path-like tokens found in the message's text parts.
66    #[serde(default)]
67    pub files: Vec<String>,
68    /// Names of any `ToolCall` or `ToolResult` content parts.
69    #[serde(default)]
70    pub tools: Vec<String>,
71    /// Short error-class tags surfaced by common error markers
72    /// (`Error:`, `error[E`, `failed`, `panicked`, `Traceback`).
73    #[serde(default)]
74    pub error_classes: Vec<String>,
75    /// Message indices this entry explicitly references
76    /// (reserved for future N-back extraction; empty in Phase B v1).
77    #[serde(default)]
78    pub explicit_refs: Vec<usize>,
79}
80
81/// CADMAS-CTX difficulty axis.
82#[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    /// Stable snake_case encoding. Never renamed — used as part of the
92    /// persisted [`crate::session::delegation::DelegationState`] key.
93    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/// CADMAS-CTX dependency axis.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum Dependency {
106    /// Single file or a small set of files in the same module.
107    Isolated,
108    /// Cross-module / multi-file reach.
109    Chained,
110}
111
112impl Dependency {
113    /// Stable snake_case encoding — see [`Difficulty::as_str`].
114    pub const fn as_str(self) -> &'static str {
115        match self {
116            Dependency::Isolated => "isolated",
117            Dependency::Chained => "chained",
118        }
119    }
120}
121
122/// CADMAS-CTX tool-use axis.
123#[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    /// Stable snake_case encoding — see [`Difficulty::as_str`].
132    pub const fn as_str(self) -> &'static str {
133        match self {
134            ToolUse::No => "no",
135            ToolUse::Yes => "yes",
136        }
137    }
138}
139
140/// Coarse context bucket — CADMAS-CTX Section 3.1.
141///
142/// Start with 3–4 active cells in practice (the paper shows over-
143/// bucketing hurts — bias-variance collapses at 12 cells on GAIA).
144#[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    /// Project the relevance signals onto a coarse CADMAS-CTX bucket.
153    ///
154    /// Heuristic (Phase B v1):
155    ///
156    /// | Bucket field     | Rule                                                |
157    /// |------------------|-----------------------------------------------------|
158    /// | `tool_use`       | `Yes` when [`Self::tools`] is non-empty             |
159    /// | `dependency`     | `Chained` when ≥ 2 files, or any path has `/`       |
160    /// | `difficulty`     | errors-per-entry ladder: 0 → Easy, 1–2 → Medium, ≥3 → Hard |
161    ///
162    /// # Examples
163    ///
164    /// ```rust
165    /// use codetether_agent::session::relevance::{
166    ///     Dependency, Difficulty, RelevanceMeta, ToolUse,
167    /// };
168    ///
169    /// let meta = RelevanceMeta {
170    ///     files: vec!["src/a.rs".into(), "tests/b.rs".into()],
171    ///     tools: vec!["Shell".into()],
172    ///     error_classes: vec!["Error:".into(), "panicked".into(), "failed".into()],
173    ///     explicit_refs: Vec::new(),
174    /// };
175    /// let bucket = meta.project_bucket();
176    /// assert_eq!(bucket.tool_use, ToolUse::Yes);
177    /// assert_eq!(bucket.dependency, Dependency::Chained);
178    /// assert_eq!(bucket.difficulty, Difficulty::Hard);
179    /// ```
180    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
204/// Short list of error-marker prefixes (lower-cased) we match literally.
205///
206/// Kept tiny and conservative — false positives in Phase C's delegation
207/// posteriors are more expensive than false negatives.
208const ERROR_MARKERS: &[&str] = &[
209    "error:",
210    "error[e",
211    "failed",
212    "panicked",
213    "traceback",
214    "stack trace",
215];
216
217/// Extract [`RelevanceMeta`] for a single chat-history entry.
218///
219/// Pure and fast: no LLM calls, no IO. Safe to call from
220/// [`Session::add_message`](crate::session::Session::add_message).
221pub 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
245/// Project a coarse CADMAS-CTX bucket from a recent message window.
246///
247/// Merges the last few entries into a single synthetic
248/// [`RelevanceMeta`] so routing decisions can key off the active task
249/// rather than a single arbitrary turn.
250pub 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
266/// Extract path-like tokens from `text` and append unique ones to `out`.
267///
268/// Heuristic: tokens that look like filesystem paths (contain `/` but
269/// not `://`, so URLs are excluded) or end with a common source-file
270/// extension. Intentionally conservative — this feeds
271/// [`Bucket`] projection, so false positives directly harm delegation
272/// calibration (over-reporting `Chained` dependency).
273fn 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); // single path contains '/'
394        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}