mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): support unambiguous read_file gutters (#4711)
Depends on #4561. Deep Agents Code now displays both legacy and current `read_file` line gutters without dropping tab-based source indentation. --- Deep Agents Code compacts `read_file` line-number gutters before displaying tool output. The SDK change in #4561 replaces the ambiguous tab separator with two spaces, but the TUI parser currently recognizes only the legacy format. Teach the parser to recognize both formats while consuming only the gutter separator. This preserves tabs that belong to source indentation and keeps compatibility with older string backends that still emit `cat -n`-style output.
This commit is contained in:
@@ -158,6 +158,19 @@ _SUCCESS_EXIT_RE = re.compile(r"\n?\[Command succeeded with exit code 0\]\s*$")
|
||||
"""Strip the SDK's `[Command succeeded with exit code 0]` trailer from tool output."""
|
||||
|
||||
|
||||
_READ_FILE_GUTTER_RE = re.compile(r"^ *(\d+(?:\.\d+)?)(?: |\t)(.*)$")
|
||||
"""Match a `read_file` gutter row into (marker, source).
|
||||
|
||||
The marker is a bare `N` or `N.M` (the latter a wrapped-line continuation) —
|
||||
both sides of the dot required, so a stray `.5` head is not a gutter. The
|
||||
separator is exactly two spaces (current format) or a single tab (legacy
|
||||
`cat -n`). Only the separator is consumed and leading padding is spaces-only, so
|
||||
source indentation — including leading tabs — after the gutter stays put. Kept in
|
||||
sync with the separator emitted by deepagents' `format_content_with_line_numbers`
|
||||
(the authoritative producer). See `ToolCallMessage._compact_line_gutter`.
|
||||
"""
|
||||
|
||||
|
||||
def _strip_success_exit_line(text: str) -> str:
|
||||
"""Remove the `[Command succeeded with exit code 0]` trailer.
|
||||
|
||||
@@ -1983,48 +1996,49 @@ class ToolCallMessage(Vertical):
|
||||
|
||||
@staticmethod
|
||||
def _compact_line_gutter(output: str) -> str:
|
||||
r"""Tighten `read_file`'s cat -n line-number gutter for display.
|
||||
r"""Tighten `read_file`'s line-number gutter for display.
|
||||
|
||||
The tool emits `f"{line_num:6d}\t{line}"` — a 6-wide right-justified
|
||||
number plus a tab — so even single-digit line numbers carry five
|
||||
leading spaces and the tab pushes content to a distant tab stop. The
|
||||
model needs that raw format for edits, but the TUI renders a compact
|
||||
gutter instead: numbers right-justified to the widest number actually
|
||||
present, then two spaces, mirroring how grep/glob results sit flush
|
||||
left. Source indentation after the gutter is preserved untouched.
|
||||
`read_file` prefixes each row with a right-justified line marker — `N`,
|
||||
or `N.M` for a wrapped-line continuation — then two spaces, then the
|
||||
original source content. (Output from deepagents versions predating the
|
||||
gutter disambiguation in #4561 used the older `cat -n` gutter — a wide
|
||||
right-justified number and a tab — which may still surface from cached or
|
||||
persisted transcripts.) The model needs the raw gutter for edits, but the
|
||||
TUI re-justifies markers to the widest marker actually present, then two
|
||||
spaces, mirroring how grep/glob results sit flush left. Source
|
||||
indentation after the gutter is preserved untouched.
|
||||
|
||||
Lines that don't match the cat -n shape (e.g. test fixtures or
|
||||
non-numbered output) are passed through unchanged.
|
||||
The gutter shape is `_READ_FILE_GUTTER_RE`. Lines that don't match a
|
||||
gutter shape (e.g. test fixtures or non-numbered output) are passed
|
||||
through unchanged.
|
||||
|
||||
Returns:
|
||||
The output with compacted gutters, or the original string if no
|
||||
line-numbered content was found.
|
||||
"""
|
||||
lines = output.split("\n")
|
||||
# Split each line on its gutter tab into (number, source). The gutter
|
||||
# tab is always the first one; any tabs in `text` are real source
|
||||
# indentation and stay put. The head must be a bare `N` or `N.M` (the
|
||||
# latter is a wrapped-line continuation marker) — both sides of the dot
|
||||
# are required, so a stray `.5` head marks a non-gutter line.
|
||||
parsed: list[tuple[str, str] | None] = []
|
||||
width = 0
|
||||
for line in lines:
|
||||
head, tab, text = line.partition("\t")
|
||||
num = head.strip()
|
||||
whole, dot, frac = num.partition(".")
|
||||
if tab and whole.isdigit() and (not dot or frac.isdigit()):
|
||||
parsed.append((num, text))
|
||||
width = max(width, len(num))
|
||||
match = _READ_FILE_GUTTER_RE.match(line)
|
||||
if match:
|
||||
marker, source = match.groups()
|
||||
parsed.append((marker, source))
|
||||
width = max(width, len(marker))
|
||||
else:
|
||||
parsed.append(None)
|
||||
|
||||
if width == 0:
|
||||
return output
|
||||
|
||||
return "\n".join(
|
||||
f"{row[0]:>{width}} {row[1]}" if row else line
|
||||
for line, row in zip(lines, parsed, strict=True)
|
||||
)
|
||||
compacted: list[str] = []
|
||||
for line, row in zip(lines, parsed, strict=True):
|
||||
if row is None:
|
||||
compacted.append(line)
|
||||
else:
|
||||
marker, source = row
|
||||
compacted.append(f"{marker:>{width}} {source}")
|
||||
return "\n".join(compacted)
|
||||
|
||||
def _format_edit_file_output(
|
||||
self, output: str, *, is_preview: bool = False
|
||||
|
||||
@@ -6,6 +6,10 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from deepagents.backends.utils import (
|
||||
MAX_LINE_LENGTH,
|
||||
format_content_with_line_numbers,
|
||||
)
|
||||
from rich.style import Style
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.content import Content
|
||||
@@ -2794,14 +2798,15 @@ class TestToolCallMessageFileOutput:
|
||||
|
||||
assert result.truncation == "2 more lines"
|
||||
|
||||
def test_format_output_compacts_line_number_gutter(self) -> None:
|
||||
r"""Line-number gutters are tightened, all rows aligned to one column.
|
||||
def test_format_output_compacts_legacy_cat_n_gutter(self) -> None:
|
||||
r"""Legacy `cat -n` gutters are tightened, all rows aligned to one column.
|
||||
|
||||
`read_file` emits `f"{line_num:6d}\t{line}"` — a 6-wide right-justified
|
||||
number plus a tab — which renders far from the line numbers and (when
|
||||
the first row's padding was stripped) misaligned. The TUI recomputes a
|
||||
compact gutter: numbers right-justified to the widest number present,
|
||||
two spaces, then the original source indentation.
|
||||
deepagents versions predating #4561 emitted `f"{line_num:6d}\t{line}"` —
|
||||
a 6-wide right-justified number plus a tab — which renders far from the
|
||||
line numbers and (when the first row's padding was stripped) misaligned.
|
||||
Such output may still surface from cached or persisted transcripts. The
|
||||
TUI recomputes a compact gutter: numbers right-justified to the widest
|
||||
number present, two spaces, then the original source indentation.
|
||||
"""
|
||||
msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
|
||||
# cat -n style: 6-wide right-justified number + tab + source line.
|
||||
@@ -2812,6 +2817,23 @@ class TestToolCallMessageFileOutput:
|
||||
# indentation (the 4 spaces on line 3) preserved verbatim.
|
||||
assert result.content.plain == '1 """doc"""\n2 \n3 indented'
|
||||
|
||||
def test_format_output_leaves_current_two_space_gutter_intact(self) -> None:
|
||||
r"""The current SDK gutter is already compact, so compaction is a no-op.
|
||||
|
||||
`read_file` now emits `f"{marker:>width} {line}"` — a right-justified
|
||||
marker, two spaces, then source. Re-justifying to the widest marker
|
||||
reproduces the same string, and crucially a tab-indented source line
|
||||
keeps its leading tab (the two-space separator, not the source tab, is
|
||||
consumed). Regression guard: the old tab-splitting gutter dropped that
|
||||
indentation, re-introducing the ambiguity this format fixes.
|
||||
"""
|
||||
msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
|
||||
# Two-digit max marker => width 2; line 2 is tab-indented source.
|
||||
output = " 9 def build_config():\n10 \treturn {}"
|
||||
result = msg._format_output(output, is_preview=False)
|
||||
|
||||
assert result.content.plain == " 9 def build_config():\n10 \treturn {}"
|
||||
|
||||
def test_compact_line_gutter_right_justifies_to_widest_number(self) -> None:
|
||||
r"""Multi-digit line numbers set a uniform, right-justified gutter."""
|
||||
# Lines 9 and 10: single- vs double-digit numbers must align right.
|
||||
@@ -2832,30 +2854,108 @@ class TestToolCallMessageFileOutput:
|
||||
|
||||
assert compacted == " 1 first\n1.1 wrapped"
|
||||
|
||||
def test_compact_line_gutter_preserves_source_tabs(self) -> None:
|
||||
r"""Only the first (gutter) tab is consumed; source tabs stay put.
|
||||
def test_compact_line_gutter_preserves_source_tabs_legacy(self) -> None:
|
||||
r"""Only the gutter tab is consumed; a legacy row's source tab stays put.
|
||||
|
||||
Tab-indented source means a tab immediately after the gutter tab.
|
||||
`partition` splits on the first tab only, so the source tab survives.
|
||||
Tab-indented source means a tab immediately after the gutter tab. The
|
||||
gutter regex consumes only the separator, so the source tab survives.
|
||||
"""
|
||||
output = " 1\t\tdef foo():"
|
||||
compacted = ToolCallMessage._compact_line_gutter(output)
|
||||
|
||||
assert compacted == "1 \tdef foo():"
|
||||
|
||||
def test_compact_line_gutter_preserves_source_tabs_current(self) -> None:
|
||||
r"""A current-format row's leading source tab (and a blank row) survive.
|
||||
|
||||
The current gutter separator is two spaces, so a tab-indented source
|
||||
line reads as `"N \tsource"`. Only the two spaces are consumed; the
|
||||
source tab must remain. This is the regression guard for the tab-
|
||||
splitting gutter that used to drop it. A blank source line (`"N "` —
|
||||
marker, separator, empty source) round-trips unchanged too; the
|
||||
`_compact_line_gutter` return preserves its trailing separator (the
|
||||
display path may later strip trailing space, but the compactor does not).
|
||||
"""
|
||||
output = "1 def foo():\n2 \treturn 1\n3 "
|
||||
compacted = ToolCallMessage._compact_line_gutter(output)
|
||||
|
||||
assert compacted == "1 def foo():\n2 \treturn 1\n3 "
|
||||
|
||||
def test_compact_line_gutter_parses_real_producer_output(self) -> None:
|
||||
r"""Round-trip guard against producer/consumer separator drift.
|
||||
|
||||
Feeds real `format_content_with_line_numbers` output (the authoritative
|
||||
producer, in the deepagents package) through the TUI parser. If the
|
||||
producer separator ever changes without this parser following, the exact
|
||||
assertion fails in CI instead of the gutter silently failing to compact
|
||||
(or, on a widened separator, leaking a phantom space into every source
|
||||
line). Line 2 is tab-indented source — the ambiguity this format fixes.
|
||||
"""
|
||||
output = format_content_with_line_numbers(
|
||||
["def f():", "\treturn 1"], start_line=1
|
||||
)
|
||||
compacted = ToolCallMessage._compact_line_gutter(output)
|
||||
|
||||
assert compacted == "1 def f():\n2 \treturn 1"
|
||||
|
||||
def test_compact_line_gutter_round_trips_continuation_and_padding(self) -> None:
|
||||
r"""Real-producer round-trip exercising continuation + multi-digit padding.
|
||||
|
||||
The base round-trip test feeds short, unpadded lines. This one forces a
|
||||
wrapped line (an `N.M` continuation marker) and a two-digit line number,
|
||||
so the continuation marker drives the column width and the shorter
|
||||
markers get leading-space padding — all through the real producer, not a
|
||||
hand-built string. Line 3 is tab-indented source, which must survive.
|
||||
Extends drift protection to the continuation and padding paths, not just
|
||||
the base separator.
|
||||
"""
|
||||
long_line = "x" * (MAX_LINE_LENGTH + 5) # forces an `N.1` continuation
|
||||
output = format_content_with_line_numbers(
|
||||
["short", long_line, "\treturn 1"], start_line=9
|
||||
)
|
||||
compacted = ToolCallMessage._compact_line_gutter(output)
|
||||
|
||||
lines = compacted.split("\n")
|
||||
assert lines[0] == " 9 short" # width 4, driven by the "10.1" marker
|
||||
assert lines[2].startswith("10.1 ")
|
||||
assert lines[-1] == " 11 \treturn 1" # tab-indented source survives
|
||||
|
||||
def test_compact_line_gutter_preserves_double_spaced_source(self) -> None:
|
||||
r"""Only the first separator is consumed; the rest of the source is verbatim.
|
||||
|
||||
A source line whose own text starts with digits and two spaces
|
||||
(`"42 meaning"`) must survive intact: the regex captures the leading
|
||||
gutter marker and emits everything after the first two-space separator
|
||||
untouched, including the embedded double space. Guards the `(.*)` capture
|
||||
against a future separator group that might reprocess or collapse
|
||||
interior spacing (e.g. widening `(?: |\t)` to `\s+`).
|
||||
"""
|
||||
# width 2 (max marker "10"); row 5's source begins with digits + 2 spaces.
|
||||
output = "5 42 meaning\n10 ok"
|
||||
compacted = ToolCallMessage._compact_line_gutter(output)
|
||||
|
||||
assert compacted == " 5 42 meaning\n10 ok"
|
||||
|
||||
def test_compact_line_gutter_passes_through_non_numbered(self) -> None:
|
||||
"""Output without a cat -n gutter is returned unchanged."""
|
||||
"""Output without a gutter is returned unchanged."""
|
||||
output = "plain text\nno line numbers here"
|
||||
assert ToolCallMessage._compact_line_gutter(output) == output
|
||||
|
||||
def test_compact_line_gutter_rejects_malformed_number_heads(self) -> None:
|
||||
r"""Heads that aren't a bare `N`/`N.M` are treated as source, not gutter.
|
||||
|
||||
Guards against corrupting tab-separated data whose first column merely
|
||||
resembles a number (leading/trailing dot, multiple dots).
|
||||
Guards against corrupting data whose first column merely resembles a
|
||||
number (leading/trailing dot, multiple dots) — in both the legacy tab
|
||||
and current two-space forms, since a malformed head fails the marker
|
||||
pattern before the separator alternation is ever reached.
|
||||
"""
|
||||
# Leading dot, trailing dot, and multi-dot heads must all pass through.
|
||||
output = " .5\tweird\n 5.\talso\n 1.2.3\tnope"
|
||||
# Leading dot, trailing dot, and multi-dot heads must all pass through,
|
||||
# whichever separator follows. All lines are non-gutter, so nothing
|
||||
# matches and the whole output is returned verbatim.
|
||||
output = (
|
||||
" .5\tweird\n 5.\talso\n 1.2.3\tnope\n"
|
||||
".5 two-space\n5. two-space\n1.2.3 two-space"
|
||||
)
|
||||
assert ToolCallMessage._compact_line_gutter(output) == output
|
||||
|
||||
def test_compact_line_gutter_preview_truncates_with_compacted_gutters(
|
||||
|
||||
Reference in New Issue
Block a user