From 39b2d528cddf0ec8eb7c298bd2e1be1ff5b9afbf Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Thu, 7 May 2026 06:56:20 -0500 Subject: [PATCH] feat(tools): retrieve spilled tool outputs (#1054) --- crates/tui/src/prompts/base.md | 2 +- crates/tui/src/prompts/base.txt | 2 +- crates/tui/src/tools/mod.rs | 1 + crates/tui/src/tools/registry.rs | 11 + crates/tui/src/tools/tool_result_retrieval.rs | 627 ++++++++++++++++++ crates/tui/src/tools/truncate.rs | 39 +- docs/TOOL_SURFACE.md | 1 + 7 files changed, 674 insertions(+), 9 deletions(-) create mode 100644 crates/tui/src/tools/tool_result_retrieval.rs diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index e001d26c..51e5a94c 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -139,7 +139,7 @@ When context is deep (past a soft seam): cache reasoning conclusions in concise ## Toolbox (fast reference — tool descriptions are authoritative) - **Planning / tracking**: `update_plan` (high-level strategy), `task_create` / `task_list` / `task_read` / `task_cancel` (durable work objects), `checklist_write` (granular progress under the active task/thread), `checklist_add` / `checklist_update` / `checklist_list`, `todo_*` aliases (legacy compatibility), `note` (persistent memory). -- **File I/O**: `read_file` (PDFs auto-extracted), `list_dir`, `write_file`, `edit_file`, `apply_patch`. +- **File I/O**: `read_file` (PDFs auto-extracted), `list_dir`, `write_file`, `edit_file`, `apply_patch`, `retrieve_tool_result` for prior spilled large tool outputs. - **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`. If foreground `exec_shell` times out, the process was killed; rerun long work with `task_shell_start` or `exec_shell` using `background: true`, then poll/wait. - **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; `github_issue_context` / `github_pr_context` (read-only); `github_comment` / `github_close_issue` (approval + evidence required); `automation_*` scheduling tools. - **Structured search**: `grep_files`, `file_search`, `web_search`, `fetch_url`, `web.run` (browse). diff --git a/crates/tui/src/prompts/base.txt b/crates/tui/src/prompts/base.txt index 5741a3af..6ca2bebc 100644 --- a/crates/tui/src/prompts/base.txt +++ b/crates/tui/src/prompts/base.txt @@ -30,7 +30,7 @@ Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking` ## Toolbox (fast reference — tool descriptions are authoritative) - **Planning / tracking**: `update_plan` (high-level strategy), `task_create` / `task_list` / `task_read` / `task_cancel` (durable work objects), `checklist_write` (granular progress under the active task/thread), `checklist_add` / `checklist_update` / `checklist_list`, `todo_*` aliases (legacy compatibility), `note` (persistent memory). -- **File I/O**: `read_file` (PDFs auto-extracted), `list_dir`, `write_file`, `edit_file`, `apply_patch`. +- **File I/O**: `read_file` (PDFs auto-extracted), `list_dir`, `write_file`, `edit_file`, `apply_patch`, `retrieve_tool_result` for prior spilled large tool outputs. - **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`. - **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; `github_issue_context` / `github_pr_context` (read-only); `github_comment` / `github_close_issue` (approval + evidence required); `automation_*` scheduling tools. - **Structured search**: `grep_files`, `file_search`, `web_search`, `fetch_url`, `web.run` (browse). diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 0788b131..f6f18574 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -35,6 +35,7 @@ pub mod subagent; pub mod tasks; pub mod test_runner; pub mod todo; +pub mod tool_result_retrieval; pub mod truncate; pub mod user_input; pub mod validate_data; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index b4450998..06f8b703 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -425,6 +425,9 @@ impl ToolRegistryBuilder { use super::file::{ListDirTool, ReadFileTool}; self.with_tool(Arc::new(ReadFileTool)) .with_tool(Arc::new(ListDirTool)) + .with_tool(Arc::new( + super::tool_result_retrieval::RetrieveToolResultTool, + )) } /// Include shell execution tool. @@ -503,6 +506,13 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(ValidateDataTool)) } + /// Include retrieval for spilled historical tool results. + #[must_use] + pub fn with_tool_result_retrieval_tool(self) -> Self { + use super::tool_result_retrieval::RetrieveToolResultTool; + self.with_tool(Arc::new(RetrieveToolResultTool)) + } + /// Include durable task, gate, PR-attempt, GitHub, and automation tools. #[must_use] pub fn with_runtime_task_tools(self) -> Self { @@ -689,6 +699,7 @@ impl ToolRegistryBuilder { .with_skill_tools() .with_test_runner_tool() .with_validation_tools() + .with_tool_result_retrieval_tool() .with_runtime_task_tools() .with_revert_turn_tool(); diff --git a/crates/tui/src/tools/tool_result_retrieval.rs b/crates/tui/src/tools/tool_result_retrieval.rs new file mode 100644 index 00000000..4dce6a48 --- /dev/null +++ b/crates/tui/src/tools/tool_result_retrieval.rs @@ -0,0 +1,627 @@ +//! `retrieve_tool_result` - selective retrieval for spilled tool outputs. +//! +//! Large successful tool results are spilled to +//! `~/.deepseek/tool_outputs/.txt` by `tools::truncate`. This +//! tool gives the model a read-only, directory-scoped way to fetch summaries or +//! slices of those historical outputs without replaying the entire file into +//! every subsequent request. + +use std::fs; +use std::path::PathBuf; + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::spec::{ + ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_str, optional_u64, + required_str, +}; + +const DEFAULT_MAX_BYTES: usize = 8 * 1024; +const HARD_MAX_BYTES: usize = 128 * 1024; +const DEFAULT_LINE_COUNT: usize = 40; +const HARD_LINE_COUNT: usize = 500; +const DEFAULT_MAX_MATCHES: usize = 20; +const HARD_MAX_MATCHES: usize = 100; +const DEFAULT_CONTEXT_LINES: usize = 1; +const HARD_CONTEXT_LINES: usize = 5; + +/// Retrieve summaries or slices of a prior spilled tool result. +pub struct RetrieveToolResultTool; + +#[async_trait] +impl ToolSpec for RetrieveToolResultTool { + fn name(&self) -> &'static str { + "retrieve_tool_result" + } + + fn description(&self) -> &'static str { + "Retrieve a previously spilled large tool result from ~/.deepseek/tool_outputs by tool call id, filename, or spillover path. Supports summary, head, tail, lines, and query modes so you can fetch only the needed historical output." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "ref": { + "type": "string", + "description": "Tool call id, tool_result:, spillover filename, or absolute spillover path." + }, + "mode": { + "type": "string", + "enum": ["summary", "head", "tail", "lines", "query"], + "description": "Retrieval mode. Defaults to summary." + }, + "query": { + "type": "string", + "description": "Case-insensitive substring to search for when mode=query." + }, + "lines": { + "type": "string", + "description": "Line selector for mode=lines, e.g. \"10\" or \"10-40\"." + }, + "start_line": { + "type": "integer", + "description": "1-based first line for mode=lines." + }, + "end_line": { + "type": "integer", + "description": "1-based final line for mode=lines." + }, + "line_count": { + "type": "integer", + "description": "Number of lines for head/tail modes. Default 40, hard cap 500." + }, + "max_bytes": { + "type": "integer", + "description": "Maximum bytes of excerpt text returned. Default 8192, hard cap 131072." + }, + "max_matches": { + "type": "integer", + "description": "Maximum query matches or signal lines returned. Default 20, hard cap 100." + }, + "context_lines": { + "type": "integer", + "description": "Extra lines around each query match. Default 1, hard cap 5." + } + }, + "required": ["ref"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + fn supports_parallel(&self) -> bool { + true + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let reference = required_str(&input, "ref")?.trim(); + if reference.is_empty() { + return Err(ToolError::invalid_input("ref cannot be empty")); + } + + let mode = optional_str(&input, "mode") + .unwrap_or("summary") + .trim() + .to_ascii_lowercase(); + let max_bytes = clamp_u64( + optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES as u64), + 1, + HARD_MAX_BYTES, + ); + let path = resolve_spillover_reference(reference)?; + let content = fs::read_to_string(&path).map_err(|err| { + ToolError::execution_failed(format!("failed to read {}: {err}", path.display())) + })?; + + let lines: Vec<&str> = content.lines().collect(); + let payload = match mode.as_str() { + "summary" => { + build_summary_payload(reference, &path, &content, &lines, &input, max_bytes) + } + "head" => build_head_tail_payload(reference, &path, "head", &lines, &input, max_bytes), + "tail" => build_head_tail_payload(reference, &path, "tail", &lines, &input, max_bytes), + "lines" => build_lines_payload(reference, &path, &lines, &input, max_bytes)?, + "query" => build_query_payload(reference, &path, &lines, &input, max_bytes)?, + other => { + return Err(ToolError::invalid_input(format!( + "unsupported mode `{other}` (expected summary, head, tail, lines, or query)" + ))); + } + }; + + ToolResult::json(&payload).map_err(|err| { + ToolError::execution_failed(format!("failed to serialize result: {err}")) + }) + } +} + +fn resolve_spillover_reference(reference: &str) -> Result { + let root = crate::tools::truncate::spillover_root() + .ok_or_else(|| ToolError::execution_failed("could not resolve ~/.deepseek/tool_outputs"))?; + let root_canonical = root.canonicalize().map_err(|err| { + ToolError::execution_failed(format!( + "spillover directory {} is not readable: {err}", + root.display() + )) + })?; + + let trimmed = reference.trim(); + let stripped = trimmed.strip_prefix("tool_result:").unwrap_or(trimmed); + let raw_path = PathBuf::from(stripped); + + let candidate = if raw_path.is_absolute() { + raw_path + } else if stripped.ends_with(".txt") + || stripped.contains('/') + || (std::path::MAIN_SEPARATOR != '/' && stripped.contains(std::path::MAIN_SEPARATOR)) + { + root.join(stripped) + } else { + crate::tools::truncate::spillover_path(stripped).ok_or_else(|| { + ToolError::invalid_input(format!("invalid spilled tool-result ref `{reference}`")) + })? + }; + + let canonical = candidate.canonicalize().map_err(|err| { + ToolError::execution_failed(format!( + "spilled tool result `{reference}` was not found at {}: {err}", + candidate.display() + )) + })?; + + if !canonical.starts_with(&root_canonical) { + return Err(ToolError::invalid_input(format!( + "ref `{reference}` does not point inside {}", + root_canonical.display() + ))); + } + if !canonical.is_file() { + return Err(ToolError::invalid_input(format!( + "ref `{reference}` does not point to a spillover file" + ))); + } + + Ok(canonical) +} + +fn build_summary_payload( + reference: &str, + path: &std::path::Path, + content: &str, + lines: &[&str], + input: &Value, + max_bytes: usize, +) -> Value { + let max_matches = clamp_u64( + optional_u64(input, "max_matches", DEFAULT_MAX_MATCHES as u64), + 1, + HARD_MAX_MATCHES, + ); + let signal_lines = collect_signal_lines(lines, max_matches); + let head_count = DEFAULT_LINE_COUNT.min(lines.len()); + let tail_count = DEFAULT_LINE_COUNT.min(lines.len()); + let head = render_numbered_lines( + lines + .iter() + .take(head_count) + .enumerate() + .map(|(idx, line)| (idx + 1, *line)), + max_bytes / 2, + ); + let tail_start = lines.len().saturating_sub(tail_count); + let tail = render_numbered_lines( + lines + .iter() + .enumerate() + .skip(tail_start) + .map(|(idx, line)| (idx + 1, *line)), + max_bytes / 2, + ); + + json!({ + "ref": reference, + "path": path.display().to_string(), + "mode": "summary", + "total_bytes": content.len(), + "total_lines": lines.len(), + "non_empty_lines": lines.iter().filter(|line| !line.trim().is_empty()).count(), + "signal_lines": signal_lines, + "head": head, + "tail": tail, + "hint": "Use mode=head, tail, lines, or query to retrieve a narrower slice." + }) +} + +fn build_head_tail_payload( + reference: &str, + path: &std::path::Path, + mode: &str, + lines: &[&str], + input: &Value, + max_bytes: usize, +) -> Value { + let count = clamp_u64( + optional_u64(input, "line_count", DEFAULT_LINE_COUNT as u64), + 1, + HARD_LINE_COUNT, + ); + let selected: Vec<(usize, &str)> = if mode == "head" { + lines + .iter() + .take(count) + .enumerate() + .map(|(idx, line)| (idx + 1, *line)) + .collect() + } else { + let start = lines.len().saturating_sub(count); + lines + .iter() + .enumerate() + .skip(start) + .map(|(idx, line)| (idx + 1, *line)) + .collect() + }; + let excerpt = render_numbered_lines(selected.iter().copied(), max_bytes); + + json!({ + "ref": reference, + "path": path.display().to_string(), + "mode": mode, + "total_lines": lines.len(), + "line_count": count, + "excerpt": excerpt, + }) +} + +fn build_lines_payload( + reference: &str, + path: &std::path::Path, + lines: &[&str], + input: &Value, + max_bytes: usize, +) -> Result { + let (start, end) = parse_line_selector(input)?; + let excerpt = if start > lines.len() { + String::new() + } else { + let end = end.min(lines.len()); + render_numbered_lines( + lines + .iter() + .enumerate() + .skip(start - 1) + .take(end.saturating_sub(start) + 1) + .map(|(idx, line)| (idx + 1, *line)), + max_bytes, + ) + }; + + Ok(json!({ + "ref": reference, + "path": path.display().to_string(), + "mode": "lines", + "total_lines": lines.len(), + "start_line": start, + "end_line": end.min(lines.len()), + "excerpt": excerpt, + })) +} + +fn build_query_payload( + reference: &str, + path: &std::path::Path, + lines: &[&str], + input: &Value, + max_bytes: usize, +) -> Result { + let query = optional_str(input, "query") + .map(str::trim) + .filter(|q| !q.is_empty()) + .ok_or_else(|| ToolError::invalid_input("query is required when mode=query"))?; + let query_lower = query.to_lowercase(); + let max_matches = clamp_u64( + optional_u64(input, "max_matches", DEFAULT_MAX_MATCHES as u64), + 1, + HARD_MAX_MATCHES, + ); + let context_lines = clamp_u64( + optional_u64(input, "context_lines", DEFAULT_CONTEXT_LINES as u64), + 0, + HARD_CONTEXT_LINES, + ); + + let mut matched_lines = 0usize; + let mut results = Vec::new(); + for (idx, line) in lines.iter().enumerate() { + if !line.to_lowercase().contains(&query_lower) { + continue; + } + matched_lines += 1; + if results.len() >= max_matches { + continue; + } + let start = idx.saturating_sub(context_lines); + let end = (idx + context_lines).min(lines.len().saturating_sub(1)); + let excerpt = render_numbered_lines( + lines + .iter() + .enumerate() + .skip(start) + .take(end.saturating_sub(start) + 1) + .map(|(line_idx, text)| (line_idx + 1, *text)), + max_bytes / max_matches.max(1), + ); + results.push(json!({ + "line": idx + 1, + "excerpt": excerpt, + })); + } + + Ok(json!({ + "ref": reference, + "path": path.display().to_string(), + "mode": "query", + "query": query, + "total_lines": lines.len(), + "matched_lines": matched_lines, + "matches_returned": results.len(), + "results": results, + })) +} + +fn parse_line_selector(input: &Value) -> Result<(usize, usize), ToolError> { + let explicit_start = input.get("start_line").and_then(Value::as_u64); + let explicit_end = input.get("end_line").and_then(Value::as_u64); + if explicit_start.is_some() || explicit_end.is_some() { + let start = explicit_start.ok_or_else(|| { + ToolError::invalid_input("start_line is required when end_line is supplied") + })?; + let end = explicit_end.unwrap_or(start); + return validate_line_range(start as usize, end as usize); + } + + let spec = optional_str(input, "lines") + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + ToolError::invalid_input( + "mode=lines requires `lines` (for example \"10-40\") or start_line/end_line", + ) + })?; + + if let Some((start, end)) = spec.split_once('-') { + let start = parse_positive_line(start.trim(), "lines start")?; + let end = parse_positive_line(end.trim(), "lines end")?; + validate_line_range(start, end) + } else { + let line = parse_positive_line(spec, "lines")?; + validate_line_range(line, line) + } +} + +fn validate_line_range(start: usize, end: usize) -> Result<(usize, usize), ToolError> { + if start == 0 || end == 0 { + return Err(ToolError::invalid_input("line numbers are 1-based")); + } + if end < start { + return Err(ToolError::invalid_input( + "end_line must be greater than or equal to start_line", + )); + } + Ok((start, end)) +} + +fn parse_positive_line(raw: &str, field: &str) -> Result { + raw.parse::().map_err(|_| { + ToolError::invalid_input(format!("{field} must be a positive integer line number")) + }) +} + +fn collect_signal_lines(lines: &[&str], max_matches: usize) -> Vec { + let mut out = Vec::new(); + for (idx, line) in lines.iter().enumerate() { + if !is_signal_line(line) { + continue; + } + out.push(json!({ + "line": idx + 1, + "text": truncate_line(line.trim(), 300), + })); + if out.len() >= max_matches { + break; + } + } + out +} + +fn is_signal_line(line: &str) -> bool { + let lower = line.to_lowercase(); + [ + "error", + "failed", + "failure", + "panic", + "warning", + "exception", + "traceback", + "assertion", + "exit code", + "test result", + "thread '", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn render_numbered_lines<'a>( + lines: impl IntoIterator, + max_bytes: usize, +) -> String { + let mut rendered = String::new(); + for (line_no, line) in lines { + rendered.push_str(&format!("{line_no}: {line}\n")); + if rendered.len() > max_bytes { + break; + } + } + truncate_text(&rendered, max_bytes) +} + +fn truncate_text(text: &str, max_bytes: usize) -> String { + if text.len() <= max_bytes { + return text.trim_end_matches('\n').to_string(); + } + let note = "\n[truncated to max_bytes]"; + let budget = max_bytes.saturating_sub(note.len()).max(1); + let cut = (0..=budget) + .rev() + .find(|idx| text.is_char_boundary(*idx)) + .unwrap_or(0); + format!("{}{}", text[..cut].trim_end_matches('\n'), note) +} + +fn truncate_line(line: &str, max_chars: usize) -> String { + if line.chars().count() <= max_chars { + return line.to_string(); + } + let mut out: String = line.chars().take(max_chars.saturating_sub(3)).collect(); + out.push_str("..."); + out +} + +fn clamp_u64(value: u64, min: usize, max: usize) -> usize { + (value as usize).clamp(min, max) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::MutexGuard; + use tempfile::tempdir; + + struct SpilloverRootGuard { + prior: Option, + } + + impl Drop for SpilloverRootGuard { + fn drop(&mut self) { + crate::tools::truncate::set_test_spillover_root(self.prior.take()); + } + } + + fn set_spillover_root(path: PathBuf) -> SpilloverRootGuard { + let prior = crate::tools::truncate::set_test_spillover_root(Some(path)); + SpilloverRootGuard { prior } + } + + fn context() -> ToolContext { + let tmp = tempdir().unwrap(); + ToolContext::new(tmp.path()) + } + + fn test_lock() -> MutexGuard<'static, ()> { + crate::tools::truncate::TEST_SPILLOVER_GUARD + .lock() + .unwrap_or_else(|err| err.into_inner()) + } + + fn execute_tool(input: Value) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(RetrieveToolResultTool.execute(input, &context())) + } + + #[test] + fn summary_reads_spillover_by_tool_call_id() { + let _lock = test_lock(); + let tmp = tempdir().unwrap(); + let _guard = set_spillover_root(tmp.path().join("tool_outputs")); + crate::tools::truncate::write_spillover( + "call-abc", + "checking crate\nerror[E0425]: missing value\nwarning: unused import\nfinished", + ) + .unwrap(); + + let result = execute_tool(json!({"ref": "call-abc"})).unwrap(); + + assert!(result.success); + let body: Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(body["mode"], "summary"); + assert!(body["signal_lines"].to_string().contains("error[E0425]")); + assert!(body["signal_lines"].to_string().contains("warning")); + } + + #[test] + fn query_returns_matching_line_with_context() { + let _lock = test_lock(); + let tmp = tempdir().unwrap(); + let _guard = set_spillover_root(tmp.path().join("tool_outputs")); + crate::tools::truncate::write_spillover( + "call-query", + "one\ntwo before\nneedle here\nafter\nlast", + ) + .unwrap(); + + let result = execute_tool(json!({ + "ref": "tool_result:call-query", + "mode": "query", + "query": "needle", + "context_lines": 1 + })) + .unwrap(); + + let body: Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(body["matched_lines"], 1); + let rendered = body["results"].to_string(); + assert!(rendered.contains("2: two before")); + assert!(rendered.contains("3: needle here")); + assert!(rendered.contains("4: after")); + } + + #[test] + fn lines_mode_accepts_filename_inside_spillover_root() { + let _lock = test_lock(); + let tmp = tempdir().unwrap(); + let root = tmp.path().join("tool_outputs"); + let _guard = set_spillover_root(root.clone()); + crate::tools::truncate::write_spillover("call-lines", "a\nb\nc\nd").unwrap(); + + let result = execute_tool(json!({ + "ref": "call-lines.txt", + "mode": "lines", + "lines": "2-3" + })) + .unwrap(); + + let body: Value = serde_json::from_str(&result.content).unwrap(); + let excerpt = body["excerpt"].as_str().unwrap(); + assert!(excerpt.contains("2: b")); + assert!(excerpt.contains("3: c")); + assert!(!excerpt.contains("1: a")); + assert!(!excerpt.contains("4: d")); + } + + #[test] + fn rejects_path_outside_spillover_root() { + let _lock = test_lock(); + let tmp = tempdir().unwrap(); + let root = tmp.path().join("tool_outputs"); + fs::create_dir_all(&root).unwrap(); + let outside = tmp.path().join("outside.txt"); + fs::write(&outside, "secret").unwrap(); + let _guard = set_spillover_root(root); + + let err = execute_tool(json!({"ref": outside.display().to_string()})).unwrap_err(); + + assert!( + err.to_string().contains("does not point inside"), + "unexpected error: {err}" + ); + } +} diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index 59a15d94..e6c66641 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -60,15 +60,39 @@ pub const SPILLOVER_THRESHOLD_BYTES: usize = 100 * 1024; // 100 KiB /// bound. Mirrors the workspace-snapshot 7-day default. pub const SPILLOVER_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); +#[cfg(test)] +static TEST_SPILLOVER_ROOT: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[cfg(test)] +pub(crate) static TEST_SPILLOVER_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Resolve `~/.deepseek/tool_outputs/`. Returns `None` if the home /// directory can't be determined (CI containers occasionally hit /// this). Callers should treat `None` as "spillover unavailable" and /// degrade gracefully rather than fail the tool call. #[must_use] pub fn spillover_root() -> Option { + #[cfg(test)] + if let Some(root) = TEST_SPILLOVER_ROOT + .lock() + .unwrap_or_else(|err| err.into_inner()) + .clone() + { + return Some(root); + } + Some(dirs::home_dir()?.join(".deepseek").join(SPILLOVER_DIR_NAME)) } +/// Override the spillover root for tests without mutating `$HOME`. +#[cfg(test)] +pub(crate) fn set_test_spillover_root(root: Option) -> Option { + let mut guard = TEST_SPILLOVER_ROOT + .lock() + .unwrap_or_else(|err| err.into_inner()); + std::mem::replace(&mut *guard, root) +} + /// Resolve the spillover-file path for a tool call id. Sanitises the /// id so that a hostile value can't escape the storage directory. /// Returns `None` for empty / fully-invalid ids; the caller should @@ -231,8 +255,10 @@ pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option` \ + if you need the elided output.]", head_kib = head.len() / 1024, total_kib = total / 1024, ); @@ -304,16 +330,15 @@ where #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex; use tempfile::tempdir; /// Tests in this module serialize through this guard because /// they mutate process-global `$HOME`. Without it, cargo's /// parallel runner would observe interleaved overrides. - static TEST_GUARD: Mutex<()> = Mutex::new(()); - fn setup() -> std::sync::MutexGuard<'static, ()> { - TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()) + super::TEST_SPILLOVER_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()) } #[test] @@ -530,7 +555,7 @@ mod tests { "footer missing: {}", &result.content[result.content.len().saturating_sub(200)..] ); - assert!(result.content.contains("read_file path=")); + assert!(result.content.contains("retrieve_tool_result ref=call-big")); // Full bytes are on disk at the returned path. assert!(path.exists(), "spillover file missing: {path:?}"); diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 5ae8b1a9..da3547dc 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -26,6 +26,7 @@ chosen over the available shell equivalent. Companion to `crates/tui/src/prompts | `write_file` | Create or overwrite a file. | | `edit_file` | Search-and-replace inside a single file. Cheaper than a full rewrite. | | `apply_patch` | Apply a unified diff. The right tool for multi-hunk edits. | +| `retrieve_tool_result` | Read summaries or slices of prior large tool outputs spilled to `~/.deepseek/tool_outputs/`; use `summary`, `head`, `tail`, `lines`, or `query` instead of replaying the whole result. | ### Search