unsloth/studio/backend/tests/test_safetensors_toolcall_wiring.py
Nilay 601155114d
Studio: persistent stdio MCP sessions so server state survives across tool calls (#7080)
* Studio: persistent stdio MCP sessions so server state survives across tool calls

call_tool_sync spawned a fresh stdio subprocess per tool call
(keep_alive=False) and tore it down when the call returned, so any stateful
MCP server lost its state between calls: with @playwright/mcp,
browser_navigate opened the page in one subprocess and
browser_take_screenshot ran in a brand-new one, screenshotting about:blank.

Keep one connected client per (command, env) on a dedicated event-loop
thread and reuse it across calls:

- idle sessions are reaped after 5 minutes (in-flight calls excluded) and
  everything closes at exit, preserving the old design's no-orphans property
- a dead subprocess is detected via is_connected() and retried once on a
  fresh session; tool-level errors leave the session alone
- cancel and timeout semantics are unchanged, and a timed-out call does not
  tear the session down
- updating a server's endpoint/env/enabled state or deleting it closes its
  live session
- HTTP/SSE servers stay one-shot per call

* address review feedback

* fix stdio session cleanup

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

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

* address review: per-thread MCP scope, close-during-connect and abort races

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

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

* address review: unblock no-limit calls on close, drain borrowers before close, scope closes to url+env

* don't retry sessions closed by config changes, re-verify server row before caching, keep env secrets out of generation keys

* fail fast on connect errors and make the stdio key-lock wait cancellable

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

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

* quote MCP scope parts so IDs with colons can't collide

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

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

* serialize per-session stdio calls, span one timeout budget across connect and call, hash urls in generation keys

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

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

* Harden persistent stdio MCP sessions: crash recovery, concurrency, scoping

- Evict a stdio session on any transport-level (non-ToolError) call failure and
  do not replay it, so a mid-call subprocess crash can no longer poison the scope.
  Never gate liveness on Client.is_connected() (it only reports that a session
  object exists, not that the subprocess is alive); add a version-adaptive
  dead-transport probe that works on fastmcp 3.0.2 and newer.
- Re-check closed/defunct/config and transport liveness after acquiring the call
  lock, and retire a session before releasing the lock, so a queued same-scope
  caller never reuses a session that another caller's timeout already retired.
- Force a ProactorEventLoop on Windows so the stdio transport can always spawn
  subprocesses regardless of the active event-loop policy.
- Scope stdio sessions per conversation: require thread_id to persist, and tag
  the fields so a session_id and a thread_id with the same value cannot collide.
  A session_id alone is project-wide, so it now falls back to a safe one-shot
  session instead of sharing browser/DB/REPL state across conversations.
- Forward thread_id on the Anthropic Messages path.
- Treat timeout=None as unlimited on connect and the key lock (was capped at 60s).
- Bound the session cache (default 32, override via
  UNSLOTH_STUDIO_MAX_STDIO_MCP_SESSIONS) with LRU eviction of idle sessions.
- Run config_check on cache hits, and log a redacted exe#digest label instead of
  the raw command so credentials in argv never reach the logs.

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

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

* Trim the stdio MCP session cache on release and skip close-generation for HTTP servers

Two fixes from review of the persistent stdio session lifecycle:

- Re-enforce the session cap when a session goes idle. A concurrent burst of
  distinct-scope calls can overshoot the cap while every cached session is busy
  (insert-time eviction only reclaims idle sessions), and the overshoot used to
  persist until the 5-minute idle reaper. _release_stdio_session now trims the
  idle overshoot back within the cap, without ever evicting an in-flight call.
- close_stdio_sessions() now no-ops for a specific non-stdio (HTTP/SSE) url.
  Those transports are never cached as stdio sessions, so calling it on every
  HTTP server update or delete used to accrue an unbounded close-generation entry.

Both are covered by regression tests that fail before the change and pass after.

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

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

* Keep the live stdio MCP session across a display-name rename

The edit dialog resends url, headers, and use_oauth unchanged whenever a
server is saved, so gating the tool-cache invalidation and stdio session
close on field presence dropped the persistent process on a plain rename
or any no-op edit. Gate on a real value change against the stored row so
only a genuine endpoint, auth, or enable change closes the session.

Regression tests: a rename that resends unchanged url/headers/oauth keeps
the session; a real command change still closes it.

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

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

* Tighten comments in the stdio MCP session lifecycle

Collapse a few verbose comments to fewer lines with the wording preserved,
and drop one that restated the clear_oauth_tokens_async docstring. Comments
only; no code change.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-07-14 02:28:43 -07:00

180 lines
7.3 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Deterministic backend-wiring test for the safetensors / MLX tool-calling path.
The parser and the cumulative-text state machine are already covered exhaustively by
``test_safetensors_tool_loop.py`` with fake generators. What that suite does not touch is the
*backend's own tool-injection seam*: both ``InferenceBackend`` (transformers) and
``MLXInferenceBackend`` render the prompt through the shared
``apply_chat_template_for_generation(..., tools=...)`` helper and stream cumulative text into the
shared ``run_safetensors_tool_loop`` (see ``core/inference/inference.py`` and
``core/inference/mlx_inference.py`` -- both call the same helper and the same loop, so a single CPU
test of that seam covers the macOS MLX path too).
This test drives that exact seam with deterministic fakes -- a fake tokenizer that records the
``tools`` it is handed, a canned tool-call generation, and a stub executor -- and asserts the full
agentic chain end to end:
tools injected into the template -> loop parses the call -> tool dispatched once ->
tool result fed back -> generation re-entered -> final answer streamed.
It is the deterministic, download-free stand-in for the real-model MLX / GGUF browser tool-calling
end-to-end: it imports no torch / unsloth / mlx, so it runs in the portable Backend CI alongside the
tool-call parser tests. Follow-up to the parser test PRs (#5620 / #5704).
"""
from core.inference.chat_template_helpers import apply_chat_template_for_generation
from core.inference.safetensors_agentic import run_safetensors_tool_loop
TOOL_NAME = "get_weather"
TOOL_ARGS = {"city": "Paris"}
FAKE_TOOL = {
"type": "function",
"function": {
"name": TOOL_NAME,
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
# Full parser matrix lives in test_safetensors_tool_loop.py.
TOOL_CALL_TEXT = '<tool_call>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool_call>'
FINAL_ANSWER = "The weather in Paris is sunny and 22C."
TOOL_RESULT = "Paris: sunny, 22C"
class RecordingTokenizer:
"""Fake tokenizer that records the ``tools`` handed to ``apply_chat_template``.
Modelled on ``TestChatTemplateHelper._Tok`` in ``test_safetensors_tool_loop.py``: it accepts the
real helper's kwargs and returns a canned prompt, so the test can assert the backend seam actually
forwarded the tool schema -- a silent drop on a chat-template fallback would leave ``tools_seen``
holding ``None``.
"""
def __init__(self):
self.tools_seen: list = []
self.call_count = 0
def apply_chat_template(
self,
messages,
*,
tokenize = False,
add_generation_prompt = True,
**kwargs,
):
self.call_count += 1
self.tools_seen.append(kwargs.get("tools"))
return "PROMPT"
class StubExecutor:
"""Stand-in for ``core.inference.tools.execute_tool``: records calls, returns a fixed result.
A fake tool name plus this stub means no real python / terminal / web / RAG side effect can run.
"""
def __init__(self, result: str):
self.result = result
self.calls: list[tuple[str, dict]] = []
def __call__(
self,
name,
arguments,
*,
cancel_event = None,
timeout = None,
session_id = None,
thread_id = None,
rag_scope = None,
disable_sandbox = False,
):
self.calls.append((name, arguments))
return self.result
def _collect(generator, max_events = 200):
events = []
for ev in generator:
events.append(ev)
if len(events) >= max_events:
break
return events
def _tool_names(tools):
return [(t.get("function") or {}).get("name") for t in (tools or [])]
def test_backend_seam_injects_tools_and_drives_full_tool_loop():
"""The shared backend seam forwards tools into the chat template, and the loop parses the call,
dispatches it once, feeds the result back, and re-enters generation for the final answer."""
tok = RecordingTokenizer()
executor = StubExecutor(TOOL_RESULT)
turns = iter([TOOL_CALL_TEXT, FINAL_ANSWER])
active_tools_seen: list = []
conversations_seen: list = []
def single_turn(conversation, *, active_tools = None):
# Mirror the real _single_turn: render via the shared helper, then yield cumulative snapshots.
active_tools_seen.append(active_tools)
conversations_seen.append([dict(m) for m in conversation])
apply_chat_template_for_generation(tok, conversation, tools = active_tools)
text = next(turns)
mid = len(text) // 2
acc = ""
for chunk in (text[:mid], text[mid:]):
acc += chunk
yield acc
events = _collect(
run_safetensors_tool_loop(
single_turn = single_turn,
messages = [{"role": "user", "content": "What is the weather in Paris?"}],
tools = [FAKE_TOOL],
execute_tool = executor,
max_tool_iterations = 3,
)
)
# 1. Helper forwarded the tool schema to the tokenizer (seam does not drop tools).
assert tok.tools_seen, "tokenizer.apply_chat_template was never called"
assert tok.tools_seen[0], "tool schema was dropped before reaching the tokenizer"
assert TOOL_NAME in _tool_names(tok.tools_seen[0])
# 2. Loop offered the tool to the first generation turn.
assert active_tools_seen and active_tools_seen[0] is not None
assert TOOL_NAME in _tool_names(active_tools_seen[0])
# 3 / 4 / 5. Exactly one tool_start, one dispatch with parsed args, one tool_end with the result.
tool_starts = [e for e in events if e["type"] == "tool_start"]
tool_ends = [e for e in events if e["type"] == "tool_end"]
assert len(tool_starts) == 1 and tool_starts[0]["tool_name"] == TOOL_NAME
assert executor.calls == [(TOOL_NAME, TOOL_ARGS)], executor.calls
assert len(tool_ends) == 1 and tool_ends[0]["result"] == TOOL_RESULT
# 6. Final answer streams after the tool result: loop appended it and re-entered generation.
contents = [e for e in events if e["type"] == "content"]
assert contents and FINAL_ANSWER in contents[-1]["text"]
last_tool_end_idx = max(i for i, e in enumerate(events) if e["type"] == "tool_end")
last_content_idx = max(i for i, e in enumerate(events) if e["type"] == "content")
assert last_content_idx > last_tool_end_idx, "final answer must stream after the tool result"
# 6b. Tool result fed back into the conversation before the final turn (6 alone misses this:
# the fake generation ignores the conversation).
assert len(conversations_seen) >= 2, "loop did not re-enter generation after the tool call"
final_turn_convo = conversations_seen[1]
assert any(
TOOL_RESULT in str(m.get("content", "")) for m in final_turn_convo
), "tool result was not fed back into the conversation before the final generation turn"
# 7. Guard: raw tool-call markup never leaked to the client as content.
for e in contents:
assert "<tool_call>" not in e["text"]
assert TOOL_NAME not in e["text"]