diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index be9a4a34f3..1e06e76bee 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1763,6 +1763,32 @@ async def _select_request_tools( return tools +def _enables_code_execution_tool(tools: list[dict]) -> bool: + """True when a resolved tool list includes a local code-execution tool + (python/terminal). ``confirm_code_execution`` only gates those, so the + streaming requirement is scoped to requests that actually expose one.""" + from core.inference.tools import CODE_EXECUTION_TOOL_NAMES + + return any( + (t.get("function") or {}).get("name") in CODE_EXECUTION_TOOL_NAMES for t in (tools or []) + ) + + +def _payload_may_enable_code_execution(payload) -> bool: + """True when a request could resolve a local code-execution tool before the + tool list is built (used by the pre-switch guard). Mirrors + ``_select_request_tools``: built-ins are off unless the tool loop is enabled, + and an explicit ``enabled_tools`` filter must then list python/terminal + (MCP/client tools are never code execution).""" + from core.inference.tools import CODE_EXECUTION_TOOL_NAMES + + if not _effective_enable_tools(payload): + return False + if payload.enabled_tools is not None: + return bool(set(payload.enabled_tools) & CODE_EXECUTION_TOOL_NAMES) + return True + + def _apply_rag_nudge(nudge: str, tools: list[dict], *, rag_scope) -> str: """Append the RAG grounding nudge to ``nudge`` when the knowledge-base tool is active (search_knowledge_base present and a retrieval scope is set). The @@ -5772,26 +5798,22 @@ async def openai_chat_completions( param = "confirm_tool_calls", ), ) - # Same pre-switch guard for confirm_code_execution: the code-execution - # confirm gate also needs streaming, so a non-stream request must not evict - # the resident model only to 400 after the swap. + # Same pre-switch guard for confirm_code_execution, but scoped to requests + # that could actually run a local code-execution tool: the gate only + # applies to python/terminal, so a non-code tool request (e.g. web_search) + # must not be rejected, and a code-execution one must not evict the + # resident model only to 400 after the swap. if ( payload.confirm_code_execution and not payload.bypass_permissions and not payload.stream - and ( - _effective_enable_tools(payload) - or (bool(payload.mcp_enabled) and _confirm_cli_policy is not False) - or bool(payload.enabled_tools) - or bool(payload.tools) - or bool(payload.openai_code_exec_container_id) - or bool(payload.anthropic_code_exec_container_id) - ) + and _payload_may_enable_code_execution(payload) ): raise HTTPException( status_code = 400, detail = openai_error_body( - "confirm_code_execution requires stream=true for local tool execution.", + "confirm_code_execution requires stream=true when a " + "code-execution tool (python/terminal) is enabled.", status = 400, code = "invalid_request_error", param = "confirm_code_execution", @@ -6273,11 +6295,13 @@ async def openai_chat_completions( payload.confirm_code_execution and not payload.bypass_permissions and not payload.stream + and _enables_code_execution_tool(tools_to_use) ): raise _reject( 400, openai_error_body( - "confirm_code_execution requires stream=true for local tool execution.", + "confirm_code_execution requires stream=true when a " + "code-execution tool (python/terminal) is enabled.", status = 400, code = "invalid_request_error", param = "confirm_code_execution", @@ -7009,11 +7033,17 @@ async def openai_chat_completions( param = "confirm_tool_calls", ), ) - if payload.confirm_code_execution and not payload.bypass_permissions and not payload.stream: + if ( + payload.confirm_code_execution + and not payload.bypass_permissions + and not payload.stream + and _enables_code_execution_tool(_sf_tools_to_use) + ): raise _reject( 400, openai_error_body( - "confirm_code_execution requires stream=true for local tool execution.", + "confirm_code_execution requires stream=true when a " + "code-execution tool (python/terminal) is enabled.", status = 400, code = "invalid_request_error", param = "confirm_code_execution", diff --git a/studio/backend/tests/test_confirm_code_execution.py b/studio/backend/tests/test_confirm_code_execution.py index e6d41e5a25..18b23be5a8 100644 --- a/studio/backend/tests/test_confirm_code_execution.py +++ b/studio/backend/tests/test_confirm_code_execution.py @@ -179,3 +179,54 @@ def test_confirm_tool_calls_still_gates_every_tool(): starts = _starts(events) assert starts[0]["awaiting_confirmation"] is True assert calls == [("web_search", {"query": "cats"})] + + +# ── scoping predicates used by the route streaming requirement ─────────────── + + +def _spec(name): + return {"type": "function", "function": {"name": name}} + + +def test_enables_code_execution_tool_predicate(): + from routes.inference import _enables_code_execution_tool + + assert _enables_code_execution_tool([_spec("python")]) is True + assert _enables_code_execution_tool([_spec("terminal")]) is True + assert _enables_code_execution_tool([_spec("web_search"), _spec("python")]) is True + # A non-code tool list must not trip the streaming requirement. + assert _enables_code_execution_tool([_spec("web_search"), _spec("render_html")]) is False + assert _enables_code_execution_tool([]) is False + assert _enables_code_execution_tool(None) is False + + +def test_payload_may_enable_code_execution_predicate(monkeypatch): + import routes.inference as inf + + monkeypatch.setattr("state.tool_policy.get_tool_policy", lambda: None) + + class _P: + def __init__(self, enable_tools = None, enabled_tools = None): + self.enable_tools = enable_tools + self.enabled_tools = enabled_tools + + # Built-ins off -> never code execution, even if enabled_tools lists python. + assert inf._payload_may_enable_code_execution(_P(enable_tools = None)) is False + assert ( + inf._payload_may_enable_code_execution(_P(enable_tools = False, enabled_tools = ["python"])) + is False + ) + # Built-ins on, explicit filter without a code tool -> not code execution (the fix). + assert ( + inf._payload_may_enable_code_execution( + _P(enable_tools = True, enabled_tools = ["web_search"]) + ) + is False + ) + # Built-ins on, code tool in the filter -> code execution. + assert ( + inf._payload_may_enable_code_execution(_P(enable_tools = True, enabled_tools = ["terminal"])) + is True + ) + # Built-ins on, no filter -> all built-ins including python/terminal. + assert inf._payload_may_enable_code_execution(_P(enable_tools = True)) is True