Skip to content

Commit 00e8252

Browse files
committed
feat(tools): add artifact system for large tool outputs
When tools like Glob, Grep, Tree, or SearchFiles return results exceeding 32KB, the full output is now saved to an artifact file and the response is truncated to prevent 'Payload Too Large' errors. The truncated output includes: - First 100 lines of results - Count of omitted lines - Path to the full artifact file - Hint to use Read tool to access full content New module: cortex-engine/src/tools/artifacts.rs - ArtifactConfig for customizing threshold and behavior - process_output() and process_tool_result() helpers - cleanup_old_artifacts() for maintenance - Comprehensive unit tests Artifacts are stored in {CORTEX_HOME}/tool_artifacts/{session_id}/
1 parent 093bfb8 commit 00e8252

File tree

5 files changed

+395
-4
lines changed

5 files changed

+395
-4
lines changed
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
//! Tool result artifacts management.
2+
//!
3+
//! When tool results exceed a size threshold, this module saves the full
4+
//! content to a file and returns a truncated version with a reference
5+
//! to the artifact file.
6+
//!
7+
//! This prevents "Payload Too Large" errors when tools like Glob or Grep
8+
//! return thousands of results.
9+
10+
use std::path::{Path, PathBuf};
11+
12+
use tracing::{debug, warn};
13+
use uuid::Uuid;
14+
15+
use super::ToolResult;
16+
use crate::error::Result;
17+
18+
/// Default threshold in bytes before truncation (32KB).
19+
pub const DEFAULT_TRUNCATE_THRESHOLD: usize = 32 * 1024;
20+
21+
/// Default number of lines to show in truncated output.
22+
pub const DEFAULT_TRUNCATE_LINES: usize = 100;
23+
24+
/// Subdirectory name for tool artifacts within cortex data dir.
25+
pub const ARTIFACTS_SUBDIR: &str = "tool_artifacts";
26+
27+
/// Configuration for artifact handling.
28+
#[derive(Debug, Clone)]
29+
pub struct ArtifactConfig {
30+
/// Directory to store artifacts.
31+
pub artifacts_dir: PathBuf,
32+
/// Threshold in bytes before truncating and saving artifact.
33+
pub truncate_threshold: usize,
34+
/// Number of lines to show in truncated output.
35+
pub truncate_lines: usize,
36+
/// Whether to enable artifact saving (can be disabled for testing).
37+
pub enabled: bool,
38+
}
39+
40+
impl Default for ArtifactConfig {
41+
fn default() -> Self {
42+
// Default to temp dir if cortex home not available
43+
let artifacts_dir = std::env::var("CORTEX_HOME")
44+
.map(PathBuf::from)
45+
.unwrap_or_else(|_| std::env::temp_dir().join("cortex"))
46+
.join(ARTIFACTS_SUBDIR);
47+
48+
Self {
49+
artifacts_dir,
50+
truncate_threshold: DEFAULT_TRUNCATE_THRESHOLD,
51+
truncate_lines: DEFAULT_TRUNCATE_LINES,
52+
enabled: true,
53+
}
54+
}
55+
}
56+
57+
impl ArtifactConfig {
58+
/// Create config with custom artifacts directory.
59+
pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
60+
Self {
61+
artifacts_dir: dir.into(),
62+
..Default::default()
63+
}
64+
}
65+
66+
/// Set the truncation threshold.
67+
pub fn with_threshold(mut self, threshold: usize) -> Self {
68+
self.truncate_threshold = threshold;
69+
self
70+
}
71+
72+
/// Set the number of truncated lines.
73+
pub fn with_truncate_lines(mut self, lines: usize) -> Self {
74+
self.truncate_lines = lines;
75+
self
76+
}
77+
78+
/// Disable artifact saving.
79+
pub fn disabled(mut self) -> Self {
80+
self.enabled = false;
81+
self
82+
}
83+
}
84+
85+
/// Result of processing tool output through artifact handling.
86+
#[derive(Debug)]
87+
pub struct ArtifactResult {
88+
/// The (potentially truncated) output for the model.
89+
pub output: String,
90+
/// Path to the full artifact file, if content was truncated.
91+
pub artifact_path: Option<PathBuf>,
92+
/// Whether the output was truncated.
93+
pub was_truncated: bool,
94+
/// Original size in bytes.
95+
pub original_size: usize,
96+
/// Original line count.
97+
pub original_lines: usize,
98+
}
99+
100+
/// Process tool output, potentially saving to artifact file if too large.
101+
///
102+
/// # Arguments
103+
/// * `output` - The full tool output
104+
/// * `session_id` - Session/conversation ID for organizing artifacts
105+
/// * `tool_name` - Name of the tool that produced the output
106+
/// * `config` - Artifact configuration
107+
///
108+
/// # Returns
109+
/// An `ArtifactResult` containing the processed output and artifact info.
110+
pub fn process_output(
111+
output: &str,
112+
session_id: &str,
113+
tool_name: &str,
114+
config: &ArtifactConfig,
115+
) -> Result<ArtifactResult> {
116+
let original_size = output.len();
117+
let lines: Vec<&str> = output.lines().collect();
118+
let original_lines = lines.len();
119+
120+
// Check if truncation is needed
121+
if !config.enabled || original_size <= config.truncate_threshold {
122+
return Ok(ArtifactResult {
123+
output: output.to_string(),
124+
artifact_path: None,
125+
was_truncated: false,
126+
original_size,
127+
original_lines,
128+
});
129+
}
130+
131+
// Save full output to artifact file
132+
let artifact_path = save_artifact(output, session_id, tool_name, &config.artifacts_dir)?;
133+
134+
// Create truncated output
135+
let truncated = create_truncated_output(&lines, config.truncate_lines, &artifact_path);
136+
137+
Ok(ArtifactResult {
138+
output: truncated,
139+
artifact_path: Some(artifact_path),
140+
was_truncated: true,
141+
original_size,
142+
original_lines,
143+
})
144+
}
145+
146+
/// Save full output to an artifact file.
147+
fn save_artifact(
148+
content: &str,
149+
session_id: &str,
150+
tool_name: &str,
151+
artifacts_dir: &Path,
152+
) -> Result<PathBuf> {
153+
// Create session-specific subdirectory
154+
let session_dir = artifacts_dir.join(session_id);
155+
std::fs::create_dir_all(&session_dir)?;
156+
157+
// Generate unique filename
158+
let artifact_id = Uuid::new_v4();
159+
let filename = format!("{}_{}.txt", tool_name, artifact_id);
160+
let artifact_path = session_dir.join(&filename);
161+
162+
// Write content
163+
std::fs::write(&artifact_path, content)?;
164+
debug!(
165+
path = %artifact_path.display(),
166+
size = content.len(),
167+
"Saved tool artifact"
168+
);
169+
170+
Ok(artifact_path)
171+
}
172+
173+
/// Create truncated output with reference to artifact file.
174+
fn create_truncated_output(lines: &[&str], max_lines: usize, artifact_path: &Path) -> String {
175+
let total_lines = lines.len();
176+
let omitted = total_lines.saturating_sub(max_lines);
177+
178+
// Take first portion of lines
179+
let shown_lines: Vec<&str> = lines.iter().take(max_lines).copied().collect();
180+
let shown_text = shown_lines.join("\n");
181+
182+
format!(
183+
"{}\n\n[... {} more lines omitted ...]\n\n\
184+
📄 Full output saved to: {}\n\
185+
💡 Use Read tool to view the full artifact if needed.",
186+
shown_text,
187+
omitted,
188+
artifact_path.display()
189+
)
190+
}
191+
192+
/// Convenience function to process a ToolResult and handle artifacts.
193+
pub fn process_tool_result(
194+
result: ToolResult,
195+
session_id: &str,
196+
tool_name: &str,
197+
config: &ArtifactConfig,
198+
) -> Result<ToolResult> {
199+
// Only process successful results
200+
if !result.success {
201+
return Ok(result);
202+
}
203+
204+
let artifact_result = process_output(&result.output, session_id, tool_name, config)?;
205+
206+
if artifact_result.was_truncated {
207+
debug!(
208+
tool = tool_name,
209+
original_size = artifact_result.original_size,
210+
original_lines = artifact_result.original_lines,
211+
artifact = ?artifact_result.artifact_path,
212+
"Truncated tool output and saved artifact"
213+
);
214+
}
215+
216+
Ok(ToolResult {
217+
output: artifact_result.output,
218+
success: result.success,
219+
error: result.error,
220+
metadata: result.metadata,
221+
})
222+
}
223+
224+
/// Clean up old artifacts for a session.
225+
pub fn cleanup_session_artifacts(session_id: &str, artifacts_dir: &Path) -> Result<()> {
226+
let session_dir = artifacts_dir.join(session_id);
227+
if session_dir.exists() {
228+
std::fs::remove_dir_all(&session_dir)?;
229+
debug!(session_id, "Cleaned up session artifacts");
230+
}
231+
Ok(())
232+
}
233+
234+
/// Clean up artifacts older than the specified duration.
235+
pub fn cleanup_old_artifacts(artifacts_dir: &Path, max_age: std::time::Duration) -> Result<usize> {
236+
let mut removed = 0;
237+
238+
if !artifacts_dir.exists() {
239+
return Ok(0);
240+
}
241+
242+
let now = std::time::SystemTime::now();
243+
244+
for entry in std::fs::read_dir(artifacts_dir)? {
245+
let entry = entry?;
246+
let path = entry.path();
247+
248+
if !path.is_dir() {
249+
continue;
250+
}
251+
252+
// Check directory modification time
253+
if let Ok(metadata) = path.metadata() {
254+
if let Ok(modified) = metadata.modified() {
255+
if let Ok(age) = now.duration_since(modified) {
256+
if age > max_age {
257+
if let Err(e) = std::fs::remove_dir_all(&path) {
258+
warn!(path = %path.display(), error = %e, "Failed to remove old artifact dir");
259+
} else {
260+
removed += 1;
261+
debug!(path = %path.display(), "Removed old artifact directory");
262+
}
263+
}
264+
}
265+
}
266+
}
267+
}
268+
269+
Ok(removed)
270+
}
271+
272+
#[cfg(test)]
273+
mod tests {
274+
use super::*;
275+
use tempfile::tempdir;
276+
277+
#[test]
278+
fn test_no_truncation_below_threshold() {
279+
let config = ArtifactConfig {
280+
artifacts_dir: tempdir().unwrap().into_path(),
281+
truncate_threshold: 1000,
282+
truncate_lines: 10,
283+
enabled: true,
284+
};
285+
286+
let output = "line1\nline2\nline3";
287+
let result = process_output(output, "test-session", "Glob", &config).unwrap();
288+
289+
assert!(!result.was_truncated);
290+
assert!(result.artifact_path.is_none());
291+
assert_eq!(result.output, output);
292+
}
293+
294+
#[test]
295+
fn test_truncation_above_threshold() {
296+
let temp_dir = tempdir().unwrap();
297+
let config = ArtifactConfig {
298+
artifacts_dir: temp_dir.path().to_path_buf(),
299+
truncate_threshold: 50,
300+
truncate_lines: 2,
301+
enabled: true,
302+
};
303+
304+
let output = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10";
305+
let result = process_output(output, "test-session", "Glob", &config).unwrap();
306+
307+
assert!(result.was_truncated);
308+
assert!(result.artifact_path.is_some());
309+
assert!(result.output.contains("line1"));
310+
assert!(result.output.contains("line2"));
311+
assert!(result.output.contains("more lines omitted"));
312+
assert!(result.output.contains("Full output saved to"));
313+
314+
// Verify artifact file was created and contains full output
315+
let artifact_content = std::fs::read_to_string(result.artifact_path.unwrap()).unwrap();
316+
assert_eq!(artifact_content, output);
317+
}
318+
319+
#[test]
320+
fn test_disabled_config() {
321+
let config = ArtifactConfig {
322+
artifacts_dir: tempdir().unwrap().into_path(),
323+
truncate_threshold: 10,
324+
truncate_lines: 1,
325+
enabled: false,
326+
};
327+
328+
let output = "this is a very long output that would normally be truncated";
329+
let result = process_output(output, "test-session", "Glob", &config).unwrap();
330+
331+
assert!(!result.was_truncated);
332+
assert_eq!(result.output, output);
333+
}
334+
335+
#[test]
336+
fn test_cleanup_old_artifacts() {
337+
let temp_dir = tempdir().unwrap();
338+
let artifacts_dir = temp_dir.path();
339+
340+
// Create a test session directory
341+
let session_dir = artifacts_dir.join("old-session");
342+
std::fs::create_dir_all(&session_dir).unwrap();
343+
std::fs::write(session_dir.join("test.txt"), "content").unwrap();
344+
345+
// Set modification time to the past (by using a very short max_age)
346+
std::thread::sleep(std::time::Duration::from_millis(100));
347+
348+
let removed =
349+
cleanup_old_artifacts(artifacts_dir, std::time::Duration::from_millis(50)).unwrap();
350+
assert_eq!(removed, 1);
351+
assert!(!session_dir.exists());
352+
}
353+
}

cortex-engine/src/tools/handlers/file_ops.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use serde_json::{Value, json};
99

1010
use super::{ToolContext, ToolHandler, ToolResult};
1111
use crate::error::Result;
12+
use crate::tools::artifacts::{ArtifactConfig, process_tool_result};
1213
use crate::tools::spec::ToolMetadata;
1314

1415
// ============================================================
@@ -369,7 +370,11 @@ impl ToolHandler for TreeHandler {
369370
entries.join("\n")
370371
};
371372

372-
Ok(ToolResult::success(output).with_metadata(metadata))
373+
let result = ToolResult::success(output).with_metadata(metadata);
374+
375+
// Process through artifact system for large directory trees
376+
let artifact_config = ArtifactConfig::default();
377+
process_tool_result(result, &context.conversation_id, "Tree", &artifact_config)
373378
}
374379
}
375380

@@ -461,7 +466,16 @@ impl ToolHandler for SearchFilesHandler {
461466
if matches.is_empty() {
462467
Ok(ToolResult::success("No files found matching the pattern"))
463468
} else {
464-
Ok(ToolResult::success(matches.join("\n")))
469+
let result = ToolResult::success(matches.join("\n"));
470+
471+
// Process through artifact system for large results
472+
let artifact_config = ArtifactConfig::default();
473+
process_tool_result(
474+
result,
475+
&context.conversation_id,
476+
"SearchFiles",
477+
&artifact_config,
478+
)
465479
}
466480
}
467481
}

0 commit comments

Comments
 (0)