Skip to content

Commit b195cc5

Browse files
committed
fix(cli): stream agent output as plain text instead of Rich markup
stream_process_output is the only reader of an agent's stdout pipe in `agentex agents run`, and it printed every line with console.print(f"[dim]{prefix}:[/dim] {decoded_line}"), so the agent's own output was parsed as Rich markup: - A line with an unmatched closing tag, such as "[INST] hi [/INST]" or "loaded [/etc/hosts]", raised MarkupError. That reached the outer handler and ended the loop, which is the freeze the function's docstring warns about: nothing drains the pipe any more and the agent blocks once it fills. - Bracketed words were eaten as styles: "[info] starting" printed as " starting", "deps: [pkg==1.0]" as "deps: ". - Markup rendering of long lines is slow and blocks the event loop while it runs: an 82,000-character line took 28.9 s here, which is also why test_large_line_within_the_limit_is_streamed_in_full times out on slower machines. Print a Text with a dim prefix and the line as plain text instead. The read loop, limits and error handling are unchanged. Verified by driving stream_process_output with an in-memory reader: on main the first markup-like line stops streaming and nothing after it is printed; here every line prints verbatim and the 82,000-character line prints in 0.01 s. Two new tests cover the reader surviving markup-like output and printing it verbatim; tests/lib/cli/test_run_handlers_streaming.py passes (7 tests, 1.5 s).
1 parent 6d68f3a commit b195cc5

2 files changed

Lines changed: 68 additions & 1 deletion

File tree

‎src/agentex/lib/cli/handlers/run_handlers.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import asyncio
66
from pathlib import Path
77

8+
from rich.text import Text
89
from rich.panel import Panel
910
from rich.console import Console
1011

@@ -291,7 +292,7 @@ async def stream_process_output(process: asyncio.subprocess.Process, prefix: str
291292
continue
292293

293294
if decoded_line: # Only print non-empty lines
294-
console.print(f"[dim]{prefix}:[/dim] {decoded_line}")
295+
console.print(Text.assemble((f"{prefix}:", "dim"), " ", decoded_line))
295296
except Exception as e:
296297
# The escalation path, including for the re-raise above. Anything reaching
297298
# here ends the loop, so the child is now at risk of blocking on a full pipe.

‎tests/lib/cli/test_run_handlers_streaming.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import re
1112
import sys
1213
import asyncio
1314
from typing import Any
@@ -40,6 +41,22 @@
4041
print("done")
4142
"""
4243

44+
MARKUP_LINES = (
45+
"[INST] hi [/INST]",
46+
"loaded [/etc/hosts]",
47+
"[info] starting",
48+
"deps: [pkg==1.0]",
49+
"[bold]x[/bold]",
50+
)
51+
52+
MARKUP_CHILD_SCRIPT = """
53+
for line in {lines!r}:
54+
print(line)
55+
for i in range(10000):
56+
print("after", i, "y" * 60)
57+
print("done")
58+
"""
59+
4360

4461
async def _drain(limit: int, oversized: int) -> int | None:
4562
"""Run the child under stream_process_output. None means it never exited."""
@@ -61,6 +78,26 @@ async def _drain(limit: int, oversized: int) -> int | None:
6178
return process.returncode
6279

6380

81+
async def _drain_markup_lines(limit: int) -> int | None:
82+
"""Run a child whose output looks like rich markup, then floods the pipe."""
83+
process = await asyncio.create_subprocess_exec(
84+
sys.executable,
85+
"-c",
86+
MARKUP_CHILD_SCRIPT.format(lines=MARKUP_LINES),
87+
stdout=asyncio.subprocess.PIPE,
88+
stderr=asyncio.subprocess.STDOUT,
89+
limit=limit,
90+
)
91+
streamer = asyncio.create_task(stream_process_output(process, "TEST"))
92+
try:
93+
await asyncio.wait_for(asyncio.gather(streamer, process.wait()), timeout=60)
94+
except TimeoutError:
95+
process.kill()
96+
await process.wait()
97+
return None
98+
return process.returncode
99+
100+
64101
async def test_oversized_line_is_skipped_without_stalling_the_child(
65102
capsys: pytest.CaptureFixture[str],
66103
) -> None:
@@ -98,6 +135,35 @@ async def test_large_line_within_the_limit_is_streamed_in_full(
98135
assert out.count(MARKER) == oversized, "the large line was dropped rather than streamed"
99136

100137

138+
async def test_markup_like_output_does_not_stop_the_reader(
139+
capsys: pytest.CaptureFixture[str],
140+
) -> None:
141+
"""An unmatched closing tag in the child's output must not end the loop.
142+
143+
Agents dump prompts and paths, so "[INST] hi [/INST]" is ordinary output.
144+
Parsed as markup it raises MarkupError, which reaches the outer handler and
145+
leaves nothing draining the pipe. The child reaching exit is the assertion,
146+
so the child writes far more than the buffers behind a 64 KiB limit hold.
147+
"""
148+
returncode = await _drain_markup_lines(limit=64 * 1024)
149+
out = capsys.readouterr().out
150+
151+
assert returncode == 0, "child did not exit: the reader stopped draining its pipe"
152+
assert "done" in out
153+
154+
155+
async def test_markup_like_output_is_printed_verbatim(
156+
capsys: pytest.CaptureFixture[str],
157+
) -> None:
158+
"""Bracketed words are the child's content, not styling, so none are eaten."""
159+
returncode = await _drain_markup_lines(limit=SUBPROCESS_STREAM_LIMIT)
160+
out = re.sub(r"\x1b\[[0-9;]*m", "", capsys.readouterr().out)
161+
162+
assert returncode == 0
163+
for line in MARKUP_LINES:
164+
assert f"TEST: {line}" in out, f"rich consumed part of {line!r}"
165+
166+
101167
class _AlwaysFailingReader:
102168
"""A reader whose readline() raises without consuming anything.
103169

0 commit comments

Comments
 (0)