feat: Add edit_file tool + file-change diffs (#1239)

* Add edit_file tool + file-change diffs

edit_file is an exact old_string -> new_string replacement on a file on disk
(fails if old_string is missing or non-unique unless replace_all); write_file
also returns a unified diff. Diffs render collapsed in the tool bubble
(filename + +adds/-dels, theme colors); the raw JSON command box is hidden.

Security: edit_file is a sensitive filesystem-write tool, treated everywhere
write_file is —
  - added to NON_ADMIN_BLOCKED_TOOLS (is_public_blocked_tool / blocked_tools_for_owner),
    so on auth-enabled deployments a non-admin cannot run it; execute_tool_block
    refuses it for non-admin owners.
  - confined by the same path policy as read_file/write_file (allowlist +
    sensitive-file deny) via _resolve_tool_path.

Disambiguation in tool descriptions + bash prompt: edit_file/write_file are the
only way to write files (they show a diff) — never edit_document (editor panel)
or a bash heredoc/redirect.

Tests (tests/test_edit_file.py): non-admin block (policy + execution gate),
successful edit, not-found old_string, non-unique old_string (+ replace_all),
and path outside the allowed roots.

Files: src/tool_execution.py, src/agent_loop.py, src/tool_schemas.py,
src/agent_tools.py, src/tool_index.py, static/js/chat.js, static/style.css,
tests/test_edit_file.py.

* Drop redundant import os in write_file closure

os is already imported at module top.
This commit is contained in:
Kenny Van de Maele
2026-06-04 18:29:10 +02:00
committed by GitHub
parent 147d1fbde6
commit 7443c36bd9
11 changed files with 351 additions and 12 deletions

View File

@@ -2264,7 +2264,7 @@
<script type="module" src="/static/js/chatRenderer.js"></script>
<script type="module" src="/static/js/codeRunner.js"></script>
<script type="module" src="/static/js/chatStream.js"></script>
<script type="module" src="/static/js/chat.js?v=20260520m"></script>
<script type="module" src="/static/js/chat.js?v=20260603n"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
<script type="module" src="/static/js/compare/index.js"></script>

View File

@@ -2074,7 +2074,33 @@ import createResearchSynapse from './researchSynapse.js';
if (json.output && json.output.trim()) {
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(json.output)}</pre></details>`;
}
const cmdHtml2 = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
// File-write diff (write_file): show a before/after unified diff.
let diffHtml = '';
if (json.diff && json.diff.text) {
const d = json.diff;
// Collapsed summary: filename + +adds (green) / dels (red).
const stat = [
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
d.removed ? `<span class="diff-stat-del">${d.removed}</span>` : '',
].filter(Boolean).join(' ');
const rows = d.text.split('\n').map(line => {
let cls = 'diff-ctx', text = line;
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
else if (line.startsWith('@@')) cls = 'diff-hunk';
// Drop the leading diff marker (+/-/space) — the row colour
// already encodes add/del, and keeping it doubles up with
// markdown "- " bullets (reads as "+-"/"--").
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
else if (line.startsWith(' ')) { text = line.slice(1); }
return `<span class="${cls}">${esc(text) || '&nbsp;'}</span>`;
}).join(''); // spans are display:block — a literal \n here would double-space the diff
diffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
}
// For file edits the "command" is the raw JSON args — redundant
// next to the diff, so hide it when we have a diff to show.
const cmdHtml2 = (cmd && !(json.diff && json.diff.text)) ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
// Preserve the user's .open choice across the innerHTML
// rewrite \u2014 otherwise expanding a running tool collapses
// it as soon as the result lands, forcing the user to
@@ -2082,7 +2108,7 @@ import createResearchSynapse from './researchSynapse.js';
// bottom of file) so no per-node listener needed.
const _wasOpen = currentToolBubble.classList.contains('open');
currentToolBubble.className = 'agent-thread-node' + (ok ? '' : ' error') + (_wasOpen ? ' open' : '');
currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}</div>`;
currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}${diffHtml}</div>`;
// Reset so thinking spinner between tools says "Thinking" not the old tool's label
_lastToolName = '';
uiModule.scrollHistory();

View File

@@ -1956,10 +1956,33 @@ export function addMessage(role, content, modelName, metadata) {
if (ev.screenshot) {
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(ev.screenshot)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
}
// File-write/edit diff (persisted in the tool event) \u2014 re-render it
// so it survives reload, matching the live stream.
let evDiffHtml = '';
if (ev.diff && ev.diff.text) {
const d = ev.diff;
const stat = [
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
d.removed ? `<span class="diff-stat-del">\u2212${d.removed}</span>` : '',
].filter(Boolean).join(' ');
const rows = d.text.split('\n').map(line => {
let cls = 'diff-ctx', text = line;
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
else if (line.startsWith('@@')) cls = 'diff-hunk';
// Drop the leading diff marker (+/-/space) — colour encodes add/del.
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
else if (line.startsWith(' ')) { text = line.slice(1); }
return `<span class="${cls}">${esc(text) || '&nbsp;'}</span>`;
}).join(''); // spans are display:block \u2014 a literal \n would double-space
evDiffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
}
const node = document.createElement('div');
node.className = 'agent-thread-node' + (ok ? '' : ' error');
const evCmdHtml = ev.command ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}</div>`;
// Hide the raw JSON command when a diff says it better (same as live).
const evCmdHtml = (ev.command && !(ev.diff && ev.diff.text)) ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}${evDiffHtml}</div>`;
// Click handling is delegated globally \u2014 see chat.js init.
threadWrap.appendChild(node);
}

View File

@@ -8835,6 +8835,57 @@ body.hide-thinking .thinking-section { display: none !important; }
list-style: none;
}
.agent-tool-output summary::-webkit-details-marker { display: none; }
/* File-write diff — neutral chrome (not the red error tint) + colored lines */
.agent-tool-diff {
background: color-mix(in srgb, var(--fg) 4%, transparent);
border-color: color-mix(in srgb, var(--fg) 18%, transparent);
}
.agent-tool-diff summary {
color: var(--fg);
background: color-mix(in srgb, var(--fg) 7%, transparent);
border-bottom-color: color-mix(in srgb, var(--fg) 12%, transparent);
}
.agent-tool-diff .diff-stat {
font-weight: 600;
opacity: 0.7;
font-family: var(--mono, monospace);
}
/* Collapsed diff summary: filename + +adds/dels (theme green/red). */
.agent-tool-diff summary {
display: flex;
align-items: center;
gap: 8px;
}
.agent-tool-diff .diff-file {
font-family: var(--mono, monospace);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-tool-diff .diff-summary-stats {
margin-left: auto;
font-family: var(--mono, monospace);
font-weight: 600;
flex-shrink: 0;
}
.agent-tool-diff .diff-summary-stats .diff-stat-add { color: var(--green, #2ecc71); }
.agent-tool-diff .diff-summary-stats .diff-stat-del { color: var(--red, #e74c3c); }
.agent-tool-diff .diff-summary-stats .diff-stat-new { color: var(--accent, var(--red)); opacity: 0.85; }
.diff-pre {
margin: 0;
padding: 8px 10px;
overflow-x: auto;
font-family: var(--mono, monospace);
font-size: 0.82em;
line-height: 1.45;
}
.diff-pre span { display: block; white-space: pre; }
.diff-pre .diff-add { background: color-mix(in srgb, #2ecc71 22%, transparent); }
.diff-pre .diff-del { background: color-mix(in srgb, #e74c3c 22%, transparent); }
.diff-pre .diff-hunk { color: var(--accent); opacity: 0.85; }
.diff-pre .diff-meta { opacity: 0.55; }
.diff-pre .diff-ctx { opacity: 0.8; }
/* Suppress the global `summary::before { content: '▶' }` left arrow this
section uses a right-side chevron instead. */
.agent-tool-output summary::before { content: none; }