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:
committed by
GitHub
parent
147d1fbde6
commit
7443c36bd9
@@ -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>
|
||||
|
||||
@@ -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) || ' '}</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();
|
||||
|
||||
@@ -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) || ' '}</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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user