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