Skip to main content

codetether_agent/tool/
session_recall.rs

1//! `session_recall` tool: RLM-powered recall over the agent's own
2//! persisted session history in `.codetether-agent/sessions/`.
3//!
4//! When the model "forgets" something that happened earlier in this
5//! workspace (an earlier prompt, a tool result, a decision), it can
6//! call this tool with a natural-language query. The tool loads
7//! one-or-more recent sessions for the current workspace, flattens
8//! their messages through [`messages_to_rlm_context`], and runs
9//! [`RlmRouter::auto_process`] against the flattened transcript to
10//! produce a focused answer.
11//!
12//! This is distinct from the automatic compaction in
13//! [`crate::session::helper::compression`]: that compresses the
14//! *active* session's in-memory messages to fit the context window.
15//! This tool recalls from the *persisted* on-disk history, across
16//! sessions, on demand.
17
18use super::{Tool, ToolResult};
19use crate::provider::Provider;
20use crate::rlm::router::AutoProcessContext;
21use crate::rlm::{RlmConfig, RlmRouter};
22use crate::session::Fault;
23use crate::session::Session;
24use crate::session::helper::error::messages_to_rlm_context;
25use anyhow::Result;
26use async_trait::async_trait;
27use serde_json::{Value, json};
28use std::sync::Arc;
29
30/// Default number of recent sessions scanned when no `session_id` is
31/// supplied. Higher values produce more complete recall at the cost of
32/// a larger RLM input (which the router will still chunk + summarise).
33const DEFAULT_SESSION_LIMIT: usize = 3;
34
35/// RLM-backed recall tool over persisted session history.
36pub struct SessionRecallTool {
37    provider: Arc<dyn Provider>,
38    model: String,
39    config: RlmConfig,
40}
41
42impl SessionRecallTool {
43    pub fn new(provider: Arc<dyn Provider>, model: String, config: RlmConfig) -> Self {
44        Self {
45            provider,
46            model,
47            config,
48        }
49    }
50}
51
52#[async_trait]
53impl Tool for SessionRecallTool {
54    fn id(&self) -> &str {
55        "session_recall"
56    }
57
58    fn name(&self) -> &str {
59        "SessionRecall"
60    }
61
62    fn description(&self) -> &str {
63        "RECALL FROM YOUR OWN PAST SESSIONS. Call this whenever:\n\
64         - You see `[AUTO CONTEXT COMPRESSION]` in the transcript and need \
65           specifics the summary dropped (exact paths, prior tool output, \
66           earlier user instructions, numbers).\n\
67         - The user references something from earlier (\"like I said before\", \
68           \"the task I gave you yesterday\", \"that file we edited\") and you \
69           cannot find it in your active context.\n\
70         - You feel uncertain about prior decisions in this workspace.\n\
71         Do NOT ask the user to repeat themselves — call this tool first. \
72         It runs RLM over `.codetether-agent/sessions/` for the current \
73         workspace. Pass a natural-language `query` describing what you \
74         need to remember. Optionally pin `session_id` or widen `limit` \
75         (recent sessions to include, default 3). Distinct from the \
76         `memory` tool: `memory` is curated notes you wrote; \
77         `session_recall` is the raw conversation archive."
78    }
79
80    fn parameters(&self) -> Value {
81        json!({
82            "type": "object",
83            "properties": {
84                "query": {
85                    "type": "string",
86                    "description": "Natural-language question about past session content"
87                },
88                "session_id": {
89                    "type": "string",
90                    "description": "Specific session UUID to recall from. \
91                                    When omitted, searches the most recent \
92                                    sessions for the current workspace."
93                },
94                "limit": {
95                    "type": "integer",
96                    "description": "How many recent sessions to include when \
97                                    session_id is not set (default 3).",
98                    "default": 3
99                }
100            },
101            "required": ["query"]
102        })
103    }
104
105    async fn execute(&self, args: Value) -> Result<ToolResult> {
106        let query = match args["query"].as_str() {
107            Some(q) if !q.trim().is_empty() => q.to_string(),
108            _ => return Ok(ToolResult::error("query is required")),
109        };
110        let session_id = args["session_id"].as_str().map(str::to_string);
111        let limit = args["limit"]
112            .as_u64()
113            .map(|n| n as usize)
114            .unwrap_or(DEFAULT_SESSION_LIMIT)
115            .max(1);
116
117        let (context, sources) = match build_recall_context(session_id, limit).await {
118            Ok(ok) => ok,
119            Err(e) => {
120                let fault = fault_from_error(&e);
121                return Ok(fault_result(fault, format!("recall load failed: {e}")));
122            }
123        };
124
125        if context.trim().is_empty() {
126            return Ok(fault_result(
127                Fault::NoMatch,
128                "No prior session history found for this workspace.",
129            ));
130        }
131
132        run_recall(
133            &context,
134            &sources,
135            &query,
136            Arc::clone(&self.provider),
137            &self.model,
138            &self.config,
139        )
140        .await
141    }
142}
143
144fn fault_from_error(err: &anyhow::Error) -> Fault {
145    let message = err.to_string();
146    let lowered = message.to_ascii_lowercase();
147    if lowered.contains("no session")
148        || lowered.contains("not found")
149        || lowered.contains("no such file")
150    {
151        Fault::NoMatch
152    } else {
153        Fault::BackendError { reason: message }
154    }
155}
156
157fn fault_result(fault: Fault, output: impl Into<String>) -> ToolResult {
158    let code = fault.code();
159    let detail = fault.to_string();
160    ToolResult::error(output)
161        .with_metadata("fault_code", json!(code))
162        .with_metadata("fault_detail", json!(detail))
163}
164
165/// Load session transcripts and flatten them into an RLM-ready string.
166///
167/// Returns the concatenated context plus a vector of human-readable
168/// source labels (id + title) for the final tool output.
169async fn build_recall_context(
170    session_id: Option<String>,
171    limit: usize,
172) -> Result<(String, Vec<String>)> {
173    let sessions = match session_id {
174        Some(id) => vec![Session::load(&id).await?],
175        None => load_recent_for_cwd(limit).await?,
176    };
177
178    let mut ctx = String::new();
179    let mut sources = Vec::with_capacity(sessions.len());
180    for s in &sessions {
181        let label = format!("{} ({})", s.title.as_deref().unwrap_or("<untitled>"), &s.id);
182        sources.push(label.clone());
183        ctx.push_str(&format!(
184            "\n===== SESSION {label} — updated {} =====\n",
185            s.updated_at
186        ));
187        ctx.push_str(&messages_to_rlm_context(&s.messages));
188    }
189    Ok((ctx, sources))
190}
191
192/// Load the most recent `limit` sessions scoped to the current working
193/// directory. Falls back to a global scan when the cwd is unavailable.
194async fn load_recent_for_cwd(limit: usize) -> Result<Vec<Session>> {
195    let cwd = std::env::current_dir().ok();
196    let summaries = match cwd.as_deref() {
197        Some(dir) => crate::session::listing::list_sessions_for_directory(dir).await?,
198        None => crate::session::list_sessions().await?,
199    };
200
201    let mut loaded = Vec::new();
202    for s in summaries.into_iter().take(limit) {
203        match Session::load(&s.id).await {
204            Ok(sess) => loaded.push(sess),
205            Err(e) => tracing::warn!(session_id = %s.id, error = %e, "session_recall: load failed"),
206        }
207    }
208    Ok(loaded)
209}
210
211/// Invoke [`RlmRouter::auto_process`] against the flattened transcript
212/// and format the result for the tool caller.
213async fn run_recall(
214    context: &str,
215    sources: &[String],
216    query: &str,
217    provider: Arc<dyn Provider>,
218    model: &str,
219    config: &RlmConfig,
220) -> Result<ToolResult> {
221    let auto_ctx = AutoProcessContext {
222        tool_id: "session_recall",
223        tool_args: json!({ "query": query }),
224        session_id: "session-recall-tool",
225        abort: None,
226        on_progress: None,
227        provider,
228        model: model.to_string(),
229        bus: None,
230        trace_id: None,
231        subcall_provider: None,
232        subcall_model: None,
233    };
234
235    // Prepend the query so the router's summarisation stays focused on
236    // what the caller is actually trying to recall, instead of producing
237    // a generic summary of the transcript.
238    let framed = format!(
239        "Recall task: {query}\n\n\
240         Use the session transcript below to answer the recall task. \
241         Quote short passages verbatim when useful; otherwise summarise.\n\n\
242         {context}"
243    );
244
245    match RlmRouter::auto_process(&framed, auto_ctx, config).await {
246        Ok(result) => Ok(ToolResult::success(format!(
247            "Recalled from {} session(s): {}\n\
248             (RLM: {} → {} tokens, {} iterations)\n\n{}",
249            sources.len(),
250            sources.join(", "),
251            result.stats.input_tokens,
252            result.stats.output_tokens,
253            result.stats.iterations,
254            result.processed
255        ))),
256        Err(e) => Ok(fault_result(
257            Fault::BackendError {
258                reason: e.to_string(),
259            },
260            format!("RLM recall failed: {e}"),
261        )),
262    }
263}