Skip to content

Commit 75d6238

Browse files
committed
Return a compact summary to the model from edit_file, write_file, and morph_edit_file instead of the full diff
1 parent 7c3c2fc commit 75d6238

5 files changed

Lines changed: 162 additions & 36 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ Gitignored scratchpad for helper files the user asks to be created there — typ
428428
- Do not leave `unwrap()` or `expect()` in normal code paths.
429429
- Use strong types where possible.
430430
- Add or update important tests and keep them self-contained.
431-
- After each important change, but only when we are ready to commit, update if relevant:
431+
- After each important change, but only when you have finalized the current task, update if relevant:
432432
- `README.md`
433433
- `CHANGELOG.md` under `[Unreleased]`. Stick to the standard Keep-a-Changelog categories (`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`) — do NOT add an `Internal` section. Pure refactors, dead-code removals, and test-only additions are not user-notable; leave them out. The changelog is for users, not contributors. Within each entry, describe the user-visible behaviour in plain English: no file paths, no bare `Type::method` shorthand, no Rust attribute syntax, no crate names. CLI flags, env vars, slash commands, and API wire formats are fine because the user encounters them directly.
434434
- Run:

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to Sofos are documented in this file.
44

55
## [Unreleased]
66

7+
### Changed
8+
9+
- **File-edit tool results are now fixed-size summaries.** `edit_file`, `write_file` (when it overwrites an existing file), and `morph_edit_file` previously returned the full syntax-highlighted diff to the model as the tool result. The colored diff carried truecolor ANSI escape sequences that roughly multiplied the byte count per line, and the tool result stayed in conversation history for every subsequent turn — so a session with many edits paid that bloated cost again on each later turn, and a single large rewrite could push the response into the hundreds of thousands of tokens. The model now sees a fixed two-line summary (`Success. Updated the following files:` followed by `M <path>`) regardless of edit size, while the terminal still renders the full colored diff exactly as before. If the model needs to verify the post-edit state it can re-read a range of the file.
10+
711
## [0.2.11] - 2026-05-16
812

913
### Added

src/repl/response_handler.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,11 @@ impl ResponseHandler {
393393
);
394394
}
395395

396-
let display_output =
397-
UI::create_tool_display_message(tool_name, tool_input, output.text());
396+
let display_output = UI::create_tool_display_message(
397+
tool_name,
398+
tool_input,
399+
output.display_text(),
400+
);
398401

399402
if !display_output.is_empty() {
400403
UI::shared().print_tool_output(&display_output);

src/tools/executor.rs

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,31 @@ const MAX_WEB_FETCH_BODY_BYTES: usize = 64 * 1024 * 1024;
3939
pub enum ToolExecutionResult {
4040
/// Simple text result (for most tools)
4141
Text(String),
42+
/// Separate text for the model vs. the on-screen display. The model
43+
/// gets a short summary; the user sees the full rendered output
44+
/// (e.g. a colored diff). Keeps the tool-result payload small in
45+
/// conversation history without losing the visual diff in the TUI.
46+
TextWithDisplay { text: String, display: String },
4247
/// Structured result with optional images (for MCP tools)
4348
Structured(McpToolResult),
4449
}
4550

4651
impl ToolExecutionResult {
47-
/// Get the text content
52+
/// Text shipped back to the model as the tool result.
4853
pub fn text(&self) -> &str {
4954
match self {
5055
ToolExecutionResult::Text(s) => s,
56+
ToolExecutionResult::TextWithDisplay { text, .. } => text,
57+
ToolExecutionResult::Structured(r) => &r.text,
58+
}
59+
}
60+
61+
/// Text rendered to the user in the TUI / session replay. Falls back
62+
/// to the model-facing text for tools that don't draw a distinction.
63+
pub fn display_text(&self) -> &str {
64+
match self {
65+
ToolExecutionResult::Text(s) => s,
66+
ToolExecutionResult::TextWithDisplay { display, .. } => display,
5167
ToolExecutionResult::Structured(r) => &r.text,
5268
}
5369
}
@@ -56,11 +72,43 @@ impl ToolExecutionResult {
5672
pub fn images(&self) -> &[ImageData] {
5773
match self {
5874
ToolExecutionResult::Text(_) => &[],
75+
ToolExecutionResult::TextWithDisplay { .. } => &[],
5976
ToolExecutionResult::Structured(r) => &r.images,
6077
}
6178
}
6279
}
6380

81+
/// Header line of the model-facing summary every file-modification tool
82+
/// emits. Mirrors a unified-diff "files changed" preamble: a fixed first
83+
/// line followed by per-file lines tagged `A` (added), `M` (modified),
84+
/// or `D` (deleted).
85+
const FILE_MUTATION_SUMMARY_HEADER: &str = "Success. Updated the following files:";
86+
87+
/// Build a [`ToolExecutionResult`] for a file-modification tool that
88+
/// wants to keep the user's colored diff while shipping a constant-size
89+
/// summary to the model. The colored diff carries syntax-highlighting
90+
/// ANSI that roughly multiplies the byte count per line, and every tool
91+
/// result stays in conversation history for the rest of the session —
92+
/// echoing the diff back to the model dominates the cost of repeated
93+
/// edits. Returning only `M <path>` to the model keeps that cost flat
94+
/// regardless of edit size; the model can `read_file` a range if it
95+
/// needs to inspect the post-edit state.
96+
fn file_modification_result(
97+
path: &str,
98+
original: &str,
99+
modified: &str,
100+
success_prefix: &str,
101+
) -> ToolExecutionResult {
102+
let diff_output = diff::generate_compact_diff(original, modified, path);
103+
let display_body = format!("{} '{}'\n\nChanges:\n{}", success_prefix, path, diff_output);
104+
let display = truncate_for_context(&display_body, MAX_DIFF_TOKENS, TruncationKind::DiffOutput);
105+
let summary = format!("{FILE_MUTATION_SUMMARY_HEADER}\nM {path}");
106+
ToolExecutionResult::TextWithDisplay {
107+
text: summary,
108+
display,
109+
}
110+
}
111+
64112
/// ToolExecutor handles execution of tool calls from AI
65113
#[derive(Clone)]
66114
pub struct ToolExecutor {
@@ -605,16 +653,12 @@ impl ToolExecutor {
605653
path
606654
))
607655
} else if let Some(original) = original_content {
608-
let diff_output = diff::generate_compact_diff(&original, content, path);
609-
let body = format!(
610-
"Successfully wrote to file '{}'\n\nChanges:\n{}",
611-
path, diff_output
612-
);
613-
Ok(truncate_for_context(
614-
&body,
615-
MAX_DIFF_TOKENS,
616-
TruncationKind::DiffOutput,
617-
))
656+
return Ok(file_modification_result(
657+
path,
658+
&original,
659+
content,
660+
"Successfully wrote to file",
661+
));
618662
} else {
619663
Ok(format!("Successfully created file '{}'", path))
620664
}
@@ -900,16 +944,12 @@ impl ToolExecutor {
900944
.write_file_with_outside_access(&resolved.canonical_str, &modified)?;
901945
}
902946

903-
let diff_output = diff::generate_compact_diff(&original, &modified, path);
904-
let body = format!(
905-
"Successfully edited '{}'\n\nChanges:\n{}",
906-
path, diff_output
907-
);
908-
Ok(truncate_for_context(
909-
&body,
910-
MAX_DIFF_TOKENS,
911-
TruncationKind::DiffOutput,
912-
))
947+
return Ok(file_modification_result(
948+
path,
949+
&original,
950+
&modified,
951+
"Successfully edited",
952+
));
913953
}
914954
ToolName::MorphEditFile => {
915955
let morph = self.morph_client.as_ref().ok_or_else(|| {
@@ -1061,18 +1101,12 @@ impl ToolExecutor {
10611101
.write_file_with_outside_access(&resolved.canonical_str, &merged_code)?;
10621102
}
10631103

1064-
// Generate diff for display
1065-
let diff_output = diff::generate_compact_diff(&original_code, &merged_code, path);
1066-
1067-
let body = format!(
1068-
"Successfully applied Morph edit to '{}'\n\nChanges:\n{}",
1069-
path, diff_output
1070-
);
1071-
Ok(truncate_for_context(
1072-
&body,
1073-
MAX_DIFF_TOKENS,
1074-
TruncationKind::DiffOutput,
1075-
))
1104+
return Ok(file_modification_result(
1105+
path,
1106+
&original_code,
1107+
&merged_code,
1108+
"Successfully applied Morph edit to",
1109+
));
10761110
}
10771111
ToolName::DeleteFile => {
10781112
let path = input["path"].as_str().ok_or_else(|| {

src/tools/tests.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,91 @@ async fn test_edit_file_replace_all() {
406406
assert_eq!(content, "ccc bbb ccc");
407407
}
408408

409+
#[tokio::test]
410+
async fn test_edit_file_returns_compact_summary_to_model_and_diff_to_display() {
411+
// After a successful edit, the model-facing `text()` is a fixed
412+
// two-line summary regardless of edit size, while `display_text()`
413+
// still carries the rendered diff for the UI. This keeps repeated
414+
// edits from re-billing the diff in conversation history every turn,
415+
// and is the contract every file-modification tool routes through
416+
// `file_modification_result` to provide.
417+
let workspace = tempdir().unwrap();
418+
let config_dir = workspace.path().join(".sofos");
419+
std::fs::create_dir_all(&config_dir).unwrap();
420+
std::fs::write(
421+
config_dir.join("config.local.toml"),
422+
"[permissions]\nallow = []\ndeny = []\nask = []\n",
423+
)
424+
.unwrap();
425+
std::fs::write(workspace.path().join("greet.txt"), "hello world").unwrap();
426+
427+
let executor =
428+
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
429+
let result = executor
430+
.execute(
431+
"edit_file",
432+
&json!({"path": "greet.txt", "old_string": "world", "new_string": "rust"}),
433+
)
434+
.await
435+
.unwrap();
436+
437+
assert_eq!(
438+
result.text(),
439+
"Success. Updated the following files:\nM greet.txt",
440+
"model should receive only the compact summary"
441+
);
442+
assert!(
443+
result
444+
.display_text()
445+
.contains("Successfully edited 'greet.txt'"),
446+
"display should keep the per-tool success heading"
447+
);
448+
assert!(
449+
result.display_text().contains("Changes:"),
450+
"display should keep the diff preamble"
451+
);
452+
assert!(
453+
!result.text().contains('\x1b'),
454+
"the summary sent to the model must be free of ANSI escapes"
455+
);
456+
}
457+
458+
#[tokio::test]
459+
async fn test_write_file_overwrite_returns_compact_summary_to_model() {
460+
// Overwriting an existing file with `write_file` is the second tool
461+
// that used to echo a colored diff back to the model. The summary
462+
// path must mirror `edit_file`'s contract.
463+
let workspace = tempdir().unwrap();
464+
let config_dir = workspace.path().join(".sofos");
465+
std::fs::create_dir_all(&config_dir).unwrap();
466+
std::fs::write(
467+
config_dir.join("config.local.toml"),
468+
"[permissions]\nallow = []\ndeny = []\nask = []\n",
469+
)
470+
.unwrap();
471+
std::fs::write(workspace.path().join("notes.txt"), "old body").unwrap();
472+
473+
let executor =
474+
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
475+
let result = executor
476+
.execute(
477+
"write_file",
478+
&json!({"path": "notes.txt", "content": "new body"}),
479+
)
480+
.await
481+
.unwrap();
482+
483+
assert_eq!(
484+
result.text(),
485+
"Success. Updated the following files:\nM notes.txt"
486+
);
487+
assert!(
488+
result
489+
.display_text()
490+
.contains("Successfully wrote to file 'notes.txt'")
491+
);
492+
}
493+
409494
#[tokio::test]
410495
async fn test_glob_files_finds_matches() {
411496
let workspace = tempdir().unwrap();

0 commit comments

Comments
 (0)