Skip to main content

codetether_agent/tool/
context_browse.rs

1//! `context_browse`: expose the session transcript as virtual files.
2//!
3//! ## Meta-Harness filesystem-as-history (Phase B step 21)
4//!
5//! Lee et al. (arXiv:2603.28052) demonstrate that agents given raw
6//! *execution traces* in a filesystem — retrievable via the grep /
7//! cat tools they already have — outperform agents given lossy
8//! summaries by **+15 accuracy points** on text classification (their
9//! Table 3). The ablation is blunt: scores-only 34.6 median, scores +
10//! summary 34.9 (essentially unchanged), full traces 50.0. Summaries
11//! "may even hurt by compressing away diagnostically useful details".
12//!
13//! This tool gives the agent the same primitive over *its own past
14//! turns*. Every entry in [`Session::messages`] is exposed as a path
15//! of the form:
16//!
17//! ```text
18//! session://<session-id>/turn-NNNN-<role>.md
19//! ```
20//!
21//! The agent's existing Shell / Read / Grep tools can already browse
22//! these paths once Phase A's history-sink backing store is populated;
23//! this tool is the *list + locate* layer on top of it.
24//!
25//! ## What it does
26//!
27//! * `list_turns` (default): returns the canonical paths for every
28//!   turn in the current session's history, one per line.
29//! * `show_turn`: returns the text body of a single turn by index.
30//!
31//! For a minimal first cut we surface the transcript from memory via
32//! [`Session::load`] — the disk format already matches what the agent
33//! would want to grep over. A future commit wires the same namespace
34//! to the MinIO-backed pointer resolver for long-horizon archives.
35//!
36//! ## Invariants
37//!
38//! * Read-only. This tool never mutates history.
39//! * Paths are stable: given an immutable `session.messages`, a path
40//!   refers to the same turn across runs.
41//!
42//! ## Examples
43//!
44//! ```rust
45//! use codetether_agent::tool::context_browse::{
46//!     ContextBrowseAction, format_turn_path, parse_browse_action,
47//! };
48//! use serde_json::json;
49//!
50//! assert_eq!(
51//!     format_turn_path("abc-123", 7, "user"),
52//!     "session://abc-123/turn-0007-user.md",
53//! );
54//!
55//! let action = parse_browse_action(&json!({})).unwrap();
56//! assert!(matches!(action, ContextBrowseAction::ListTurns));
57//!
58//! let action = parse_browse_action(&json!({"action": "show_turn", "turn": 3})).unwrap();
59//! assert!(matches!(action, ContextBrowseAction::ShowTurn { turn: 3 }));
60//! ```
61
62use super::{Tool, ToolResult};
63use crate::provider::{ContentPart, Message, Role};
64use crate::session::Session;
65use anyhow::Result;
66use async_trait::async_trait;
67use serde_json::{Value, json};
68
69/// Parsed form of the JSON `args` payload.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ContextBrowseAction {
72    /// Default action — enumerate every turn as a virtual path.
73    ListTurns,
74    /// Return the text body of the turn at `turn`.
75    ShowTurn { turn: usize },
76}
77
78/// Parse a tool-args [`Value`] into a typed [`ContextBrowseAction`].
79///
80/// Factored out so the parsing logic is unit-testable without routing
81/// through the full [`Tool`] trait machinery.
82pub fn parse_browse_action(args: &Value) -> Result<ContextBrowseAction, String> {
83    let action = args.get("action").and_then(Value::as_str).unwrap_or("list");
84    match action {
85        "list" | "list_turns" => Ok(ContextBrowseAction::ListTurns),
86        "show" | "show_turn" => {
87            let turn = args
88                .get("turn")
89                .and_then(Value::as_u64)
90                .ok_or_else(|| "`turn` (integer) is required for show_turn".to_string())?
91                as usize;
92            Ok(ContextBrowseAction::ShowTurn { turn })
93        }
94        other => Err(format!("unknown action: {other}")),
95    }
96}
97
98/// Produce the canonical virtual path for a turn.
99pub fn format_turn_path(session_id: &str, turn: usize, role: &str) -> String {
100    format!("session://{session_id}/turn-{turn:04}-{role}.md")
101}
102
103/// Lower-case role label used in the path.
104fn role_label(role: &Role) -> &'static str {
105    match role {
106        Role::System => "system",
107        Role::User => "user",
108        Role::Assistant => "assistant",
109        Role::Tool => "tool",
110    }
111}
112
113/// Render a message body as the text the agent would see if it opened
114/// the virtual file. Text and tool-result parts are concatenated in
115/// order; non-textual parts are replaced by a short placeholder so the
116/// path stays readable.
117fn render_turn(msg: &Message) -> String {
118    let mut buf = String::new();
119    for part in &msg.content {
120        if !buf.is_empty() {
121            buf.push_str("\n\n");
122        }
123        match part {
124            ContentPart::Text { text } => buf.push_str(text),
125            ContentPart::ToolResult {
126                tool_call_id,
127                content,
128            } => {
129                buf.push_str(&format!("[tool_result tool_call_id={tool_call_id}]\n"));
130                buf.push_str(content);
131            }
132            ContentPart::ToolCall {
133                name, arguments, ..
134            } => {
135                buf.push_str(&format!("[tool_call {name}]\n{arguments}"));
136            }
137            ContentPart::Image { url, .. } => {
138                buf.push_str(&format!("[image {url}]"));
139            }
140            ContentPart::File { path, .. } => {
141                buf.push_str(&format!("[file {path}]"));
142            }
143            ContentPart::Thinking { text } => {
144                buf.push_str(&format!("[thinking]\n{text}"));
145            }
146        }
147    }
148    buf
149}
150
151/// Render a listing of virtual paths, one per line.
152fn render_listing(session_id: &str, messages: &[Message]) -> String {
153    let mut out = String::new();
154    for (idx, msg) in messages.iter().enumerate() {
155        out.push_str(&format_turn_path(session_id, idx, role_label(&msg.role)));
156        out.push('\n');
157    }
158    out
159}
160
161/// Meta-Harness filesystem-as-history tool.
162pub struct ContextBrowseTool;
163
164#[async_trait]
165impl Tool for ContextBrowseTool {
166    fn id(&self) -> &str {
167        "context_browse"
168    }
169
170    fn name(&self) -> &str {
171        "ContextBrowse"
172    }
173
174    fn description(&self) -> &str {
175        "BROWSE YOUR OWN HISTORY AS A FILESYSTEM (Meta-Harness, \
176         arXiv:2603.28052). Lists virtual paths like \
177         `session://<id>/turn-NNNN-<role>.md` — one per turn in the \
178         canonical transcript — and returns the body of any specific \
179         turn on request. Use this when the active context doesn't \
180         have what you need but you suspect it was said earlier. \
181         Distinct from `session_recall` (RLM-summarised archive) and \
182         `memory` (curated notes). Actions: `list` (default) or \
183         `show_turn` with an integer `turn`."
184    }
185
186    fn parameters(&self) -> Value {
187        json!({
188            "type": "object",
189            "properties": {
190                "action": {
191                    "type": "string",
192                    "enum": ["list", "list_turns", "show", "show_turn"],
193                    "description": "Operation: 'list' enumerates turn paths, \
194                                    'show_turn' returns one turn body."
195                },
196                "turn": {
197                    "type": "integer",
198                    "description": "Zero-based turn index, required with \
199                                    action='show_turn'.",
200                    "minimum": 0
201                }
202            },
203            "required": []
204        })
205    }
206
207    async fn execute(&self, args: Value) -> Result<ToolResult> {
208        let action = match parse_browse_action(&args) {
209            Ok(a) => a,
210            Err(e) => return Ok(ToolResult::error(e)),
211        };
212        let session = match latest_session_for_cwd().await {
213            Ok(Some(s)) => s,
214            Ok(None) => {
215                return Ok(ToolResult::error(
216                    "No session found for the current workspace.",
217                ));
218            }
219            Err(e) => return Ok(ToolResult::error(format!("failed to load session: {e}"))),
220        };
221        let messages = session.history();
222        match action {
223            ContextBrowseAction::ListTurns => {
224                Ok(ToolResult::success(render_listing(&session.id, messages))
225                    .truncate_to(super::tool_output_budget()))
226            }
227            ContextBrowseAction::ShowTurn { turn } => {
228                match messages.get(turn) {
229                    Some(msg) => Ok(ToolResult::success(render_turn(msg))
230                        .truncate_to(super::tool_output_budget())),
231                    None => Ok(ToolResult::error(format!(
232                        "turn {turn} out of range (have {} entries)",
233                        messages.len()
234                    ))),
235                }
236            }
237        }
238    }
239}
240
241/// Resolve the session this tool should browse.
242///
243/// For Phase B v1 we browse the most recent session rooted at the
244/// current working directory — the same one `session_recall` uses.
245/// The agent-owning session is not yet threaded through the Tool
246/// trait; a future commit will switch to the in-memory live session
247/// once that signature lands.
248///
249/// Distinguishes "no sessions exist yet for this workspace" (returns
250/// `Ok(None)`) from real I/O or parse errors (returns `Err(...)`) so
251/// the caller can surface a `ToolResult::error` rather than silently
252/// masking a broken session store.
253async fn latest_session_for_cwd() -> Result<Option<Session>> {
254    let cwd = std::env::current_dir().ok();
255    let workspace = cwd.as_deref();
256    match Session::last_for_directory(workspace).await {
257        Ok(s) => Ok(Some(s)),
258        Err(err) => {
259            let msg = err.to_string().to_lowercase();
260            // `Session::last_for_directory` returns an error when no sessions
261            // exist for the workspace. Treat those as `Ok(None)` so the tool
262            // can report "no session found"; bubble everything else up.
263            if msg.contains("no session")
264                || msg.contains("not found")
265                || msg.contains("no such file")
266            {
267                tracing::debug!(%err, "context_browse: no session for workspace");
268                Ok(None)
269            } else {
270                tracing::warn!(%err, "context_browse: failed to load latest session");
271                Err(err)
272            }
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn parse_browse_action_defaults_to_list() {
283        let action = parse_browse_action(&json!({})).unwrap();
284        assert!(matches!(action, ContextBrowseAction::ListTurns));
285    }
286
287    #[test]
288    fn parse_browse_action_requires_turn_for_show() {
289        let err = parse_browse_action(&json!({"action": "show_turn"})).unwrap_err();
290        assert!(err.contains("turn"));
291    }
292
293    #[test]
294    fn parse_browse_action_rejects_unknown() {
295        let err = parse_browse_action(&json!({"action": "truncate"})).unwrap_err();
296        assert!(err.contains("unknown"));
297    }
298
299    #[test]
300    fn format_turn_path_pads_index_to_four_digits() {
301        assert_eq!(
302            format_turn_path("abc-123", 0, "user"),
303            "session://abc-123/turn-0000-user.md"
304        );
305        assert_eq!(
306            format_turn_path("abc-123", 9999, "assistant"),
307            "session://abc-123/turn-9999-assistant.md"
308        );
309    }
310
311    fn text(role: Role, s: &str) -> Message {
312        Message {
313            role,
314            content: vec![ContentPart::Text {
315                text: s.to_string(),
316            }],
317        }
318    }
319
320    #[test]
321    fn render_listing_emits_one_path_per_turn() {
322        let msgs = vec![
323            text(Role::User, "hi"),
324            text(Role::Assistant, "hello"),
325            text(Role::User, "more"),
326        ];
327        let listing = render_listing("sid", &msgs);
328        assert_eq!(listing.lines().count(), 3);
329        assert!(listing.contains("session://sid/turn-0000-user.md"));
330        assert!(listing.contains("session://sid/turn-0001-assistant.md"));
331        assert!(listing.contains("session://sid/turn-0002-user.md"));
332    }
333
334    #[test]
335    fn render_turn_preserves_text_body() {
336        let body = render_turn(&text(Role::User, "quick brown fox"));
337        assert_eq!(body, "quick brown fox");
338    }
339}