codetether_agent/tool/
context_browse.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ContextBrowseAction {
72 ListTurns,
74 ShowTurn { turn: usize },
76}
77
78pub 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
98pub fn format_turn_path(session_id: &str, turn: usize, role: &str) -> String {
100 format!("session://{session_id}/turn-{turn:04}-{role}.md")
101}
102
103fn 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
113fn 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
151fn 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
161pub 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
241async 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 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}