# 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 "�" in baseline # replacement character, not a crash assert "".join(chunks) == "ok�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 \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"