Studio: stream live tool output with SSE heartbeats, fix web page extraction, and surface interrupted turns (#7083)

* Studio: stream live tool output with SSE heartbeats and fix web page extraction

Server-side python/terminal tools now stream incremental stdout to the chat
UI while running (new tool_output SSE event), and every blocking tool
execution emits heartbeat keepalives so reverse proxies (Cloudflare tunnels
cap idle streams at ~100s) cannot drop the connection mid-turn. The tool
loop routes also emit a stall keepalive during silent prompt prefill between
tool iterations. The final role=tool message the model sees is byte-identical
to before, so tool-call parsing, nudging, and healing are untouched.

web_search page fetches now extract main content: GitHub repo root pages are
rewritten to the README API (with HTML fallback), hidden/aria-hidden client
error placeholders are dropped, conversion scopes to article/main, and known
boilerplate fragments are stripped. Non-HTML responses are returned raw
instead of being run through the HTML converter.

The frontend renders live-scrolling tool output inside running python and
terminal cards, and a chat stream that ends without a terminal signal now
surfaces an explicit interrupted state with a Retry action instead of
silently ending the turn.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fix content-type sniffing, unlimited-timeout drain, and env parity in tool streaming

Content-Type sniffing: get_content_type() defaults to text/plain when the
header is absent, so the sniffing fallback never fired and header-less HTML
came back as raw markup. Report an empty type for a missing header and sniff
the body whenever the declared type is not HTML, so mislabeled text/plain
HTML pages are converted like before the extraction change.

Unlimited timeout drain: with tool_call_timeout disabled the old path used
communicate(timeout=None) and waited for EOF, but the streaming drain capped
the post-exit drain at a 5 second join, truncating output from a grandchild
that holds stdout open. When timeout is None, drain until EOF or the cancel
event fires; finite timeouts keep the bounded remaining-budget join.

Env parity: drop the PYTHONUNBUFFERED=1 injection on the streaming path so
the child invocation is byte-identical with and without streaming (the env
var was model-visible via os.getenv). Live streaming granularity now depends
on the child flushing; unflushed output arrives in ~8 KB chunks or at exit
and the final result is unchanged, with SSE heartbeats covering the gaps.

* Studio: stream tool-call arguments while the model writes them

A model writing a large tool call (a full python game is minutes of
generation) produced nothing on the stream: the structured path
accumulated delta.tool_calls fragments silently after the provisional
card, and the text path's DRAINING state consumed everything until
stream end. The user saw a dead Running spinner while the model was in
fact writing code, and the byte-silent SSE segment was also the window
where proxies drop the connection.

New tool_args SSE events stream the arguments as they generate. The
structured path forwards each fragment once a provisional card exists
(backlog first, so the card starts from the top of the call). The text
path sniffs the drained call for an enabled tool name and streams the
raw call text under the id the stream-end parser assigns its first call
(call_0), so the final tool_start reconciles the same card; the sniff is
gated on enabled names plus the provisional size floor, and prose or
ordinary JSON answers never spawn a card. The safetensors loop streams
the drained render_html call to its existing provisional card the same
way.

The chat adapter accumulates the raw stream per card and feeds a partial
JSON parse (call envelopes and stringified arguments unwrapped) into the
part's args, so the python and terminal cards render the code live and
the render_html canvas builds while streaming; both cards now say
Writing code / Writing command during this phase via useToolArgsStatus.
Display only: the parser input, the executed call, and the conversation
the model sees are byte-identical, covered by new loop-level tests for
the structured path, the text path, and the no-tool JSON answer.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: keep full tool output visible past the model cap; heal /mnt/data habits

Live testing surfaced two issues in the tool streaming UX.

First, a long python stdout ended in '... (truncated' in the finished
card: the model-visible result is capped by tools._truncate
(_MAX_OUTPUT_CHARS, previously 8000 chars) to protect the context
window, and the card rendered that capped text even though the live
stream had already shown everything. The cap stays (raised to 16000,
overridable via UNSLOTH_TOOL_RESULT_MAX_CHARS) but display and model
concerns are now split: the adapter preserves the accumulated live
stream on tool_end whenever it captured more than the final result, and
the finished python/terminal cards prefer it. The live-stream ceiling
rises from 16 KB to 400 KB (chunks batch per poll, so SSE stays cheap),
and both the live pane and the finished card render only the last 2000
lines with a Show all control so a huge output cannot jank the DOM. The
truncation notice now tells the model the user saw the full output and
that written files persist in the working directory. The final result
string remains byte-identical with and without streaming.

Second, models trained on ChatGPT code-interpreter transcripts write to
/mnt/data, which does not exist here (the sandbox CWD is a per-thread
persistent dir). Three layers, all identical across streaming and
non-streaming paths: the python/terminal tool descriptions gain one
sentence saying to use relative paths in the persistent CWD; a failed
execution whose output shows a missing-file error on a known
code-interpreter prefix (/mnt/data, /mnt/outputs, /home/sandbox,
/workspace) gets a model-visible retry hint appended after truncation
so it always survives; and a sitecustomize shim on the sandbox
PYTHONPATH remaps those prefixes onto the CWD in open()/os.makedirs()
with a one-line stderr notice, covering the python tool and any Python
launched from the terminal tool without touching the exec wrapper (so
tracebacks keep their line numbers). Bash-level file operations cannot
be redirected without root or mount namespaces, so they rely on the
description and the hint.

* Studio: fix hidden-element parsing, heartbeat gaps, and tool output id collisions

Review follow-ups on the tool streaming work:

- _html_to_md: treat any present hidden attribute value as hidden (it is an
  enumerated attribute whose invalid value default is the Hidden state, so
  hidden="false" is still hidden), and implement HTML5 optional end tags so
  an unclosed <p hidden> or <li hidden> ends at the next sibling start tag
  instead of swallowing every following sibling until the parent closes
- tool_stream_exec: keep heartbeats flowing after the live-output cap; a
  tool that keeps printing past the cap kept the queue non-empty, so neither
  tool_output nor heartbeat events were emitted and the SSE stream went
  silent past proxy idle timeouts
- routes/inference: forward tool heartbeats before the
  disable_parallel_tool_use drop window swallows events, so a dropped call
  that executes server-side cannot leave the Anthropic stream silent
- llama_cpp: close the provisional text tool card with a tool_end when the
  drained call fails to parse (DRAINING false-positive path), so the card
  cannot spin forever while the text is delivered as content
- tools: decode terminal output as utf-8 with errors=replace like the python
  tool; invalid bytes used to raise UnicodeDecodeError from communicate() on
  the non-streaming path and silently truncate the streaming reader, so the
  two paths diverged
- sitecustomize: patch io.open alongside builtins.open; pathlib Path.open,
  read_text and write_text call io.open directly and bypassed the remap
- frontend: scope the toolLiveOutput/toolFullOutput store keys by pane
  (modelType and pairId) and clear stale entries on tool_start; backend ids
  like call_0 repeat across turns and across concurrently streaming panes
  (compare mode), so a later turn or another pane could display the wrong
  preserved output, and run-end cleanup now clears only its own keys

Each backend fix carries a regression test that fails on the previous code;
the byte-identity tests between streaming and non-streaming stay green.

* Studio: keep tool failure status visible and truncation/remap notices truthful

Finished python/terminal cards preferred the fuller live stream by length
alone, so a tool that printed a lot then timed out or exited non-zero showed
the captured stdout but dropped the final result's status (timeout notice,
Exit code N). preferFullToolOutput now shows the stream when the result is
just its truncated prefix, and appends the result otherwise so the failure
tail always survives and the copy button copies both.

The result truncation notice claimed the user was shown the full output, but
the same wrapper serves non-streaming chat/API and direct execute_tool()
callers where nothing is streamed to anyone. The notice is now mode-neutral
and stays byte-identical with and without an output_callback, keeping the
streaming vs non-streaming invariant intact.

The sandbox sitecustomize shim now remaps /tmp/outputs into the working
directory only while it does not already exist, so a real /tmp/outputs the
user's own code created is never shadowed; /tmp/outputs also joins the
missing-path retry-hint list.

* Studio: suppress hidden void elements and keep live output scroll pinned only when at bottom

* Studio: drop capped tool output without concatenating; remap pathlib mkdir

Past the live-output cap stream_tool_execution built item + _drain_pending()
(the current chunk joined with every queued sibling) only to discard it in the
capped branch, so a chatty tool (yes, a tight print loop) could enqueue far
more than one poll interval of text and blow past the memory/CPU ceiling the
cap exists to enforce. Drain and drop queued items without building a combined
string, still counting each drain toward the heartbeat cadence so the SSE
keepalive survives.

Generated code often prepares code-interpreter paths with
Path('/mnt/data').mkdir(parents=True, exist_ok=True); pathlib drives that
through os.mkdir (not the patched os.makedirs) per component and, on
FileExistsError, probes the unpatched os.stat via Path.is_dir(), so the setup
raised before open() ever ran. Patch os.mkdir with the same remap and patch
Path.mkdir so the whole parents/exist_ok dance lands on the mapped working
directory and stays idempotent; real paths still pass through.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: generalize sandbox write remap and hint to any hallucinated absolute path

Models invent absolute paths from seeing their CWD (a DeepSeek run did
open('/home/ubuntu/Sandbox/flappy_bird.html', 'w') and died with
FileNotFoundError). A prefix list cannot enumerate these, so the sitecustomize
shim gains a write-mode fallback in open()/io.open(): when a write/create-mode
open targets an absolute path outside the CWD whose parent directory does not
exist, redirect it to the basename in the CWD and emit the same one-line stderr
notice, echoing the original path. The prefix remaps still run first (they cover
reads and preserve subpaths); read modes never hit the fallback so real system
files fail or succeed truthfully; bytes paths pass through. The fallback is not
applied to mkdir/makedirs/Path.mkdir, since creating an arbitrary absolute
directory can legitimately succeed on the host, and that decision is documented
in a comment.

The model-visible retry hint now echoes the real failing path (parsed from the
traceback tail) instead of the canned /mnt/data example, and fires for any
absolute path outside the working directory, not just the enumerated prefixes,
while a relative miss still gets no hint.

The shim wrapper still adds one frame to tracebacks that surface open() errors;
suppressing only our frame has no clean standard mechanism (a wrapper always
adds a frame), so the frame is left as an accepted compromise.

Tests: hallucinated absolute write remaps to the CWD basename across w/a/x/w+;
reads of a missing absolute path pass through untouched; writes to an existing
external dir pass through; prefix subpaths still preserved; end-to-end write
fallback lands the file in the sandbox workdir identically with and without
streaming; the hint echoes the actual path for convention and non-convention
absolute paths alike.

* Studio: kill exited process groups on drain; bound the over-cap output batch

_drain_process_output killed the process only via _kill_process_tree, which
short-circuits once the parent has exited, so a grandchild that inherited
stdout and outlived the parent was never signaled: a finite-timeout run could
return while it kept holding the pipe, and a timeout=None cancel left it
behind. Capture the setsid process group before waiting and SIGKILL that group
at both give-up points so the whole tree is torn down.

The streaming wrapper's first over-cap batch joined the current chunk with the
entire pending backlog before enforcing the live-output cap, so a chatty tool
could allocate far past the cap on the crossing batch. Bound the drain to the
remaining budget and drop the surplus in place, keeping the truncated output
byte-identical to joining everything.

* Studio: harden sandbox path healing and process/generator cleanup

Sandbox sitecustomize shim:
- Make the generalized write fallback collision-safe: never redirect an
  invented absolute path onto an already-present CWD file (refuse and let the
  original open raise FileNotFoundError, preserving the workspace file).
- Only w/a/x create a file; r+/rb+ are read-update modes that require the
  target to exist, so a bare + no longer trips the write fallback.
- Gate every convention-prefix remap (/mnt/data, /mnt/outputs, /home/sandbox,
  /workspace) on the prefix root being absent, so a real host mount is never
  shadowed; a miss under an existing real prefix passes through.
- Patch os.open so Path.touch and other low-level creators heal convention
  paths too, matching the Path.mkdir patch.

Local code execution (tools.py):
- Capture the setsid process group right after Popen (before any watcher can
  poll/reap the leader) and thread it through the cancel watcher and drain.
- Kill the captured group in the non-streaming python/terminal timeout branch
  so an exited leader no longer leaks a stdout-holding grandchild (matches the
  streaming drain path).
- Guard os.getpgid/os.killpg by platform so streamed execution no longer
  raises on Windows; fall back to single-pid kill.
- Judge missing-path hints against the executor's real workdir so a legitimate
  miss inside a project workspace outside the sandbox root is not mislabeled.

Tool streaming routes (routes/inference.py):
- Drain a pending next(gen) worker before closing the generator in the
  safetensors and Anthropic tool streams, so a disconnect no longer races
  gen.close() (generator already executing) or leaks the thread/generator.

HTML to markdown:
- Only drop boilerplate lines composed entirely of known furniture phrases so
  real prose that merely quotes one (for example "we use cookies to
  authenticate requests") is preserved.

Adds hermetic tests for each change.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: keep aside callouts, contain sandbox path remaps, and keepalive dropped Anthropic tool events

_html_to_md: stop dropping <aside> unconditionally. Documentation pages
render notes/warnings/examples as aside admonition callouts; those inside
the selected article/main scope are real content. A furniture aside outside
the scope is already excluded by the main-content pass.

sitecustomize: contain the code-interpreter path remap under the sandbox
CWD. A hallucinated habit path such as /mnt/data/../other_session/file no
longer escapes the per-conversation workdir; parent-traversal components in
the suffix are dropped and a '.'/'..' write-fallback basename is refused.

routes/inference: emit a rate-limited comment keepalive when the Anthropic
Messages stream drops tool_output/tool_args events. A chatty tool keeps the
generator busy so the stall keepalive never fires and the tool wrapper emits
heartbeats only while idle, which left the SSE stream silent past proxy idle
caps; the OpenAI passthrough paths forward these events, this path now keeps
the connection alive.

* Studio: bound the tool-output chunk that first crosses the live cap

_drain_queue joined the entire chunk that first crossed the live-output
cap before dropping the rest, so a single multi-megabyte line (or any
chunk dequeued once the budget was already met at max_chars <= 0) was
materialized in full only to be truncated away, defeating the memory
ceiling the cap enforces. Slice the crossing chunk to one character past
the budget: that preserves the caller's overflow signal and its
byte-identical truncation while dropping the arbitrarily large remainder
in place.

* Studio: scope missing-path hint to the failing line, keepalive dropped-call output, and preserve truncated tool streams over byte length

- tools._missing_path_hint: the code-interpreter convention-prefix trigger
  scanned the whole output, so a convention prefix mentioned only in a
  traceback frame (a /workspace project root) or printed by the user's code
  would add a misleading 'use a relative path' hint even when the actual
  FileNotFoundError was a relative or in-workdir path. Scope the convention
  test to the failing-path error line(s), matching _extract_missing_abs_path.

- _anthropic_tool_stream: the tool_output/tool_args rate-limited keepalive sat
  after the drop_until_tool_end skip, so under disable_parallel_tool_use a
  chatty second-or-later tool call was dropped whole with no keepalive, letting
  an idle proxy kill the SSE stream. Check the keepalive branch before the drop
  skip (like the heartbeat branch) so dropped-call output keeps the stream alive.

- preferFullToolOutput / chat-adapter: a truncated result can be longer than
  the live stream by byte count once its footer, an 'Exit code N:' notice, or an
  __IMAGES__ base64 tail is appended, so the length-only gate discarded the full
  stream and the finished card fell back to the truncated text. Add a shared
  truncation-aware shouldPreserveFullOutput used by both the write and read
  sites: preserve the stream whenever the result carries the truncation footer.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: skip the habit-path hint for real project paths under a convention prefix

* Kill captured process group on streamed wait-timeout

The streamed drain path's proc.wait() timeout branch only called
_kill_process_tree(proc). If the leader exits in the narrow window between
the wait timing out and _kill_process_tree sampling its pgid, that helper
short-circuits on the reaped leader and a stdout-holding grandchild in the
same group survives. Also kill the captured pgid there, matching the
non-streaming communicate() timeout path. Adds a hermetic regression test
that models the reaped-leader race by stubbing _kill_process_tree.

* Fix 3.10 pathlib write_text remap and honor cancel in finite drain

On Python < 3.11 pathlib routes Path.open / read_text / write_text through
a module-level accessor singleton whose open attribute captured the original
io.open at import time (_NormalAccessor.open = io.open). Patching io.open in
the sandbox shim therefore never reached that captured reference, so a
Path('/mnt/data/x').write_text(...) raised FileNotFoundError on 3.10 while
passing on 3.11+ (which dropped the accessor and calls io.open at call time).
Repoint _NormalAccessor.open at the same io.open wrapper via a staticmethod,
guarded so it is an idempotent no-op on 3.11+. Keep the test save/restore
helpers symmetric so the accessor is restored too, and add a hermetic
write_text/read_text remap test that covers every version.

Also honor cancellation while draining inherited stdout after the leader
exits. Once the leader is reaped the cancel watcher returns (its loop is
while proc.poll() is None), so the finite-timeout drain did one blocking
reader.join(timeout=remaining) that ignored cancel_event and kept draining a
chatty grandchild for the whole budget after a disconnect/Stop. Poll
cancel_event in 0.5s slices against a deadline like the timeout=None branch
and kill the captured process group promptly on cancel. The normal path still
reaches EOF on its own, so the streamed vs non-streamed result is unchanged.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: port no-tool stream keepalive/drain and fix subprocess/queue/extraction asymmetries

Streaming no-tool paths now match their tool twins:
- _anthropic_plain_stream, safetensors/MLX no-tool stream, and standard GGUF
  no-tool stream run next(gen) in a worker with a timed SSE keepalive loop so a
  long prompt prefill cannot leave the stream idle past a proxy cap.
- The Anthropic plain and safetensors/MLX no-tool teardowns now drain the
  pending next(gen) worker and close the generator on disconnect instead of
  leaking the suspended generator.

Other asymmetries:
- Non-streaming _python_exec/_bash_exec always drain via _drain_process_output
  (output_callback may be None) so a cancelled run reaps a stdout-holding
  grandchild that outlived the leader instead of blocking in communicate(). The
  joined bytes are identical to communicate(), so streamed vs non-streamed
  results stay byte-identical.
- _build_bypass_env installs the sitecustomize path shim on PYTHONPATH (prepend,
  keeping the operator's entries) so /mnt/data remap works in bypass mode too.
- GGUF forwards output_callback to execute_tool only when the callable accepts
  it (shared accepts_output_callback), matching safetensors and preserving
  legacy monkey-patched signatures.
- tool_stream_exec bounds accepted live output at the producer boundary so a
  chatty tool cannot grow the queue without limit under consumer backpressure
  and cannot keep the drain spinning and starve heartbeats.
- html_to_md implicit-close now searches past unclosed inline descendants so a
  hidden <p>/<li> is closed by a following block; main-content scoping gates on
  the largest single <article>/<main> so a swarm of tiny cards cannot pass the
  threshold in aggregate and displace the real main.
- preferFullToolOutput re-attaches the "Exit code N:" prefix to the fuller
  stream instead of appending the still-prefixed result, so a failed truncated
  tool no longer duplicates its stdout in the finished card.

Adds hermetic tests for each.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Preserve short live output on timed-out tools; strip inline-CSS-hidden subtrees and score truncated main-content scopes

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten chat tool streaming comments and docstrings

* Keep HTML READMEs from the GitHub API and preserve interrupted tool output

Convert a 200 HTML README body from the GitHub README API to Markdown
instead of discarding it and falling back to the repo page chrome, and
promote captured live stdout to full output when a tool never reaches
tool_end (stream interrupted or cancelled) so the partial diagnostics
stay on the finished card.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: anchor HTML sniff, keep repeated sandbox writes, reuse textual tool ids

Anchor _looks_like_html to the leading doctype/tag so a Markdown README that
opens with a fenced HTML example stays Markdown (no html_to_markdown
corruption), while bare HTML fragments (<body>/<article>/<section>) are still
detected and converted on a missing/wrong Content-Type.

Let the sandbox write fallback re-serve a target it already healed for the same
invented absolute path, so iterative overwrites of a generated artifact stop
failing with FileNotFoundError while the anti-clobber guard still refuses
unrelated same-basename files.

Reconcile the first textual tool call carrying an explicit id onto the open
provisional TEXT card instead of spawning a duplicate card under that id.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: run implicit-close before skipping tags and keep leading README tables as Markdown

A skipped block (<nav>/<footer>) is an HTML5 optional-end-tag closer of an
open <p>, but handle_starttag returned before the implicit-close bookkeeping,
so a never-closed <p hidden> kept its hidden mark and swallowed every following
sibling. Run _close_implicit before the skip decision so the hidden mark is
released and trailing content renders.

Drop <table> (and its <thead>/<tbody>/<tr>/<td>/<th> children) from the
_looks_like_html leading set: Markdown READMEs routinely open with a raw HTML
<table> badge/layout row, and sniffing that as HTML collapsed the whole
Markdown body through html_to_markdown, exactly like the already-excluded
<div align>/<p align> layout headers.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: make bypass-permissions Popen double faithful to the unified drain path

The non-streaming _python_exec/_bash_exec now share _drain_process_output,
which reads proc.stdout in a reader thread and calls proc.wait(); the test
double only implemented communicate(), so bypass-mode bash returned an
AttributeError instead of the faked output. Give _FakeProc a readable stdout
pipe (yields the fake line then EOF), wait()/poll()/pid, so the test exercises
the real drain path on both the python and bash bypass branches.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: persist sandbox path heals across runs and suppress nested hidden lists

* Studio: run tool Python child unbuffered (-u) so unflushed prints stream live

A long-running snippet doing bare print() without flush=True never reached
the live-output pane: CPython block-buffers stdout when writing to a pipe, so
_drain_process_output's readline() saw nothing until the buffer filled or the
process exited. Launch the child with the interpreter -u flag so stdout is
unbuffered and each print streams as it is produced.

-u is applied unconditionally on both the streaming and non-streaming path, so
the child invocation stays byte-identical with and without streaming and the
final joined result is unchanged (buffering/timing only). Unlike the earlier
PYTHONUNBUFFERED=1 env injection that was removed, -u does not pollute the
child's os.environ and is not visible via os.getenv.

* Render only the selected main-content subtree in html_to_markdown

The main-content heuristic sized each <article>/<main> candidate
individually to pick the largest subtree, but then rendered every
matching tag in the document. A page with one real article plus
sibling related-post cards or comment threads passed the size gate on
the real article yet still emitted the unrelated siblings.

Size and render the same chosen subtree so only the selected
main-content subtree reaches the output.

* Studio: tighten chat-tool-streaming fix comments

* Studio: store tool-output-scope separators as unicode escapes

The pane-scope and tool-output-key separators were literal NUL (0x00) bytes, which made git treat the file as binary and hide its diff and blame. Write them as \u0000 escapes instead; the runtime key value is unchanged.

* Studio: bound tool-stream teardown when the client disconnects

stream_tool_execution ran its yield loop with no try/finally, so a gen.close() on client disconnect (GeneratorExit at a yield) skipped the worker join and never signalled cancellation. A tool that does not poll cancel_event mid-flight (web_search, MCP, search_knowledge_base) then kept request teardown blocked until the tool's own timeout. Thread the request cancel_event into the wrapper, set it only on the abnormal-exit path so a clean multi-tool turn is unaffected, and bound the worker join to a few seconds; the daemon worker cannot outlive the process.

* Studio: sandbox path remap no longer masks missing reads

The sandbox sitecustomize shim remapped code-interpreter prefixes (/mnt/data, /workspace, ...) onto the working directory for every open mode, including reads. A read of a path that truly did not exist was silently redirected onto a same-basename workdir file instead of raising on the path the model used, hiding real missing-input errors. Remap writes and creates as before, but remap a read only when the mapped workdir target already exists (re-reading a just-written artifact); otherwise keep the original absolute path so the failure stays truthful.

* Studio: bound web fetch with one overall deadline and cancellation

The web fetch applied timeouts per network operation, so a GitHub README API attempt plus its HTML fallback plus up to five redirect hops could run well past the tool timeout, and nothing aborted once the client had disconnected. Add a single wall-clock deadline shared across the API attempt, the fallback, every redirect hop and the body read, cap each hop's socket timeout at the time left on the budget, and poll cancel_event. SSRF host pinning, per-hop redirect revalidation, the five-hop cap and the size cap are unchanged.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: keep tool-stream teardown off the event loop on disconnect

The bounded worker join added for disconnect safety still ran on an abnormal close, so a client disconnect could wait the full join timeout; and the safetensors and Anthropic tool streams closed their generator synchronously on the event loop, unlike the GGUF path. On abnormal exit the daemon worker is abandoned, so join with a zero timeout instead of waiting; offload the safetensors and Anthropic gen.close to a thread to match GGUF; and surface a heartbeat as soon as cancel_event is set while the worker is silent so the route regains control at once instead of after a heartbeat interval.

* Studio: extend the web-fetch deadline to DNS, the body read, and search

The overall fetch deadline did not cover host resolution or the response body read, and query-mode web_search ignored cancellation. Resolve hosts (initial and every redirect) on a budget-polled helper so a slow or pre-cancelled getaddrinfo aborts on time; read the capped body in chunks with the budget re-checked between them so a slow-drip server cannot stretch a single read past the deadline; and gate the blocking DDGS query on cancel_event on both sides. SSRF host pinning, per-hop redirect revalidation, the five-hop cap and the size cap are unchanged.

* Studio: defer the sandbox remap notice and tighten os.open create flags

The one-shot remap notice fired while computing the mapping, so a read that kept its original path emitted a false notice and spent the notice a later genuine remap needed. Only emit it once _remap_open commits to the redirect. Separately, os.open classified O_TRUNC / O_APPEND without O_CREAT as creating, but those cannot create a missing file, so a missing target now stays truthful (only O_CREAT maps to the creating mode).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: convert only genuine HTML README bodies, not Markdown with a leading block tag

The GitHub README API returns the raw file, almost always Markdown. _looks_like_html classified a Markdown README opening with a block tag (<ul>, <ol>, <dl>, <pre>, <blockquote>) as HTML, so _fetch_page_text ran it through html_to_markdown and collapsed its headings, lists and fenced code into a single line. Sniff the README body with a stricter document-level check (doctype or a leading <html>/<head>/<body>) so only a real .html README is converted; the general page path is unchanged.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Surface unclassified mid-stream Anthropic errors as SSE error events

The local Anthropic tool-stream and plain-stream paths called
_anthropic_stream_error_event(e) with force defaulting to False, so an
unclassified mid-stream failure (llama-server crash, decode OOM, a
dropped upstream socket) returned no event. The except block then fell
through to emitter.finish(), emitting a normal message_delta and
message_stop that masked a truncated turn as a clean finish.

Pass force = True at both fall-through sites so an unclassified failure
emits a 500 SSE error event and returns, matching the Anthropic
passthrough path that already forces it. Add regression tests covering
both stream paths.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: give each tool run a unique part id so finished cards keep their own output

Backend tool ids restart at call_0 every assistant response, and the
transient toolLiveOutput/toolFullOutput store maps were keyed by pane
scope plus that bare backend id. Two turns in the same pane therefore
shared one key: the stale-clear at tool_start only guards the forward
direction, so when a later call_0 finished and wrote its preserved full
output, every earlier still-mounted finished card reading the same key
re-rendered and displayed the newer tool's output instead of its own.

Mint one per-run-unique part id per backend id (call_0:<uuid>) and route
tool_start/output/args/end through a single resolver so all events for a
call resolve the same id. The durable part carries the unique id, so the
finished-card readers derive a collision-free key with no change, and the
awaiting-confirmation path keeps its own synthesized id. Outbound replay
stays paired (the assistant tool_call id and the role=tool result
tool_call_id both come from the part id) and gains unique ids across
turns, which strict providers require.

* Studio: tighten PR comments

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
This commit is contained in:
Daniel Han 2026-07-15 08:41:00 -07:00 committed by GitHub
commit 73af334d11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 6558 additions and 262 deletions

View file

@ -7,6 +7,11 @@ Minimal HTML-to-Markdown converter using only the standard library.
Replaces the external ``html2text`` (GPL-3.0) dependency with a ~250-line
``html.parser.HTMLParser`` subclass. Covers headings, links, bold/italic,
lists, tables, blockquotes, code blocks, and entity decoding.
``main_content=True`` also applies a readability-style heuristic: scope
conversion to the page's ``<article>`` (else ``<main>``) subtree when it
carries substantial text, and strip known boilerplate fragments (skip-links,
error placeholders, session banners, cookie prompts) from the result.
"""
from __future__ import annotations
@ -27,8 +32,138 @@ _SKIP_TAGS = frozenset(
"math",
"nav",
"footer",
# Never-rendered / form-chrome elements, not page content.
"template",
"dialog",
"button",
"select",
"datalist",
}
)
# <aside> is NOT skipped: docs use it for admonition callouts (real content);
# page-furniture asides are excluded by the main-content scoping pass instead.
# Void elements never produce an end tag, so they must not join the
# open-element stack used to bound hidden subtrees.
_VOID_TAGS = frozenset(
{
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
)
def _style_hides_element(style: str) -> bool:
"""True when an inline ``style`` sets ``display:none`` / ``visibility:hidden``.
Parsed per property so an unrelated value that merely contains ``none`` is
not misread as hidden."""
lowered = style.lower()
if "none" not in lowered and "hidden" not in lowered:
return False
for declaration in style.split(";"):
prop, sep, value = declaration.partition(":")
if not sep:
continue
prop = prop.strip().lower()
# Drop any !important flag and keep the first token of the value.
value = value.split("!", 1)[0].strip().lower()
if prop == "display" and value == "none":
return True
if prop == "visibility" and value == "hidden":
return True
return False
def _is_hidden_element(attr_dict: dict) -> bool:
"""True when the element is not rendered: ``hidden`` attribute,
``aria-hidden="true"``, or an inline ``style`` hiding it. Such JS-only
placeholders ship in the HTML but must not reach the output. ``hidden`` is
enumerated: any present value (even ``hidden="false"``) means not rendered."""
if "hidden" in attr_dict:
return True
if (attr_dict.get("aria-hidden") or "").strip().lower() == "true":
return True
return _style_hides_element(attr_dict.get("style") or "")
# HTML5 optional end tags: a listed start tag implicitly closes an open element
# of the key type (as browsers do), else an unclosed ``<p hidden>``/``<li hidden>``
# swallows every following sibling. Keys: closable elements; values: closers.
_P_CLOSING_TAGS = frozenset(
{
"address",
"article",
"aside",
"blockquote",
"details",
"div",
"dl",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hgroup",
"hr",
"main",
"menu",
"nav",
"ol",
"p",
"pre",
"section",
"table",
"ul",
}
)
_IMPLICIT_CLOSERS: dict = {
"p": _P_CLOSING_TAGS,
"li": frozenset({"li"}),
"dt": frozenset({"dt", "dd"}),
"dd": frozenset({"dt", "dd"}),
"tr": frozenset({"tr"}),
"td": frozenset({"td", "th", "tr"}),
"th": frozenset({"td", "th", "tr"}),
"option": frozenset({"option", "optgroup"}),
"optgroup": frozenset({"optgroup"}),
}
# Item tag -> container tags that re-scope it: a nested container makes an inner
# item a descendant, not an optional-close sibling, so recovery must stop there
# rather than close (and un-hide) the outer item and leak its nested content.
_CLOSE_BARRIERS: dict = {
"li": frozenset({"ul", "ol", "menu"}),
"dt": frozenset({"dl"}),
"dd": frozenset({"dl"}),
"tr": frozenset({"table"}),
"td": frozenset({"table"}),
"th": frozenset({"table"}),
"option": frozenset({"select", "datalist"}),
"optgroup": frozenset({"select", "datalist"}),
}
_BLOCK_TAGS = frozenset(
{
"p",
@ -51,13 +186,33 @@ _INLINE_EMPHASIS = {"strong": "**", "b": "**", "em": "*", "i": "*"}
class _MarkdownRenderer(HTMLParser):
"""HTMLParser subclass that emits Markdown tokens into a list."""
"""HTMLParser subclass that emits Markdown tokens into a list.
def __init__(self):
``scope_tags`` restricts emission to the subtree(s) of the given tags
(e.g. ``{"article"}``): outside them every handler is a no-op, which is
how the readability-style main-content pass drops page furniture.
"""
def __init__(self, scope_tags: frozenset[str] | None = None):
super().__init__(convert_charrefs = False)
self._out: list[str] = []
self._skip_depth: int = 0
# Main-content scoping: emit only while inside a scope tag.
self._scope_tags = scope_tags
self._scope_depth: int = 0
# Output boundaries per top-level scope element, so a caller can size each
# candidate alone and a swarm of tiny sibling cards can't clear the threshold.
self.scope_segments: list[str] = []
self._scope_seg_start: int | None = None
# Hidden-subtree tracking: stack of open non-void tags plus the indices
# where a hidden element started. End tags pop to the matching tag, so
# an omitted </p>/<li> close cannot leave the renderer stuck hidden.
self._open_tags: list[str] = []
self._hidden_marks: list[int] = []
# Link state
self._link_href: str | None = None
self._link_text_parts: list[str] = []
@ -150,16 +305,95 @@ class _MarkdownRenderer(HTMLParser):
# ------------------------------------------------------------------
# Tag handlers
# ------------------------------------------------------------------
# Structural bookkeeping shared by every start tag (skip/hidden/scope).
def _close_implicit(self, tag: str) -> None:
"""HTML5 optional-end-tag recovery for a start tag about to open.
Pops each implicitly-closed ancestor (and its hidden marks), scanning the
whole stack so an open ``<p>``/``<li>`` still closes under an unclosed inline
``<span>``. Stops at a ``_CLOSE_BARRIERS`` container so recovery never crosses
a nested list/table/dl and leaks the outer item's hidden content. Runs even
for skipped ``<nav>``/``<footer>``, which also close ``<p>``."""
barriers = _CLOSE_BARRIERS.get(tag, ())
while True:
close_at = None
for i in range(len(self._open_tags) - 1, -1, -1):
name = self._open_tags[i]
if tag in _IMPLICIT_CLOSERS.get(name, ()):
close_at = i
break
# A barrier container re-scopes the item; stop before it.
if name in barriers:
break
if close_at is None:
break
del self._open_tags[close_at:]
while self._hidden_marks and self._hidden_marks[-1] >= close_at:
self._hidden_marks.pop()
def _enter_tag(self, tag: str, attr_dict: dict) -> bool:
"""Track open/hidden/scope state; return True when the tag's content
should be rendered (False = suppressed). Caller runs ``_close_implicit``
first so recovery also fires for skipped tags."""
if tag not in _VOID_TAGS:
self._open_tags.append(tag)
if _is_hidden_element(attr_dict):
self._hidden_marks.append(len(self._open_tags) - 1)
elif _is_hidden_element(attr_dict):
# Void elements never join the stack, so suppress a hidden one inline.
return False
if self._scope_tags is not None and tag in self._scope_tags:
if self._scope_depth == 0:
self._scope_seg_start = len(self._out)
self._scope_depth += 1
if self._hidden_marks:
return False
if self._scope_tags is not None and self._scope_depth == 0:
return False
return True
def _exit_tag(self, tag: str) -> bool:
"""Pop to the matching open tag; return True when the end tag should
be rendered (False = it closed inside a hidden / out-of-scope region)."""
suppressed = bool(self._hidden_marks) or (
self._scope_tags is not None and self._scope_depth == 0
)
if tag not in _VOID_TAGS:
# Pop to the innermost matching open tag (recovers omitted closes).
for i in range(len(self._open_tags) - 1, -1, -1):
if self._open_tags[i] == tag:
del self._open_tags[i:]
while self._hidden_marks and self._hidden_marks[-1] >= i:
self._hidden_marks.pop()
break
if self._scope_tags is not None and tag in self._scope_tags and self._scope_depth > 0:
self._scope_depth -= 1
if self._scope_depth == 0 and self._scope_seg_start is not None:
self.scope_segments.append("".join(self._out[self._scope_seg_start :]))
self._scope_seg_start = None
return not suppressed
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if self._skip_depth:
# Inside a skipped subtree: only track nested skip depth.
if tag in _SKIP_TAGS:
self._skip_depth += 1
return
# Recover optional end tags before the skip decision: a skipped
# <nav>/<footer> still implicitly closes an open <p>, releasing its
# hidden mark so following siblings render.
self._close_implicit(tag)
if tag in _SKIP_TAGS:
self._skip_depth += 1
return
if self._skip_depth:
return
attr_dict = dict(attrs)
if not self._enter_tag(tag, attr_dict):
return
if tag in _HEADING_TAGS:
level = int(tag[1])
@ -250,6 +484,9 @@ class _MarkdownRenderer(HTMLParser):
if self._skip_depth:
return
if not self._exit_tag(tag):
return
if tag in _HEADING_TAGS:
self._emit("\n\n")
@ -308,8 +545,13 @@ class _MarkdownRenderer(HTMLParser):
# ------------------------------------------------------------------
# Text / entity handlers
# ------------------------------------------------------------------
def _text_suppressed(self) -> bool:
if self._skip_depth or self._hidden_marks:
return True
return self._scope_tags is not None and self._scope_depth == 0
def handle_data(self, data: str) -> None:
if self._skip_depth:
if self._text_suppressed():
return
if self._in_pre:
self._pre_parts.append(data)
@ -326,12 +568,12 @@ class _MarkdownRenderer(HTMLParser):
self._emit(text)
def handle_entityref(self, name: str) -> None:
if self._skip_depth:
if self._text_suppressed():
return
self._emit(html.unescape(f"&{name};"))
def handle_charref(self, name: str) -> None:
if self._skip_depth:
if self._text_suppressed():
return
self._emit(html.unescape(f"&#{name};"))
@ -366,6 +608,14 @@ class _MarkdownRenderer(HTMLParser):
else:
self._out.append("\n\n" + prefixed + "\n\n")
# A scope left open by truncated HTML never reached _exit_tag, so its output
# never joined scope_segments and would score 0. Flush the still-open segment
# here (after the side-buffers) so a truncated main-content page is scored.
if self._scope_seg_start is not None:
self.scope_segments.append("".join(self._out[self._scope_seg_start :]))
self._scope_seg_start = None
self._scope_depth = 0
# Post-processing
def _cleanup(text: str) -> str:
@ -399,17 +649,124 @@ def _cleanup(text: str) -> str:
return "\n".join(out).strip()
# Public API
def html_to_markdown(source_html: str) -> str:
"""Convert HTML to Markdown (headings, links, emphasis, lists, tables, blockquotes, code, entities).
# Known boilerplate fragments stripped from main-content conversions, matched
# only against short lines. Sources: GitHub page furniture / client-side error
# placeholders, skip-links, cookie banners.
_BOILERPLATE_FRAGMENTS = (
"skip to content",
"skip to main content",
"there was an error while loading",
"please reload this page",
"you can't perform that action at this time",
"you signed in with another tab or window",
"you signed out in another tab or window",
"you switched accounts on another tab or window",
"reload to refresh your session",
"you must be signed in to change notification settings",
"uh oh!",
"{{ message }}",
"this website uses cookies",
"we use cookies",
"accept all cookies",
"manage cookie preferences",
)
# Only shorter lines are eligible for boilerplate dropping; real content
# sentences quoting a fragment run longer.
_BOILERPLATE_MAX_LINE_CHARS = 300
``<script>``, ``<style>``, and ``<head>`` are stripped entirely.
"""
# Normalize line endings before parsing.
source_html = source_html.replace("\r\n", "\n").replace("\r", "\n")
renderer = _MarkdownRenderer()
# Normalized furniture phrases for whole-segment matching. See _line_is_boilerplate.
_BOILERPLATE_NORMALIZED = frozenset(
re.sub(r"\s+", " ", fragment).strip().casefold().rstrip(".!:")
for fragment in _BOILERPLATE_FRAGMENTS
)
def _line_is_boilerplate(line: str) -> bool:
"""True only when a whole line is composed of known furniture phrases.
Splits on sentence terminators and requires every segment to be furniture, so a
line stacking several phrases is dropped while prose that merely quotes one is
kept (its other words leave a non-furniture segment)."""
normalized = re.sub(r"\s+", " ", line).strip().casefold()
if not normalized:
return False
segments = [segment.strip().rstrip(".!:") for segment in re.split(r"[.!]", normalized)]
segments = [segment for segment in segments if segment]
return bool(segments) and all(segment in _BOILERPLATE_NORMALIZED for segment in segments)
def _strip_boilerplate_lines(text: str) -> str:
"""Drop short lines that consist entirely of known page-furniture phrases.
Fenced code blocks are preserved verbatim: boilerplate never renders
inside ``<pre>``, while READMEs legitimately quote error strings."""
out: list[str] = []
in_fence = False
for line in text.split("\n"):
if line.lstrip().startswith("```"):
in_fence = not in_fence
out.append(line)
continue
if not in_fence and len(line) <= _BOILERPLATE_MAX_LINE_CHARS and _line_is_boilerplate(line):
continue
out.append(line)
# Collapse blank runs the dropped lines may have left behind.
return re.sub(r"\n{3,}", "\n\n", "\n".join(out)).strip()
def _render(source_html: str, scope_tags: frozenset[str] | None) -> str:
renderer = _MarkdownRenderer(scope_tags = scope_tags)
renderer.feed(source_html)
renderer.close()
renderer.flush_pending()
raw = "".join(renderer._out)
return _cleanup(raw)
def _select_main_scope_render(source_html: str, tag: str) -> tuple[int, str]:
"""Length and boilerplate-stripped render of the largest single ``<tag>``
subtree. Sizing candidates one at a time stops many tiny sibling cards from
clearing the threshold together, and returning that one subtree keeps
unrelated siblings (related cards, comment threads) out of the output."""
renderer = _MarkdownRenderer(scope_tags = frozenset({tag}))
renderer.feed(source_html)
renderer.close()
renderer.flush_pending()
best_len = 0
best_render = ""
for seg in renderer.scope_segments:
rendered = _strip_boilerplate_lines(_cleanup(seg))
if len(rendered) > best_len:
best_len = len(rendered)
best_render = rendered
return best_len, best_render
# A scoped conversion below this size is judged not to be the page's main
# content (e.g. an empty <article> stub) and the next candidate is tried.
_MIN_MAIN_CONTENT_CHARS = 200
# Public API
def html_to_markdown(source_html: str, *, main_content: bool = False) -> str:
"""Convert HTML to Markdown (headings, links, emphasis, lists, tables, blockquotes, code, entities).
``<script>``, ``<style>``, and ``<head>`` are stripped entirely, as are
subtrees hidden from rendering (``hidden`` / ``aria-hidden="true"``).
``main_content=True`` applies a readability-style heuristic for page
fetches: prefer the ``<article>`` subtree (GitHub renders READMEs there),
then ``<main>``, falling back to the whole document, and strip known
boilerplate fragments from the result.
"""
# Normalize line endings before parsing.
source_html = source_html.replace("\r\n", "\n").replace("\r", "\n")
if main_content:
for scope_tag in ("article", "main"):
# Render only the chosen subtree so sibling <article>/<main>
# elements do not leak in once the largest passes the size gate.
length, rendered = _select_main_scope_render(source_html, scope_tag)
if length >= _MIN_MAIN_CONTENT_CHARS:
return rendered
return _strip_boilerplate_lines(_render(source_html, None))
return _render(source_html, None)

View file

@ -301,6 +301,27 @@ def _gguf_has_genuine_tool_signal(text: str, signals, active_tools: list[dict])
return False
_TEXT_TOOL_NAME_RE = re.compile(r'"name"\s*:\s*"([\w.\-]+)"')
_TEXT_TOOL_GEMMA_RE = re.compile(r"\s*call:([\w.\-]+)")
_TEXT_TOOL_REHEARSAL_RE = re.compile(r"\s*([\w.\-]+)\s*\[ARGS\]")
def _sniff_text_tool_name(text: str, enabled_names: set) -> str:
"""Best-effort tool name from a partially drained TEXT tool call, gated on
enabled names so prose can never spawn a card. Used only to open the live
argument pane early; the authoritative parse still happens at stream end."""
m = _TEXT_TOOL_NAME_RE.search(text[:4096])
if m and m.group(1) in enabled_names:
return m.group(1)
m = _TEXT_TOOL_GEMMA_RE.match(text[:256])
if m and m.group(1) in enabled_names:
return m.group(1)
m = _TEXT_TOOL_REHEARSAL_RE.match(text[:256])
if m and m.group(1) in enabled_names:
return m.group(1)
return ""
def _is_rehearsal_prefix(stripped: str, active_tools: list[dict]) -> bool:
"""True if ``stripped`` is a (possibly partial) prefix of ``NAME[ARGS]`` for an
active tool -- the bare tool name arriving in its own chunk before ``[ARGS]{...}``.
@ -8984,6 +9005,7 @@ class LlamaCppBackend:
{"type": "content", "text": "token"} -- streamed content tokens (cumulative)
{"type": "reasoning", "text": "token"} -- streamed reasoning tokens (cumulative)
"""
from core.inference.tool_stream_exec import accepts_output_callback, stream_tool_execution
from core.inference.tools import (
build_rag_autoinject,
execute_tool,
@ -9268,6 +9290,17 @@ class LlamaCppBackend:
provisional_started_tool_calls: dict[str, str] = {}
resolved_provisional_tool_call_ids: set[str] = set()
_suppress_visible_output = _forced_tool_call_pending
# Cards that already got their first tool_args event; later
# fragments stream individually so a big payload isn't a dead spinner.
arg_streamed_tool_call_ids: set[str] = set()
# TEXT tool-call path: a committed call's raw text streams to a
# provisional card under the parser's first-call id ("call_0"), so
# the final tool_start reconciles in place.
_text_args_call_start = -1
_text_args_streamed_upto = -1
_text_args_id = ""
_text_args_name = ""
_confirm_gated_iteration = bool(confirm_tool_calls) and not bypass_permissions
with self._open_stream(url, payload, cancel_event) as (
response,
@ -9430,6 +9463,29 @@ class LlamaCppBackend:
provisional = True,
),
}
# Stream argument text so the UI shows the code being
# written: first event the backlog, later the fragment.
# Display only; accumulator untouched.
if current_id in provisional_started_tool_calls:
if current_id not in arg_streamed_tool_call_ids:
arg_streamed_tool_call_ids.add(current_id)
_args_backlog = tool_calls_acc[idx]["function"].get(
"arguments", ""
)
if _args_backlog:
yield {
"type": "tool_args",
"tool_call_id": current_id,
"tool_name": current_name,
"text": _args_backlog,
}
elif func.get("arguments"):
yield {
"type": "tool_args",
"tool_call_id": current_id,
"tool_name": current_name,
"text": func["arguments"],
}
continue
# ── Reasoning tokens ──
@ -9468,7 +9524,60 @@ class LlamaCppBackend:
content_accum += token
if detect_state == _S_DRAINING:
pass # accumulate silently
# Accumulate silently for parsing, but stream the drained
# TEXT call to a provisional card. Gated on an enabled-name
# sniff + size floor so prose/small calls spawn no pane; id
# matches the first call so the final tool_start reconciles.
if (
not has_structured_tc
and not _confirm_gated_iteration
and _text_args_call_start >= 0
):
if not _text_args_id:
_call_text = content_accum[_text_args_call_start:]
_sniffed = _sniff_text_tool_name(
_call_text, _enabled_tool_names
)
if _sniffed and (
_sniffed == "render_html"
or len(_call_text)
>= _PROVISIONAL_ARGS_MIN_CHARS
):
_text_args_id = "call_0"
_text_args_name = _sniffed
if (
_text_args_id
not in provisional_started_tool_calls
):
provisional_started_tool_calls[
_text_args_id
] = _sniffed
yield {
"type": "tool_start",
"tool_name": _sniffed,
"tool_call_id": _text_args_id,
"arguments": {},
"provenance": tool_event_provenance(
provisional = True,
),
}
yield {
"type": "tool_args",
"tool_call_id": _text_args_id,
"tool_name": _sniffed,
"text": _call_text,
}
_text_args_streamed_upto = len(content_accum)
elif len(content_accum) > _text_args_streamed_upto:
yield {
"type": "tool_args",
"tool_call_id": _text_args_id,
"tool_name": _text_args_name,
"text": content_accum[
_text_args_streamed_upto:
],
}
_text_args_streamed_upto = len(content_accum)
elif detect_state == _S_STREAMING:
if in_thinking:
@ -9575,6 +9684,11 @@ class LlamaCppBackend:
# without yielding. A live <think> prefix is
# separate from it -- close that.
detect_state = _S_DRAINING
# Call text begins at the held buffer
# (live arg display only; UI extracts the code).
_text_args_call_start = len(content_accum) - len(
content_buffer
)
if _close_streamed_think():
yield {
"type": "content",
@ -9602,6 +9716,11 @@ class LlamaCppBackend:
"text": cleaned,
}
detect_state = _S_DRAINING
# Live-arg display starts at the held buffer
# (visible prefix flushed above; UI extracts the code).
_text_args_call_start = len(content_accum) - len(
content_buffer
)
elif _hold_buffer or (
is_prefix
and (
@ -9849,9 +9968,20 @@ class LlamaCppBackend:
f"{'structured delta' if has_structured_tc else 'content text'}"
)
if not tool_calls:
# DRAINING but no tool calls (false positive). Merge
# accumulated metrics from prior tool iterations so
# they aren't silently dropped.
# DRAINING but no tool calls (false positive): close any
# provisional cards (a sniff can open one whose call never
# parses); without a tool_end the card spins forever.
for _pid, _pname in provisional_started_tool_calls.items():
if _pid not in resolved_provisional_tool_call_ids:
resolved_provisional_tool_call_ids.add(_pid)
yield {
"type": "tool_end",
"tool_name": _pname,
"tool_call_id": _pid,
"result": "",
"provenance": tool_event_provenance(provisional = True),
}
# Merge metrics from prior tool iterations so they aren't dropped.
yield {"type": "status", "text": ""}
if content_accum:
# Strip leaked tool-call XML before yielding.
@ -9912,9 +10042,22 @@ class LlamaCppBackend:
assistant_msg: dict = {"role": "assistant", "content": content_text}
assistant_appended = False
# The text-path provisional card uses the parser's default id ("call_0");
# a Mistral-style call carries its own id and would open a duplicate. Reuse
# the card's id for the first matching call (same tool name) to reconcile.
_text_provisional_id = _text_args_id if not has_structured_tc else ""
for tc in tool_calls or []:
func = tc.get("function", {})
tool_name = func.get("name", "")
if (
_text_provisional_id
and _text_provisional_id in provisional_started_tool_calls
and _text_provisional_id not in resolved_provisional_tool_call_ids
and tc.get("id") not in provisional_started_tool_calls
and provisional_started_tool_calls[_text_provisional_id] == tool_name
):
tc = {**tc, "id": _text_provisional_id}
provisional_match = tc.get("id") in provisional_started_tool_calls
decision = tool_controller.prepare_call(
tc,
@ -10026,15 +10169,33 @@ class LlamaCppBackend:
):
result = RAG_SEARCH_CAP_NUDGE
else:
result = execute_tool(
decision.tool_name,
decision.arguments,
# Execute in a worker thread so live stdout chunks and heartbeats
# stream while the tool blocks (the SSE route turns heartbeats into
# keepalives). Result is byte-identical to a direct call.
def _invoke_tool(_output_callback, _decision = decision):
# execute_tool is injectable and may be monkey-patched with the
# pre-PR signature; forward output_callback only if it's accepted.
kwargs = dict(
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
thread_id = thread_id,
rag_scope = rag_scope,
disable_sandbox = bypass_permissions,
)
if accepts_output_callback(execute_tool):
kwargs["output_callback"] = _output_callback
return execute_tool(
_decision.tool_name,
_decision.arguments,
**kwargs,
)
result = yield from stream_tool_execution(
_invoke_tool,
tool_name = decision.tool_name,
tool_call_id = decision.tool_call_id,
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
thread_id = thread_id,
rag_scope = rag_scope,
disable_sandbox = bypass_permissions,
)
if decision.tool_name == "search_knowledge_base":
_kb_search_count += 1

View file

@ -15,6 +15,7 @@ parses tool calls from the cumulative text and dispatches via
"""
import bisect
import inspect
import re
import threading
from typing import Callable, Generator, Optional
@ -60,6 +61,7 @@ from core.inference.tool_loop_controller import (
status_for_tool,
tool_event_provenance,
)
from core.inference.tool_stream_exec import stream_tool_execution
from state.tool_approvals import (
TOOL_REJECTED_MESSAGE,
abort_tool_decision,
@ -402,6 +404,22 @@ def _tool_event_provenance(**flags: object) -> dict[str, object]:
return tool_event_provenance(**flags)
def _accepts_output_callback(func: Callable[..., str]) -> bool:
"""Whether an injectable ``execute_tool`` supports ``output_callback``.
The loop's ``execute_tool`` is a parameter (tests inject fakes), so forward
the live-output kwarg only when the callable declares it or takes ``**kwargs``.
"""
try:
sig = inspect.signature(func)
except (TypeError, ValueError):
return False
params = sig.parameters
if "output_callback" in params:
return True
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
def _call_single_turn(single_turn, conversation: list, active_tools: list[dict]):
"""Call a single-turn generator with active tool schemas when supported."""
try:
@ -552,6 +570,9 @@ def run_safetensors_tool_loop(
provisional_render_html_started = False
provisional_resolved = False
provisional_render_html_id = f"call_{next_call_id}"
# Live-args offset for the provisional render_html card: the drained call
# text streams as tool_args so the canvas shows the HTML being written.
_live_args_streamed_upto = -1
# When a human confirmation gate is active the real tool_start is keyed
# by an approval id and carries awaiting_confirmation, so an early
# provisional card (keyed by tool_call_id, no approval) would show the
@ -623,6 +644,30 @@ def run_safetensors_tool_loop(
"arguments": {},
"provenance": _tool_event_provenance(provisional = True),
}
# Backlog first: everything drained so far.
yield {
"type": "tool_args",
"tool_call_id": provisional_render_html_id,
"tool_name": "render_html",
"text": content_accum,
}
_live_args_streamed_upto = len(content_accum)
elif (
provisional_render_html_started
and not provisional_resolved
and _live_args_streamed_upto >= 0
and len(content_accum) > _live_args_streamed_upto
):
# Still writing the call: stream the fragment so the canvas
# renders live. Display only; content_accum still feeds the
# stream-end parser verbatim.
yield {
"type": "tool_args",
"tool_call_id": provisional_render_html_id,
"tool_name": "render_html",
"text": content_accum[_live_args_streamed_upto:],
}
_live_args_streamed_upto = len(content_accum)
continue
if detect_state == _state_streaming:
@ -663,6 +708,13 @@ def run_safetensors_tool_loop(
"arguments": {},
"provenance": _tool_event_provenance(provisional = True),
}
yield {
"type": "tool_args",
"tool_call_id": provisional_render_html_id,
"tool_name": "render_html",
"text": content_accum,
}
_live_args_streamed_upto = len(content_accum)
continue
cumulative_display = candidate
cleaned = strip_tool_markup_streaming(
@ -819,6 +871,13 @@ def run_safetensors_tool_loop(
"arguments": {},
"provenance": _tool_event_provenance(provisional = True),
}
yield {
"type": "tool_args",
"tool_call_id": provisional_render_html_id,
"tool_name": "render_html",
"text": content_accum,
}
_live_args_streamed_upto = len(content_accum)
elif is_prefix and (is_rehearsal_prefix or len(stripped) < _MAX_BUFFER_CHARS):
# A rehearsal prefix is self-bounded; the buffer cap must not cut long MCP names short.
continue
@ -1146,10 +1205,12 @@ def run_safetensors_tool_loop(
):
result = RAG_SEARCH_CAP_NUDGE
else:
try:
result = execute_tool(
decision.tool_name,
decision.arguments,
# Execute in a worker thread so live stdout chunks and heartbeats
# stream while the tool blocks (the SSE route turns heartbeats into
# keepalives). execute_tool is injectable; pass output_callback
# only when it accepts it.
def _invoke_tool(_output_callback, _decision = decision):
kwargs = dict(
cancel_event = cancel_event,
timeout = eff_timeout,
session_id = session_id,
@ -1157,6 +1218,17 @@ def run_safetensors_tool_loop(
rag_scope = rag_scope,
disable_sandbox = bypass_permissions,
)
if _accepts_output_callback(execute_tool):
kwargs["output_callback"] = _output_callback
return execute_tool(_decision.tool_name, _decision.arguments, **kwargs)
try:
result = yield from stream_tool_execution(
_invoke_tool,
tool_name = decision.tool_name,
tool_call_id = decision.tool_call_id,
cancel_event = cancel_event,
)
except Exception as exc:
logger.exception("Tool %s raised: %s", decision.tool_name, exc)
result = f"Error: tool raised an exception: {exc}"

View file

@ -0,0 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# Package marker only, so wheel builds ship this directory. It goes on the
# sandbox PYTHONPATH so site machinery imports the sibling ``sitecustomize`` at
# startup; nothing in the backend imports it directly.

View file

@ -0,0 +1,313 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Sandbox-side compatibility shim for ChatGPT code-interpreter paths.
Models habitually write to /mnt/data (or /mnt/outputs, /home/sandbox,
/workspace), none of which exist in the Studio sandbox. This module sits on the
sandbox subprocess PYTHONPATH (see ``tools._build_safe_env``), so it loads at
interpreter startup in every sandboxed ``python`` run and any Python the
``terminal`` tool launches.
It remaps those prefixes onto the CWD in ``open`` / ``io.open``, ``os.open``,
``os.makedirs`` / ``os.mkdir`` and ``pathlib.Path.mkdir``. A write/create to a
convention prefix always heals onto the CWD; a READ heals only when the mapped
target already exists (re-reading an earlier write), so a genuinely missing
input stays truthful on the path the model used instead of silently reading a
same-basename workdir file. Since prefix lists cannot cover every invented path,
``open`` / ``io.open`` also get a create-mode fallback: an absolute path outside
the CWD whose parent is missing is redirected to the basename in the CWD. Reads
and mkdir never use the fallback (an arbitrary absolute directory can legitimately
succeed). It is collision-safe: it refuses to redirect onto an existing CWD file
(letting open raise). The patch set (io.open, os.open, os.mkdir, Path.mkdir, and
the <3.11 ``_NormalAccessor.open``) covers the low-level entry points pathlib
routes through. A one-line stderr notice fires on the first remap, and everything
is wrapped in try/except so a failure never breaks the interpreter.
Identical with and without output streaming because the child env is.
"""
import builtins
import io
import json
import os
import sys
# Code-interpreter convention prefixes. Remapping is gated on the prefix being
# ABSENT (see _remap) so a genuine host mount / user dir is never shadowed.
_PREFIXES = ("/mnt/data", "/mnt/outputs", "/home/sandbox", "/workspace")
# /tmp exists on the host; separate only to note that. The absence gate applies alike.
_CONDITIONAL_PREFIXES = ("/tmp/outputs",)
_notified = False
# Invented absolute write path -> healed CWD target, so re-writing the same
# artifact re-serves it instead of tripping the anti-clobber guard.
_remapped_writes: dict = {}
# Each tool call is a fresh subprocess (in-process map starts empty), so this
# on-disk sidecar carries the map across runs. It records only sources the
# fallback healed, so an unrelated same-basename file is never adopted.
_REMAP_SIDECAR = ".unsloth_sandbox_remap.json"
def _note(subject, original, mapped):
"""Print the one-shot stderr notice so the model learns the real location.
``subject`` is what "does not exist" (the prefix, or the whole invented
path); ``original`` is echoed in the ``(original -> mapped)`` tail.
"""
global _notified
if _notified:
return
_notified = True
print(
f"note: {subject} does not exist in this sandbox; "
f"using the working directory instead ({original} -> {mapped})",
file = sys.stderr,
)
def _contained_join(cwd, rel):
"""Join ``rel`` onto ``cwd`` so the result can never escape ``cwd``.
A habit path can carry ``..`` segments; joining verbatim would let the target
climb above the sandbox. ``..`` components are dropped and empty / ``.`` ones
ignored, keeping the result under ``cwd``.
"""
parts = []
for part in rel.split("/"):
if part == "" or part == ".":
continue
if part == "..":
if parts:
parts.pop()
continue
parts.append(part)
return os.path.join(cwd, *parts) if parts else cwd
def _map_onto_cwd(
prefix,
text,
notify = True,
):
"""Map ``<prefix>/rest`` onto ``./rest`` in the CWD, noting it once.
The suffix is contained under the CWD (see ``_contained_join``) so a path
like ``/mnt/data/../other_session/file`` cannot escape the workdir.
``notify`` is False when the caller may keep the original path (a read), so
the one-shot notice is not spent on a remap that never happens.
"""
rel = text[len(prefix) :].lstrip("/")
mapped = _contained_join(os.getcwd(), rel)
if notify:
_note(prefix, text, mapped)
return mapped
def _sidecar_path(cwd):
return os.path.join(cwd, _REMAP_SIDECAR)
def _load_sidecar(cwd):
"""Return the persisted ``source -> healed target`` map, or {} on any error
(missing/corrupt/foreign sidecar degrades to in-process-only behaviour)."""
try:
with open(_sidecar_path(cwd)) as fh:
data = json.load(fh)
except Exception: # noqa: BLE001 - a bad sidecar must never break user code
return {}
return data if isinstance(data, dict) else {}
def _record_sidecar(cwd, source, target):
"""Persist ``source -> target`` so the next run re-serves it.
Written atomically (temp + ``os.replace``) and wrapped so a read-only/full
filesystem never breaks the interpreter. The path is inside the CWD, so the
patched ``open`` leaves it untouched (no remap, no recursion).
"""
try:
data = _load_sidecar(cwd)
if data.get(source) == target:
return
data[source] = target
tmp = _sidecar_path(cwd) + ".tmp"
with open(tmp, "w") as fh:
json.dump(data, fh)
os.replace(tmp, _sidecar_path(cwd))
except Exception: # noqa: BLE001 - persistence is best effort only
pass
def _is_creating_mode(mode):
"""True only when an ``open()`` mode string can CREATE a missing file.
Only ``w`` / ``a`` / ``x`` create. ``r+`` / ``rb+`` require the path to exist,
so they must not trip the write fallback (which would corrupt an unrelated
same-basename file); ``w+`` / ``a+`` / ``x+`` still match.
"""
return isinstance(mode, str) and any(c in mode for c in ("w", "a", "x"))
def _remap_open(file, mode):
"""Remap for ``open()`` / ``io.open()``.
A prefix remap runs first: a write/create heals onto the CWD; a READ heals
only when the mapped target already exists (re-reading an earlier write),
else the original path is kept so a genuine missing input fails truthfully
instead of silently reading a same-basename workdir file. Only if no prefix
matched and the call creates does the fallback kick in: an absolute target
outside the CWD whose parent is missing is redirected to the basename in the
CWD, unless ``CWD/<basename>`` already exists (an unrelated file), in which
case the original path is kept so open raises.
"""
creating = _is_creating_mode(mode)
# notify=False: emit the notice only once we commit to the mapping below.
mapped = _remap(file, notify = False)
if mapped is not file:
# Write always heals; a read only when the mapped target exists (else keep
# the original path so a missing input stays truthful).
if creating or os.path.exists(mapped):
# Commit: emit the notice now (the notify=False peek above deferred it).
_remap(file, notify = True)
return mapped
return file
if not creating:
return file
try:
text = os.fspath(file)
except TypeError:
return file
# bytes paths left untouched (str-only, matching the prefix remaps).
if not isinstance(text, str) or not os.path.isabs(text):
return file
cwd = os.getcwd()
# Already inside the CWD: a real target the model meant; leave it alone.
if text == cwd or text.startswith(cwd + os.sep):
return file
parent = os.path.dirname(text)
# Redirect only when the parent is missing; an existing external directory is
# a deliberate target and stays truthful (os.path.exists follows symlinks).
if parent and os.path.exists(parent):
return file
base = os.path.basename(text)
# A trailing sep or '.'/'..' basename would redirect onto the CWD or its
# parent; refuse and let open raise.
if base in ("", ".", ".."):
return file
remapped = os.path.join(cwd, base)
# Never clobber an unrelated file sharing this basename (lexists catches
# dangling symlinks). But a target this fallback already healed for the same
# invented path (in-process map or cross-run sidecar) is the artifact being
# re-written, so re-serve it instead of raising on every overwrite.
if os.path.lexists(remapped) and remapped not in (
_remapped_writes.get(text),
_load_sidecar(cwd).get(text),
):
return file
_remapped_writes[text] = remapped
_record_sidecar(cwd, text, remapped)
_note(text, text, remapped)
return remapped
def _remap(path, notify = True):
"""Map ``<prefix>/rest`` onto ``./rest`` in the CWD; other paths pass through.
``notify`` is forwarded to ``_map_onto_cwd``; ``_remap_open`` passes False so
a read that keeps its original path emits no false notice.
"""
try:
text = os.fspath(path)
except TypeError:
return path
if not isinstance(text, str):
return path
for prefix in _PREFIXES + _CONDITIONAL_PREFIXES:
# Heal only while the real prefix directory is absent, so a genuine host
# mount / user directory at that prefix is never shadowed.
if (text == prefix or text.startswith(prefix + "/")) and not os.path.exists(prefix):
return _map_onto_cwd(prefix, text, notify = notify)
return path
def _install():
import pathlib
original_open = builtins.open
original_io_open = io.open
original_os_open = os.open
original_makedirs = os.makedirs
original_mkdir = os.mkdir
original_path_mkdir = pathlib.Path.mkdir
def _open(
file,
mode = "r",
*args,
**kwargs,
):
return original_open(_remap_open(file, mode), mode, *args, **kwargs)
def _io_open(
file,
mode = "r",
*args,
**kwargs,
):
return original_io_open(_remap_open(file, mode), mode, *args, **kwargs)
# mkdir/makedirs get only the prefix remap, never the write-mode fallback:
# an arbitrary absolute directory can legitimately succeed on the host.
def _makedirs(name, *args, **kwargs):
return original_makedirs(_remap(name), *args, **kwargs)
def _mkdir(path, *args, **kwargs):
return original_mkdir(_remap(path), *args, **kwargs)
def _os_open(
path,
flags,
mode = 0o777,
*,
dir_fd = None,
):
# Path.touch() etc. go through os.open, not builtins.open. Only O_CREAT
# can create, so only it maps to "creating" mode; O_TRUNC / O_APPEND
# without O_CREAT still require the file to exist, so behave as a read.
logical_mode = "w" if (flags & os.O_CREAT) else "r"
mapped = _remap_open(path, logical_mode)
if dir_fd is None:
return original_os_open(mapped, flags, mode)
return original_os_open(mapped, flags, mode, dir_fd = dir_fd)
def _path_mkdir(self, *args, **kwargs):
# pathlib probes Path.is_dir()/os.stat (unpatched) on FileExistsError, so
# a bare os.mkdir remap would still raise when the target exists. Remap
# the receiver up front so parents/exist_ok stays idempotent.
mapped = _remap(self)
target = self if mapped is self else self.__class__(mapped)
return original_path_mkdir(target, *args, **kwargs)
builtins.open = _open
# pathlib.Path.open / write_text / read_text call io.open directly, so patch both.
io.open = _io_open
# Python < 3.11 only: pathlib's accessor captured the ORIGINAL io.open at
# import (``_NormalAccessor.open = io.open``), so the io.open patch misses it.
# Repoint it at the same wrapper (staticmethod to stay unbound); 3.11+ dropped
# the accessor, so this is a no-op there.
accessor = getattr(pathlib, "_NormalAccessor", None)
if accessor is not None and hasattr(accessor, "open"):
accessor.open = staticmethod(_io_open)
# Path.touch() and other low-level opens call os.open directly, so patch it too.
os.open = _os_open
os.makedirs = _makedirs
# Path.mkdir(parents=True) calls os.mkdir per component, so patch os.mkdir;
# patch Path.mkdir itself too so exist_ok/parents land on the mapped path.
os.mkdir = _mkdir
pathlib.Path.mkdir = _path_mkdir
try:
_install()
except Exception: # noqa: BLE001 - a broken shim must never break user code
pass

View file

@ -0,0 +1,284 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Streaming wrapper around blocking server-side tool execution.
``stream_tool_execution`` runs a blocking tool call in a worker thread and
turns it into a generator that yields:
* ``{"type": "tool_output", "tool_name", "tool_call_id", "text"}`` -- an
incremental stdout/stderr chunk (python/terminal tools) for live UI output;
* ``{"type": "heartbeat"}`` -- emitted whenever nothing else has been yielded
for ``heartbeat_interval_s`` seconds, so the SSE route can write a
keepalive and reverse proxies (Cloudflare tunnels cap idle streams at
~100 s) never see a silent connection while a tool runs;
and *returns* the tool's final result string via ``StopIteration.value``
(``result = yield from stream_tool_execution(...)``). The returned result is
byte-identical to calling the tool directly, so tool-result parsing, nudging,
and healing downstream are untouched.
"""
from __future__ import annotations
import inspect
import queue
import threading
import time
from typing import Any, Callable, Generator
from loggers import get_logger
logger = get_logger(__name__)
def accepts_output_callback(func: Callable[..., str]) -> bool:
"""Whether an injectable ``execute_tool`` supports ``output_callback``.
``execute_tool`` is replaceable (tests inject fakes / the pre-PR signature),
so forward the kwarg only when the callable declares it or takes ``**kwargs``
(passing it unconditionally would ``TypeError`` on an old signature).
"""
try:
params = inspect.signature(func).parameters
except (TypeError, ValueError):
return False
if "output_callback" in params:
return True
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
# Cadence of heartbeat events while a tool blocks with no output. Well under
# common proxy idle caps (Cloudflare ~100 s, nginx default 60 s).
TOOL_HEARTBEAT_INTERVAL_S = 10.0
# How often the wrapper wakes to poll for output / completion / cancellation.
_POLL_INTERVAL_S = 0.25
# Upper bound on how long teardown waits for the worker once the stream is
# closed or errors. A cancel-observing tool returns within this after
# ``cancel_event`` is set; a cancel-ignoring one is a daemon left to finish on
# its own rather than blocking teardown for the tool's full timeout.
_WORKER_JOIN_TIMEOUT_S = 5.0
# Cap on total streamed live-output characters per tool call, bounding the
# transient UI stream so a tight print loop cannot flood the SSE channel. Much
# higher than the model-visible result cap (tools._MAX_OUTPUT_CHARS) since the
# UI keeps the live stream as the displayed output when the result is truncated.
TOOL_OUTPUT_STREAM_MAX_CHARS = 400_000
_STREAM_CAPPED_NOTICE = "\n... (further live output not streamed)\n"
def _drain_queue(q: "queue.Queue", sentinel: object, max_chars: int | None) -> tuple[str, bool]:
"""Pull every currently-queued item, joining chunks in FIFO order.
With ``max_chars`` set, stop concatenating at the budget and discard the
remaining chunks in place, bounding peak allocation when a chatty tool queues
far more than the cap before the consumer wakes. The crossing chunk is sliced
to one char past the budget, enough for the caller's truncation to stay
byte-identical. Returns ``(joined_text, hit_sentinel)``; the surplus is still
scanned so completion is detected promptly.
"""
parts: list[str] = []
total = 0
dropping = False
hit_sentinel = False
while True:
try:
item = q.get_nowait()
except queue.Empty:
break
if item is sentinel:
hit_sentinel = True
break
if dropping:
continue
if max_chars is not None and total + len(item) > max_chars:
# Keep one char past the budget as the overflow signal; drop the rest.
parts.append(item[: max(0, max_chars - total) + 1])
dropping = True
continue
parts.append(item)
total += len(item)
return "".join(parts), hit_sentinel
def stream_tool_execution(
invoke: Callable[[Callable[[str], None]], str],
*,
tool_name: str,
tool_call_id: str = "",
cancel_event: Any = None,
heartbeat_interval_s: float = TOOL_HEARTBEAT_INTERVAL_S,
poll_interval_s: float = _POLL_INTERVAL_S,
) -> Generator[dict, None, str]:
"""Run ``invoke(output_callback)`` in a thread; yield live events; return the result.
``invoke`` receives a thread-safe ``callable(str)`` it may call with
incremental output chunks (or ignore entirely). Exceptions raised by the
tool propagate to the caller unchanged after the worker thread finishes.
``cancel_event`` is the request-level cancellation signal already handed to
the tool. If the consumer closes this generator early (an SSE disconnect
calls ``gen.close()``, raising ``GeneratorExit`` at a ``yield``), the wrapper
sets it so a cancel-observing tool stops, then joins the worker with a bounded
timeout. Set ONLY on that abnormal-exit path, never on a clean finish, because
the event is shared across a turn's tool calls and setting it early would
abort the next tool.
"""
output_queue: queue.Queue[Any] = queue.Queue()
done_sentinel = object()
outcome: dict[str, Any] = {}
# Bound accepted output at the PRODUCER boundary: the consumer-side cap alone
# wouldn't stop a fast worker enqueuing unboundedly while a slow SSE client
# backpressures. Accept at most one char past the cap (so the consumer still
# emits the capped notice) and drop the rest. The final result is captured
# independently, so this never changes the byte-identical result.
accepted_output_chars = 0
accepted_output_lock = threading.Lock()
def _on_output(text: str) -> None:
nonlocal accepted_output_chars
if not text:
return
with accepted_output_lock:
remaining = TOOL_OUTPUT_STREAM_MAX_CHARS + 1 - accepted_output_chars
if remaining <= 0:
return
accepted = text[:remaining]
accepted_output_chars += len(accepted)
output_queue.put(accepted)
def _run() -> None:
try:
outcome["result"] = invoke(_on_output)
except BaseException as exc: # noqa: BLE001 - re-raised on the caller side
outcome["error"] = exc
finally:
# Posted after the result/error is recorded; wakes the consumer
# immediately so fast tools pay no poll-interval latency.
output_queue.put(done_sentinel)
worker = threading.Thread(
target = _run,
daemon = True,
name = f"tool-exec-{tool_name or 'unknown'}",
)
worker.start()
# Heartbeats are paced by counting idle queue polls rather than a wall clock
# (tests patch ``time.monotonic`` globally, so the wrapper must not read it).
idle_polls_per_heartbeat = max(1, int(round(heartbeat_interval_s / poll_interval_s)))
idle_polls = 0
streamed_chars = 0
stream_capped = False
finished = False
def _drain_pending(max_chars: int | None = None) -> str:
nonlocal finished
text, hit_sentinel = _drain_queue(output_queue, done_sentinel, max_chars)
if hit_sentinel:
finished = True
return text
def _drain_and_drop() -> None:
"""Discard the current and every queued chunk without concatenating.
Past the cap every chunk is dropped, so don't pay to build a combined
string only to drop it. Still detect completion so the loop can exit.
"""
nonlocal finished
while True:
try:
item = output_queue.get_nowait()
except queue.Empty:
return
if item is done_sentinel:
finished = True
return
abnormal_exit = False
try:
while not finished:
try:
item = output_queue.get(timeout = poll_interval_s)
except queue.Empty:
# A disconnect sets cancel_event while the worker is silent;
# surface a heartbeat this poll so the route regains control and
# tears down at once, not after a full heartbeat interval.
if cancel_event is not None and cancel_event.is_set():
yield {"type": "heartbeat"}
continue
idle_polls += 1
if idle_polls >= idle_polls_per_heartbeat:
idle_polls = 0
yield {"type": "heartbeat"}
continue
if item is done_sentinel:
break
if stream_capped:
# Past the cap: drop this chunk and every queued sibling (see
# _drain_and_drop). Pace with one time.sleep per poll (not
# time.monotonic -- tests patch the clock), counted as an idle
# poll so heartbeats keep flowing while the queue stays non-empty.
_drain_and_drop()
if finished:
break
time.sleep(poll_interval_s)
idle_polls += 1
if idle_polls >= idle_polls_per_heartbeat:
idle_polls = 0
yield {"type": "heartbeat"}
continue
# Bound the join to the remaining budget so the crossing batch can't
# allocate far past the cap (surplus is truncated below anyway); the
# prefix is long enough that truncation stays byte-identical.
budget = TOOL_OUTPUT_STREAM_MAX_CHARS - streamed_chars
chunk = item + _drain_pending(max_chars = budget - len(item))
idle_polls = 0
if streamed_chars + len(chunk) > TOOL_OUTPUT_STREAM_MAX_CHARS:
chunk = chunk[: max(0, TOOL_OUTPUT_STREAM_MAX_CHARS - streamed_chars)]
chunk += _STREAM_CAPPED_NOTICE
stream_capped = True
streamed_chars += len(chunk)
if chunk:
yield {
"type": "tool_output",
"tool_name": tool_name,
"tool_call_id": tool_call_id,
"text": chunk,
}
except BaseException:
# The loop only raises when the consumer closes us early: an SSE
# disconnect calls gen.close() (GeneratorExit at the yield) or the route
# throws in. Signal cancellation so a cancel-observing tool returns; the
# daemon worker is then abandoned (see finally). Re-raise so the caller
# sees the real cause (GeneratorExit must not be swallowed). Runs ONLY on
# abnormal exit, so the shared cancel_event is never set out from under
# the next tool in a clean multi-tool turn.
abnormal_exit = True
if cancel_event is not None:
try:
cancel_event.set()
except Exception:
pass
raise
finally:
# Clean finish: the worker already recorded its result and queued the
# sentinel we consumed, so this join returns at once. Abnormal exit:
# cancel_event is set and the daemon worker abandoned, so join with a zero
# timeout -- teardown never blocks the caller (the route may close this
# generator on the event loop), and the daemon cannot outlive the process.
worker.join(timeout = 0 if abnormal_exit else _WORKER_JOIN_TIMEOUT_S)
error = outcome.get("error")
if error is not None:
raise error
# Returned verbatim (the loop's record_result handles non-str), so the
# final tool result is byte-identical to a direct execute_tool call.
return outcome.get("result")

File diff suppressed because it is too large Load diff

View file

@ -1083,6 +1083,10 @@ _OPENAI_PASSTHROUGH_PREHEADER_STATUS_WINDOW_S = 0.1
_OPENAI_PASSTHROUGH_PENDING_RESPONSE_KEEPALIVE_S = 5.0
_OPENAI_PASSTHROUGH_SSE_KEEPALIVE = ": keep-alive\n\n"
_OPENAI_LLAMA_ADMISSION_POLL_S = 0.25
# Idle window before a local tool-loop stream emits an SSE keepalive comment
# (e.g. prompt prefill between tool iterations). A second layer atop the
# tool_stream_exec heartbeats, keeping proxies (Cloudflare drops idle at ~100s).
_LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S = 15.0
def _openai_llama_admission_capacity(request: Optional[Request], llama_backend = None) -> int:
@ -2298,6 +2302,36 @@ async def _stop_local_disconnect_cancel_watcher(watcher) -> None:
pass
async def _drain_pending_next_task(task, cancel_event) -> None:
"""Wait for a pending ``asyncio.to_thread(next, gen, ...)`` task to finish
before its generator is closed.
On disconnect a ``next(gen)`` call may still run in a worker thread;
cancelling the awaiting task does NOT stop it, and ``gen.close()`` mid-
``next(gen)`` raises ``ValueError: generator already executing``, leaking the
generator's cleanup. So re-set the cancel flag (the generator polls it) and
shield the task until the worker returns. No-op when there is no pending task.
"""
if task is None:
return
if cancel_event is not None:
cancel_event.set()
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
if cancel_event is not None:
cancel_event.set()
continue
except Exception:
break
if task.done():
try:
task.exception()
except (asyncio.CancelledError, Exception):
pass
# Centralized local/server tool nudge. Keep render_html guidance gated to turns
# where the canvas tool is actually present in the tool schema; otherwise
# small local models can hallucinate a missing tool call instead of following
@ -7462,13 +7496,35 @@ async def openai_chat_completions(
asyncio.to_thread(next, gen, _tool_sentinel)
)
try:
event = await asyncio.shield(next_task)
# Stall-timeout wait: keepalive while the generator stays
# silent (e.g. prefill between tool iterations). asyncio.wait
# never cancels next_task, matching the finally-drain shield.
while True:
done_tasks, _ = await asyncio.wait(
{next_task},
timeout = _LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S,
)
if done_tasks:
break
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
event = next_task.result()
finally:
if next_task.done():
next_task = None
if event is _tool_sentinel:
break
if event["type"] == "heartbeat":
# Tool-wrapper heartbeat while a server-side tool blocks; keeps SSE alive.
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
continue
if event["type"] in ("tool_output", "tool_args"):
# Live stdout/stderr or tool-call arguments, forwarded
# verbatim for the UI. Final result still arrives in tool_end.
yield f"data: {json.dumps(event)}\n\n"
continue
if event["type"] == "status":
# Empty status marks an iteration boundary in the
# GGUF tool loop (e.g. after a re-prompt). Reset the
@ -8018,7 +8074,18 @@ async def openai_chat_completions(
asyncio.to_thread(next, gen, _gguf_sentinel)
)
try:
cumulative = await asyncio.shield(next_task)
# Stall-timeout wait: keepalive while the generator stays
# silent (e.g. no-tool prefill). asyncio.wait never cancels
# next_task, matching the finally-drain shield (see GGUF stream).
while True:
done_tasks, _ = await asyncio.wait(
{next_task},
timeout = _LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S,
)
if done_tasks:
break
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
cumulative = next_task.result()
finally:
if next_task.done():
next_task = None
@ -8709,6 +8776,7 @@ async def openai_chat_completions(
async def sf_tool_stream():
gen = None
_sf_next_task = None
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(request, cancel_event)
)
@ -8740,10 +8808,35 @@ async def openai_chat_completions(
api_monitor.finish(monitor_id, "cancelled")
return
event = await asyncio.to_thread(next, gen, _sf_tool_sentinel)
# Stall keepalive (see GGUF tool stream): silent backend segments
# must not leave the SSE stream idle past proxy timeouts.
_sf_next_task = asyncio.create_task(
asyncio.to_thread(next, gen, _sf_tool_sentinel)
)
while True:
_sf_done, _ = await asyncio.wait(
{_sf_next_task},
timeout = _LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S,
)
if _sf_done:
break
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
event = _sf_next_task.result()
# Done; drop the reference so the finally-block drain no-ops.
_sf_next_task = None
if event is _sf_tool_sentinel:
break
if event["type"] == "heartbeat":
# Tool-execution wrapper heartbeat -> SSE keepalive.
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
continue
if event["type"] in ("tool_output", "tool_args"):
# Live stdout/stderr, or tool-call arguments as the model writes them.
yield f"data: {json.dumps(event)}\n\n"
continue
if event["type"] == "status":
if not event["text"]:
# Iteration boundary: flush reasoning, then a fresh prefilled extractor for the next turn.
@ -8835,9 +8928,15 @@ async def openai_chat_completions(
yield _openai_stream_error_sse(error_chunk)
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
# Drain a still-running next(gen) worker before closing: closing
# mid-next(gen) raises ValueError('generator already executing') and
# skips the generator's cleanup finally. Matches the GGUF tool stream.
await _drain_pending_next_task(_sf_next_task, cancel_event)
if gen is not None:
try:
gen.close()
# Offload the close so the generator's cleanup runs off the event
# loop (matches the GGUF SSE path); a disconnect can't stall the loop.
await asyncio.to_thread(gen.close)
except (RuntimeError, ValueError):
pass
_sf_tracker.__exit__(None, None, None)
@ -9027,6 +9126,8 @@ async def openai_chat_completions(
_tracker.__enter__()
async def stream_chunks():
gen = None
_next_task = None
disconnect_watcher = asyncio.create_task(
_await_disconnect_then_cancel(request, cancel_event)
)
@ -9041,23 +9142,30 @@ async def openai_chat_completions(
prev_text = ""
# Split prefilled <think> into reasoning_content deltas (GGUF parity); single turn, serves MLX.
reasoning_extractor = _new_sf_reasoning_extractor()
# Run the sync generator in a thread pool to avoid blocking the
# event loop. Critical for compare mode: two SSE requests arrive
# concurrently but the orchestrator serializes them via
# _gen_lock; without run_in_executor the second request's
# blocking lock acquisition would freeze the entire event loop,
# stalling both streams.
# Run the sync generator in a worker thread so it can't block the event
# loop. Critical for compare mode: a second request's blocking _gen_lock
# acquisition would otherwise freeze the loop and stall both streams.
_DONE = object() # sentinel for generator exhaustion
loop = asyncio.get_event_loop()
gen = generate()
while True:
if cancel_event.is_set():
backend.reset_generation_state()
break
# next(gen, _DONE) returns _DONE instead of raising
# StopIteration -- StopIteration can't propagate through
# asyncio futures (Python limitation).
cumulative = await loop.run_in_executor(None, next, gen, _DONE)
# Stall keepalive (see safetensors tool stream) each window while
# next(gen) runs in a worker. next(gen, _DONE) returns _DONE rather
# than raising StopIteration (which can't cross asyncio futures).
_next_task = asyncio.create_task(asyncio.to_thread(next, gen, _DONE))
while True:
_done_tasks, _ = await asyncio.wait(
{_next_task},
timeout = _LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S,
)
if _done_tasks:
break
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
cumulative = _next_task.result()
# Done; drop the reference so the finally-block drain no-ops.
_next_task = None
if cumulative is _DONE:
break
if await request.is_disconnected():
@ -9180,6 +9288,17 @@ async def openai_chat_completions(
yield _openai_stream_error_sse(error_chunk)
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
# Drain a still-running next(gen) worker before closing: closing
# mid-next(gen) raises ValueError('generator already executing') and
# skips the generator's cleanup finally. Matches the safetensors stream.
await _drain_pending_next_task(_next_task, cancel_event)
if gen is not None:
try:
# Offload the close so the generator's cleanup runs off the event
# loop (matches the GGUF SSE path); a disconnect can't stall the loop.
await asyncio.to_thread(gen.close)
except (RuntimeError, ValueError):
pass
_tracker.__exit__(None, None, None)
return _SameTaskStreamingResponse(
@ -12389,8 +12508,12 @@ async def _anthropic_tool_stream(
ends_on_tool_use = False
tool_blocks_emitted = 0
drop_until_tool_end = False
# Last drop-branch keepalive, seeded to stream start so a chatty tool busy
# past the stall window still gets a keepalive though its events are dropped.
_last_drop_keepalive = time.monotonic()
gen = run_gen()
_next_task = None
# Watcher to cancel on disconnect: the in-loop poll fires only between
# events, so a mid-prefill disconnect would otherwise hold the decode slot.
disconnect_watcher = asyncio.create_task(
@ -12401,13 +12524,42 @@ async def _anthropic_tool_stream(
if cancel_event.is_set() or await request.is_disconnected():
cancel_event.set()
return
event = await asyncio.to_thread(next, gen, _sentinel)
# Stall keepalive (see GGUF tool stream): silent backend segments
# must not leave the SSE stream idle past proxy timeouts.
_next_task = asyncio.create_task(asyncio.to_thread(next, gen, _sentinel))
while True:
_done_tasks, _ = await asyncio.wait(
{_next_task},
timeout = _LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S,
)
if _done_tasks:
break
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
event = _next_task.result()
# Done; drop the reference so the finally-block drain no-ops.
_next_task = None
if event is _sentinel:
break
etype = event.get("type")
if etype == "heartbeat":
# Tool-wrapper heartbeat -> SSE keepalive, checked BEFORE the drop
# skip: a dropped tool still runs server-side and its events keep the
# stall keepalive from firing, so dropping heartbeats would go silent.
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
continue
if etype in ("tool_output", "tool_args"):
# Live stdout / arg streaming have no Anthropic Messages equivalent
# (the full call/result follow in tool_use / tool_result), so drop them.
# They keep the stall keepalive from firing, so a chatty tool would go
# silent past the ~100s proxy cap; emit a rate-limited keepalive instead.
_now = time.monotonic()
if _now - _last_drop_keepalive >= _LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S:
_last_drop_keepalive = _now
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
continue
if drop_until_tool_end:
# disable_parallel_tool_use: a later tool call is being
# dropped — skip every event until (and including) its tool_end.
# disable_parallel_tool_use: skip every event until (and
# including) this dropped tool call's tool_end.
if etype == "tool_end":
drop_until_tool_end = False
continue
@ -12448,12 +12600,24 @@ async def _anthropic_tool_stream(
yield line
except Exception as e:
logger.error("anthropic_messages stream error: %s", e)
_error_event = _anthropic_stream_error_event(e)
# force = True so an unclassified mid-stream failure (llama-server crash,
# decode OOM, dropped socket) still emits an SSE error and returns, instead
# of a normal message_stop that masks a truncated turn as a clean finish.
_error_event = _anthropic_stream_error_event(e, force = True)
if _error_event is not None:
yield _error_event
return
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
# Drain a still-running next(gen) worker before closing, so a mid-prefill
# disconnect releases the thread/generator/tool resources. Closing first
# would race into ValueError('generator already executing').
await _drain_pending_next_task(_next_task, cancel_event)
if gen is not None:
try:
await asyncio.to_thread(gen.close)
except (RuntimeError, ValueError):
pass
stop_reason = openai_finish_to_anthropic_stop(
captured_finish_reason, had_tool_calls = ends_on_tool_use
@ -12490,6 +12654,7 @@ async def _anthropic_plain_stream(
captured_finish_reason = None
gen = run_gen()
_next_task = None
# Watcher to cancel on disconnect: the in-loop poll fires only between
# chunks, so a mid-prefill disconnect would otherwise hold the decode slot.
disconnect_watcher = asyncio.create_task(
@ -12500,7 +12665,20 @@ async def _anthropic_plain_stream(
if cancel_event.is_set() or await request.is_disconnected():
cancel_event.set()
return
cumulative = await asyncio.to_thread(next, gen, _sentinel)
# Stall keepalive (see Anthropic tool stream) each window while
# next(gen) runs in a worker.
_next_task = asyncio.create_task(asyncio.to_thread(next, gen, _sentinel))
while True:
_done_tasks, _ = await asyncio.wait(
{_next_task},
timeout = _LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S,
)
if _done_tasks:
break
yield _OPENAI_PASSTHROUGH_SSE_KEEPALIVE
cumulative = _next_task.result()
# Done; drop the reference so the finally-block drain no-ops.
_next_task = None
if cumulative is _sentinel:
break
if isinstance(cumulative, dict):
@ -12516,12 +12694,24 @@ async def _anthropic_plain_stream(
yield line
except Exception as e:
logger.error("anthropic_messages stream error: %s", e)
_error_event = _anthropic_stream_error_event(e)
# force = True so an unclassified mid-stream failure (llama-server crash,
# decode OOM, dropped socket) still emits an SSE error and returns, instead
# of a normal message_stop that masks a truncated turn as a clean finish.
_error_event = _anthropic_stream_error_event(e, force = True)
if _error_event is not None:
yield _error_event
return
finally:
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
# Drain a still-running next(gen) worker before closing, so a mid-prefill
# disconnect releases the thread/generator/model resources. Closing first
# would race into ValueError('generator already executing').
await _drain_pending_next_task(_next_task, cancel_event)
if gen is not None:
try:
await asyncio.to_thread(gen.close)
except (RuntimeError, ValueError):
pass
stop_reason = openai_finish_to_anthropic_stop(captured_finish_reason, had_tool_calls = False)
for line in emitter.finish(stop_reason = stop_reason, stop_sequence = None):

View file

@ -1631,6 +1631,48 @@ class TestAnthropicMessagesToolRouting:
assert entry["status"] == "cancelled"
assert monitor.active_count() == 0
@staticmethod
def _sse_blob(chunks):
# StreamingResponse may hand back str or already-encoded bytes.
return "".join(c.decode() if isinstance(c, (bytes, bytearray)) else c for c in chunks)
def test_plain_streaming_unclassified_error_emits_error_event(self, monkeypatch):
# An unclassified mid-stream failure must surface as an SSE `error` event
# and stop, not a message_stop that masks a truncated turn as clean.
def _gen_boom(**_kwargs):
yield "partial"
raise RuntimeError("llama-server crashed mid-decode")
_mock_backend(monkeypatch, generate_chat_completion = _gen_boom)
payload = _basic_payload(stream = True)
response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t"))
blob = self._sse_blob(self._consume_response(response))
assert "event: error" in blob
assert '"type": "error"' in blob
assert "event: message_stop" not in blob
def test_tool_streaming_unclassified_error_emits_error_event(self, monkeypatch):
# Same guarantee on the tool-calling stream path.
def _gen_tools_boom(**_kwargs):
yield {"type": "content", "text": "partial"}
raise RuntimeError("llama-server crashed mid-decode")
_mock_backend(monkeypatch, generate_chat_completion_with_tools = _gen_tools_boom)
payload = _basic_payload(
stream = True,
enable_tools = True,
tools = [{"type": "web_search_20250305", "name": "web_search"}],
)
response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t"))
blob = self._sse_blob(self._consume_response(response))
assert "event: error" in blob
assert '"type": "error"' in blob
assert "event: message_stop" not in blob
def test_mixed_server_and_client_tools_rejected_with_400(self, monkeypatch):
_mock_backend(monkeypatch)
payload = _basic_payload(
@ -2076,3 +2118,339 @@ def test_resumed_null_assistant_between_users_coalesced_on_messages_route(monkey
if isinstance(merged, list):
merged = " ".join(p.get("text", "") for p in merged if isinstance(p, dict))
assert "first question" in merged and "please continue" in merged
def test_disable_parallel_tool_use_forwards_heartbeats_while_dropping():
"""Heartbeats from a parallel-disabled, dropped tool call must still reach
the client as SSE keepalives: the dropped call runs server-side and the
stall keepalive never fires while the generator keeps producing events, so
swallowing them recreates the silent window keepalives exist to prevent."""
import threading as _threading
from routes.inference import (
_OPENAI_PASSTHROUGH_SSE_KEEPALIVE,
_anthropic_tool_stream,
)
def run_gen():
def gen():
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_0",
"arguments": {},
}
yield {"type": "heartbeat"}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_0",
"result": "r1",
}
# Second call: dropped by disable_parallel_tool_use, still executed
# server-side (heartbeats + live output).
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_1",
"arguments": {},
}
yield {"type": "heartbeat"}
yield {
"type": "tool_output",
"tool_name": "python",
"tool_call_id": "call_1",
"text": "x",
}
yield {"type": "heartbeat"}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_1",
"result": "r2",
}
yield {"type": "content", "text": "final answer"}
return gen()
async def _drive():
async def _is_disconnected():
return False
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_tool_stream(
request,
_threading.Event(),
run_gen,
"msg_hb",
"m",
disable_parallel_tool_use = True,
)
return [chunk async for chunk in resp.body_iterator]
chunks = asyncio.run(_drive())
keepalives = [c for c in chunks if c == _OPENAI_PASSTHROUGH_SSE_KEEPALIVE]
# One heartbeat inside the kept call, two inside the dropped window.
assert len(keepalives) >= 3
# The dropped call must not surface as a second tool_use block.
tool_use_starts = [c for c in chunks if "content_block_start" in c and '"tool_use"' in c]
assert len(tool_use_starts) == 1
def test_dropped_tool_output_events_emit_rate_limited_keepalives(monkeypatch):
"""A chatty tool streaming tool_output/tool_args with no heartbeats keeps the
generator busy (stall keepalive never fires); the Anthropic path can't
translate those events and drops them. Dropping silently would let an idle
proxy kill the stream, so the drop branch emits a rate-limited keepalive."""
import threading as _threading
import routes.inference as inf_mod
from routes.inference import (
_OPENAI_PASSTHROUGH_SSE_KEEPALIVE,
_anthropic_tool_stream,
)
# Deterministic clock: only the drop-branch keepalive uses time.monotonic
# here, so jumping past the stall window per call makes each dropped event
# cross the rate-limit threshold. asyncio.wait uses the loop clock and
# next(gen) returns promptly, so the outer stall keepalive never fires --
# every keepalive here is from the drop branch.
_real_time = inf_mod.time
_tick = {"v": 0.0}
def _fast_monotonic():
_tick["v"] += 100.0
return _tick["v"]
fake_time = SimpleNamespace(
monotonic = _fast_monotonic,
sleep = _real_time.sleep,
time = _real_time.time,
perf_counter = _real_time.perf_counter,
)
monkeypatch.setattr(inf_mod, "time", fake_time)
n_output = 4
def run_gen():
def gen():
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_0",
"arguments": {},
}
# Chatty streamed stdout, no heartbeats.
for i in range(n_output):
yield {
"type": "tool_output",
"tool_name": "python",
"tool_call_id": "call_0",
"text": f"line {i}\n",
}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_0",
"result": "done",
}
yield {"type": "content", "text": "final answer"}
return gen()
async def _drive():
async def _is_disconnected():
return False
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_tool_stream(
request,
_threading.Event(),
run_gen,
"msg_drop_ka",
"m",
)
return [chunk async for chunk in resp.body_iterator]
chunks = asyncio.run(_drive())
keepalives = [c for c in chunks if c == _OPENAI_PASSTHROUGH_SSE_KEEPALIVE]
assert len(keepalives) == n_output
# Final answer still reaches the client (drop is transport-only).
assert any("final answer" in c for c in chunks)
def test_parallel_disabled_dropped_call_output_emits_rate_limited_keepalives(monkeypatch):
"""Under disable_parallel_tool_use a chatty second call is dropped whole
(drop_until_tool_end). Its tool_output/tool_args events must still emit
rate-limited keepalives: the drop window can last minutes with no heartbeats
and no stall keepalive, so swallowing them silently would let an idle proxy
kill the stream. The keepalive branch runs before the drop skip."""
import threading as _threading
import routes.inference as inf_mod
from routes.inference import (
_OPENAI_PASSTHROUGH_SSE_KEEPALIVE,
_anthropic_tool_stream,
)
# Deterministic clock: jumps past the stall window per call (see sibling test).
_real_time = inf_mod.time
_tick = {"v": 0.0}
def _fast_monotonic():
_tick["v"] += 100.0
return _tick["v"]
fake_time = SimpleNamespace(
monotonic = _fast_monotonic,
sleep = _real_time.sleep,
time = _real_time.time,
perf_counter = _real_time.perf_counter,
)
monkeypatch.setattr(inf_mod, "time", fake_time)
n_output = 4
def run_gen():
def gen():
# First (kept) call.
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_0",
"arguments": {},
}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_0",
"result": "r1",
}
# Second call: dropped whole by disable_parallel_tool_use but still
# executed server-side, streaming chatty stdout with no heartbeats.
yield {
"type": "tool_start",
"tool_name": "python",
"tool_call_id": "call_1",
"arguments": {},
}
for i in range(n_output):
yield {
"type": "tool_output",
"tool_name": "python",
"tool_call_id": "call_1",
"text": f"line {i}\n",
}
yield {
"type": "tool_end",
"tool_name": "python",
"tool_call_id": "call_1",
"result": "r2",
}
yield {"type": "content", "text": "final answer"}
return gen()
async def _drive():
async def _is_disconnected():
return False
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_tool_stream(
request,
_threading.Event(),
run_gen,
"msg_drop_ka2",
"m",
disable_parallel_tool_use = True,
)
return [chunk async for chunk in resp.body_iterator]
chunks = asyncio.run(_drive())
keepalives = [c for c in chunks if c == _OPENAI_PASSTHROUGH_SSE_KEEPALIVE]
assert len(keepalives) == n_output
# The dropped call must not surface as a second tool_use block.
tool_use_starts = [c for c in chunks if "content_block_start" in c and '"tool_use"' in c]
assert len(tool_use_starts) == 1
assert any("final answer" in c for c in chunks)
def test_plain_stream_emits_keepalive_during_prompt_stall(monkeypatch):
"""No-tool Anthropic stream must emit SSE keepalives while a long prompt
prefill blocks next(gen), matching the tool stream (finding 5). The old
single unbounded to_thread(next, ...) could sit silent past a proxy idle cap."""
import threading as _threading
import time as _time
from routes import inference as inf_mod
from routes.inference import _OPENAI_PASSTHROUGH_SSE_KEEPALIVE, _anthropic_plain_stream
monkeypatch.setattr(inf_mod, "_LOCAL_TOOL_STREAM_STALL_KEEPALIVE_S", 0.05)
def run_gen():
def gen():
_time.sleep(0.24) # stall past several shortened keepalive windows
yield "hello world"
return gen()
async def _drive():
async def _is_disconnected():
return False
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_plain_stream(
request, _threading.Event(), run_gen, "msg_plain_ka", "m"
)
return [chunk async for chunk in resp.body_iterator]
chunks = asyncio.run(_drive())
keepalives = [c for c in chunks if c == _OPENAI_PASSTHROUGH_SSE_KEEPALIVE]
assert len(keepalives) >= 2
assert any("hello world" in c for c in chunks)
def test_plain_stream_closes_generator_on_disconnect():
"""On disconnect the no-tool teardown must drain any pending worker and close
the generator (finding 6). The old finally only stopped the disconnect
watcher, leaking the generator. A fake generator records close() so the
teardown is asserted deterministically, not via GC."""
import threading as _threading
from routes.inference import _anthropic_plain_stream
closed = _threading.Event()
class _FakeGen:
def __init__(self):
self._items = iter(["tok0", "tok1", "tok2", "tok3"])
def __next__(self):
return next(self._items)
def close(self):
closed.set()
def run_gen():
return _FakeGen()
state = {"disconnected": False}
async def _drive():
async def _is_disconnected():
return state["disconnected"]
request = SimpleNamespace(is_disconnected = _is_disconnected)
resp = await _anthropic_plain_stream(
request, _threading.Event(), run_gen, "msg_plain_close", "m"
)
out = []
async for chunk in resp.body_iterator:
out.append(chunk)
if "tok0" in chunk:
# Client drops after the first token; the next loop turn tears down.
state["disconnected"] = True
return out
asyncio.run(_drive())
assert closed.is_set()

View file

@ -13,6 +13,7 @@ never gating under bypass.
Run with: ``PYTHONPATH=studio/backend python -m pytest studio/backend/tests/test_bypass_permissions.py -q``
"""
import io
import os
import sys
@ -94,10 +95,23 @@ def test_safe_env_excludes_host_and_secret(monkeypatch, tmp_path):
class _FakeProc:
returncode = 0
"""A subprocess.Popen double for the drain path (``tools._drain_process_output``):
a readable ``stdout`` pipe yielding the fake output then EOF, plus
``wait()`` / ``poll()`` / ``pid``. The pid is non-existent so
``_capture_process_group``'s ``os.getpgid`` returns None; ``wait`` returns
immediately so the drain never kills.
"""
def communicate(self, timeout = None):
return ("FAKEOUT", None)
returncode = 0
# Unlikely-to-exist pid: os.getpgid raises ProcessLookupError (caught) -> None.
pid = 2**22
def __init__(self):
# Readable stdout: iter(readline, "") yields "FAKEOUT" then hits EOF.
self.stdout = io.StringIO("FAKEOUT")
def wait(self, timeout = None):
return 0
def poll(self):
return 0

View file

@ -1498,6 +1498,59 @@ def test_textual_mistral_marker_not_leaked_when_inline_with_preface(monkeypatch)
assert any("Let me search." in t for t in content_texts)
def test_textual_explicit_id_reuses_provisional_card(monkeypatch):
# A textual Mistral-style call with an explicit ``id`` must reconcile onto the
# open provisional TEXT card (keyed "call_0"), not spawn a duplicate under the
# explicit id (which the parser keeps for execution).
big_query = "cats " * 80 # push the drained call past the provisional floor
call = "[TOOL_CALLS]" + json.dumps(
[{"name": "web_search", "arguments": {"query": big_query}, "id": "explicit-42"}]
)
assert len(call) > 256
# Small chunks so the provisional card opens mid-generation (a single-shot
# delta parses instantly and never shows a provisional to exercise).
chunks = [call[i : i + 24] for i in range(0, len(call), 24)]
streams = [
[_sse({"content": c}) for c in chunks] + [_done()],
[_sse({"content": "done"}), _done()],
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, streams, payloads)
calls: list[tuple[str, dict]] = []
def fake_execute_tool(name, arguments, **_kwargs):
calls.append((name, arguments))
return "result"
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "search"}],
tools = [{"type": "function", "function": {"name": "web_search"}}],
max_tool_iterations = 1,
)
)
assert calls == [("web_search", {"query": big_query})]
tool_starts = [e for e in events if e.get("type") == "tool_start"]
# Empty-args card = provisional open; full-args card = reconciled real start.
provisional = [e for e in tool_starts if not e.get("arguments")]
real = [e for e in tool_starts if e.get("arguments", {}).get("query")]
assert len(provisional) == 1, tool_starts # provisional actually opened
prov_id = provisional[0]["tool_call_id"]
# Exactly one real card, sharing the provisional id, not a duplicate under
# the explicit "explicit-42" id.
assert len(real) == 1, tool_starts
assert real[0]["tool_call_id"] == prov_id
assert real[0]["tool_name"] == "web_search"
assert {e["tool_call_id"] for e in tool_starts} == {prov_id}
# A single tool_end reconciles the card; no stale empty-result close.
ends = [e for e in events if e.get("type") == "tool_end"]
assert [e["tool_call_id"] for e in ends] == [prov_id]
assert ends[0]["result"] == "result"
def test_textual_llama_python_tag_marker_not_leaked(monkeypatch):
# Same leak class for the Llama-3 built-in ``<|python_tag|>NAME.call(...)`` form.
streams = [
@ -2916,3 +2969,204 @@ def test_gguf_valid_tool_calls_respect_max_tool_iterations(monkeypatch):
m.get("role") == "user" and "used all available tool calls" in m.get("content", "")
for m in payloads[2]["messages"]
), payloads[2]["messages"]
# ── Live tool-call argument streaming (tool_args events) ─────────────────────
def _python_tool_schema() -> list[dict]:
return [
{
"type": "function",
"function": {
"name": "python",
"description": "Run python code.",
"parameters": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
},
},
}
]
def test_structured_tool_args_stream_to_provisional_card(monkeypatch):
"""A large structured tool call must stream its arguments as tool_args events
to the provisional card (backlog that triggered the card, then each
fragment), while the executed call and the model's view stay exactly what the
accumulator built."""
code = "print('x')\n" + ("# pad\n" * 80)
args_json = json.dumps({"code": code})
call_id = "call_live_args"
split = _PROVISIONAL_ARGS_MIN_CHARS + 16
frag1, frag2, frag3 = (
args_json[:split],
args_json[split : split + 40],
args_json[split + 40 :],
)
def _tc_delta(fragment: str, with_header: bool) -> str:
entry: dict = {"index": 0, "function": {"arguments": fragment}}
if with_header:
entry.update({"id": call_id, "type": "function"})
entry["function"]["name"] = "python"
return _sse({"tool_calls": [entry]})
first_stream = [
_tc_delta(frag1, with_header = True),
_tc_delta(frag2, with_header = False),
_tc_delta(frag3, with_header = False),
_done(),
]
second_stream = [_sse({"content": "Done."}), _done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [first_stream, second_stream], payloads)
executed: list[tuple[str, dict]] = []
def fake_execute_tool(name, arguments, **_kwargs):
executed.append((name, arguments))
return "ok"
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 = _python_tool_schema(),
max_tool_iterations = 1,
)
)
starts = [e for e in events if e.get("type") == "tool_start"]
assert starts and starts[0]["tool_call_id"] == call_id
args_events = [e for e in events if e.get("type") == "tool_args"]
assert args_events, "no tool_args events were streamed"
assert all(e["tool_call_id"] == call_id for e in args_events)
# First event is the backlog, the rest raw fragments; together the args JSON.
assert args_events[0]["text"] == frag1
assert "".join(e["text"] for e in args_events) == args_json
# The streamed display path must not perturb execution or the model view.
assert executed == [("python", {"code": code})]
assistant_messages = [m for m in payloads[1]["messages"] if m.get("role") == "assistant"]
tc = assistant_messages[-1]["tool_calls"][0]
assert tc["id"] == call_id
# Controller re-serializes args (normalized JSON); parsed payload unchanged.
assert json.loads(tc["function"]["arguments"]) == {"code": code}
def test_text_tool_call_streams_args_and_reconciles_card(monkeypatch):
"""A TEXT (XML) tool call must stream its raw call text as tool_args under the
id the stream-end parser assigns ("call_0"), so the provisional card and the
final tool_start reconcile."""
code = "print('hello')\n" + ("# filler\n" * 60)
call_json = json.dumps({"name": "python", "arguments": {"code": code}})
call_text = f"<tool_call>{call_json}</tool_call>"
chunks = [call_text[i : i + 48] for i in range(0, len(call_text), 48)]
first_stream = [_sse({"content": chunk}) for chunk in chunks] + [_done()]
second_stream = [_sse({"content": "Done."}), _done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [first_stream, second_stream], payloads)
executed: list[tuple[str, dict]] = []
def fake_execute_tool(name, arguments, **_kwargs):
executed.append((name, arguments))
return "ok"
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 = _python_tool_schema(),
max_tool_iterations = 1,
)
)
starts = [e for e in events if e.get("type") == "tool_start"]
assert starts, "no tool_start emitted"
# Provisional card first (parser's first-call id), then the reconciling start.
assert starts[0]["tool_call_id"] == "call_0"
assert starts[0]["arguments"] == {}
assert starts[-1]["tool_call_id"] == "call_0"
args_events = [e for e in events if e.get("type") == "tool_args"]
assert args_events, "no tool_args events for the text call"
assert all(e["tool_call_id"] == "call_0" for e in args_events)
streamed = "".join(e["text"] for e in args_events)
# Streamed text is the drained call (display only); it must never leak into
# content events.
assert '"name": "python"' in streamed
assert executed == [("python", {"code": code})]
content_events = [e for e in events if e.get("type") == "content"]
assert not any("<tool_call>" in e["text"] for e in content_events)
def test_ordinary_json_answer_streams_no_tool_args(monkeypatch):
"""A large ordinary JSON answer (no enabled tool name) must not spawn a
provisional card or tool_args events; it stays a normal content answer."""
answer = json.dumps({"result": "fine", "data": ["x" * 40] * 12, "note": "not a tool call"})
chunks = [answer[i : i + 64] for i in range(0, len(answer), 64)]
stream = [_sse({"content": chunk}) for chunk in chunks] + [_done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [stream], payloads)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "give me json"}],
tools = _python_tool_schema(),
max_tool_iterations = 1,
)
)
assert not [e for e in events if e.get("type") == "tool_args"]
assert not [e for e in events if e.get("type") == "tool_start"]
content_events = [e for e in events if e.get("type") == "content"]
assert content_events and answer in content_events[-1]["text"]
def test_provisional_text_card_closed_when_parse_fails(monkeypatch):
"""A >=256-char enabled-name text sniff opens a provisional card; if the
drained text then fails to parse (auto-heal off, truncated call), the
DRAINING false-positive path must close the card with a tool_end instead of
leaving it spinning forever."""
# Truncated mid-arguments and never closed: unparseable without healing.
call_text = '<tool_call>{"name": "python", "arguments": {"code": "' + "x" * (
_PROVISIONAL_ARGS_MIN_CHARS + 64
)
chunks = [call_text[i : i + 48] for i in range(0, len(call_text), 48)]
stream = [_sse({"content": chunk}) for chunk in chunks] + [_done()]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [stream], payloads)
executed: list[tuple[str, dict]] = []
def fake_execute_tool(name, arguments, **_kwargs):
executed.append((name, arguments))
return "ok"
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 = _python_tool_schema(),
max_tool_iterations = 1,
auto_heal_tool_calls = False,
)
)
starts = [e for e in events if e.get("type") == "tool_start"]
ends = [e for e in events if e.get("type") == "tool_end"]
assert starts and starts[0]["tool_call_id"] == "call_0"
assert executed == [] # nothing parsed, nothing ran
assert ends, "provisional card left dangling (no tool_end)"
assert ends[-1]["tool_call_id"] == "call_0"

View file

@ -0,0 +1,522 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Hermetic tests for the sandbox sitecustomize path-remap shim.
The shim (``core/inference/sandbox_site/sitecustomize.py``) runs at interpreter
startup inside every sandboxed tool subprocess and remaps ChatGPT
code-interpreter habit paths (``/mnt/data`` etc.) onto the per-conversation
working directory. Importing it calls ``_install()``, which monkeypatches
``builtins.open`` / ``io.open`` / ``os.makedirs`` / ``os.mkdir`` /
``pathlib.Path.mkdir`` process-wide, so these tests
load it into a throwaway module and restore those globals immediately, then
exercise the pure ``_remap()`` function directly -- no subprocess, and no real
``/mnt`` or ``/tmp`` writes. The mkdir test keeps the patch installed under a
``chdir`` into ``tmp_path`` so the only real writes land in that temp dir.
"""
from __future__ import annotations
import builtins
import importlib.util
import io
import os
import pathlib
from pathlib import Path
import pytest
_SHIM = (
Path(__file__).resolve().parent.parent
/ "core"
/ "inference"
/ "sandbox_site"
/ "sitecustomize.py"
)
def _save_patch_targets():
"""Snapshot every global the shim patches, so tests can restore them.
On Python < 3.11 the shim also repoints ``pathlib._NormalAccessor.open``
(pathlib captured the original io.open at import there); the accessor is
absent on 3.11+, so the snapshot skips it.
"""
accessor = getattr(pathlib, "_NormalAccessor", None)
return (
(builtins.open, io.open, os.open, os.makedirs, os.mkdir, pathlib.Path.mkdir),
accessor,
accessor.open if accessor is not None else None,
)
def _restore_patch_targets(saved):
"""Undo _save_patch_targets so the test process stays clean."""
globals_tuple, accessor, accessor_open = saved
(builtins.open, io.open, os.open, os.makedirs, os.mkdir, pathlib.Path.mkdir) = globals_tuple
if accessor is not None:
accessor.open = accessor_open
def _load_shim():
"""Import the shim without leaving its open()/mkdir patches installed."""
saved = _save_patch_targets()
spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_under_test", _SHIM)
mod = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(mod) # runs _install(), patching the globals
finally:
# Undo the process-wide patch so the test process stays clean.
_restore_patch_targets(saved)
mod._notified = True # silence the one-shot stderr notice in tests
return mod
def test_always_remap_prefixes_map_into_cwd(monkeypatch, tmp_path):
mod = _load_shim()
monkeypatch.chdir(tmp_path)
cwd = os.getcwd()
assert mod._remap("/mnt/data/out.txt") == os.path.join(cwd, "out.txt")
assert mod._remap("/mnt/data") == cwd
# Unrelated absolute and relative paths pass straight through.
assert mod._remap("/etc/passwd") == "/etc/passwd"
assert mod._remap("relative.txt") == "relative.txt"
def test_prefix_remap_contains_parent_traversal_inside_cwd(monkeypatch, tmp_path):
# A hallucinated habit path can carry '..' in its suffix. The remapped target
# must stay under the per-conversation CWD, never climbing into a sibling
# session's directory: '..' components are dropped, the rest of the subpath kept.
mod = _load_shim()
workdir = tmp_path / "session_current" / "work"
workdir.mkdir(parents = True)
monkeypatch.chdir(workdir)
cwd = os.getcwd()
for escaping in (
"/mnt/data/../other_session/file",
"/mnt/data/../../secrets.txt",
"/mnt/data/a/../../b/c.txt",
"/mnt/data/./sub/./x.txt",
):
mapped = mod._remap(escaping)
# Never escapes the CWD subtree.
assert mapped == cwd or mapped.startswith(cwd + os.sep), (escaping, mapped)
assert os.path.realpath(mapped).startswith(os.path.realpath(cwd))
# '../other_session/file' collapses to CWD/other_session/file.
assert mod._remap("/mnt/data/../other_session/file") == os.path.join(
cwd, "other_session", "file"
)
# A bare '/mnt/data/..' with nothing left maps onto the CWD itself.
assert mod._remap("/mnt/data/..") == cwd
def test_write_fallback_refuses_dotdot_basename(monkeypatch, tmp_path):
# basename('/no/such/tree/..') == '..'; joining that onto the CWD would target
# its parent (outside the sandbox). The fallback must refuse such non-filename
# basenames and return the path unchanged so the real open raises.
mod = _load_shim()
workdir = tmp_path / "work"
workdir.mkdir()
monkeypatch.chdir(workdir)
for escaping in ("/no/such/tree/..", "/no/such/tree/.", "/no/such/tree/"):
assert mod._remap_open(escaping, "w") == escaping
def test_write_fallback_remaps_hallucinated_absolute_path(monkeypatch, tmp_path):
# Models invent absolute paths from their CWD (e.g. /home/ubuntu/Sandbox/x.html),
# which prefix lists cannot enumerate. A write/create-mode open on an absolute
# path outside the CWD whose parent is missing is redirected to the basename in the CWD.
mod = _load_shim()
workdir = tmp_path / "workdir"
workdir.mkdir()
monkeypatch.chdir(workdir)
cwd = os.getcwd()
hallucinated = "/home/ubuntu/Sandbox/flappy_bird.html"
for mode in ("w", "a", "x", "w+"):
assert mod._remap_open(hallucinated, mode) == os.path.join(cwd, "flappy_bird.html")
# A nested missing tree collapses to just the basename in the CWD.
assert mod._remap_open("/no/such/tree/report.txt", "w") == os.path.join(cwd, "report.txt")
def test_write_fallback_never_touches_read_modes(monkeypatch, tmp_path):
# Reading a real (or genuinely missing) file must succeed/fail truthfully --
# the fallback is write-only.
mod = _load_shim()
monkeypatch.chdir(tmp_path)
for mode in ("r", "rb", "r+"):
assert mod._remap_open("/etc/definitely_missing_xyz.conf", mode) == (
"/etc/definitely_missing_xyz.conf"
)
def test_write_fallback_passes_through_existing_external_dir(monkeypatch, tmp_path):
# A write to an absolute path whose parent dir exists is a deliberate, working
# target and must NOT be redirected.
mod = _load_shim()
external = tmp_path / "external"
external.mkdir()
workdir = tmp_path / "workdir"
workdir.mkdir()
monkeypatch.chdir(workdir)
target = str(external / "out.txt")
assert mod._remap_open(target, "w") is target
def test_write_fallback_never_clobbers_same_basename(monkeypatch, tmp_path):
# A same-named CWD file is an unrelated persistent conversation file.
# Redirecting an invented absolute path (missing parent) onto it would clobber
# data the model never asked to touch, so the fallback refuses on collision for
# every create mode: it returns the original path and the real open() raises.
mod = _load_shim()
monkeypatch.chdir(tmp_path)
existing = tmp_path / "report.txt"
existing.write_text("KEEP-ME")
requested = "/definitely_missing_parent_7083/report.txt"
for mode in ("w", "a", "x", "w+", "a+"):
# Refused: returns the original absolute path unchanged (no redirect).
assert mod._remap_open(requested, mode) == requested
# And opening the refused path really does raise, leaving the file intact.
with pytest.raises(FileNotFoundError):
open(mod._remap_open(requested, "w"), "w")
assert existing.read_text() == "KEEP-ME"
# No collision -> still healed into the working directory as before.
fresh = "/definitely_missing_parent_7083/brand_new.txt"
assert mod._remap_open(fresh, "w") == os.path.join(os.getcwd(), "brand_new.txt")
def test_write_fallback_reserves_same_target_on_repeated_writes(monkeypatch, tmp_path):
# Iterative overwrite of the SAME invented path must keep landing on the CWD
# target the fallback first healed it to. Once ./app.html exists, a naive
# anti-clobber guard would return the original (parent-missing) path and every
# regenerate would raise; the fallback must recognise its own prior remap and re-serve it.
mod = _load_shim()
monkeypatch.chdir(tmp_path)
cwd = os.getcwd()
invented = "/home/ubuntu/Sandbox/app.html"
target = os.path.join(cwd, "app.html")
# First write: healed into the CWD, and create the file so the collision guard
# would trigger on the next call without the fix.
assert mod._remap_open(invented, "w") == target
with open(mod._remap_open(invented, "w"), "w") as fh:
fh.write("v1")
# Repeated overwrites of the same invented path stay on the same target.
for _ in range(3):
assert mod._remap_open(invented, "w") == target
with open(mod._remap_open(invented, "w"), "w") as fh:
fh.write("v2")
assert Path(target).read_text() == "v2"
# A DIFFERENT invented source colliding on basename is still refused, so it can
# never clobber the artifact the first path owns.
other = "/opt/other/app.html"
assert mod._remap_open(other, "w") == other
def test_write_fallback_reserves_healed_target_across_separate_runs(monkeypatch, tmp_path):
# Each tool call is a FRESH subprocess, so the in-process remap map is empty on
# the next run while the healed file persists in the working directory. A second
# run overwriting the SAME invented path (whose healed basename now exists) must
# still re-serve that target via the on-disk sidecar, else the model could never
# overwrite last turn's artifact. Each _load_shim() simulates a brand-new interpreter.
monkeypatch.chdir(tmp_path)
cwd = os.getcwd()
invented = "/home/ubuntu/Sandbox/app.html"
target = os.path.join(cwd, "app.html")
# Run 1: heal the invented path, create the file, persist source->target to the sidecar.
run1 = _load_shim()
assert run1._remap_open(invented, "w") == target
with open(run1._remap_open(invented, "w"), "w") as fh:
fh.write("v1")
# Run 2: brand-new interpreter, nothing in memory -- still recognises its prior
# heal from the sidecar and re-serves it, even though ./app.html now exists
# (which without the sidecar would trip the anti-clobber guard and raise).
run2 = _load_shim()
assert run2._remapped_writes == {}
assert run2._remap_open(invented, "w") == target
with open(run2._remap_open(invented, "w"), "w") as fh:
fh.write("v2")
assert Path(target).read_text() == "v2"
# A DIFFERENT invented source colliding only on basename is still refused across
# runs: the sidecar records solely the source it healed, so an unrelated path
# can never adopt/clobber the artifact.
other = "/opt/other/app.html"
assert run2._remap_open(other, "w") == other
# A foreign CWD file (created directly, never healed) stays protected in a later
# run from an invented path sharing its basename.
(tmp_path / "notes.txt").write_text("KEEP-ME")
run3 = _load_shim()
assert run3._remap_open("/some/missing/notes.txt", "w") == "/some/missing/notes.txt"
with pytest.raises(FileNotFoundError):
open(run3._remap_open("/some/missing/notes.txt", "w"), "w")
assert (tmp_path / "notes.txt").read_text() == "KEEP-ME"
@pytest.mark.parametrize("mode", ["r+", "rb+"])
def test_read_update_modes_never_redirected_even_with_missing_parent(monkeypatch, tmp_path, mode):
# r+ / rb+ REQUIRE the target to exist and never create; a "+" must not qualify
# as creation, or a missing absolute path would be redirected onto a same-basename
# workspace file and corrupt it. The parent is missing, so only the mode predicate
# protects the victim.
mod = _load_shim()
monkeypatch.chdir(tmp_path)
victim = tmp_path / "victim.txt"
victim.write_text("original")
requested = "/definitely_missing_parent_xyz/victim.txt"
assert mod._remap_open(requested, mode) == requested
with pytest.raises(FileNotFoundError):
open(mod._remap_open(requested, mode), mode)
assert victim.read_text() == "original"
def test_existing_convention_prefix_is_not_shadowed(monkeypatch, tmp_path):
# A convention prefix (/mnt/data etc.) is remapped ONLY while absent. If a real
# host directory exists there it must pass through so its own filesystem semantics
# apply: a real read succeeds, and a missing file under it is created there by a
# write, never shadowed by a CWD file.
mod = _load_shim()
external = tmp_path / "real_prefix"
external.mkdir()
(external / "data.txt").write_text("real external content")
workdir = tmp_path / "conversation"
workdir.mkdir()
monkeypatch.chdir(workdir)
monkeypatch.setattr(mod, "_PREFIXES", (str(external),))
monkeypatch.setattr(mod, "_CONDITIONAL_PREFIXES", ())
target = str(external / "data.txt")
# Prefix exists -> pass through for read and write.
assert mod._remap(target) == target
assert mod._remap_open(target, "r") == target
assert mod._remap_open(target, "w") == target
# A missing file under the EXISTING real prefix is left alone (parent exists),
# so the real directory creates it -- not a CWD shadow.
missing = str(external / "new.txt")
assert mod._remap_open(missing, "w") == missing
# Remove the prefix directory -> healing resumes (absent prefix).
(external / "data.txt").unlink()
external.rmdir()
assert mod._remap(target) == os.path.join(os.getcwd(), "data.txt")
def test_os_open_and_path_touch_remap_convention_path(monkeypatch, tmp_path):
# Path.touch() and other low-level creators go through os.open, not builtins/io.open.
# Keep the shim's patches installed under a chdir into tmp_path so os.open is
# patched, and confirm a convention path is healed into the CWD instead of raising.
saved = _save_patch_targets()
spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_osopen", _SHIM)
mod = importlib.util.module_from_spec(spec)
monkeypatch.chdir(tmp_path)
cwd = os.getcwd()
try:
spec.loader.exec_module(mod) # installs the os.open patch
mod._notified = True
pathlib.Path("/mnt/data/touched.txt").touch()
assert os.path.isfile(os.path.join(cwd, "touched.txt"))
# Direct os.open with create flags is healed too.
fd = os.open("/mnt/data/via_os_open.txt", os.O_CREAT | os.O_WRONLY, 0o600)
os.close(fd)
assert os.path.isfile(os.path.join(cwd, "via_os_open.txt"))
finally:
_restore_patch_targets(saved)
def test_path_write_read_text_remap_convention_path(monkeypatch, tmp_path):
# Path.open / write_text / read_text route through io.open (3.11+) or the captured
# accessor open (< 3.11). Keep the patches installed under a chdir into tmp_path
# and confirm a convention path is healed into the CWD on every version. This is
# the hermetic guard for the 3.10 accessor path a plain io.open patch misses.
saved = _save_patch_targets()
spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_writetext", _SHIM)
mod = importlib.util.module_from_spec(spec)
monkeypatch.chdir(tmp_path)
cwd = os.getcwd()
try:
spec.loader.exec_module(mod) # installs the io.open / accessor patch
mod._notified = True
pathlib.Path("/mnt/data/note.txt").write_text("pathlib remap")
assert os.path.isfile(os.path.join(cwd, "note.txt"))
# read_text goes through the same mapped path and sees what was written.
assert pathlib.Path("/mnt/data/note.txt").read_text() == "pathlib remap"
# A real absolute path passes through both patches untouched.
real = tmp_path / "real.txt"
pathlib.Path(str(real)).write_text("verbatim")
assert real.read_text() == "verbatim"
finally:
_restore_patch_targets(saved)
def test_write_fallback_leaves_relative_and_bytes_paths(monkeypatch, tmp_path):
mod = _load_shim()
monkeypatch.chdir(tmp_path)
# Relative paths are already inside the CWD.
assert mod._remap_open("out.txt", "w") == "out.txt"
# Bytes paths are left untouched (prefix remap skips non-str).
assert mod._remap_open(b"/no/such/tree/x.bin", "w") == b"/no/such/tree/x.bin"
def test_remap_open_still_applies_prefix_remaps(monkeypatch, tmp_path):
# The prefix remap runs first and preserves subpaths. A write heals onto the CWD
# unconditionally; the write-mode fallback is only the last resort.
mod = _load_shim()
monkeypatch.chdir(tmp_path)
cwd = os.getcwd()
assert mod._remap_open("/mnt/data/sub/out.txt", "w") == os.path.join(cwd, "sub", "out.txt")
# A read whose mapped target does NOT exist keeps the original path: a missing
# input stays truthful, not silently redirected into the CWD.
assert mod._remap_open("/mnt/data/sub/out.txt", "r") == "/mnt/data/sub/out.txt"
def test_prefix_read_heals_only_when_mapped_target_exists(monkeypatch, tmp_path):
# A convention-prefix READ must not redirect onto the CWD when the mapped target
# is absent -- that masks a genuine missing-input error and could serve an
# unrelated same-basename workdir file. It heals only when the mapped CWD target
# exists, so re-reading an artifact an earlier write produced still works.
mod = _load_shim()
monkeypatch.chdir(tmp_path)
cwd = os.getcwd()
# Mapped target absent: read keeps the original path (truthful miss).
assert mod._remap_open("/mnt/data/input.csv", "r") == "/mnt/data/input.csv"
with pytest.raises(FileNotFoundError):
open(mod._remap_open("/mnt/data/input.csv", "r"))
# r+ (never creates) behaves the same: no redirect while absent.
assert mod._remap_open("/mnt/data/input.csv", "r+") == "/mnt/data/input.csv"
# A write heals onto the CWD and creates the artifact...
mapped = mod._remap_open("/mnt/data/input.csv", "w")
assert mapped == os.path.join(cwd, "input.csv")
with open(mapped, "w") as fh:
fh.write("col\n1\n")
# ...and now a READ of the same convention path heals onto that existing artifact.
read_target = mod._remap_open("/mnt/data/input.csv", "r")
assert read_target == os.path.join(cwd, "input.csv")
with open(read_target) as fh:
assert fh.read() == "col\n1\n"
def test_prefix_boundary_not_matched_by_similar_paths(monkeypatch, tmp_path):
# The prefix match is anchored on a segment boundary (prefix or prefix + '/'), so
# a sibling merely sharing the textual prefix must NOT be remapped: /workspace2
# is not /workspace, /mnt/database is not /mnt/data.
mod = _load_shim()
monkeypatch.chdir(tmp_path)
for unrelated in ("/workspace2/file.txt", "/mnt/database/x", "/home/sandboxed/y"):
assert mod._remap(unrelated) == unrelated
# And through open() for a read too (no silent redirect).
assert mod._remap_open(unrelated, "r") == unrelated
def test_tmp_outputs_is_a_conditional_prefix():
mod = _load_shim()
assert "/tmp/outputs" in mod._CONDITIONAL_PREFIXES
# NOT in the always-remap set: /tmp exists on the host, so an unconditional remap
# could shadow a real /tmp/outputs the user code made.
assert "/tmp/outputs" not in mod._PREFIXES
def test_tmp_outputs_remapped_only_while_absent(monkeypatch, tmp_path):
mod = _load_shim()
monkeypatch.chdir(tmp_path)
cwd = os.getcwd()
# Point the conditional prefix at a real temp location so we can toggle its
# existence on disk instead of mocking os.path.exists.
cond = str(tmp_path / "cond_outputs")
monkeypatch.setattr(mod, "_CONDITIONAL_PREFIXES", (cond,))
# Absent: heal the habit path into the working directory (preserved/served).
assert not os.path.exists(cond)
assert mod._remap(cond + "/plot.png") == os.path.join(cwd, "plot.png")
assert mod._remap(cond) == cwd
# Present (the user's own code created it): pass through, never shadowed.
os.makedirs(cond)
assert mod._remap(cond + "/plot.png") == cond + "/plot.png"
assert mod._remap(cond) == cond
def test_pathlib_mkdir_parents_remaps_convention_path(monkeypatch, tmp_path):
# `Path('/mnt/data').mkdir(parents=True, exist_ok=True)` is a stock setup line.
# pathlib drives it through os.mkdir per component and Path.is_dir()/os.stat on
# FileExistsError, so the shim must patch os.mkdir AND Path.mkdir for the whole
# parents/exist_ok dance to land in the CWD instead of raising. Keeps the mkdir
# patches installed under a chdir into tmp_path and restores them in finally.
saved = _save_patch_targets()
spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_mkdir", _SHIM)
mod = importlib.util.module_from_spec(spec)
monkeypatch.chdir(tmp_path)
cwd = os.getcwd()
try:
spec.loader.exec_module(mod) # installs the os.mkdir / Path.mkdir patches
mod._notified = True
# Bare convention path maps onto the CWD, which already exists: exist_ok=True
# must be honoured against the mapped location, not raise.
pathlib.Path("/mnt/data").mkdir(parents = True, exist_ok = True)
# A nested convention path is created inside the CWD, parents and all.
pathlib.Path("/mnt/data/plots/run1").mkdir(parents = True, exist_ok = True)
assert os.path.isdir(os.path.join(cwd, "plots", "run1"))
# Idempotent: exist_ok is evaluated on the mapped path (which now exists),
# not the never-present /mnt/data.
pathlib.Path("/mnt/data/plots/run1").mkdir(parents = True, exist_ok = True)
# Passthrough: real paths are created verbatim through both patches,
# never remapped into the CWD.
real_dir = tmp_path / "real_via_path"
pathlib.Path(str(real_dir)).mkdir()
assert real_dir.is_dir()
real_os = tmp_path / "real_via_os"
os.mkdir(str(real_os))
assert real_os.is_dir()
finally:
_restore_patch_targets(saved)
def test_read_of_missing_prefix_path_emits_no_notice(monkeypatch, tmp_path, capsys):
# A read of a missing convention path keeps the original path and must not spend
# the one-shot notice; a genuine remap afterward still notifies.
mod = _load_shim()
monkeypatch.chdir(tmp_path)
mod._notified = False # re-arm the one-shot notice for this test
# Read of a missing prefixed path: original kept, no notice, flag unspent.
assert mod._remap_open("/mnt/data/missing.csv", "r") == "/mnt/data/missing.csv"
assert mod._notified is False
assert "does not exist" not in capsys.readouterr().err
# A committed write then heals and fires the notice exactly once.
assert mod._remap_open("/mnt/data/out.txt", "w") == os.path.join(os.getcwd(), "out.txt")
assert mod._notified is True
assert "/mnt/data does not exist in this sandbox" in capsys.readouterr().err
def test_os_open_trunc_without_creat_missing_stays_truthful(monkeypatch, tmp_path):
# O_TRUNC / O_APPEND without O_CREAT cannot create a missing file, so the shim
# treats them as a read: a missing convention path stays truthful (the error
# names the caller's path) and nothing is created in the CWD.
saved = _save_patch_targets()
spec = importlib.util.spec_from_file_location("_sandbox_sitecustomize_trunc", _SHIM)
mod = importlib.util.module_from_spec(spec)
monkeypatch.chdir(tmp_path)
try:
spec.loader.exec_module(mod)
mod._notified = True
with pytest.raises(FileNotFoundError) as exc:
os.open("/mnt/data/missing_xyz.bin", os.O_WRONLY | os.O_TRUNC)
assert exc.value.filename == "/mnt/data/missing_xyz.bin"
assert not os.path.exists(os.path.join(os.getcwd(), "missing_xyz.bin"))
finally:
_restore_patch_targets(saved)

View file

@ -294,11 +294,16 @@ class TestSandboxEnvIsolation:
"LANG",
"TERM",
"PYTHONIOENCODING",
"PYTHONPATH",
"VIRTUAL_ENV",
"SystemRoot",
}
extras = set(env.keys()) - allowed
assert not extras, f"sandbox env added unexpected keys: {extras}"
# PYTHONPATH is whitelist-built, never inherited: only the sandbox
# sitecustomize shim dir (code-interpreter path remap).
assert env["PYTHONPATH"].endswith("sandbox_site")
assert "leak-me" not in env["PYTHONPATH"]
def test_home_points_at_sandbox_workdir(self, tmp_path):
from core.inference.tools import _build_safe_env
@ -315,6 +320,23 @@ class TestSandboxEnvIsolation:
env = _build_safe_env(str(tmp_path))
assert env["TERM"] == "dumb"
def test_bypass_env_installs_sitecustomize_path_shim(self, tmp_path):
# Bypass mode must install the same /mnt/data path-remap shim as the safe
# env (finding 17), else /mnt/data writes work only in normal mode.
from core.inference.tools import _SANDBOX_SITE_DIR, _build_bypass_env
env = _build_bypass_env(str(tmp_path))
assert _SANDBOX_SITE_DIR in env["PYTHONPATH"].split(os.pathsep)
def test_bypass_env_prepends_shim_and_keeps_inherited_pythonpath(self, monkeypatch, tmp_path):
from core.inference.tools import _SANDBOX_SITE_DIR, _build_bypass_env
monkeypatch.setenv("PYTHONPATH", "/operator/libs")
env = _build_bypass_env(str(tmp_path))
parts = env["PYTHONPATH"].split(os.pathsep)
# Shim first so its open()/makedirs remap wins, operator entries kept.
assert parts[0] == _SANDBOX_SITE_DIR
assert "/operator/libs" in parts
class TestSandboxCpuRlimitDefault:
"""Pin the default so a regression below 600s without opt-in is caught."""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Regression tests for generator-close cleanup in the tool-streaming routes.
Tool streams run ``next(gen)`` in an ``asyncio.to_thread`` worker. Closing the
generator while that worker is still inside ``next`` raises ``ValueError:
generator already executing`` and skips the generator's ``finally`` (tool
cleanup); the routes drain the pending task first (``_drain_pending_next_task``),
which these tests exercise.
"""
from __future__ import annotations
import asyncio
import threading
import pytest
from routes.inference import _drain_pending_next_task
def test_drain_before_close_avoids_generator_already_executing():
cancel_event = threading.Event()
entered = threading.Event()
finally_ran = threading.Event()
def blocking_gen():
try:
entered.set()
# Blocking call inside next(gen) that respects the cancel flag.
cancel_event.wait()
yield "value"
finally:
finally_ran.set()
async def scenario():
gen = blocking_gen()
next_task = asyncio.create_task(asyncio.to_thread(next, gen, object()))
await asyncio.to_thread(entered.wait) # worker now inside next(gen)
# Closing mid-next races and raises, leaving the finally unrun.
with pytest.raises(ValueError):
gen.close()
assert not finally_ran.is_set()
# Draining sets the cancel flag so the worker returns; then close is
# clean and the generator's finally runs.
await _drain_pending_next_task(next_task, cancel_event)
gen.close()
return
asyncio.run(scenario())
assert finally_ran.is_set()
def test_drain_pending_next_task_is_noop_without_task():
# None (task already consumed): draining is a no-op, cancel flag untouched.
cancel_event = threading.Event()
asyncio.run(_drain_pending_next_task(None, cancel_event))
assert not cancel_event.is_set()
def test_drain_pending_next_task_returns_when_worker_finishes():
# A worker finishing on its own drains without error; the cancel flag stays
# set (the caller is tearing the stream down).
cancel_event = threading.Event()
release = threading.Event()
def gen():
release.wait()
yield "done"
async def scenario():
g = gen()
task = asyncio.create_task(asyncio.to_thread(next, g, object()))
release.set() # let the worker complete before draining
await _drain_pending_next_task(task, cancel_event)
assert task.done()
asyncio.run(scenario())
assert cancel_event.is_set()

File diff suppressed because it is too large Load diff

View file

@ -3444,8 +3444,18 @@ const ComposerRightControls: FC<{
const MessageError: FC = () => {
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
<ErrorPrimitive.Root className="aui-message-error-root mt-2 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2 min-w-0 flex-1" />
{/* Recovery path for interrupted/failed turns: regenerate in place. */}
<ActionBarPrimitive.Reload asChild={true}>
<button
type="button"
className="aui-message-error-retry inline-flex shrink-0 items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-destructive/15"
>
<RefreshCwIcon strokeWidth={1.75} className="size-3.5" />
Retry
</button>
</ActionBarPrimitive.Reload>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);

View file

@ -10,6 +10,7 @@ import {
} from "react";
import { useAuiState } from "@assistant-ui/react";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { toolOutputKey, useToolPaneScope } from "@/features/chat";
import { ChevronDownIcon } from "lucide-react";
import { Wrench01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -235,13 +236,29 @@ const ToolGroupImpl: FC<
const messageRunning = useAuiState(
({ message }) => message.status?.type === "running",
);
// Keep the group open once a confirmation forced it open, so answering an
// allow/deny doesn't snap it shut between sequential tool calls. It reverts
// to the default collapsed state once the turn finishes.
// Force the group open when any call is receiving tool_output events.
const toolLiveOutput = useChatRuntimeStore((s) => s.toolLiveOutput);
const paneScope = useToolPaneScope();
const hasLiveOutput = useAuiState(({ message }) =>
message.parts
.slice(startIndex, endIndex + 1)
.some(
(part) =>
part.type === "tool-call" &&
Object.prototype.hasOwnProperty.call(
toolLiveOutput,
toolOutputKey(paneScope, part.toolCallId),
),
),
);
// Keep the group open once a confirmation or live output forced it (so an
// allow/deny doesn't snap it shut between calls); reverts once the turn ends.
const forcedOpenRef = useRef(false);
if (hasPendingConfirmation) forcedOpenRef.current = true;
if (hasPendingConfirmation || hasLiveOutput) forcedOpenRef.current = true;
const forceOpen =
hasPendingConfirmation || (forcedOpenRef.current && messageRunning);
hasPendingConfirmation ||
(hasLiveOutput && messageRunning) ||
(forcedOpenRef.current && messageRunning);
// Render single tool calls and canvases directly so cards never hide in a
// collapsed group.

View file

@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { toolOutputKey, useToolPaneScope } from "@/features/chat";
import { useEffect, useMemo, useRef } from "react";
import { tailText } from "./tool-result-output";
/**
* Live-scrolling stdout/stderr pane for a running server-side tool, backed by
* the transient `toolLiveOutput` map fed by `tool_output` SSE events. Renders
* nothing until the first chunk, then follows the tail. Mounted only while
* running; the finished card shows the persisted result instead.
*/
export function ToolLiveOutput({ toolCallId }: { toolCallId: string }) {
const paneScope = useToolPaneScope();
const output = useChatRuntimeStore(
(s) => s.toolLiveOutput[toolOutputKey(paneScope, toolCallId)] ?? "",
);
const scrollRef = useRef<HTMLPreElement>(null);
// Pinned to the bottom until the user scrolls up (handler below), so
// streaming chunks no longer yank them down.
const pinnedToBottom = useRef(true);
// The stream can reach hundreds of KB; render only the tail while live.
const visible = useMemo(() => tailText(output).visible, [output]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) {
return;
}
// Within 40px of the bottom counts as pinned (tolerates small nudges).
pinnedToBottom.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 40;
};
useEffect(() => {
const el = scrollRef.current;
if (el && pinnedToBottom.current) {
el.scrollTop = el.scrollHeight;
}
}, [visible]);
if (!output) {
return null;
}
return (
<div className="aui-tool-live-output mt-2 border-t border-dashed pt-2">
<span className="text-xs font-medium text-muted-foreground">output</span>
<pre
ref={scrollRef}
onScroll={handleScroll}
className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono text-xs"
>
{visible}
</pre>
</div>
);
}

View file

@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { useMemo, useState } from "react";
/** Tail-line cap so a huge output never mounts a megabyte <pre> block. */
const TAIL_LINES = 2000;
/** Char backstop for pathological single-line outputs. */
const TAIL_CHARS = 200_000;
interface Tail {
visible: string;
hiddenLines: number;
hiddenChars: number;
}
export function tailText(text: string): Tail {
let visible = text;
let hiddenLines = 0;
let hiddenChars = 0;
const lines = visible.split("\n");
if (lines.length > TAIL_LINES) {
hiddenLines = lines.length - TAIL_LINES;
visible = lines.slice(hiddenLines).join("\n");
}
if (visible.length > TAIL_CHARS) {
hiddenChars = visible.length - TAIL_CHARS;
visible = visible.slice(hiddenChars);
}
return { visible, hiddenLines, hiddenChars };
}
/**
* Finished-tool output pane: renders the tail (~2000 lines) with a "Show all"
* toggle so a large output stays scrollable without janking the DOM. Copy
* buttons still copy the FULL text (owned by the caller), not the tail.
*/
export function ToolResultOutput({ text }: { text: string }) {
const [showAll, setShowAll] = useState(false);
const tail = useMemo(() => tailText(text), [text]);
const truncated = !showAll && (tail.hiddenLines > 0 || tail.hiddenChars > 0);
return (
<>
{truncated && (
<button
type="button"
onClick={() => setShowAll(true)}
className="mt-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{tail.hiddenLines > 0
? `Show all (${tail.hiddenLines.toLocaleString()} earlier lines hidden)`
: `Show all (${tail.hiddenChars.toLocaleString()} earlier chars hidden)`}
</button>
)}
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono text-xs">
{showAll ? text : tail.visible}
</pre>
</>
);
}

View file

@ -6,6 +6,7 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { getAuthToken } from "@/features/auth/session";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { useToolArgsStatus } from "@assistant-ui/react";
import { code as codePlugin } from "@streamdown/code";
import { CodeIcon, CopyIcon } from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
@ -18,6 +19,14 @@ import {
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
import { ToolLiveOutput } from "./tool-live-output";
import { ToolResultOutput } from "./tool-result-output";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import {
preferFullToolOutput,
toolOutputKey,
useToolPaneScope,
} from "@/features/chat";
interface StructuredResult {
text: string;
@ -105,6 +114,7 @@ function isStructuredResult(val: unknown): val is StructuredResult {
}
const PythonToolUIImpl: ToolCallMessagePartComponent = ({
toolCallId,
args,
result,
status,
@ -112,6 +122,9 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
const code = (args as { code?: string })?.code ?? "";
const firstLine = code.split("\n")[0]?.slice(0, 60) ?? "";
const isRunning = status?.type === "running";
// Args still streaming = the model is WRITING the code, not running it yet.
const { propStatus } = useToolArgsStatus();
const isWritingCode = isRunning && propStatus.code === "streaming";
let output: string;
let images: string[] = [];
@ -129,10 +142,19 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
output = "";
}
// Show the fuller live stream over a truncated result, keeping its exit
// status. Session-transient: after a reload only the result remains.
const paneScope = useToolPaneScope();
const fullOutput = useChatRuntimeStore(
(s) => s.toolFullOutput[toolOutputKey(paneScope, toolCallId)] ?? "",
);
const displayOutput = preferFullToolOutput(fullOutput, output);
const authToken = getAuthToken();
return (
<ToolFallbackRoot>
// Open when mounted mid-run so live output shows; collapsed from history.
<ToolFallbackRoot defaultOpen={isRunning}>
<ToolFallbackTrigger
toolName={firstLine ? `Python: ${firstLine}` : "Python"}
status={status}
@ -150,19 +172,21 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
{/* Output */}
{isRunning ? (
<div className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-3.5" />
<span>Running&hellip;</span>
</div>
) : output ? (
<>
<div className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-3.5" />
<span>{isWritingCode ? "Writing code…" : "Running…"}</span>
</div>
{/* Live stdout streamed via tool_output SSE events. */}
<ToolLiveOutput toolCallId={toolCallId} />
</>
) : displayOutput ? (
<div className="mt-2 border-t border-dashed pt-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">output</span>
<CopyBtn text={output} />
<CopyBtn text={displayOutput} />
</div>
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono text-xs">
{truncate(output)}
</pre>
<ToolResultOutput text={displayOutput} />
</div>
) : null}

View file

@ -5,6 +5,7 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { useToolArgsStatus } from "@assistant-ui/react";
import { CopyIcon, TerminalIcon } from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
@ -15,16 +16,17 @@ import {
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
import { ToolLiveOutput } from "./tool-live-output";
import { ToolResultOutput } from "./tool-result-output";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import {
preferFullToolOutput,
toolOutputKey,
useToolPaneScope,
} from "@/features/chat";
const MAX_DISPLAY = 10_000;
const COPY_RESET_MS = 2000;
function truncate(text: string): string {
return text.length <= MAX_DISPLAY
? text
: `${text.slice(0, MAX_DISPLAY)}\n... (truncated)`;
}
function CopyBtn({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -65,12 +67,16 @@ function CopyBtn({ text }: { text: string }) {
}
const TerminalToolUIImpl: ToolCallMessagePartComponent = ({
toolCallId,
args,
result,
status,
}) => {
const command = (args as { command?: string })?.command ?? "";
const isRunning = status?.type === "running";
// Args still streaming = the model is WRITING the command, not running it yet.
const { propStatus } = useToolArgsStatus();
const isWritingCommand = isRunning && propStatus.command === "streaming";
const output =
typeof result === "string"
? result
@ -78,8 +84,17 @@ const TerminalToolUIImpl: ToolCallMessagePartComponent = ({
? JSON.stringify(result, null, 2)
: "";
// Show the fuller live stream over a truncated result, keeping its exit
// status. Session-transient: after a reload only the result remains.
const paneScope = useToolPaneScope();
const fullOutput = useChatRuntimeStore(
(s) => s.toolFullOutput[toolOutputKey(paneScope, toolCallId)] ?? "",
);
const displayOutput = preferFullToolOutput(fullOutput, output);
return (
<ToolFallbackRoot>
// Open when mounted mid-run so live output shows; collapsed from history.
<ToolFallbackRoot defaultOpen={isRunning}>
<ToolFallbackTrigger
toolName={command ? `$ ${command.slice(0, 60)}` : "Terminal"}
status={status}
@ -88,19 +103,21 @@ const TerminalToolUIImpl: ToolCallMessagePartComponent = ({
<ToolFallbackContent>
<div className="border-l-2 border-muted-foreground/20 pl-2">
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-3.5" />
<span>Running&hellip;</span>
</div>
) : output ? (
<>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-3.5" />
<span>{isWritingCommand ? "Writing command…" : "Running…"}</span>
</div>
{/* Live stdout streamed via tool_output SSE events. */}
<ToolLiveOutput toolCallId={toolCallId} />
</>
) : displayOutput ? (
<div>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">output</span>
<CopyBtn text={output} />
<CopyBtn text={displayOutput} />
</div>
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono text-xs">
{truncate(output)}
</pre>
<ToolResultOutput text={displayOutput} />
</div>
) : null}
</div>

View file

@ -12,6 +12,7 @@ import { parseParamCountB } from "@/lib/model-size";
import { toast } from "@/lib/toast";
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
import type { ChatModelAdapter } from "@assistant-ui/react";
import { parsePartialJsonObject } from "assistant-stream/utils";
import {
getExternalProviderApiKey,
isCustomProviderType,
@ -51,6 +52,11 @@ import {
useChatRuntimeStore,
} from "../stores/chat-runtime-store";
import { useExternalProvidersStore } from "../stores/external-providers-store";
import {
shouldPreserveFullOutput,
toolOutputKey,
toolPaneScope,
} from "../tool-output-scope";
import type { ModelType } from "../types";
import { isMultimodalResponse } from "../types/api";
import type {
@ -85,6 +91,7 @@ import {
listGgufVariants,
loadModel,
streamChatCompletions,
StreamInterruptedError,
validateModel,
} from "./chat-api";
import {
@ -228,6 +235,49 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Best-effort partial parse of a live tool_args stream into a tool part's
* `args`, so cards render the payload while the model is still writing it. The
* structured path streams raw arguments JSON; the text path wraps it in call
* markup, unwrapped here. Returns null until something parses; never throws.
*/
function parseLiveToolArgs(
raw: string,
): { args: Record<string, unknown>; argsText: string } | null {
let candidate = raw.trimStart();
if (!candidate.startsWith("{")) {
const brace = candidate.indexOf("{");
if (brace < 0) return null;
candidate = candidate.slice(brace);
}
const parsed = parsePartialJsonObject(candidate) as
| Record<string, unknown>
| undefined;
if (!parsed || typeof parsed !== "object") return null;
// Call envelope from the text path: unwrap to the arguments payload.
const inner = parsed.arguments ?? parsed.parameters;
if (typeof parsed.name === "string" && inner !== undefined) {
if (typeof inner === "string") {
// Stringified arguments: partial-parse the inner JSON string.
const innerParsed = parsePartialJsonObject(inner) as
| Record<string, unknown>
| undefined;
if (innerParsed && typeof innerParsed === "object") {
return { args: innerParsed, argsText: inner };
}
return null;
}
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
return {
args: inner as Record<string, unknown>,
argsText: JSON.stringify(inner),
};
}
return null;
}
return { args: parsed, argsText: candidate };
}
function parseSystemVariablesMap(raw: string): Record<string, unknown> {
if (!raw.trim()) {
return {};
@ -1877,6 +1927,16 @@ export function createOpenAIStreamAdapter(
? `${sandboxSessionId || "_default"}:${resolvedThreadId}`
: sandboxSessionId || "_default";
const toolConfirmationIdsByBackendId = new Map<string, string>();
// Store keys are pane-scoped since local tool ids ("call_0") repeat across
// turns and concurrent panes (compare mode). Track this run's keys so
// cleanup can't wipe another pane's.
const toolOutputPaneScope = toolPaneScope(
options.modelType,
options.pairId,
);
const scopedToolOutputKey = (id: string) =>
toolOutputKey(toolOutputPaneScope, id);
const runToolLiveOutputKeys = new Set<string>();
const resolvedThreadKey = resolvedThreadId ?? null;
const pendingImageEditReferenceForRun = runtime.pendingImageEditReference;
const selectedImageEditReference =
@ -2426,6 +2486,34 @@ export function createOpenAIStreamAdapter(
};
// Tool call parts, cumulative; result lands on tool_end.
const toolCallParts: PositionedToolCallPart[] = [];
// Raw tool_args accumulator per card: the backend forwards arguments while
// the model is still WRITING them, and the partial parse below feeds the
// card's args so the code renders live.
const liveArgsTextById = new Map<string, string>();
// Backend tool ids ("call_0", ...) restart every response, so a bare id as
// store key lets a later turn's stream overwrite the preserved output an
// earlier still-mounted finished card reads (the tool_start stale-clear
// only guards the forward direction). Mint one per-run-unique part id per
// backend id (confirmation ids already synthesize their own) so each card
// key is unique; every tool_start/output/args/end resolves the same id via
// this map, dropped at tool_end.
const toolPartIdByBackendId = new Map<string, string>();
const resolveToolPartId = (backendToolCallId: string): string => {
if (!backendToolCallId) {
return toolCallParts[toolCallParts.length - 1]?.toolCallId ?? "";
}
const confirmationId =
toolConfirmationIdsByBackendId.get(backendToolCallId);
if (confirmationId) {
return confirmationId;
}
let partId = toolPartIdByBackendId.get(backendToolCallId);
if (!partId) {
partId = `${backendToolCallId}:${crypto.randomUUID()}`;
toolPartIdByBackendId.set(backendToolCallId, partId);
}
return partId;
};
// Latest Gemini text-part thoughtSignature; pinned onto the final
// text MessagePart so next-turn replay carries it.
let latestTextThoughtSignature: string | undefined;
@ -3160,6 +3248,66 @@ export function createOpenAIStreamAdapter(
anthropicRefusalSeen = true;
continue;
}
if (toolEvent.type === "tool_output") {
// Incremental stdout from a running tool: append to the live
// store so the card renders it while the spinner runs. Final
// result arrives via tool_end.
const backendToolCallId =
(toolEvent.tool_call_id as string) || "";
const liveId = resolveToolPartId(backendToolCallId);
const liveText =
typeof toolEvent.text === "string" ? toolEvent.text : "";
if (liveId && liveText) {
const liveKey = scopedToolOutputKey(liveId);
runToolLiveOutputKeys.add(liveKey);
useChatRuntimeStore
.getState()
.appendToolLiveOutput(liveKey, liveText);
}
continue;
}
if (toolEvent.type === "tool_args") {
// The model is still WRITING this call's arguments: accumulate
// the raw stream and feed a partial parse into the part's args
// so the card shows the code live. tool_start later replaces
// args with the authoritative parse.
const backendToolCallId =
(toolEvent.tool_call_id as string) || "";
const liveId = resolveToolPartId(backendToolCallId);
const fragment =
typeof toolEvent.text === "string" ? toolEvent.text : "";
if (liveId && fragment) {
const accum =
(liveArgsTextById.get(liveId) ?? "") + fragment;
liveArgsTextById.set(liveId, accum);
const partial = parseLiveToolArgs(accum);
const idx = toolCallParts.findIndex(
(p) => p.toolCallId === liveId,
);
if (partial && idx !== -1) {
const existing = toolCallParts[
idx
] as PositionedToolCallPart;
toolCallParts[idx] = {
...existing,
args: partial.args as ToolCallMessagePart["args"],
argsText: partial.argsText,
};
yield {
content: buildAssistantContent(cumulativeText),
metadata: {
timing: buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
},
};
}
}
continue;
}
closeReasoningContent();
const toolProvenance = parseToolProvenance(
toolEvent.provenance,
@ -3173,12 +3321,18 @@ export function createOpenAIStreamAdapter(
const id =
awaitingConfirmation && approvalId
? `${toolConfirmationScopeId}:${approvalId}`
: backendToolCallId ||
approvalId ||
`${toolEvent.tool_name}_${Date.now()}`;
: backendToolCallId
? resolveToolPartId(backendToolCallId)
: approvalId ||
`${toolEvent.tool_name}_${Date.now()}`;
if (awaitingConfirmation && backendToolCallId) {
toolConfirmationIdsByBackendId.set(backendToolCallId, id);
}
// "call_0" restarts every response: drop stale live/preserved
// output under this key, else the card shows the previous call's.
const staleKey = scopedToolOutputKey(id);
useChatRuntimeStore.getState().clearToolLiveOutput(staleKey);
useChatRuntimeStore.getState().clearToolFullOutput(staleKey);
const toolArgs = (toolEvent.arguments ??
{}) as ToolCallMessagePart["args"];
const idx = toolCallParts.findIndex(
@ -3222,17 +3376,35 @@ export function createOpenAIStreamAdapter(
} else if (toolEvent.type === "tool_end") {
const backendToolCallId =
(toolEvent.tool_call_id as string) || "";
const id =
(backendToolCallId
? toolConfirmationIdsByBackendId.get(backendToolCallId)
: undefined) ||
backendToolCallId ||
toolCallParts[toolCallParts.length - 1]?.toolCallId ||
"";
const id = resolveToolPartId(backendToolCallId);
if (backendToolCallId) {
toolConfirmationIdsByBackendId.delete(backendToolCallId);
toolPartIdByBackendId.delete(backendToolCallId);
}
useChatRuntimeStore.getState().clearToolConfirmation(id);
// The result replaces the live output, but if the stream
// captured MORE than the truncated result, preserve it so the
// finished card keeps everything. Uses the shared predicate,
// not a length compare (footer / "Exit code N:" / __IMAGES__
// tail can make the result longer by byte).
const liveKey = scopedToolOutputKey(id);
const liveOutput =
useChatRuntimeStore.getState().toolLiveOutput[liveKey] ??
"";
if (
id &&
shouldPreserveFullOutput(
liveOutput,
(toolEvent.result as string) ?? "",
)
) {
useChatRuntimeStore
.getState()
.setToolFullOutput(liveKey, liveOutput);
}
useChatRuntimeStore.getState().clearToolLiveOutput(liveKey);
runToolLiveOutputKeys.delete(liveKey);
liveArgsTextById.delete(id);
const idx = toolCallParts.findIndex(
(p) => p.toolCallId === id,
);
@ -3797,7 +3969,16 @@ export function createOpenAIStreamAdapter(
);
if (!abortSignal.aborted) {
const msg = err instanceof Error ? err.message : String(err);
if (isContextLimitError(msg)) {
if (err instanceof StreamInterruptedError) {
// Connection dropped mid-turn: surface it explicitly (the rethrow
// below also marks the message with an inline error + Retry).
toast.error("Response interrupted", {
description:
"The connection dropped before the model finished. " +
"The partial answer is kept. Use Retry to regenerate.",
duration: 8000,
});
} else if (isContextLimitError(msg)) {
// llama-server runs with --no-context-shift, returning a hard
// error instead of silently dropping old KV-cache turns. Point
// the user at the control that raises the ceiling.
@ -3823,6 +4004,19 @@ export function createOpenAIStreamAdapter(
}
runtime.setGeneratingStatus(null);
runtime.setToolStatus(null);
// Clear only this run's live keys (a concurrent pane owns its own). A
// key still here streamed stdout but never reached tool_end (SSE drop or
// cancel), so promote it to full output first, else the partial
// diagnostics the user was watching vanish from the card.
for (const liveKey of runToolLiveOutputKeys) {
const store = useChatRuntimeStore.getState();
const liveOutput = store.toolLiveOutput[liveKey] ?? "";
if (liveOutput) {
store.setToolFullOutput(liveKey, liveOutput);
}
store.clearToolLiveOutput(liveKey);
}
runToolLiveOutputKeys.clear();
// Drop the transient denoising canvas so the finished bubble shows only
// the committed markdown answer (cancellation/error included).
runtime.setActiveDiffusionCanvas(null);

View file

@ -29,6 +29,21 @@ import type {
export const CHAT_HISTORY_UPDATED_EVENT = "unsloth-chat-history-updated";
/**
* Thrown when the chat SSE stream ends without a terminal signal (`[DONE]` or a
* finish_reason chunk): the connection dropped mid-generation. The adapter
* surfaces it as an explicit interrupted state instead of ending the turn.
*/
export class StreamInterruptedError extends Error {
constructor() {
super(
"Response interrupted: the connection dropped before the model finished. " +
"Use Retry to regenerate.",
);
this.name = "StreamInterruptedError";
}
}
export function notifyChatHistoryUpdated(): void {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(CHAT_HISTORY_UPDATED_EVENT));
@ -851,12 +866,18 @@ export async function* streamChatCompletions(
const decoder = new TextDecoder();
let buffer = "";
let completed = false;
// EOF without `[DONE]` or a finish_reason chunk means the stream was cut
// mid-generation: surface as interrupted, not silent success.
let sawTerminalSignal = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
completed = true;
if (!sawTerminalSignal) {
throw new StreamInterruptedError();
}
break;
}
@ -877,6 +898,7 @@ export async function* streamChatCompletions(
const dataText = dataLines.join("\n");
if (dataText === "[DONE]") {
completed = true;
sawTerminalSignal = true;
return;
}
@ -903,10 +925,14 @@ export async function* streamChatCompletions(
separatorIndex = buffer.search(/\r?\n\r?\n/);
continue;
}
// Tool start/end events carry full input/output for the tool outputs panel
// tool_start/end carry full input/output; tool_output streams
// incremental stdout and tool_args streams the call arguments live.
if (
"type" in parsed &&
(parsed.type === "tool_start" || parsed.type === "tool_end")
(parsed.type === "tool_start" ||
parsed.type === "tool_end" ||
parsed.type === "tool_output" ||
parsed.type === "tool_args")
) {
yield { _toolEvent: parsed } as unknown as OpenAIChatChunk;
separatorIndex = buffer.search(/\r?\n\r?\n/);
@ -925,6 +951,16 @@ export async function* streamChatCompletions(
separatorIndex = buffer.search(/\r?\n\r?\n/);
continue;
}
// finish_reason is a valid terminal signal for providers that close
// the stream without an explicit [DONE] sentinel.
const finishReason = (
parsed as {
choices?: Array<{ finish_reason?: string | null }>;
}
).choices?.[0]?.finish_reason;
if (finishReason) {
sawTerminalSignal = true;
}
yield parsed as OpenAIChatChunk;
separatorIndex = buffer.search(/\r?\n\r?\n/);
}

View file

@ -17,6 +17,11 @@ export {
type Preset,
} from "./chat-settings-sheet";
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
export {
preferFullToolOutput,
toolOutputKey,
useToolPaneScope,
} from "./tool-output-scope";
export { PermissionModeDropdown } from "./permission-mode-select";
export { useChatSearchStore } from "./stores/chat-search-store";
export { usePinnedChatsStore } from "./stores/pinned-chats-store";

View file

@ -54,6 +54,7 @@ import {
} from "./open-document";
import { AudioAttachmentAdapter } from "./audio-attachment-adapter";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { ToolPaneScopeContext, toolPaneScope } from "./tool-output-scope";
import type { MessageRecord, ModelType, ThreadRecord } from "./types";
import {
deleteStoredChatThreads,
@ -1347,26 +1348,33 @@ export function ChatRuntimeProvider({
return (
<AssistantRuntimeProvider runtime={runtime} aui={aui}>
<ActiveThreadSync
enabled={
modelType === "base" && !pairId && !newThreadNonce && !initialThreadId
}
/>
<ThreadBackendAutosave modelType={modelType} pairId={pairId} />
<CancelRegistrar />
{initialThreadId && (
<ThreadAutoSwitch
threadId={initialThreadId}
syncActiveThreadId={syncActiveThreadId}
{/* Pane identity for the tool-output store maps: the adapter prefixes its
keys with this scope so concurrent panes with colliding tool ids
("call_0") can't bleed live output into each other's cards. */}
<ToolPaneScopeContext.Provider value={toolPaneScope(modelType, pairId)}>
<ActiveThreadSync
enabled={
modelType === "base" &&
!pairId &&
!newThreadNonce &&
!initialThreadId
}
/>
)}
{!initialThreadId && newThreadNonce && (
<ThreadNewChatSwitch nonce={newThreadNonce} />
)}
{/* The view stays mounted (only CSS-hidden by RootLayout) while off-route
so assistant-ui keeps the run attached and the stream alive. Unmounting
it here aborts the in-flight generation. */}
{children}
<ThreadBackendAutosave modelType={modelType} pairId={pairId} />
<CancelRegistrar />
{initialThreadId && (
<ThreadAutoSwitch
threadId={initialThreadId}
syncActiveThreadId={syncActiveThreadId}
/>
)}
{!initialThreadId && newThreadNonce && (
<ThreadNewChatSwitch nonce={newThreadNonce} />
)}
{/* The view stays mounted (only CSS-hidden) while off-route so the run
stays attached and the stream alive; unmounting aborts generation. */}
{children}
</ToolPaneScopeContext.Provider>
</AssistantRuntimeProvider>
);
}

View file

@ -701,6 +701,13 @@ type ChatRuntimeStore = {
*/
webFetchToolsEnabled: boolean;
toolStatus: string | null;
/** Live stdout/stderr from running tools, keyed by toolCallId. Transient:
* appended by tool_output, cleared on tool_end or run end. */
toolLiveOutput: Record<string, string>;
/** Full live output of finished tools whose result was truncated for the
* model, keyed by toolCallId. Set from tool_end; finished cards prefer it
* over the truncated result. Session-transient. */
toolFullOutput: Record<string, string>;
generatingStatus: string | null;
autoHealToolCalls: boolean;
nudgeToolCalls: boolean;
@ -836,6 +843,13 @@ type ChatRuntimeStore = {
setRagOcrScanned: (enabled: boolean) => void;
setRagCaptionFigures: (enabled: boolean) => void;
setToolStatus: (status: string | null) => void;
appendToolLiveOutput: (toolCallId: string, text: string) => void;
/** Clear one tool's live output, or all when no id is given. */
clearToolLiveOutput: (toolCallId?: string) => void;
/** Preserve a finished tool's full live-streamed output for display. */
setToolFullOutput: (toolCallId: string, text: string) => void;
/** Drop a stale preserved full output (a new run is reusing the id). */
clearToolFullOutput: (toolCallId: string) => void;
setGeneratingStatus: (status: string | null) => void;
setActiveDiffusionCanvas: (canvas: DiffusionCanvasFrame | null) => void;
setAutoHealToolCalls: (enabled: boolean) => void;
@ -1166,6 +1180,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
ragOcrScanned: loadBool(CHAT_RAG_OCR_KEY, DEFAULT_RAG_OCR),
ragCaptionFigures: loadBool(CHAT_RAG_CAPTION_KEY, DEFAULT_RAG_CAPTION),
toolStatus: null,
toolLiveOutput: {},
toolFullOutput: {},
generatingStatus: null,
activeDiffusionCanvas: null,
autoHealToolCalls: true,
@ -1415,6 +1431,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
// Only the per-session enable pill resets; source/mode/top_k persist.
ragEnabled: false,
toolStatus: null,
toolLiveOutput: {},
toolFullOutput: {},
activeDiffusionCanvas: null,
kvCacheDtype: null,
loadedKvCacheDtype: null,
@ -1638,6 +1656,43 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
return { ragCaptionFigures };
}),
setToolStatus: (toolStatus) => set({ toolStatus }),
appendToolLiveOutput: (toolCallId, text) =>
set((state) => ({
toolLiveOutput: {
...state.toolLiveOutput,
[toolCallId]: (state.toolLiveOutput[toolCallId] ?? "") + text,
},
})),
setToolFullOutput: (toolCallId, text) =>
set((state) => ({
toolFullOutput: {
...state.toolFullOutput,
[toolCallId]: text,
},
})),
clearToolFullOutput: (toolCallId) =>
set((state) => {
if (!(toolCallId in state.toolFullOutput)) {
return {};
}
const next = { ...state.toolFullOutput };
delete next[toolCallId];
return { toolFullOutput: next };
}),
clearToolLiveOutput: (toolCallId) =>
set((state) => {
if (toolCallId === undefined) {
return Object.keys(state.toolLiveOutput).length
? { toolLiveOutput: {} }
: {};
}
if (!(toolCallId in state.toolLiveOutput)) {
return {};
}
const next = { ...state.toolLiveOutput };
delete next[toolCallId];
return { toolLiveOutput: next };
}),
setActiveDiffusionCanvas: (activeDiffusionCanvas) =>
set({ activeDiffusionCanvas }),
setGeneratingStatus: (generatingStatus) => set({ generatingStatus }),

View file

@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { createContext, useContext } from "react";
import type { ModelType } from "./types";
/**
* Pane scope prefix for the transient tool-output store keys.
*
* Local GGUF tool ids are only unique within one response ("call_0", "call_1",
* ...), and panes stream concurrently (compare mode mounts two runtimes; the
* main view stays CSS-hidden off-route), so a bare id would let one pane's
* stdout bleed into another's same-id card. Derived from static props
* (`modelType` + `pairId`) shared by writer (adapter) and reader (components)
* via one `ChatRuntimeProvider`, so they can never disagree.
*/
export function toolPaneScope(modelType?: ModelType, pairId?: string): string {
return `${modelType ?? "base"}\u0000${pairId ?? ""}`;
}
export const ToolPaneScopeContext = createContext<string>(toolPaneScope());
export function useToolPaneScope(): string {
return useContext(ToolPaneScopeContext);
}
/** Store key for the live/full tool output maps: pane scope + tool call id. */
export function toolOutputKey(paneScope: string, toolCallId: string): string {
return `${paneScope}\u0000${toolCallId}`;
}
// Footer the backend appends when it truncates a result to protect the context
// window (see backend tools._truncate). Marks where the result stops being a
// copy of the stream, so it distinguishes "just truncated" from "carries
// failure/exit status the stream never produced".
const TRUNCATION_FOOTER_MARKER = "\n\n... (truncated";
/**
* Whether the live stdout holds more real output than the model-visible
* `result` and should be preserved for the finished card. Shared by writer
* (retain?) and reader (display?) so they agree.
*
* True when the result is truncated, OR the stream is longer. Truncation can't
* fall back to length: a truncated result may be longer by byte count once its
* footer / `Exit code N:` / `__IMAGES__` blob is appended, yet the stream still
* holds more stdout. Also true when a short stream is absent from the result: a
* timed-out/cancelled tool returns only a status line, so length alone would
* drop the partial stdout the stream captured.
*/
export function shouldPreserveFullOutput(full: string, result: string): boolean {
if (!full) {
return false;
}
if (result.includes(TRUNCATION_FOOTER_MARKER)) {
return true;
}
if (full.length > result.length) {
return true;
}
// Stream no longer than the result, but a timed-out/cancelled tool's status
// line never echoes the captured stdout: preserve the stream whenever its
// content is absent from the result (trimmed to ignore trailing-newline drift).
const core = full.trim();
return core.length > 0 && !result.includes(core);
}
/**
* Pick what a finished python/terminal card shows. Prefer the fuller live
* stream over the truncated `result`, but the result can carry failure/exit
* text that never reached stdout ("Exit code N: ...", timeouts), so show the
* stream when the result is just a truncated prefix of it, else append the
* result so its status survives (and the copy button copies both).
*/
export function preferFullToolOutput(full: string, result: string): string {
if (!shouldPreserveFullOutput(full, result)) {
return result;
}
const marker = result.indexOf(TRUNCATION_FOOTER_MARKER);
const core = marker === -1 ? result : result.slice(0, marker);
if (!core || full === result || full.startsWith(core)) {
return full;
}
// Failed executions prefix the result (not the stream) with "Exit code N:\n",
// so `full.startsWith(core)` above misses and a plain append would duplicate
// the stdout. Re-attach just the exit prefix (and any missing-path hint) to
// the fuller stream so the status survives without duplicating stdout.
const exitMatch = core.match(/^(Exit code -?\d+:\n)([\s\S]*)$/);
if (exitMatch && full.startsWith(exitMatch[2])) {
const hint = result.match(/\nHint:[\s\S]*$/)?.[0] ?? "";
return `${exitMatch[1]}${full}${hint}`;
}
return `${full.replace(/\s+$/, "")}\n\n${result}`;
}