use super::{Tool, ToolResult};
use crate::provider::{ContentPart, Message, Role};
use crate::session::Session;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{Value, json};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextBrowseAction {
ListTurns,
ShowTurn { turn: usize },
}
pub fn parse_browse_action(args: &Value) -> Result<ContextBrowseAction, String> {
let action = args.get("action").and_then(Value::as_str).unwrap_or("list");
match action {
"list" | "list_turns" => Ok(ContextBrowseAction::ListTurns),
"show" | "show_turn" => {
let turn = args
.get("turn")
.and_then(Value::as_u64)
.ok_or_else(|| "`turn` (integer) is required for show_turn".to_string())?
as usize;
Ok(ContextBrowseAction::ShowTurn { turn })
}
other => Err(format!("unknown action: {other}")),
}
}
pub fn format_turn_path(session_id: &str, turn: usize, role: &str) -> String {
format!("session://{session_id}/turn-{turn:04}-{role}.md")
}
fn role_label(role: &Role) -> &'static str {
match role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
}
}
fn render_turn(msg: &Message) -> String {
let mut buf = String::new();
for part in &msg.content {
if !buf.is_empty() {
buf.push_str("\n\n");
}
match part {
ContentPart::Text { text } => buf.push_str(text),
ContentPart::ToolResult {
tool_call_id,
content,
} => {
buf.push_str(&format!("[tool_result tool_call_id={tool_call_id}]\n"));
buf.push_str(content);
}
ContentPart::ToolCall {
name, arguments, ..
} => {
buf.push_str(&format!("[tool_call {name}]\n{arguments}"));
}
ContentPart::Image { url, .. } => {
buf.push_str(&format!("[image {url}]"));
}
ContentPart::File { path, .. } => {
buf.push_str(&format!("[file {path}]"));
}
ContentPart::Thinking { text } => {
buf.push_str(&format!("[thinking]\n{text}"));
}
}
}
buf
}
fn render_listing(session_id: &str, messages: &[Message]) -> String {
let mut out = String::new();
for (idx, msg) in messages.iter().enumerate() {
out.push_str(&format_turn_path(session_id, idx, role_label(&msg.role)));
out.push('\n');
}
out
}
pub struct ContextBrowseTool;
#[async_trait]
impl Tool for ContextBrowseTool {
fn id(&self) -> &str {
"context_browse"
}
fn name(&self) -> &str {
"ContextBrowse"
}
fn description(&self) -> &str {
"BROWSE YOUR OWN HISTORY AS A FILESYSTEM (Meta-Harness, \
arXiv:2603.28052). Lists virtual paths like \
`session://<id>/turn-NNNN-<role>.md` — one per turn in the \
canonical transcript — and returns the body of any specific \
turn on request. Use this when the active context doesn't \
have what you need but you suspect it was said earlier. \
Distinct from `session_recall` (RLM-summarised archive) and \
`memory` (curated notes). Actions: `list` (default) or \
`show_turn` with an integer `turn`."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "list_turns", "show", "show_turn"],
"description": "Operation: 'list' enumerates turn paths, \
'show_turn' returns one turn body."
},
"turn": {
"type": "integer",
"description": "Zero-based turn index, required with \
action='show_turn'.",
"minimum": 0
}
},
"required": []
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let action = match parse_browse_action(&args) {
Ok(a) => a,
Err(e) => return Ok(ToolResult::error(e)),
};
let session = match latest_session_for_cwd().await {
Ok(Some(s)) => s,
Ok(None) => {
return Ok(ToolResult::error(
"No session found for the current workspace.",
));
}
Err(e) => return Ok(ToolResult::error(format!("failed to load session: {e}"))),
};
let messages = session.history();
match action {
ContextBrowseAction::ListTurns => {
Ok(ToolResult::success(render_listing(&session.id, messages))
.truncate_to(super::tool_output_budget()))
}
ContextBrowseAction::ShowTurn { turn } => {
match messages.get(turn) {
Some(msg) => Ok(ToolResult::success(render_turn(msg))
.truncate_to(super::tool_output_budget())),
None => Ok(ToolResult::error(format!(
"turn {turn} out of range (have {} entries)",
messages.len()
))),
}
}
}
}
}
async fn latest_session_for_cwd() -> Result<Option<Session>> {
let cwd = std::env::current_dir().ok();
let workspace = cwd.as_deref();
match Session::last_for_directory(workspace).await {
Ok(s) => Ok(Some(s)),
Err(err) => {
let msg = err.to_string().to_lowercase();
if msg.contains("no session")
|| msg.contains("not found")
|| msg.contains("no such file")
{
tracing::debug!(%err, "context_browse: no session for workspace");
Ok(None)
} else {
tracing::warn!(%err, "context_browse: failed to load latest session");
Err(err)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_browse_action_defaults_to_list() {
let action = parse_browse_action(&json!({})).unwrap();
assert!(matches!(action, ContextBrowseAction::ListTurns));
}
#[test]
fn parse_browse_action_requires_turn_for_show() {
let err = parse_browse_action(&json!({"action": "show_turn"})).unwrap_err();
assert!(err.contains("turn"));
}
#[test]
fn parse_browse_action_rejects_unknown() {
let err = parse_browse_action(&json!({"action": "truncate"})).unwrap_err();
assert!(err.contains("unknown"));
}
#[test]
fn format_turn_path_pads_index_to_four_digits() {
assert_eq!(
format_turn_path("abc-123", 0, "user"),
"session://abc-123/turn-0000-user.md"
);
assert_eq!(
format_turn_path("abc-123", 9999, "assistant"),
"session://abc-123/turn-9999-assistant.md"
);
}
fn text(role: Role, s: &str) -> Message {
Message {
role,
content: vec![ContentPart::Text {
text: s.to_string(),
}],
}
}
#[test]
fn render_listing_emits_one_path_per_turn() {
let msgs = vec![
text(Role::User, "hi"),
text(Role::Assistant, "hello"),
text(Role::User, "more"),
];
let listing = render_listing("sid", &msgs);
assert_eq!(listing.lines().count(), 3);
assert!(listing.contains("session://sid/turn-0000-user.md"));
assert!(listing.contains("session://sid/turn-0001-assistant.md"));
assert!(listing.contains("session://sid/turn-0002-user.md"));
}
#[test]
fn render_turn_preserves_text_body() {
let body = render_turn(&text(Role::User, "quick brown fox"));
assert_eq!(body, "quick brown fox");
}
}