* 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>
172 lines
5.5 KiB
Python
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]"
|