Skip to main content

codetether_agent/tool/
context_browse.rs

1//! `context_browse`: expose the session transcript as real turn 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 materialized as a file
15//! under the workspace data dir:
16//!
17//! ```text
18//! .codetether-agent/history/<session-id>/turn-NNNN-<role>.md
19//! ```
20//!
21//! The agent's existing Shell / Read / Grep tools can browse those files
22//! directly after this tool materializes them; this tool is the *list +
23//! locate* layer on top of that directory.
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 std::path::Path;
46//!
47//! use codetether_agent::tool::context_browse::{ContextBrowseAction, format_turn_path, parse_browse_action};
48//! use serde_json::json;
49//!
50//! assert_eq!(
51//!     format_turn_path(Path::new("/tmp/history/abc-123"), 7, "user"),
52//!     Path::new("/tmp/history/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::session::Fault;
64use crate::session::Session;
65use crate::session::history_files::{materialize_session_history, render_turn};
66use anyhow::Result;
67use async_trait::async_trait;
68use serde_json::{Value, json};
69use std::path::{Path, PathBuf};
70
71/// Parsed form of the JSON `args` payload.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ContextBrowseAction {
74    /// Default action — enumerate every turn as a virtual path.
75    ListTurns,
76    /// Return the text body of the turn at `turn`.
77    ShowTurn { turn: usize },
78}
79
80/// Parse a tool-args [`Value`] into a typed [`ContextBrowseAction`].
81///
82/// Factored out so the parsing logic is unit-testable without routing
83/// through the full [`Tool`] trait machinery.
84pub fn parse_browse_action(args: &Value) -> Result<ContextBrowseAction, String> {
85    let action = args.get("action").and_then(Value::as_str).unwrap_or("list");
86    match action {
87        "list" | "list_turns" => Ok(ContextBrowseAction::ListTurns),
88        "show" | "show_turn" => {
89            let turn = args
90                .get("turn")
91                .and_then(Value::as_u64)
92                .ok_or_else(|| "`turn` (integer) is required for show_turn".to_string())?
93                as usize;
94            Ok(ContextBrowseAction::ShowTurn { turn })
95        }
96        other => Err(format!("unknown action: {other}")),
97    }
98}
99
100/// Produce the canonical materialized filesystem path for a turn.
101pub fn format_turn_path(session_dir: &Path, turn: usize, role: &str) -> PathBuf {
102    crate::session::history_files::format_turn_path(session_dir, turn, role)
103}
104
105/// Render a listing of materialized paths, one per line.
106fn render_listing(paths: &[PathBuf]) -> String {
107    paths
108        .iter()
109        .map(|path| path.display().to_string())
110        .collect::<Vec<_>>()
111        .join("\n")
112}
113
114/// Meta-Harness filesystem-as-history tool.
115pub struct ContextBrowseTool;
116
117#[async_trait]
118impl Tool for ContextBrowseTool {
119    fn id(&self) -> &str {
120        "context_browse"
121    }
122
123    fn name(&self) -> &str {
124        "ContextBrowse"
125    }
126
127    fn description(&self) -> &str {
128        "BROWSE YOUR OWN HISTORY AS A FILESYSTEM (Meta-Harness, \
129         arXiv:2603.28052). Materializes real files under \
130         `.codetether-agent/history/<session-id>/turn-NNNN-<role>.md` \
131         — one per turn in the canonical transcript — and returns the \
132         body of any specific turn on request. Use this when the active \
133         context doesn't have what you need but you suspect it was said earlier. \
134         Distinct from `session_recall` (RLM-summarised archive) and \
135         `memory` (curated notes). Actions: `list` (default) or \
136         `show_turn` with an integer `turn`."
137    }
138
139    fn parameters(&self) -> Value {
140        json!({
141            "type": "object",
142            "properties": {
143                "action": {
144                    "type": "string",
145                    "enum": ["list", "list_turns", "show", "show_turn"],
146                    "description": "Operation: 'list' enumerates turn paths, \
147                                    'show_turn' returns one turn body."
148                },
149                "turn": {
150                    "type": "integer",
151                    "description": "Zero-based turn index, required with \
152                                    action='show_turn'.",
153                    "minimum": 0
154                }
155            },
156            "required": []
157        })
158    }
159
160    async fn execute(&self, args: Value) -> Result<ToolResult> {
161        let action = match parse_browse_action(&args) {
162            Ok(a) => a,
163            Err(e) => return Ok(ToolResult::error(e)),
164        };
165        let session = match latest_session_for_cwd().await {
166            Ok(Some(s)) => s,
167            Ok(None) => {
168                return Ok(fault_result(
169                    Fault::NoMatch,
170                    "No session found for the current workspace.",
171                ));
172            }
173            Err(e) => {
174                return Ok(fault_result(
175                    Fault::BackendError {
176                        reason: e.to_string(),
177                    },
178                    format!("failed to load session: {e}"),
179                ));
180            }
181        };
182        let paths = match materialize_session_history(&session).await {
183            Ok(paths) => paths,
184            Err(err) => {
185                return Ok(fault_result(
186                    Fault::BackendError {
187                        reason: err.to_string(),
188                    },
189                    format!("failed to materialize history files: {err}"),
190                ));
191            }
192        };
193        let messages = session.history();
194        match action {
195            ContextBrowseAction::ListTurns => Ok(ToolResult::success(render_listing(&paths))
196                .with_metadata("session_id", json!(session.id))
197                .truncate_to(super::tool_output_budget())),
198            ContextBrowseAction::ShowTurn { turn } => match messages.get(turn) {
199                Some(msg) => Ok(ToolResult::success(render_turn(msg))
200                    .with_metadata(
201                        "path",
202                        json!(
203                            paths
204                                .get(turn)
205                                .map(|path| path.display().to_string())
206                                .unwrap_or_else(String::new)
207                        ),
208                    )
209                    .truncate_to(super::tool_output_budget())),
210                None => Ok(ToolResult::error(format!(
211                    "turn {turn} out of range (have {} entries)",
212                    messages.len()
213                ))),
214            },
215        }
216    }
217}
218
219fn fault_result(fault: Fault, output: impl Into<String>) -> ToolResult {
220    let code = fault.code();
221    let detail = fault.to_string();
222    ToolResult::error(output)
223        .with_metadata("fault_code", json!(code))
224        .with_metadata("fault_detail", json!(detail))
225}
226
227/// Resolve the session this tool should browse.
228///
229/// For Phase B v1 we browse the most recent session rooted at the
230/// current working directory — the same one `session_recall` uses.
231/// The agent-owning session is not yet threaded through the Tool
232/// trait; a future commit will switch to the in-memory live session
233/// once that signature lands.
234///
235/// Distinguishes "no sessions exist yet for this workspace" (returns
236/// `Ok(None)`) from real I/O or parse errors (returns `Err(...)`) so
237/// the caller can surface a `ToolResult::error` rather than silently
238/// masking a broken session store.
239async fn latest_session_for_cwd() -> Result<Option<Session>> {
240    let cwd = std::env::current_dir().ok();
241    let workspace = cwd.as_deref();
242    match Session::last_for_directory(workspace).await {
243        Ok(s) => Ok(Some(s)),
244        Err(err) => {
245            let msg = err.to_string().to_lowercase();
246            // `Session::last_for_directory` returns an error when no sessions
247            // exist for the workspace. Treat those as `Ok(None)` so the tool
248            // can report "no session found"; bubble everything else up.
249            if msg.contains("no session")
250                || msg.contains("not found")
251                || msg.contains("no such file")
252            {
253                tracing::debug!(%err, "context_browse: no session for workspace");
254                Ok(None)
255            } else {
256                tracing::warn!(%err, "context_browse: failed to load latest session");
257                Err(err)
258            }
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use std::path::{Path, PathBuf};
267
268    #[test]
269    fn parse_browse_action_defaults_to_list() {
270        let action = parse_browse_action(&json!({})).unwrap();
271        assert!(matches!(action, ContextBrowseAction::ListTurns));
272    }
273
274    #[test]
275    fn parse_browse_action_requires_turn_for_show() {
276        let err = parse_browse_action(&json!({"action": "show_turn"})).unwrap_err();
277        assert!(err.contains("turn"));
278    }
279
280    #[test]
281    fn parse_browse_action_rejects_unknown() {
282        let err = parse_browse_action(&json!({"action": "truncate"})).unwrap_err();
283        assert!(err.contains("unknown"));
284    }
285
286    #[test]
287    fn format_turn_path_uses_real_filesystem_paths() {
288        let path0 = format_turn_path(Path::new("/tmp/history/sid"), 0, "user");
289        let path = format_turn_path(Path::new("/tmp/history/sid"), 7, "assistant");
290        assert_eq!(path0, PathBuf::from("/tmp/history/sid/turn-0000-user.md"));
291        assert_eq!(
292            path,
293            PathBuf::from("/tmp/history/sid/turn-0007-assistant.md")
294        );
295    }
296
297    #[test]
298    fn render_listing_emits_one_path_per_turn() {
299        let listing = render_listing(&[
300            PathBuf::from("/tmp/history/sid/turn-0000-user.md"),
301            PathBuf::from("/tmp/history/sid/turn-0001-assistant.md"),
302        ]);
303        assert_eq!(listing.lines().count(), 2);
304        assert!(listing.contains("/tmp/history/sid/turn-0000-user.md"));
305        assert!(listing.contains("/tmp/history/sid/turn-0001-assistant.md"));
306    }
307}