use super::{Tool, ToolResult};
use crate::provider::Provider;
use crate::rlm::router::AutoProcessContext;
use crate::rlm::{RlmConfig, RlmRouter};
use crate::session::Fault;
use crate::session::Session;
use crate::session::helper::error::messages_to_rlm_context;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{Value, json};
use std::sync::Arc;
const DEFAULT_SESSION_LIMIT: usize = 3;
pub struct SessionRecallTool {
provider: Arc<dyn Provider>,
model: String,
config: RlmConfig,
}
impl SessionRecallTool {
pub fn new(provider: Arc<dyn Provider>, model: String, config: RlmConfig) -> Self {
Self {
provider,
model,
config,
}
}
}
#[async_trait]
impl Tool for SessionRecallTool {
fn id(&self) -> &str {
"session_recall"
}
fn name(&self) -> &str {
"SessionRecall"
}
fn description(&self) -> &str {
"RECALL FROM YOUR OWN PAST SESSIONS. Call this whenever:\n\
- You see `[AUTO CONTEXT COMPRESSION]` in the transcript and need \
specifics the summary dropped (exact paths, prior tool output, \
earlier user instructions, numbers).\n\
- The user references something from earlier (\"like I said before\", \
\"the task I gave you yesterday\", \"that file we edited\") and you \
cannot find it in your active context.\n\
- You feel uncertain about prior decisions in this workspace.\n\
Do NOT ask the user to repeat themselves — call this tool first. \
It runs RLM over `.codetether-agent/sessions/` for the current \
workspace. Pass a natural-language `query` describing what you \
need to remember. Optionally pin `session_id` or widen `limit` \
(recent sessions to include, default 3). Distinct from the \
`memory` tool: `memory` is curated notes you wrote; \
`session_recall` is the raw conversation archive."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural-language question about past session content"
},
"session_id": {
"type": "string",
"description": "Specific session UUID to recall from. \
When omitted, searches the most recent \
sessions for the current workspace."
},
"limit": {
"type": "integer",
"description": "How many recent sessions to include when \
session_id is not set (default 3).",
"default": 3
}
},
"required": ["query"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let query = match args["query"].as_str() {
Some(q) if !q.trim().is_empty() => q.to_string(),
_ => return Ok(ToolResult::error("query is required")),
};
let session_id = args["session_id"].as_str().map(str::to_string);
let limit = args["limit"]
.as_u64()
.map(|n| n as usize)
.unwrap_or(DEFAULT_SESSION_LIMIT)
.max(1);
let (context, sources) = match build_recall_context(session_id, limit).await {
Ok(ok) => ok,
Err(e) => {
let fault = fault_from_error(&e);
return Ok(fault_result(fault, format!("recall load failed: {e}")));
}
};
if context.trim().is_empty() {
return Ok(fault_result(
Fault::NoMatch,
"No prior session history found for this workspace.",
));
}
run_recall(
&context,
&sources,
&query,
Arc::clone(&self.provider),
&self.model,
&self.config,
)
.await
}
}
fn fault_from_error(err: &anyhow::Error) -> Fault {
let message = err.to_string();
let lowered = message.to_ascii_lowercase();
if lowered.contains("no session")
|| lowered.contains("not found")
|| lowered.contains("no such file")
{
Fault::NoMatch
} else {
Fault::BackendError { reason: message }
}
}
fn fault_result(fault: Fault, output: impl Into<String>) -> ToolResult {
let code = fault.code();
let detail = fault.to_string();
ToolResult::error(output)
.with_metadata("fault_code", json!(code))
.with_metadata("fault_detail", json!(detail))
}
async fn build_recall_context(
session_id: Option<String>,
limit: usize,
) -> Result<(String, Vec<String>)> {
let sessions = match session_id {
Some(id) => vec![Session::load(&id).await?],
None => load_recent_for_cwd(limit).await?,
};
let mut ctx = String::new();
let mut sources = Vec::with_capacity(sessions.len());
for s in &sessions {
let label = format!("{} ({})", s.title.as_deref().unwrap_or("<untitled>"), &s.id);
sources.push(label.clone());
ctx.push_str(&format!(
"\n===== SESSION {label} — updated {} =====\n",
s.updated_at
));
ctx.push_str(&messages_to_rlm_context(&s.messages));
}
Ok((ctx, sources))
}
async fn load_recent_for_cwd(limit: usize) -> Result<Vec<Session>> {
let cwd = std::env::current_dir().ok();
let summaries = match cwd.as_deref() {
Some(dir) => crate::session::listing::list_sessions_for_directory(dir).await?,
None => crate::session::list_sessions().await?,
};
let mut loaded = Vec::new();
for s in summaries.into_iter().take(limit) {
match Session::load(&s.id).await {
Ok(sess) => loaded.push(sess),
Err(e) => tracing::warn!(session_id = %s.id, error = %e, "session_recall: load failed"),
}
}
Ok(loaded)
}
async fn run_recall(
context: &str,
sources: &[String],
query: &str,
provider: Arc<dyn Provider>,
model: &str,
config: &RlmConfig,
) -> Result<ToolResult> {
let auto_ctx = AutoProcessContext {
tool_id: "session_recall",
tool_args: json!({ "query": query }),
session_id: "session-recall-tool",
abort: None,
on_progress: None,
provider,
model: model.to_string(),
bus: None,
trace_id: None,
subcall_provider: None,
subcall_model: None,
};
let framed = format!(
"Recall task: {query}\n\n\
Use the session transcript below to answer the recall task. \
Quote short passages verbatim when useful; otherwise summarise.\n\n\
{context}"
);
match RlmRouter::auto_process(&framed, auto_ctx, config).await {
Ok(result) => Ok(ToolResult::success(format!(
"Recalled from {} session(s): {}\n\
(RLM: {} → {} tokens, {} iterations)\n\n{}",
sources.len(),
sources.join(", "),
result.stats.input_tokens,
result.stats.output_tokens,
result.stats.iterations,
result.processed
))),
Err(e) => Ok(fault_result(
Fault::BackendError {
reason: e.to_string(),
},
format!("RLM recall failed: {e}"),
)),
}
}