mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 09:45:24 -04:00
fix(code): harden approval content rendering (#4581)
Closes #4575 Defend against crashes where malformed streamed file-edit tool arguments could break the manual approval screen before the tool call was validated. --- The manual approval UI renders tool-call arguments before the filesystem tool schema has a chance to validate them. If a streamed `write_file` or `edit_file` call contains malformed non-string content, the approval widgets could call string methods on raw objects and crash the TUI. This change makes approval rendering defensive by formatting arbitrary raw content into displayable text before line counting, diff generation, or Markdown rendering. Valid string content still passes through unchanged; malformed objects are JSON-formatted when possible and fall back to `str()` otherwise.
This commit is contained in:
@@ -10,6 +10,7 @@ from deepagents_code.tui.widgets.tool_widgets import (
|
||||
EditFileApprovalWidget,
|
||||
GenericApprovalWidget,
|
||||
WriteFileApprovalWidget,
|
||||
format_display_content,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -52,7 +53,7 @@ class WriteFileRenderer(ToolRenderer):
|
||||
) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
|
||||
# Extract file extension for syntax highlighting
|
||||
file_path = tool_args.get("file_path", "")
|
||||
content = tool_args.get("content", "")
|
||||
content = format_display_content(tool_args.get("content", ""))
|
||||
|
||||
# Get file extension
|
||||
file_extension = "text"
|
||||
@@ -121,8 +122,8 @@ class EditFileRenderer(ToolRenderer):
|
||||
assistant_id: str | None = None, # noqa: ARG004
|
||||
) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
|
||||
file_path = tool_args.get("file_path", "")
|
||||
old_string = tool_args.get("old_string", "")
|
||||
new_string = tool_args.get("new_string", "")
|
||||
old_string = format_display_content(tool_args.get("old_string", ""))
|
||||
new_string = format_display_content(tool_args.get("new_string", ""))
|
||||
|
||||
# Generate unified diff
|
||||
diff_lines = EditFileRenderer._generate_diff(old_string, new_string)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from textual.containers import Vertical
|
||||
@@ -20,6 +21,23 @@ _MAX_DIFF_LINES = 50
|
||||
_MAX_PREVIEW_LINES = 20
|
||||
|
||||
|
||||
def format_display_content(content: object) -> str:
|
||||
"""Coerce arbitrary tool-arg content into a displayable string.
|
||||
|
||||
Strings pass through unchanged; other values are JSON-formatted for
|
||||
readability, falling back to `str()` when serialization fails.
|
||||
|
||||
Returns:
|
||||
A string safe to render in an approval widget.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
try:
|
||||
return json.dumps(content, ensure_ascii=False, indent=2)
|
||||
except (TypeError, ValueError, RecursionError):
|
||||
return str(content)
|
||||
|
||||
|
||||
def _format_stats(additions: int, deletions: int) -> Content:
|
||||
"""Format addition/deletion stats as styled Content.
|
||||
|
||||
@@ -142,7 +160,7 @@ class WriteFileApprovalWidget(ToolApprovalWidget):
|
||||
Widgets displaying file path header and syntax-highlighted content.
|
||||
"""
|
||||
file_path = self.data.get("file_path", "")
|
||||
content = self.data.get("content", "")
|
||||
content = format_display_content(self.data.get("content", ""))
|
||||
file_extension = self.data.get("file_extension", "text")
|
||||
|
||||
# Content with syntax highlighting via Markdown code block
|
||||
@@ -175,8 +193,8 @@ class EditFileApprovalWidget(ToolApprovalWidget):
|
||||
"""
|
||||
file_path = self.data.get("file_path", "")
|
||||
diff_lines = self.data.get("diff_lines", [])
|
||||
old_string = self.data.get("old_string", "")
|
||||
new_string = self.data.get("new_string", "")
|
||||
old_string = format_display_content(self.data.get("old_string", ""))
|
||||
new_string = format_display_content(self.data.get("new_string", ""))
|
||||
|
||||
additions, deletions = _count_diff_stats(diff_lines, old_string, new_string)
|
||||
yield from _file_header(file_path, additions, deletions)
|
||||
|
||||
@@ -4,9 +4,68 @@ from deepagents_code.tui.widgets.tool_renderers import get_renderer
|
||||
from deepagents_code.tui.widgets.tool_widgets import (
|
||||
EditFileApprovalWidget,
|
||||
GenericApprovalWidget,
|
||||
WriteFileApprovalWidget,
|
||||
)
|
||||
|
||||
|
||||
def test_write_renderer_formats_non_string_content() -> None:
|
||||
widget_class, data = get_renderer("write_file").get_approval_widget(
|
||||
{"file_path": "data.json", "content": {"a": "b"}}
|
||||
)
|
||||
|
||||
assert widget_class is WriteFileApprovalWidget
|
||||
assert data["content"] == '{\n "a": "b"\n}'
|
||||
|
||||
|
||||
def test_write_renderer_falls_back_to_str_for_unserializable_content() -> None:
|
||||
widget_class, data = get_renderer("write_file").get_approval_widget(
|
||||
{"file_path": "data.txt", "content": {1, 2, 3}}
|
||||
)
|
||||
|
||||
assert widget_class is WriteFileApprovalWidget
|
||||
assert data["content"] == str({1, 2, 3})
|
||||
|
||||
|
||||
def test_write_widget_formats_non_string_content() -> None:
|
||||
widgets = list(
|
||||
WriteFileApprovalWidget(
|
||||
{"file_path": "data.json", "content": {"a": "b"}}
|
||||
).compose()
|
||||
)
|
||||
|
||||
assert len(widgets) == 3
|
||||
|
||||
|
||||
def test_edit_renderer_formats_non_string_content() -> None:
|
||||
widget_class, data = get_renderer("edit_file").get_approval_widget(
|
||||
{
|
||||
"file_path": "data.json",
|
||||
"old_string": {"a": "b"},
|
||||
"new_string": {"a": "c"},
|
||||
}
|
||||
)
|
||||
|
||||
assert widget_class is EditFileApprovalWidget
|
||||
assert data["old_string"] == '{\n "a": "b"\n}'
|
||||
assert data["new_string"] == '{\n "a": "c"\n}'
|
||||
assert '- "a": "b"' in data["diff_lines"]
|
||||
assert '+ "a": "c"' in data["diff_lines"]
|
||||
|
||||
|
||||
def test_edit_widget_formats_non_string_content() -> None:
|
||||
widgets = list(
|
||||
EditFileApprovalWidget(
|
||||
{
|
||||
"file_path": "data.json",
|
||||
"old_string": {"a": "b"},
|
||||
"new_string": {"a": "c"},
|
||||
}
|
||||
).compose()
|
||||
)
|
||||
|
||||
assert widgets
|
||||
|
||||
|
||||
def test_delete_renderer_shows_removed_file_diff(tmp_path: Path) -> None:
|
||||
target = tmp_path / "old.txt"
|
||||
target.write_text("alpha\nbeta\n", encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user