1use 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
30const DEFAULT_SESSION_LIMIT: usize = 3;
34
35pub 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
165async 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
192async 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
211async 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 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}