codetether_agent/tool/
context_browse.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ContextBrowseAction {
74 ListTurns,
76 ShowTurn { turn: usize },
78}
79
80pub 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
100pub 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
105fn render_listing(paths: &[PathBuf]) -> String {
107 paths
108 .iter()
109 .map(|path| path.display().to_string())
110 .collect::<Vec<_>>()
111 .join("\n")
112}
113
114pub 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
227async 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 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}