unsloth/studio/backend/tests/test_tool_confirm_loop.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

172 lines
5.5 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
"""Integration tests for the confirmation gate inside the real tool loop.
These drive ``run_safetensors_tool_loop`` (no model -- hand-crafted fake
generators) with ``confirm_tool_calls=True`` and resolve each pending
decision inline. The slot is registered before ``tool_start`` is yielded,
so resolving right after receiving that event always lands before the
loop blocks. Covers: allow executes once, deny skips execution and feeds
back the rejection, disabled/duplicate calls are not prompted, and a
denied call does not pollute duplicate detection.
"""
import pytest
from core.inference.safetensors_agentic import run_safetensors_tool_loop
from state import tool_approvals
from state.tool_approvals import TOOL_REJECTED_MESSAGE, resolve_tool_decision
_SESSION = "loop-session"
@pytest.fixture(autouse = True)
def _clear_pending():
with tool_approvals._lock:
tool_approvals._pending.clear()
yield
with tool_approvals._lock:
tool_approvals._pending.clear()
class _FakeExecuteTool:
def __init__(self):
self.calls = []
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 f"RESULT[{name}]"
def _tool_call(name, args_json):
return f'<tool_call>{{"name": "{name}", "arguments": {args_json}}}</tool_call>'
def _multi_turn(turns):
"""A single_turn generator that yields one full snapshot per turn."""
turn_iter = iter(turns)
def _gen(_messages):
try:
yield next(turn_iter)
except StopIteration:
return
return _gen
_DEFAULT_TOOLS = [
{"type": "function", "function": {"name": "python"}},
{"type": "function", "function": {"name": "web_search"}},
]
def _drive(
turns,
decisions,
*,
tools = None,
):
"""Run the loop, resolving each gated tool_start with the next decision.
The advertised ``tools`` list drives the loop's enabled-tool filter
(pass a list omitting a tool to make a call to it "disabled").
Returns (events, execute_calls).
"""
decision_iter = iter(decisions)
exec_fn = _FakeExecuteTool()
gen = run_safetensors_tool_loop(
single_turn = _multi_turn(turns),
messages = [{"role": "user", "content": "hi"}],
tools = _DEFAULT_TOOLS if tools is None else tools,
execute_tool = exec_fn,
session_id = _SESSION,
confirm_tool_calls = True,
)
events = []
for ev in gen:
events.append(ev)
if ev["type"] == "tool_start" and ev.get("awaiting_confirmation"):
# Slot is already registered (begin ran before this yield), so
# the decision lands before the loop enters its blocking wait.
resolve_tool_decision(ev["approval_id"], next(decision_iter), session_id = _SESSION)
return events, exec_fn.calls
def _tool_starts(events):
return [e for e in events if e["type"] == "tool_start"]
def _tool_ends(events):
return [e for e in events if e["type"] == "tool_end"]
def test_allow_executes_the_tool_once():
events, calls = _drive(
[_tool_call("python", '{"code": "print(1)"}'), "final answer"],
["allow"],
)
starts = _tool_starts(events)
assert len(starts) == 1
assert starts[0]["awaiting_confirmation"] is True
assert starts[0]["approval_id"]
assert calls == [("python", {"code": "print(1)"})]
assert _tool_ends(events)[0]["result"] == "RESULT[python]"
def test_deny_skips_execution_and_feeds_rejection():
events, calls = _drive(
[_tool_call("python", '{"code": "print(1)"}'), "final answer"],
["deny"],
)
assert calls == [] # tool never ran
assert _tool_ends(events)[0]["result"] == TOOL_REJECTED_MESSAGE
def test_disabled_tool_is_not_prompted():
events, calls = _drive(
[_tool_call("python", '{"code": "print(1)"}'), "final answer"],
[],
tools = [{"type": "function", "function": {"name": "web_search"}}],
)
assert _tool_starts(events) == []
assert _tool_ends(events) == []
assert calls == []
def test_duplicate_call_is_not_prompted():
same = _tool_call("python", '{"code": "print(1)"}')
events, calls = _drive([same, same, "final answer"], ["allow"])
starts = _tool_starts(events)
assert len(starts) == 1
assert starts[0]["awaiting_confirmation"] is True
assert calls == [("python", {"code": "print(1)"})]
assert len(_tool_ends(events)) == 1
def test_denied_call_can_be_reissued_and_approved():
# Deny, then the model re-issues the identical call -> approving it must
# execute, not get suppressed as a duplicate (denied calls are not added
# to the duplicate-detection history).
same = _tool_call("python", '{"code": "print(1)"}')
events, calls = _drive([same, same, "final answer"], ["deny", "allow"])
starts = _tool_starts(events)
assert len(starts) == 2
assert starts[0]["awaiting_confirmation"] is True
assert starts[1]["awaiting_confirmation"] is True # not treated as dup
assert calls == [("python", {"code": "print(1)"})] # ran once, on approve
ends = _tool_ends(events)
assert ends[0]["result"] == TOOL_REJECTED_MESSAGE
assert ends[1]["result"] == "RESULT[python]"