unsloth/studio/backend/tests/test_tool_output_streaming.py
2026-07-21 23:18:50 +00:00

1261 lines
47 KiB
Python
Raw Blame History

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Live tool-output streaming and heartbeats for server-side tool execution.
Covers three invariants:
* ``stream_tool_execution`` yields incremental ``tool_output`` events and
``heartbeat`` events while a tool blocks, and returns the tool's result
byte-identical to a direct call;
* ``_python_exec`` / ``_bash_exec`` produce the same result string with and
without an ``output_callback`` (the final tool message the model sees is
untouched by streaming);
* the GGUF agentic loop emits ``tool_output`` between ``tool_start`` and
``tool_end`` and feeds the model the same ``role=tool`` message as before.
"""
from __future__ import annotations
import json
import os
import sys
import threading
import time
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
_TESTS_DIR = str(Path(__file__).resolve().parent)
if _TESTS_DIR not in sys.path:
sys.path.insert(0, _TESTS_DIR)
from core.inference.tool_stream_exec import (
TOOL_OUTPUT_STREAM_MAX_CHARS,
stream_tool_execution,
)
from core.inference.tools import _bash_exec, _python_exec
from test_llama_cpp_tool_loop import _done, _make_backend, _sse
def _run_stream(invoke, **kwargs):
"""Drive the wrapper generator; return (events, result)."""
gen = stream_tool_execution(invoke, **kwargs)
events = []
while True:
try:
events.append(next(gen))
except StopIteration as stop:
return events, stop.value
# ── stream_tool_execution ────────────────────────────────────────
def test_result_returned_verbatim_without_output():
events, result = _run_stream(
lambda _cb: "final result",
tool_name = "web_search",
)
assert result == "final result"
assert [e for e in events if e["type"] == "tool_output"] == []
def test_incremental_output_streams_as_tool_output_events():
def tool(callback):
callback("line 1\n")
callback("line 2\n")
return "line 1\nline 2\n"
events, result = _run_stream(tool, tool_name = "python", tool_call_id = "call_1")
assert result == "line 1\nline 2\n"
outputs = [e for e in events if e["type"] == "tool_output"]
assert outputs, "expected tool_output events"
assert "".join(e["text"] for e in outputs) == "line 1\nline 2\n"
assert all(e["tool_name"] == "python" for e in outputs)
assert all(e["tool_call_id"] == "call_1" for e in outputs)
def test_heartbeats_emitted_while_tool_blocks():
release = threading.Event()
def tool(_cb):
release.wait(timeout = 5)
return "done"
gen = stream_tool_execution(
tool,
tool_name = "web_search",
heartbeat_interval_s = 0.04,
poll_interval_s = 0.02,
)
events = []
result = None
try:
while True:
event = next(gen)
events.append(event)
if len([e for e in events if e["type"] == "heartbeat"]) >= 2:
release.set()
except StopIteration as stop:
result = stop.value
assert result == "done"
assert len([e for e in events if e["type"] == "heartbeat"]) >= 2
def test_output_resets_heartbeat_pacing():
# A steady output stream means no heartbeats are needed.
def tool(callback):
for i in range(5):
callback(f"tick {i}\n")
time.sleep(0.01)
return "ok"
events, result = _run_stream(
tool,
tool_name = "python",
heartbeat_interval_s = 10.0,
poll_interval_s = 0.02,
)
assert result == "ok"
assert [e for e in events if e["type"] == "heartbeat"] == []
def test_tool_exception_propagates_after_stream():
def tool(_cb):
raise RuntimeError("boom")
gen = stream_tool_execution(tool, tool_name = "python")
try:
while True:
next(gen)
except RuntimeError as exc:
assert str(exc) == "boom"
else:
raise AssertionError("expected RuntimeError")
def test_output_before_worker_raises_is_preserved():
# Output streamed before the worker raises survives; the exception still propagates.
def tool(callback):
callback("partial before crash\n")
time.sleep(0.02)
raise RuntimeError("late boom")
gen = stream_tool_execution(tool, tool_name = "python", poll_interval_s = 0.01)
events = []
with pytest.raises(RuntimeError, match = "late boom"):
while True:
events.append(next(gen))
streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
assert "partial before crash" in streamed
def test_generator_close_cancels_observing_tool():
# gen.close() (SSE client disconnect) sets the shared cancel_event, so a
# cancel-observing tool returns at once.
cancel_event = threading.Event()
started = threading.Event()
returned = threading.Event()
def tool(_cb):
started.set()
cancel_event.wait(timeout = 5) # cancel-observing: unblocks on cancel
returned.set()
return "cancelled cleanly"
gen = stream_tool_execution(
tool,
tool_name = "web_search",
cancel_event = cancel_event,
heartbeat_interval_s = 0.02,
poll_interval_s = 0.01,
)
next(gen) # prime the worker; returns a heartbeat while the tool blocks
assert started.wait(timeout = 2)
gen.close() # GeneratorExit -> sets cancel_event, then bounded join
assert cancel_event.is_set()
assert returned.wait(timeout = 2) # the tool actually observed cancellation
def test_generator_close_is_bounded_for_cancel_ignoring_tool(monkeypatch):
# A tool that ignores cancel_event must not stall teardown: gen.close() waits
# at most the bounded join, not the tool's full runtime.
monkeypatch.setattr("core.inference.tool_stream_exec._WORKER_JOIN_TIMEOUT_S", 0.2)
release = threading.Event()
def tool(_cb):
# Ignores cancel_event; stands in for a web_search/MCP call that never polls it.
release.wait(timeout = 30)
return "slow"
gen = stream_tool_execution(
tool,
tool_name = "web_search",
cancel_event = threading.Event(),
heartbeat_interval_s = 0.02,
poll_interval_s = 0.01,
)
next(gen)
started = time.monotonic()
gen.close()
elapsed = time.monotonic() - started
release.set() # let the daemon worker finish so no sleeper lingers
assert elapsed < 2.0 # bounded by _WORKER_JOIN_TIMEOUT_S, not the 30s tool
def test_cancel_event_not_set_on_clean_finish():
# cancel_event is shared across a turn; a clean finish must leave it unset so
# the next tool in the same turn is not aborted.
cancel_event = threading.Event()
def tool(_cb):
return "ok"
events, result = _run_stream(
tool,
tool_name = "python",
cancel_event = cancel_event,
)
assert result == "ok"
assert not cancel_event.is_set()
def test_no_worker_thread_leak_under_repeated_close(monkeypatch):
# Repeated start-then-close must not leak worker threads: each cancel-observing
# worker exits once close() signals it.
monkeypatch.setattr("core.inference.tool_stream_exec._WORKER_JOIN_TIMEOUT_S", 0.2)
def _live_tool_workers():
return [t for t in threading.enumerate() if t.name.startswith("tool-exec-")]
for _ in range(50): # let workers from earlier tests drain
if not _live_tool_workers():
break
time.sleep(0.02)
baseline = len(_live_tool_workers())
for _ in range(60):
cancel_event = threading.Event()
def tool(_cb, _ev = cancel_event):
_ev.wait(timeout = 5)
return "done"
gen = stream_tool_execution(
tool,
tool_name = "soak",
cancel_event = cancel_event,
heartbeat_interval_s = 0.02,
poll_interval_s = 0.01,
)
next(gen)
gen.close() # sets cancel_event -> tool returns -> worker exits
for _ in range(100):
if len(_live_tool_workers()) <= baseline:
break
time.sleep(0.02)
assert len(_live_tool_workers()) <= baseline
def test_streamed_output_is_capped_but_result_is_not():
big = "x" * (TOOL_OUTPUT_STREAM_MAX_CHARS + 5000)
def tool(callback):
callback(big)
return big
events, result = _run_stream(tool, tool_name = "python")
assert result == big # final result untouched by the stream cap
streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
assert len(streamed) < len(big)
assert "further live output not streamed" in streamed
def test_heartbeats_continue_while_capped_output_flows():
# After the cap, discarded chunks must not starve the keepalive: a chatty tool
# keeps the queue non-empty, so without the fix no heartbeat fires and the SSE
# stream stays silent past proxy idle timeouts.
release = threading.Event()
def tool(callback):
callback("x" * (TOOL_OUTPUT_STREAM_MAX_CHARS + 10)) # trip the cap
while not release.is_set():
callback("post-cap spam")
time.sleep(0.005)
return "done"
# Watchdog: on regressed code next(gen) blocks forever while spam flows; the
# timer ends the tool, turning that hang into a clean assertion failure.
watchdog = threading.Timer(8.0, release.set)
watchdog.start()
gen = stream_tool_execution(
tool,
tool_name = "python",
heartbeat_interval_s = 0.04,
poll_interval_s = 0.02,
)
events = []
result = None
try:
while True:
event = next(gen)
events.append(event)
if len([e for e in events if e["type"] == "heartbeat"]) >= 2:
release.set()
except StopIteration as stop:
result = stop.value
finally:
release.set()
watchdog.cancel()
assert result == "done"
assert len([e for e in events if e["type"] == "heartbeat"]) >= 2
streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
assert "further live output not streamed" in streamed
assert "post-cap spam" not in streamed # cap still enforced
def test_drain_queue_bounds_the_over_cap_batch():
# _drain_queue stops concatenating once the cap is first exceeded and discards
# the rest in place, so a chatty tool's huge backlog never defeats the memory ceiling.
import queue as _queue
from core.inference.tool_stream_exec import _drain_queue
q: _queue.Queue = _queue.Queue()
sentinel = object()
chunk = "z" * 1000
for _ in range(5000): # 5 MB queued ahead of the drain
q.put(chunk)
q.put(sentinel)
text, hit_sentinel = _drain_queue(q, sentinel, max_chars = 100)
assert hit_sentinel is True
# At most cap + one chunk is joined, not the full 5 MB backlog.
assert len(text) <= 100 + len(chunk)
assert q.empty() # surplus still drained so completion is detected
def test_drain_queue_does_not_materialize_surplus_crossing_chunk():
# The single chunk that first crosses the cap must not be materialized in full
# (a tool can emit one multi-megabyte line). Keep just one char past the budget
# to preserve the overflow signal and byte-identical truncation, even when the
# budget is already met (max_chars <= 0).
import queue as _queue
from core.inference.tool_stream_exec import _drain_queue
sentinel = object()
huge = "z" * 1_000_000
# Budget already met (non-positive): keep one char, a true prefix.
for cap in (0, -500):
q: _queue.Queue = _queue.Queue()
q.put(huge)
q.put("more")
q.put(sentinel)
text, hit_sentinel = _drain_queue(q, sentinel, max_chars = cap)
assert hit_sentinel is True
assert len(text) == 1
assert huge.startswith(text)
assert q.empty()
# Positive cap crossed by one huge chunk: bounded to cap + 1, prefix kept.
q = _queue.Queue()
q.put(huge)
q.put(sentinel)
text, hit_sentinel = _drain_queue(q, sentinel, max_chars = 100)
assert len(text) == 101
assert text == huge[:101]
def test_drain_queue_unbounded_joins_everything():
# Without a cap the join is complete and ordered (the sub-cap path streams
# every chunk verbatim on this).
import queue as _queue
from core.inference.tool_stream_exec import _drain_queue
q: _queue.Queue = _queue.Queue()
sentinel = object()
for i in range(3):
q.put(f"c{i}")
q.put(sentinel)
text, hit_sentinel = _drain_queue(q, sentinel, max_chars = None)
assert hit_sentinel is True
assert text == "c0c1c2"
def test_over_cap_crossing_batch_streams_capped_output():
# End-to-end: a burst crossing the cap in one drain still yields a capped live
# stream and an untouched final result.
chunk = "z" * 1000
def tool(callback):
for _ in range(3000): # ~3 MB, well past the cap, in one burst
callback(chunk)
return "final"
events, result = _run_stream(tool, tool_name = "python")
assert result == "final"
streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
assert len(streamed) <= TOOL_OUTPUT_STREAM_MAX_CHARS + len(
"\n... (further live output not streamed)\n"
)
assert "further live output not streamed" in streamed
# ── python / terminal executors ──────────────────────────────────
_PY_CODE = "for i in range(5):\n print('row', i)\n"
def test_python_exec_result_identical_with_streaming():
baseline = _python_exec(_PY_CODE, timeout = 60)
chunks: list[str] = []
streamed = _python_exec(_PY_CODE, timeout = 60, output_callback = chunks.append)
assert streamed == baseline
assert "".join(chunks) == "".join(f"row {i}\n" for i in range(5))
def test_python_exec_streams_lines_incrementally():
# The first of two sleep-separated prints must reach the callback well before exit.
code = (
"import time\n"
"print('first', flush=True)\n"
"time.sleep(1.0)\n"
"print('second', flush=True)\n"
)
first_seen_at: list[float] = []
def on_chunk(_text: str) -> None:
if not first_seen_at:
first_seen_at.append(time.monotonic())
started = time.monotonic()
result = _python_exec(code, timeout = 60, output_callback = on_chunk)
finished = time.monotonic()
assert "first" in result and "second" in result
assert first_seen_at, "callback never invoked"
# First line arrived before the sleep completed (margin for slow interpreter start).
assert first_seen_at[0] - started < finished - started - 0.5
def test_python_exec_unflushed_print_streams_live_and_result_identical():
# A bare print() WITHOUT flush=True then a sleep. -u forces the child's stdout
# unbuffered so the line reaches the callback before exit (else CPython
# block-buffers the pipe and the live pane stays empty). -u changes timing only,
# so the joined result stays byte-identical to the non-streaming run.
code = (
"import time\n"
"print('progress')\n" # no flush=True
"time.sleep(1.0)\n"
"print('done')\n"
)
first_seen_at: list[float] = []
def on_chunk(_text: str) -> None:
if not first_seen_at:
first_seen_at.append(time.monotonic())
baseline = _python_exec(code, timeout = 60)
started = time.monotonic()
streamed = _python_exec(code, timeout = 60, output_callback = on_chunk)
finished = time.monotonic()
assert streamed == baseline
assert "progress" in streamed and "done" in streamed
assert first_seen_at, "callback never invoked for unflushed print"
# Unflushed line arrived before the sleep finished: streamed live, not at exit.
assert first_seen_at[0] - started < finished - started - 0.5
def test_python_exec_error_exit_identical_with_streaming():
code = "print('before')\nraise SystemExit(3)\n"
baseline = _python_exec(code, timeout = 60)
streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
assert streamed == baseline
assert streamed.startswith("Exit code 3:")
def test_python_exec_timeout_message_identical_with_streaming():
code = "import time\ntime.sleep(30)\n"
baseline = _python_exec(code, timeout = 1)
streamed = _python_exec(code, timeout = 1, output_callback = lambda _t: None)
assert streamed == baseline == "Execution timed out after 1 seconds."
def test_python_exec_callback_errors_do_not_break_execution():
def bad_callback(_text: str) -> None:
raise ValueError("observer bug")
result = _python_exec("print('ok')", timeout = 60, output_callback = bad_callback)
assert result.strip() == "ok"
def test_bash_exec_result_identical_with_streaming():
command = "echo one; echo two"
baseline = _bash_exec(command, timeout = 60)
chunks: list[str] = []
streamed = _bash_exec(command, timeout = 60, output_callback = chunks.append)
assert streamed == baseline
assert "".join(chunks) == "one\ntwo\n"
def test_bash_exec_invalid_utf8_identical_with_streaming():
# Invalid UTF-8 must not kill either path: the pipe decodes with
# errors="replace", so the streaming reader thread cannot die on the
# UnicodeDecodeError readline raises, and both paths return the same replaced text.
command = "printf 'ok\\377bad\\n'" # \377 = 0xFF, invalid UTF-8
baseline = _bash_exec(command, timeout = 60)
chunks: list[str] = []
streamed = _bash_exec(command, timeout = 60, output_callback = chunks.append)
assert streamed == baseline
assert not baseline.startswith("Execution error")
assert "ok" in baseline and "bad" in baseline
assert "<EFBFBD>" in baseline # replacement character, not a crash
assert "".join(chunks) == "ok<EFBFBD>bad\n"
def test_bash_exec_unlimited_timeout_waits_for_grandchild_output():
# A background grandchild holds the pipe open past the shell's exit and writes
# ~7s later. With timeout=None the drain must wait for EOF like
# communicate(timeout=None), so the late output is included.
command = "( sleep 7; echo late-grandchild-output ) & echo parent-done"
chunks: list[str] = []
result = _bash_exec(command, timeout = None, output_callback = chunks.append)
assert "parent-done" in result
assert "late-grandchild-output" in result
assert "late-grandchild-output" in "".join(chunks)
def test_bash_exec_finite_timeout_kills_grandchild_holding_stdout(tmp_path):
# A backgrounded grandchild holds the pipe open past the finite timeout, then
# would write a sentinel. The parent shell has already exited, so killing only
# the reaped parent leaves the grandchild running; the drain must kill the
# process group captured before the wait so the grandchild never writes.
sentinel = tmp_path / "grandchild_ran"
command = f"( sleep 3; touch '{sentinel}' ) & echo parent-done"
result = _bash_exec(command, timeout = 1, output_callback = lambda _t: None)
assert "timed out" in result
time.sleep(4.0) # past the grandchild's 3s sleep
assert not sentinel.exists(), "grandchild survived the timeout process-group kill"
@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
def test_bash_exec_nonstreaming_timeout_kills_grandchild(tmp_path):
# The NON-streaming path (communicate() + _kill_process_tree) short-circuits
# once the reaped leader has exited, so a stdout-holding grandchild survives
# unless the group captured right after spawn is killed too. Must match the
# streaming path's exited-leader handling.
sentinel = tmp_path / "grandchild_ran"
command = f"( sleep 3; touch '{sentinel}' ) & echo parent-done"
result = _bash_exec(command, timeout = 1) # no output_callback -> communicate path
assert "timed out" in result
time.sleep(4.0)
assert (
not sentinel.exists()
), "non-streaming timeout leaked a stdout-holding grandchild"
@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
def test_python_exec_nonstreaming_timeout_kills_grandchild(tmp_path):
sentinel = tmp_path / "grandchild_ran"
code = (
"import subprocess\n"
f"subprocess.Popen(['bash', '-c', \"sleep 3; touch '{sentinel}'\"])\n"
"print('parent-done')\n"
"import time; time.sleep(30)\n"
)
result = _python_exec(code, timeout = 1) # no output_callback -> communicate path
assert "timed out" in result
time.sleep(4.0)
assert (
not sentinel.exists()
), "non-streaming timeout leaked a stdout-holding grandchild"
def test_drain_process_output_without_posix_process_group_apis(monkeypatch):
# On Windows os.getpgid / os.killpg are absent; _drain_process_output must not
# raise AttributeError before reading the child's output. Removing the APIs and
# flipping os.name: the child still runs and is captured, only the group kill is skipped.
import subprocess as _sp
from core.inference.tools import _drain_process_output
monkeypatch.delattr(os, "getpgid", raising = False)
monkeypatch.delattr(os, "killpg", raising = False)
monkeypatch.setattr(os, "name", "nt")
proc = _sp.Popen(
[sys.executable, "-c", "print('ok-no-pgid')"],
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
)
output, timed_out = _drain_process_output(proc, 10, lambda _t: None)
assert not timed_out
assert "ok-no-pgid" in output
@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
def test_captured_group_survives_fast_leader_reap(tmp_path):
# Capture the group after spawn, reap the leader first (as a polling cancel
# watcher would), then drain: the pre-captured pgid must still reap the
# stdout-holding grandchild even though os.getpgid(pid) would now fail.
import subprocess as _sp
from core.inference.tools import _capture_process_group, _drain_process_output
sentinel = tmp_path / "grandchild_ran"
proc = _sp.Popen(
["bash", "-c", f"( sleep 3; touch '{sentinel}' ) & echo parent-done"],
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
preexec_fn = os.setsid,
)
pgid = _capture_process_group(proc)
assert pgid is not None
proc.wait() # reap the leader before draining
output, timed_out = _drain_process_output(proc, 0.5, None, pgid = pgid)
assert timed_out
assert "parent-done" in output
time.sleep(4.0)
assert not sentinel.exists(), "pre-captured group failed to reap the grandchild"
@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
def test_finite_drain_honors_cancel_after_leader_exit(tmp_path):
# Once the leader exits the cancel watcher (which loops on proc.poll()) is gone,
# so the finite-timeout drain itself must honor cancellation: a mid-drain
# cancel_event must break the drain promptly and kill the process group instead
# of draining a chatty grandchild for the whole large budget.
import subprocess as _sp
import threading as _th
from core.inference.tools import _capture_process_group, _drain_process_output
sentinel = tmp_path / "grandchild_late"
# Grandchild holds the pipe open, streams every 0.2s, and touches the sentinel
# only after 10s -- well past the cancel. The leader exits immediately, so the
# drain enters the finite branch with a live, chatty reader.
proc = _sp.Popen(
[
"bash",
"-c",
"( for i in $(seq 1 100); do echo tick-$i; sleep 0.2; done; "
f"touch '{sentinel}' ) & echo parent-done",
],
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
preexec_fn = os.setsid,
)
pgid = _capture_process_group(proc)
assert pgid is not None
proc.wait() # leader exits at once; the cancel watcher would now be gone
cancel_event = _th.Event()
_th.Timer(0.6, cancel_event.set).start() # cancel shortly into the drain
started = time.monotonic()
# Large finite timeout (30s); without the cancel poll the drain keeps reading
# the grandchild until the pipe closes ~20s later.
output, timed_out = _drain_process_output(
proc, 30, lambda _t: None, cancel_event, pgid = pgid
)
elapsed = time.monotonic() - started
assert elapsed < 5.0, f"finite drain ignored cancel_event (took {elapsed:.1f}s)"
# Cancellation is not a timeout: the budget never elapsed.
assert not timed_out
assert "parent-done" in output
time.sleep(11.0) # past the grandchild's 10s sentinel write
assert (
not sentinel.exists()
), "cancel did not kill the stdout-holding grandchild group"
@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
def test_streamed_wait_timeout_kills_grandchild_when_leader_reaped(
tmp_path, monkeypatch
):
# The proc.wait() timeout branch normally kills the group via _kill_process_tree.
# But the leader can exit before _kill_process_tree samples its pgid, which then
# short-circuits on the reaped leader and leaves a stdout-holding grandchild.
# Model that race with _kill_process_tree as a no-op; the captured-pgid kill in
# the timeout branch must still reap the grandchild, matching non-streaming.
import subprocess as _sp
from core.inference import tools as _tools_mod
from core.inference.tools import _capture_process_group, _drain_process_output
monkeypatch.setattr(_tools_mod, "_kill_process_tree", lambda proc: None)
sentinel = tmp_path / "grandchild_ran"
# Leader sleeps past the timeout so proc.wait() genuinely times out; a same-group
# grandchild holds stdout and would touch the sentinel unless the group is killed.
proc = _sp.Popen(
["bash", "-c", f"( sleep 3; touch '{sentinel}' ) & sleep 30"],
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
preexec_fn = os.setsid,
)
pgid = _capture_process_group(proc)
assert pgid is not None
output, timed_out = _drain_process_output(proc, 0.5, None, pgid = pgid)
assert timed_out
time.sleep(4.0) # past the grandchild's 3s sleep
assert not sentinel.exists(), (
"streamed wait timeout leaked a stdout-holding grandchild when the "
"process-tree kill short-circuited on the reaped leader"
)
# ── GGUF loop regression: model-visible messages unchanged ───────
def _run_gguf_tool_turn(monkeypatch, fake_execute_tool):
tool_stream = [
_sse(
{
"tool_calls": [
{
"id": "call_1",
"index": 0,
"function": {
"name": "python",
"arguments": json.dumps({"code": "print('hi')"}),
},
}
]
}
),
_done(),
]
final_stream = [_sse({"content": "All done."}), _done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [tool_stream, final_stream], payloads)
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "run it"}],
tools = [{"type": "function", "function": {"name": "python"}}],
max_tool_iterations = 1,
)
)
return events, payloads
def test_gguf_loop_final_tool_message_unchanged_by_streaming(monkeypatch):
result_text = "hi\nline 2\n"
def plain_tool(name, arguments, **_kwargs):
return result_text
def streaming_tool(
name,
arguments,
output_callback = None,
**_kwargs,
):
if output_callback is not None:
output_callback("hi\n")
output_callback("line 2\n")
return result_text
events_plain, payloads_plain = _run_gguf_tool_turn(monkeypatch, plain_tool)
events_streaming, payloads_streaming = _run_gguf_tool_turn(
monkeypatch, streaming_tool
)
def _tool_messages(payloads):
return [
msg
for payload in payloads
for msg in payload["messages"]
if msg.get("role") == "tool"
]
# The role=tool message fed to the model is byte-identical: streaming is purely
# observational and must not perturb parsing/nudging/healing.
assert _tool_messages(payloads_streaming) == _tool_messages(payloads_plain)
assert _tool_messages(payloads_streaming) == [
{
"role": "tool",
"name": "python",
"content": result_text,
"tool_call_id": "call_1",
}
]
# tool_end results match too.
ends_plain = [e for e in events_plain if e["type"] == "tool_end"]
ends_streaming = [e for e in events_streaming if e["type"] == "tool_end"]
assert [e["result"] for e in ends_streaming] == [e["result"] for e in ends_plain]
def test_gguf_loop_emits_tool_output_between_start_and_end(monkeypatch):
def streaming_tool(
name,
arguments,
output_callback = None,
**_kwargs,
):
if output_callback is not None:
output_callback("progress 1\n")
output_callback("progress 2\n")
return "progress 1\nprogress 2\n"
events, _payloads = _run_gguf_tool_turn(monkeypatch, streaming_tool)
types = [e["type"] for e in events]
assert "tool_output" in types
start_idx = types.index("tool_start")
end_idx = types.index("tool_end")
output_indices = [i for i, t in enumerate(types) if t == "tool_output"]
assert all(start_idx < i < end_idx for i in output_indices)
streamed = "".join(e["text"] for e in events if e["type"] == "tool_output")
assert streamed == "progress 1\nprogress 2\n"
for e in events:
if e["type"] == "tool_output":
assert e["tool_name"] == "python"
assert e["tool_call_id"] == "call_1"
def test_gguf_loop_plain_tool_yields_no_tool_output(monkeypatch):
def plain_tool(name, arguments, **_kwargs):
return "quiet"
events, _payloads = _run_gguf_tool_turn(monkeypatch, plain_tool)
assert [e for e in events if e["type"] == "tool_output"] == []
# ── result truncation notice, env cap, missing-path healing ──────
import os as _os
import uuid as _uuid
from core.inference.tools import (
PYTHON_TOOL,
TERMINAL_TOOL,
_MAX_OUTPUT_CHARS,
_env_int,
_missing_path_hint,
_truncate,
get_sandbox_workdir,
)
def test_truncate_notice_is_neutral_and_mentions_workdir():
out = _truncate("y" * 50, limit = 10)
assert out.startswith("y" * 10)
assert "truncated" in out and "50 chars total" in out
assert "persist in the working directory" in out
# The notice must NOT claim the user saw the output: this wrapper also serves
# non-streaming callers where no output_callback delivers anything.
assert "the user was shown the full output" not in out
assert "shown" not in out
# Under the limit: untouched.
assert _truncate("short", limit = 10) == "short"
def test_truncated_result_identical_and_notice_neutral_with_streaming():
# The truncation notice must be byte-identical with and without an
# output_callback (the streaming vs non-streaming invariant a mode-dependent
# notice would break) and must not claim the user was shown the full output.
code = f"print('x' * {_MAX_OUTPUT_CHARS + 5000})"
baseline = _python_exec(code, timeout = 60)
streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
assert streamed == baseline
assert "truncated" in baseline
assert "the user was shown the full output" not in baseline
assert "persist in the working directory" in baseline
def test_result_cap_env_override(monkeypatch):
monkeypatch.delenv("UNSLOTH_TOOL_RESULT_MAX_CHARS", raising = False)
assert _env_int("UNSLOTH_TOOL_RESULT_MAX_CHARS", 16000) == 16000
monkeypatch.setenv("UNSLOTH_TOOL_RESULT_MAX_CHARS", "50000")
assert _env_int("UNSLOTH_TOOL_RESULT_MAX_CHARS", 16000) == 50000
# Garbage and non-positive values fall back to the default.
monkeypatch.setenv("UNSLOTH_TOOL_RESULT_MAX_CHARS", "lots")
assert _env_int("UNSLOTH_TOOL_RESULT_MAX_CHARS", 16000) == 16000
monkeypatch.setenv("UNSLOTH_TOOL_RESULT_MAX_CHARS", "-5")
assert _env_int("UNSLOTH_TOOL_RESULT_MAX_CHARS", 16000) == 16000
def test_missing_path_hint_detection():
err = "FileNotFoundError: [Errno 2] No such file or directory: '/mnt/data/x.html'"
hint = _missing_path_hint(err)
assert "working directory is writable" in hint
assert "relative path" in hint
# The hint echoes the actual failing path, not a canned example.
assert "'x.html', not '/mnt/data/x.html'" in hint
# A failure on a local path gets no hint.
assert _missing_path_hint("FileNotFoundError: 'local.txt'") == ""
# Mentioning /mnt/data without a file error gets no hint.
assert _missing_path_hint("saved to /mnt/data, all good") == ""
assert _missing_path_hint("") == ""
def test_missing_path_hint_generalizes_beyond_convention_prefixes():
# A hallucinated absolute path outside the enumerated prefixes still earns the
# hint, echoing that path.
err = (
"FileNotFoundError: [Errno 2] No such file or directory: "
"'/home/ubuntu/Sandbox/flappy_bird.html'"
)
hint = _missing_path_hint(err)
assert "working directory is writable" in hint
assert "'flappy_bird.html', not '/home/ubuntu/Sandbox/flappy_bird.html'" in hint
# A bash-style error on an absolute path outside the workdir is echoed too.
bash_err = "cat: /var/data/report.csv: No such file or directory"
assert "'report.csv', not '/var/data/report.csv'" in _missing_path_hint(bash_err)
def test_missing_path_hint_respects_project_workdir():
# Project-backed sessions run under a root OUTSIDE ~/studio_sandbox. A legitimate
# miss INSIDE that project workspace must not be misclassified as an external
# habit path and flattened to its basename; judged against the real workdir it
# gets no hint. The fabricated paths carry no convention prefix, so only the
# workdir judgement decides.
workdir = "/srv/projroot/session_area"
missing = "/srv/projroot/session_area/data/missing.csv"
output = f"FileNotFoundError: [Errno 2] No such file or directory: '{missing}'"
# Against the static sandbox root (no workdir) it looks external and wrongly earns the hint.
assert "working directory is writable" in _missing_path_hint(output)
# Against the real project workdir it is local -> no hint.
assert _missing_path_hint(output, workdir) == ""
# A path genuinely outside the project workdir still earns the hint.
outside_err = (
"FileNotFoundError: [Errno 2] No such file or directory: '/srv/other/x.html'"
)
assert "working directory is writable" in _missing_path_hint(outside_err, workdir)
def test_missing_path_hint_project_workdir_under_convention_prefix():
# A project workdir can live under a convention prefix like /workspace (common in
# containers). A genuine miss INSIDE it carries the "/workspace" substring but is
# a real local path, not a habit path: the convention fast path must not fire and
# flatten it to a bare basename (which would drop the project subdirectory).
workdir = "/workspace/proj"
nested = "/workspace/proj/sub/data.csv"
output = f"FileNotFoundError: [Errno 2] No such file or directory: '{nested}'"
# Against the real project workdir the miss is local -> no hint, so
# /workspace/proj/sub is not flattened away.
assert _missing_path_hint(output, workdir) == ""
# A miss at the project root itself is likewise local.
at_root = "/workspace/proj/data.csv"
root_output = f"FileNotFoundError: [Errno 2] No such file or directory: '{at_root}'"
assert _missing_path_hint(root_output, workdir) == ""
# A convention path genuinely outside the project workdir still earns the
# hint (e.g. a /mnt/data habit path with a /workspace-rooted project).
outside = (
"FileNotFoundError: [Errno 2] No such file or directory: '/mnt/data/x.html'"
)
assert "'x.html', not '/mnt/data/x.html'" in _missing_path_hint(outside, workdir)
# Without an explicit workdir the default sandbox root applies, so a
# /workspace path is out of sandbox and keeps the habit-path hint.
assert "working directory is writable" in _missing_path_hint(root_output)
def test_missing_path_hint_convention_scoped_to_failing_line():
# A convention prefix appearing only OUTSIDE the failing-path line (a traceback
# frame under /workspace, or the user's code printing /mnt/data) must not trigger
# the hint when the actual miss was a relative / in-workdir path.
frame_err = (
"Traceback (most recent call last):\n"
' File "/workspace/proj/script.py", line 5, in <module>\n'
" open('data.csv')\n"
"FileNotFoundError: [Errno 2] No such file or directory: 'data.csv'"
)
assert _missing_path_hint(frame_err) == ""
printed_err = (
"outputs go to /mnt/data normally\n"
"FileNotFoundError: [Errno 2] No such file or directory: 'notes.txt'"
)
assert _missing_path_hint(printed_err) == ""
# But a convention path ON the error line still earns the hint.
on_line = (
"FileNotFoundError: [Errno 2] No such file or directory: '/mnt/data/x.html'"
)
assert "'x.html', not '/mnt/data/x.html'" in _missing_path_hint(on_line)
def test_code_tool_descriptions_mention_relative_paths():
for tool in (PYTHON_TOOL, TERMINAL_TOOL):
description = tool["function"]["description"]
assert "relative paths" in description
assert "/mnt/data" in description
def test_python_exec_mnt_data_open_is_remapped_into_workdir():
# The shim remaps open()/os.makedirs() on /mnt/data into the sandbox CWD and
# prints a one-line stderr notice, identically with and without streaming.
fname = f"remap_{_uuid.uuid4().hex}.txt"
code = (
"import os\n"
"os.makedirs('/mnt/data', exist_ok=True)\n"
f"with open('/mnt/data/{fname}', 'w') as f:\n"
" f.write('hello remap')\n"
f"print(open('/mnt/data/{fname}').read())\n"
)
target = _os.path.join(get_sandbox_workdir(), fname)
try:
baseline = _python_exec(code, timeout = 60)
assert _os.path.isfile(target), baseline
with open(target) as f:
assert f.read() == "hello remap"
assert "hello remap" in baseline
assert "/mnt/data does not exist in this sandbox" in baseline
_os.remove(target)
streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
assert streamed == baseline
assert _os.path.isfile(target)
finally:
if _os.path.exists(target):
_os.remove(target)
def test_python_exec_pathlib_write_text_is_remapped_into_workdir():
# pathlib.Path.open / write_text / read_text call io.open directly,
# bypassing the builtins.open patch, so the shim must remap io.open too.
fname = f"remap_{_uuid.uuid4().hex}.txt"
code = (
"from pathlib import Path\n"
f"p = Path('/mnt/data/{fname}')\n"
"p.write_text('pathlib remap')\n"
"print(p.read_text())\n"
)
target = _os.path.join(get_sandbox_workdir(), fname)
try:
baseline = _python_exec(code, timeout = 60)
assert _os.path.isfile(target), baseline
with open(target) as f:
assert f.read() == "pathlib remap"
assert "pathlib remap" in baseline
assert "/mnt/data does not exist in this sandbox" in baseline
_os.remove(target)
streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
assert streamed == baseline
assert _os.path.isfile(target)
finally:
if _os.path.exists(target):
_os.remove(target)
def test_python_exec_hallucinated_absolute_write_is_remapped_into_workdir():
# The model invents an absolute path outside the enumerated prefixes and opens
# it for writing; the write-mode fallback redirects it to the basename in the
# sandbox workdir instead of dying with FileNotFoundError.
fname = f"remap_{_uuid.uuid4().hex}.html"
hallucinated = f"/nonexistent_root_xyz/Sandbox/{fname}"
# Read-back goes through the mapped basename: reads are never redirected, only
# the write is healed.
code = (
f"with open('{hallucinated}', 'w') as f:\n"
" f.write('hello fallback')\n"
f"print(open('{fname}').read())\n"
)
target = _os.path.join(get_sandbox_workdir(), fname)
try:
baseline = _python_exec(code, timeout = 60)
assert _os.path.isfile(target), baseline
with open(target) as f:
assert f.read() == "hello fallback"
assert "hello fallback" in baseline
assert "does not exist in this sandbox" in baseline
_os.remove(target)
streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
assert streamed == baseline
assert _os.path.isfile(target)
finally:
if _os.path.exists(target):
_os.remove(target)
def test_python_exec_unremapped_mnt_data_failure_gets_hint():
# os.listdir is deliberately not remapped: the failure carries the retry hint
# instead, identically with and without streaming.
import re as _re
code = "import os\nos.listdir('/mnt/data/nonexistent_dir_xyz')\n"
baseline = _python_exec(code, timeout = 60)
assert "FileNotFoundError" in baseline
assert "working directory is writable" in baseline
streamed = _python_exec(code, timeout = 60, output_callback = lambda _t: None)
# Normalize each run's random temp filename (byte-identity is per-execution).
def normalize(text: str) -> str:
return _re.sub(r"studio_exec_\w+\.py", "studio_exec.py", text)
assert normalize(streamed) == normalize(baseline)
def test_bash_exec_missing_path_hint():
baseline = _bash_exec("cat /mnt/data/definitely_missing.txt", timeout = 60)
assert "No such file or directory" in baseline
assert "working directory is writable" in baseline
streamed = _bash_exec(
"cat /mnt/data/definitely_missing.txt",
timeout = 60,
output_callback = lambda _t: None,
)
assert streamed == baseline
def test_bash_exec_local_failure_gets_no_hint():
result = _bash_exec("cat definitely_missing_local_file.txt", timeout = 60)
assert "No such file or directory" in result
assert "working directory is writable" not in result
def test_producer_queue_is_bounded_under_tight_print_loop(monkeypatch):
# The consumer-side cap only bounds the concatenated stream; a fast worker can
# still enqueue unboundedly while the SSE consumer is backpressured. The producer
# boundary now discards callbacks past the cap so the queue cannot grow without
# limit (finding 12).
import queue as _queue
from core.inference import tool_stream_exec
observed = []
class _TrackingQueue(_queue.Queue):
def put(self, *args, **kwargs):
result = super().put(*args, **kwargs)
observed.append(self.qsize())
return result
monkeypatch.setattr(tool_stream_exec.queue, "Queue", _TrackingQueue)
def tool(callback):
for _ in range(200_000):
callback("x")
return "done"
events, result = _run_stream(tool, tool_name = "python")
assert result == "done"
# At most cap + 1 chars enter the queue, so 1-char items cannot exceed that
# regardless of consumer lag.
assert observed
assert max(observed) <= TOOL_OUTPUT_STREAM_MAX_CHARS + 2
def test_continuous_over_cap_output_does_not_starve_heartbeats():
# Once the cap is tripped, a continuously producing tool must not spin the drain
# forever with no heartbeat: callbacks past the budget never enter the queue, so
# the idle heartbeat path resumes (finding 13).
release = threading.Event()
def tool(callback):
callback("x" * (TOOL_OUTPUT_STREAM_MAX_CHARS + 10)) # trip the cap
while not release.is_set():
callback("spam") # discarded at the producer boundary
return "done"
watchdog = threading.Timer(8.0, release.set)
watchdog.start()
gen = stream_tool_execution(
tool,
tool_name = "python",
heartbeat_interval_s = 0.04,
poll_interval_s = 0.02,
)
events = []
result = None
try:
while True:
event = next(gen)
events.append(event)
if len([e for e in events if e["type"] == "heartbeat"]) >= 2:
release.set()
except StopIteration as stop:
result = stop.value
finally:
release.set()
watchdog.cancel()
assert result == "done"
assert len([e for e in events if e["type"] == "heartbeat"]) >= 2
def test_accepts_output_callback_signature_detection():
from core.inference.tool_stream_exec import accepts_output_callback
def legacy(
name,
arguments,
cancel_event = None,
timeout = None,
):
return "ok"
def modern(
name,
arguments,
output_callback = None,
):
return "ok"
def kwargs_only(name, arguments, **kw):
return "ok"
assert accepts_output_callback(legacy) is False
assert accepts_output_callback(modern) is True
assert accepts_output_callback(kwargs_only) is True
# Uninspectable callables (e.g. some builtins) fall back to not-supported.
assert accepts_output_callback(len) is False
@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
def test_bash_exec_nonstreaming_cancel_kills_grandchild_after_leader_exit(tmp_path):
# NON-streaming cancellation: the leader exits at once while a grandchild holds
# stdout. The cancel watcher loops on the leader's poll() and is gone, so before
# the fix communicate() blocked until the grandchild finished. The unified drain
# kills the captured group on cancel instead.
sentinel = tmp_path / "grandchild_ran"
command = f"( sleep 3; touch '{sentinel}' ) & echo parent-done"
cancel_event = threading.Event()
timer = threading.Timer(0.5, cancel_event.set)
timer.start()
started = time.monotonic()
try:
result = _bash_exec(command, cancel_event = cancel_event, timeout = 30)
finally:
timer.cancel()
assert time.monotonic() - started < 2.5
assert result == "Execution cancelled."
time.sleep(3.5)
assert (
not sentinel.exists()
), "non-streaming cancel leaked a stdout-holding grandchild"
@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX process groups")
def test_python_exec_nonstreaming_cancel_kills_grandchild_after_leader_exit(tmp_path):
sentinel = tmp_path / "grandchild_ran"
code = (
"import subprocess\n"
f"subprocess.Popen(['bash', '-c', \"sleep 3; touch '{sentinel}'\"])\n"
"print('parent-done')\n"
)
cancel_event = threading.Event()
timer = threading.Timer(0.5, cancel_event.set)
timer.start()
started = time.monotonic()
try:
result = _python_exec(code, cancel_event = cancel_event, timeout = 30)
finally:
timer.cancel()
assert time.monotonic() - started < 2.5
assert result == "Execution cancelled."
time.sleep(3.5)
assert (
not sentinel.exists()
), "non-streaming cancel leaked a stdout-holding grandchild"