Skip to content

Commit 77c5ce9

Browse files
committed
Fix Bash output buffering/wedge, add silence timeout and exit-code reporting
- Read output incrementally with a bounded head+tail buffer: a huge stream can no longer exhaust memory, and delivery never waits on a detached child holding the stdout pipe open. - Kill the process group by the pgid captured at spawn (no kill-time getpgid race); the observer thread kills directly, so Ctrl-C can never leave orphans. - Add BASH_TIMEOUT_SILENCE (120s, config): a command silent that long is SIGTERM'd then SIGKILL'd and reported as timed out, preserving partial output. Builds that keep printing are never affected. BASH_TIMEOUT_MAX is an opt-in absolute wall-clock cap. - Append 'Exit code: N' after truncation so it always survives. - Share the 20 KB output cap via config.MAX_OUTPUT_CHARS (Bash truncation + filesystem spool threshold).
1 parent a8b17b7 commit 77c5ce9

5 files changed

Lines changed: 455 additions & 80 deletions

File tree

python_agent_harness/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,23 @@
9797
"Question",
9898
]
9999

100+
# ---- tool output limits --------------------------------------------------------
101+
# Shared cap for tool results. Bash truncates output to a head+tail at
102+
# this size; Read/Glob/Grep spill results larger than this to a temp
103+
# file (the model then Reads the file for the full content). One
104+
# definition, used by all tools, so the limits never drift apart.
105+
MAX_OUTPUT_CHARS = 20_000
106+
107+
# ---- Bash tool timeout --------------------------------------------------------
108+
# Silence-based timeout: a command that produces no output for this
109+
# long (seconds) is killed (SIGTERM, then SIGKILL after 2s) and
110+
# reported as timed out. Builds that keep printing are never affected
111+
# — only genuinely stuck commands surface. None disables the check.
112+
BASH_TIMEOUT_SILENCE: float | None = 120.0
113+
# Optional absolute wall-clock cap (seconds): no command may run longer
114+
# than this regardless of output. None disables the cap (default).
115+
BASH_TIMEOUT_MAX: float | None = None
116+
100117
# ---- LLM client ----------------------------------------------------------------
101118
DEFAULT_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
102119
DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5-mini")

python_agent_harness/tools/bash.py

Lines changed: 228 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,219 @@
99
1010
A session cancel (Ctrl-C) kills the process group and delivers a
1111
cancelled error.
12+
13+
Output is read incrementally with a bounded buffer (head + tail), so a
14+
huge stream (e.g. ``cat`` of a multi-GB log) can never exhaust memory,
15+
and delivery never waits on a detached child that keeps the stdout
16+
pipe open after the shell has exited.
17+
18+
Normal completion appends ``Exit code: N`` (N negative = killed by a
19+
signal). A command that produces no output for ``BASH_TIMEOUT_SILENCE``
20+
seconds is killed (SIGTERM then SIGKILL) and reported as timed out;
21+
``BASH_TIMEOUT_MAX`` optionally caps the total runtime. Ctrl-C kills
22+
the process group immediately.
1223
"""
1324

1425
from __future__ import annotations
1526

27+
import codecs
1628
import contextlib
1729
import os
30+
import select
1831
import signal
1932
import subprocess
2033
import threading
34+
import time
35+
from collections import deque
2136

37+
from ..config import BASH_TIMEOUT_MAX, BASH_TIMEOUT_SILENCE
38+
from ..config import MAX_OUTPUT_CHARS as _MAX_OUTPUT
2239
from .base import PendingToolResult, Tool, ToolContext
2340

24-
# Maximum output size (chars) before truncation. Matches the 20 KB
25-
# spool threshold used by Glob/Grep/Read so that a model-issued
26-
# command cannot exhaust memory.
27-
_MAX_OUTPUT = 20_000
41+
# Tail lines kept after truncation (the head budget is derived from
42+
# MAX_OUTPUT_CHARS, shared with the filesystem spool threshold).
2843
_TAIL_LINES = 50 # lines kept from the tail after truncation
44+
_READ_CHUNK = 64 * 1024 # bytes read from the pipe per iteration
45+
_POLL_INTERVAL = 0.02 # seconds between cancel/exit checks
46+
_DRAIN_GRACE = 0.25 # seconds to keep reading after the process exits
47+
48+
49+
def _kill_pgid(pgid: int) -> None:
50+
"""Kill the process group PGID, ignoring "already gone" errors.
51+
52+
The group id is captured at spawn (with ``start_new_session=True``
53+
the child is the group leader, so its pid IS the pgid): it must
54+
never be resolved at kill time via ``os.getpgid`` — by then the
55+
shell may already be dead while a detached child that keeps the
56+
stdout pipe open is still running in the group.
57+
"""
58+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
59+
os.killpg(pgid, signal.SIGKILL)
2960

3061

31-
def _kill_process(proc: subprocess.Popen) -> None:
32-
"""Kill PROC and its whole process group.
62+
def _kill_graceful(pgid: int, proc: subprocess.Popen) -> None:
63+
"""SIGTERM the process group; SIGKILL if it is still alive 2s later.
3364
34-
The process is started in its own session so a shell's children
35-
(e.g. `sleep 30` spawned by the shell) die too; otherwise they keep
36-
the stdout pipe open and ``communicate()`` blocks until they exit.
65+
Used for timeouts so shells/compilers get a chance to clean up
66+
children before the hard kill.
3767
"""
68+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
69+
os.killpg(pgid, signal.SIGTERM)
3870
try:
39-
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
40-
except (ProcessLookupError, PermissionError, OSError):
41-
with contextlib.suppress(ProcessLookupError):
42-
proc.kill()
71+
proc.wait(timeout=2)
72+
except subprocess.TimeoutExpired:
73+
_kill_pgid(pgid)
74+
75+
76+
def _timeout_message(out: str, silence: bool) -> str:
77+
"""Report a timed-out command, preserving any output so far."""
78+
if silence:
79+
timeout = BASH_TIMEOUT_SILENCE or 0.0
80+
reason = f"no output for {timeout:.0f}s"
81+
else:
82+
timeout = BASH_TIMEOUT_MAX or 0.0
83+
reason = f"exceeded the {timeout:.0f}s maximum"
84+
out = out.rstrip("\n")
85+
suffix = f"Error: Bash command timed out ({reason})."
86+
return f"{out}\n\n{suffix}" if out else suffix
87+
88+
89+
def _append_exit_code(out: str, proc: subprocess.Popen) -> str:
90+
"""Append ``Exit code: N`` (N negative = killed by a signal).
91+
92+
The exit-code line is added AFTER truncation, so it is always the
93+
last line and survives the head+tail retention.
94+
"""
95+
try:
96+
rc = proc.wait(timeout=2)
97+
except subprocess.TimeoutExpired:
98+
return out # still alive; nothing useful to report
99+
if out and not out.endswith("\n"):
100+
out += "\n"
101+
return f"{out}Exit code: {rc}"
102+
103+
104+
def _assemble_truncated(head: str, tail: deque[str]) -> str:
105+
"""Assemble a truncated output within the cap.
106+
107+
``head`` holds the first ``_MAX_OUTPUT`` chars, ``tail`` the last
108+
``_TAIL_LINES`` lines (each already line-capped). The tail is
109+
preferred: as many trailing lines as fit are kept and the head gets
110+
the remaining budget, so the delivered string never exceeds
111+
``_MAX_OUTPUT`` (plus the truncation notice).
112+
"""
113+
notice = f"... [truncated: output exceeded {_MAX_OUTPUT} chars] ..."
114+
budget = _MAX_OUTPUT - len(notice) - 4 # room for the "\n\n" separators
115+
tail_parts: list[str] = []
116+
used = 0
117+
for line in reversed(tail):
118+
cost = len(line) + (1 if tail_parts else 0)
119+
if used + cost > budget:
120+
break
121+
tail_parts.append(line)
122+
used += cost
123+
head = head[: max(0, budget - used)]
124+
out = f"{head}\n\n{notice}"
125+
if tail_parts:
126+
out += "\n\n" + "\n".join(reversed(tail_parts))
127+
return out
128+
129+
130+
def _collect_output(proc: subprocess.Popen, cancel: threading.Event | None) -> tuple[str, str]:
131+
"""Read PROC's merged output incrementally; return (text, status).
132+
133+
Status is one of ``"ok"``, ``"cancelled"``, ``"timeout_silence"``,
134+
``"timeout_max"``. Keeps the head (first ``_MAX_OUTPUT`` chars) and
135+
the tail (last ``_TAIL_LINES`` lines) and discards the middle, so
136+
memory stays bounded no matter how much the process writes. The
137+
read loop is poll-based: a cancel is noticed promptly, a process
138+
silent for ``BASH_TIMEOUT_SILENCE`` seconds (or running past
139+
``BASH_TIMEOUT_MAX``) is reported as timed out, and a process that
140+
has exited is only drained for ``_DRAIN_GRACE`` seconds — a
141+
detached child holding the pipe open can never wedge delivery.
142+
"""
143+
stdout = proc.stdout
144+
if stdout is None: # unreachable (stdout=PIPE), kept for the type checker
145+
return "", "ok"
146+
fd = stdout.fileno()
147+
os.set_blocking(fd, False)
148+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
149+
head: list[str] = []
150+
head_len = 0
151+
tail: deque[str] = deque(maxlen=_TAIL_LINES)
152+
pending_line = ""
153+
total = 0
154+
exited = False
155+
drain_until: float | None = None
156+
start = time.monotonic()
157+
last_output = start
158+
159+
def finish() -> str:
160+
nonlocal pending_line
161+
if pending_line:
162+
if len(pending_line) > _MAX_OUTPUT:
163+
pending_line = pending_line[:_MAX_OUTPUT]
164+
tail.append(pending_line)
165+
head_text = "".join(head)
166+
if total > _MAX_OUTPUT:
167+
return _assemble_truncated(head_text, tail)
168+
return head_text
169+
170+
while True:
171+
if cancel is not None and cancel.is_set():
172+
return "", "cancelled"
173+
now = time.monotonic()
174+
if not exited:
175+
if BASH_TIMEOUT_SILENCE is not None and now - last_output >= BASH_TIMEOUT_SILENCE:
176+
return finish(), "timeout_silence"
177+
if BASH_TIMEOUT_MAX is not None and now - start >= BASH_TIMEOUT_MAX:
178+
return finish(), "timeout_max"
179+
if exited and drain_until is not None and now >= drain_until:
180+
break
181+
readable, _, _ = select.select([fd], [], [], _POLL_INTERVAL)
182+
if not readable:
183+
if not exited and proc.poll() is not None:
184+
exited = True
185+
drain_until = time.monotonic() + _DRAIN_GRACE
186+
continue
187+
try:
188+
raw = os.read(fd, _READ_CHUNK)
189+
except BlockingIOError:
190+
continue
191+
except OSError:
192+
break
193+
if not raw:
194+
break # EOF: every writer closed the pipe
195+
chunk = decoder.decode(raw)
196+
total += len(chunk)
197+
last_output = time.monotonic()
198+
if head_len < _MAX_OUTPUT:
199+
take = chunk[: _MAX_OUTPUT - head_len]
200+
head.append(take)
201+
head_len += len(take)
202+
parts = chunk.split("\n")
203+
parts[0] = pending_line + parts[0]
204+
pending_line = parts.pop()
205+
for line in parts:
206+
if len(line) > _MAX_OUTPUT:
207+
line = line[:_MAX_OUTPUT]
208+
tail.append(line)
209+
return finish(), "ok"
43210

44211

45212
class Bash(Tool):
46213
name = "Bash"
214+
_timeout_silence = BASH_TIMEOUT_SILENCE
215+
_timeout_note = (
216+
f"A command silent for {_timeout_silence:.0f}s is killed and reported as timed out. "
217+
if _timeout_silence is not None
218+
else ""
219+
)
47220
description = (
48-
"Execute a shell command. Returns stdout, or an error string. "
49-
"A session cancel (Ctrl-C) kills the process."
221+
"Execute a shell command. Returns stdout followed by 'Exit code: N' "
222+
"(N is the command's exit status; negative means killed by a signal). "
223+
+ _timeout_note
224+
+ "A session cancel (Ctrl-C) kills the process."
50225
)
51226
parameters = {
52227
"type": "object",
@@ -61,60 +236,63 @@ def run(self, args: dict, ctx: ToolContext) -> str | PendingToolResult:
61236
return self._execute(command, ctx)
62237

63238
def _execute(self, command: str, ctx: ToolContext) -> str | PendingToolResult:
239+
cancel = ctx.cancel_event
240+
if cancel is not None and cancel.is_set():
241+
# Ctrl-C already pending: do not spawn a process that would
242+
# be killed moments later.
243+
return "Error: Bash command cancelled."
64244
try:
65245
proc = subprocess.Popen(
66246
command,
67247
shell=True,
68248
stdin=subprocess.DEVNULL,
69249
stdout=subprocess.PIPE,
70250
stderr=subprocess.STDOUT,
71-
text=True,
72-
encoding="utf-8",
73-
errors="replace",
251+
bufsize=0,
74252
cwd=ctx.cwd,
75253
start_new_session=True,
76254
)
77255
except OSError as e:
78256
return f"Error: {e}"
79257

258+
# start_new_session=True makes the child the session/group
259+
# leader, so its pid IS the group id — captured once here,
260+
# never resolved again at kill time.
261+
pgid = proc.pid
80262
pending = PendingToolResult()
81-
cancel = ctx.cancel_event
82-
done = threading.Event()
83-
killed = threading.Event()
84-
85-
def watcher() -> None:
86-
"""Kill the process group when the session is cancelled."""
87-
while True:
88-
if done.wait(0.05):
89-
return
90-
if cancel is not None and cancel.is_set():
91-
killed.set()
92-
_kill_process(proc)
93-
return
94263

95264
def deliverer() -> None:
96-
"""Collect output; deliver it once the process exits."""
265+
"""Collect output; deliver it once the process exits.
266+
267+
The kill happens HERE (not in a separate watcher thread):
268+
the collector is the thread that observed the condition, so
269+
there is no race window in which a watcher exits without
270+
killing and the process group survives. Cancel is an
271+
immediate SIGKILL; a timeout kills gracefully (SIGTERM,
272+
then SIGKILL after 2s) so children get a chance to clean up.
273+
"""
97274
try:
98-
out, _ = proc.communicate()
275+
out, status = _collect_output(proc, cancel)
99276
except Exception as e: # noqa: BLE001 - delivered as an error string
100277
out = f"Error: Bash failed — {e}"
101-
finally:
102-
done.set()
103-
if killed.is_set():
104-
pending.deliver("Error: Bash command cancelled.")
105278
else:
106-
out = out or ""
107-
if len(out) > _MAX_OUTPUT:
108-
# Keep head + tail so the model sees the start and end
109-
tail = "\n".join(out.splitlines()[-_TAIL_LINES:])
110-
head_budget = _MAX_OUTPUT - len(tail) - 200 # room for notice
111-
head = out[: max(head_budget, 1000)]
112-
out = (
113-
f"{head}\n\n... [truncated: output exceeded "
114-
f"{_MAX_OUTPUT} chars] ...\n\n{tail}"
115-
)
116-
pending.deliver(out)
117-
118-
threading.Thread(target=watcher, daemon=True).start()
279+
if status == "cancelled":
280+
_kill_pgid(pgid)
281+
out = "Error: Bash command cancelled."
282+
elif status == "timeout_silence":
283+
_kill_graceful(pgid, proc)
284+
out = _timeout_message(out, silence=True)
285+
elif status == "timeout_max":
286+
_kill_graceful(pgid, proc)
287+
out = _timeout_message(out, silence=False)
288+
elif status == "ok":
289+
out = _append_exit_code(out, proc)
290+
pending.deliver(out)
291+
with contextlib.suppress(Exception):
292+
if proc.stdout is not None:
293+
proc.stdout.close()
294+
with contextlib.suppress(Exception):
295+
proc.wait(timeout=2) # reap (bounded; never wedges)
296+
119297
threading.Thread(target=deliverer, daemon=True).start()
120298
return pending

python_agent_harness/tools/filesystem.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@
4646
from pathlib import Path
4747
from typing import TypeGuard
4848

49+
from ..config import MAX_OUTPUT_CHARS as MAX_OUTPUT
4950
from ..diffrender import unified_diff
5051
from .base import Tool, ToolContext
5152

52-
MAX_OUTPUT = 20_000 # spill threshold (chars), matching gptel-agent--truncate-buffer
5353
SPOOL_LINES = 50 # preview lines kept when results are spilled
5454
READ_SIZE_LIMIT = 400 * 1024 # whole-file reads above this are refused
5555
# (mirrors gptel-agent-read-file-size-threshold)

0 commit comments

Comments
 (0)