Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
282b8f9
Studio: Add inline confirmation (Allow/Always allow/Deny) for tool calls
oobabooga May 29, 2026
f6f3b48
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 29, 2026
a758b4a
Fix race in tool-call confirmation gate
oobabooga May 29, 2026
cfca72c
Studio: gate built-in tool calls and harden the confirmation handshake
danielhanchen May 31, 2026
f1451fc
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 31, 2026
ce3fddb
Merge branch 'main' into tool-call-confirmation
danielhanchen May 31, 2026
27331f1
Merge branch 'main' into tool-call-confirmation
oobabooga May 31, 2026
e5b2e9b
Move "Confirm tool calls" to the Tools section
oobabooga May 31, 2026
57f8456
Studio: add Bypass Permissions (skip confirmation, disable tool sandbox)
danielhanchen May 31, 2026
eef8983
Studio: harden Bypass Permissions secret handling and fix Anthropic t…
danielhanchen Jun 11, 2026
d906139
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 11, 2026
0deffdf
Studio: strip cred-location env vars (HF_HOME etc.) in Bypass Permiss…
danielhanchen Jun 14, 2026
46abcfa
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 14, 2026
4b36f90
Studio: lock in bypass HF token resolution with an end-to-end test
danielhanchen Jun 14, 2026
b58752f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 14, 2026
6d7cfd2
Studio: strip npm _auth, MYSQL_PWD, and BASH_ENV from bypass env
danielhanchen Jun 15, 2026
c1cb8e3
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 15, 2026
dec05d9
Studio: extend bypass env scrubber and enforce confirm precedence in …
danielhanchen Jun 15, 2026
3c8d719
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 15, 2026
d247e8d
Studio: add GGUF loop test for bypass-over-confirm precedence
danielhanchen Jun 15, 2026
23344c8
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 15, 2026
cb08c02
Studio: add red Bypass Permissions badge in the composer
danielhanchen Jun 15, 2026
92de6c0
Studio: show Bypass Permissions badge in the Thread composer too
danielhanchen Jun 15, 2026
6fa2283
Merge branch 'main' into bypass-permissions
danielhanchen Jun 15, 2026
dd81d97
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 15, 2026
71efbc9
Studio: keep the Bypass Permissions badge visible when the composer i…
danielhanchen Jun 15, 2026
b6bc604
Studio: make the Bypass Permissions confirm button a solid red button
danielhanchen Jun 15, 2026
bf0f0c8
Studio: add Bypass Permissions to the composer + More menu
danielhanchen Jun 15, 2026
78db58a
Studio: harden bypass env scrubber for IMDS opt-out and connection st…
danielhanchen Jun 15, 2026
0033c84
Studio: let Bypass Permissions suppress the confirm-tool-calls guards
danielhanchen Jun 15, 2026
8a2b050
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 70 additions & 26 deletions studio/backend/core/inference/llama_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4577,6 +4577,8 @@ def generate_chat_completion_with_tools(
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
confirm_tool_calls: bool = False,
bypass_permissions: bool = False,
) -> Generator[dict, None, None]:
"""
Agentic loop: let the model call tools, execute them, and continue.
Expand All @@ -4587,6 +4589,12 @@ def generate_chat_completion_with_tools(
{"type": "reasoning", "text": "token"} -- streamed reasoning tokens (cumulative)
"""
from core.inference.tools import execute_tool
from state.tool_approvals import (
TOOL_REJECTED_MESSAGE,
begin_tool_decision,
new_approval_id,
wait_tool_decision,
)

if not self.is_loaded:
raise RuntimeError("llama-server is not loaded")
Expand Down Expand Up @@ -5197,20 +5205,50 @@ def _strip_tool_markup(text: str, *, final: bool = False) -> str:
status_text = f"Calling: {tool_name}"
yield {"type": "status", "text": status_text}

# ── Duplicate call detection ──────────────
# str(dict) is stable here: arguments always comes from
# json.loads on the same model output within one request,
# so insertion order is deterministic (Python 3.7+).
_tc_key = tool_name + str(arguments)
_prev = _tool_call_history[-1] if _tool_call_history else None
_is_duplicate = bool(_prev) and _prev[0] == _tc_key and not _prev[1]
# Guard against the model emitting a tool not in the
# per-request advertised set: filtered MCP names, a
# built-in the caller opted out of, or a stale name
# from a prior turn. Mirrors the safetensors loop's
# allowed_tool_names check.
_allowed = {
(t.get("function") or {}).get("name")
for t in (tools or [])
if (t.get("function") or {}).get("name")
}
_is_disabled = bool(_allowed) and tool_name not in _allowed
# Only gate calls that would actually run: duplicate or
# disabled calls are short-circuited below and never
# execute, so prompting for them would be noise.
# Registering the slot before tool_start closes the race
# where the confirmation could arrive before the waiter.
_needs_confirm = (
confirm_tool_calls and not _is_duplicate and not _is_disabled
)
_approval_id = new_approval_id() if _needs_confirm else ""
_decision_slot = (
begin_tool_decision(session_id, _approval_id)
if _needs_confirm
else None
)

yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
"approval_id": _approval_id,
"awaiting_confirmation": _needs_confirm,
}

# ── Duplicate call detection ──────────────
# str(dict) is stable here: arguments always comes from
# json.loads on the same model output within one request,
# so insertion order is deterministic (Python 3.7+).
_tc_key = tool_name + str(arguments)
_prev = _tool_call_history[-1] if _tool_call_history else None
if _prev and _prev[0] == _tc_key and not _prev[1]:
_denied = False
if _is_duplicate:
result = (
"You already made this exact call. "
"Do not repeat the same tool call. "
Expand All @@ -5219,33 +5257,35 @@ def _strip_tool_markup(text: str, *, final: bool = False) -> str:
"process data you already have, or "
"provide your final answer now."
)
else:
_effective_timeout = (
None if tool_call_timeout >= 9999 else tool_call_timeout
elif _is_disabled:
result = (
f"Error: tool '{tool_name}' is not enabled "
"for this request. Use one of the enabled "
"tools or provide a final answer."
)
# Guard against the model emitting a tool not in the
# per-request advertised set: filtered MCP names, a
# built-in the caller opted out of, or a stale name
# from a prior turn. Mirrors the safetensors loop's
# allowed_tool_names check.
_allowed = {
(t.get("function") or {}).get("name")
for t in (tools or [])
if (t.get("function") or {}).get("name")
}
if _allowed and tool_name not in _allowed:
result = (
f"Error: tool '{tool_name}' is not enabled "
"for this request. Use one of the enabled "
"tools or provide a final answer."
else:
_denied = (
_decision_slot is not None
and wait_tool_decision(
_decision_slot,
_approval_id,
cancel_event = cancel_event,
)
== "deny"
)
if _denied:
result = TOOL_REJECTED_MESSAGE
else:
_effective_timeout = (
None if tool_call_timeout >= 9999 else tool_call_timeout
)
result = execute_tool(
tool_name,
arguments,
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
disable_sandbox = bypass_permissions,
)

yield {
Expand All @@ -5269,7 +5309,11 @@ def _strip_tool_markup(text: str, *, final: bool = False) -> str:
_is_error = isinstance(result, str) and result.lstrip().startswith(
_error_prefixes
)
_tool_call_history.append((_tc_key, _is_error))
# A user-denied call never executed, so it must not count
# toward duplicate detection — otherwise re-issuing and
# approving the same call would be rejected as a duplicate.
if not _denied:
_tool_call_history.append((_tc_key, _is_error))
# Strip image sentinel before feeding result to the LLM
# (the full result with sentinel is still yielded via
# tool_end so the frontend can extract image paths).
Expand Down
4 changes: 4 additions & 0 deletions studio/backend/core/inference/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,8 @@ def generate_chat_completion_with_tools(
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
use_adapter: Optional[Union[bool, str]] = None,
confirm_tool_calls: bool = False,
bypass_permissions: bool = False,
**_unused,
):
"""Run the safetensors agentic tool loop in this (parent)
Expand Down Expand Up @@ -895,6 +897,8 @@ def _single_turn(conv: list):
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,
confirm_tool_calls = confirm_tool_calls,
bypass_permissions = bypass_permissions,
)

def generate_with_adapter_control(
Expand Down
56 changes: 49 additions & 7 deletions studio/backend/core/inference/safetensors_agentic.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@

from loggers import get_logger

from state.tool_approvals import (
TOOL_REJECTED_MESSAGE,
begin_tool_decision,
new_approval_id,
wait_tool_decision,
)

from core.inference.tool_call_parser import (
BUDGET_EXHAUSTED_NUDGE,
DUPLICATE_CALL_NUDGE,
Expand Down Expand Up @@ -105,6 +112,8 @@ def run_safetensors_tool_loop(
max_tool_iterations: int = 25,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
confirm_tool_calls: bool = False,
bypass_permissions: bool = False,
) -> Generator[dict, None, None]:
"""Drive an agentic tool loop on top of a cumulative-text generator.

Expand Down Expand Up @@ -308,27 +317,55 @@ def run_safetensors_tool_loop(
tool_name = tool_name,
)

tc_key = tool_name + str(arguments)
is_disabled = (
bool(allowed_tool_names) and tool_name not in allowed_tool_names
)
already_ran_ok = any(
k == tc_key and not err for k, err in tool_call_history
)
# Only gate calls that would actually run: a disabled or
# duplicate call is short-circuited below and never executes, so
# asking the user to approve it would be noise. Registering the
# approval slot *before* tool_start closes the race where the
# confirmation could arrive before the waiter exists.
needs_confirm = (
confirm_tool_calls and not is_disabled and not already_ran_ok
)
approval_id = new_approval_id() if needs_confirm else ""
decision_slot = (
begin_tool_decision(session_id, approval_id) if needs_confirm else None
)

yield {"type": "status", "text": _status_for_tool(tool_name, arguments)}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
"approval_id": approval_id,
"awaiting_confirmation": needs_confirm,
}

tc_key = tool_name + str(arguments)
if allowed_tool_names and tool_name not in allowed_tool_names:
denied = False
if is_disabled:
result = (
f"Error: tool '{tool_name}' is not enabled for this "
"request. Use one of the enabled tools or provide a "
"final answer."
)
elif already_ran_ok:
result = DUPLICATE_CALL_NUDGE
else:
already_ran_ok = any(
k == tc_key and not err for k, err in tool_call_history
denied = (
decision_slot is not None
and wait_tool_decision(
decision_slot, approval_id, cancel_event = cancel_event
)
== "deny"
)
if already_ran_ok:
result = DUPLICATE_CALL_NUDGE
if denied:
result = TOOL_REJECTED_MESSAGE
else:
eff_timeout = (
None if tool_call_timeout >= 9999 else tool_call_timeout
Expand All @@ -340,6 +377,7 @@ def run_safetensors_tool_loop(
cancel_event = cancel_event,
timeout = eff_timeout,
session_id = session_id,
disable_sandbox = bypass_permissions,
)
except Exception as exc:
logger.exception("Tool %s raised: %s", tool_name, exc)
Expand All @@ -355,7 +393,11 @@ def run_safetensors_tool_loop(
is_error = isinstance(result, str) and result.lstrip().startswith(
TOOL_ERROR_PREFIXES
)
tool_call_history.append((tc_key, is_error))
# A user-denied call never executed, so it must not count toward
# duplicate detection — otherwise re-issuing and approving the
# same call would be wrongly rejected as a duplicate.
if not denied:
tool_call_history.append((tc_key, is_error))

# Strip frontend image sentinel from the model's view.
# Cut at the first occurrence so leading and consecutive
Expand Down
Loading
Loading