Skip to content

Studio: Add inline confirmation (Allow/Always allow/Deny) for tool calls#5869

Merged
wasimysaid merged 28 commits into
unslothai:mainfrom
oobabooga:tool-call-confirmation
Jun 12, 2026
Merged

Studio: Add inline confirmation (Allow/Always allow/Deny) for tool calls#5869
wasimysaid merged 28 commits into
unslothai:mainfrom
oobabooga:tool-call-confirmation

Conversation

@oobabooga

Copy link
Copy Markdown
Member

When tools are enabled the model runs them automatically. This PR adds an opt-in confirmation step so the user approves each call first.

A "Confirm tool calls" toggle (in the MCP settings section, below "Use MCP Servers") gates every tool call: web search, python, terminal, and MCP tools. When on, the tool card shows inline Allow / Always allow / Deny buttons. Allow runs the call once. Always allow auto-approves that tool for the rest of the session. Deny feeds a rejection back to the model so it keeps responding.

How to use

  1. Load a tool-capable model and enable a tool (Search, code, or MCP).
  2. Chat settings -> MCP Servers -> turn on "Confirm tool calls":
confirm
  1. Ask something that triggers a tool. The call pauses with Allow / Always allow / Deny. Pick one to continue:
print

Implementation

The tool loop already yields a tool_start event before executing each call, so the gate slots in there. It blocks on a session-keyed threading.Event until the decision arrives over a separate POST /api/inference/tool-confirm, then resumes the same SSE stream. No bidirectional streaming or architecture change. The loop is sequential, so the gate keys on session id alone.

New confirm_tool_calls request field and one endpoint on the backend. The toggle and buttons reuse the existing toggle pattern and tool card on the frontend. Both the GGUF and safetensors loops get the gate.

@oobabooga oobabooga requested a review from rolandtannous as a code owner May 29, 2026 19:10
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a "Confirm tool calls" feature, allowing users to manually approve or deny tool executions during agentic loops. It adds a backend confirmation gate using threading events, a new /tool-confirm API endpoint, and frontend UI controls for user interaction. Feedback on the changes highlights a critical concurrency race condition and potential resource leak in the backend's tool approval state management (request_tool_decision), where concurrent requests for the same session could overwrite pending states and cause threads to hang.

Comment thread studio/backend/state/tool_approvals.py Outdated
oobabooga and others added 2 commits May 29, 2026 16:20
The Allow / Always allow / Deny controls only lived in the fallback tool
card, but the built-in tools (web search, python, terminal, code
execution, image generation) render with their own components and so
never showed the buttons. Those calls paused after tool_start with no way
to approve them, hanging until the 1 hour timeout. Only MCP tools, which
use the fallback renderer, actually worked.

Render the controls for every tool card by wrapping each registered tool
component (and the fallback) in thread.tsx with a shared
ToolConfirmationControls, so the gate applies uniformly.

Also make the handshake robust:
- The gate keys on a per-call approval_id minted by the backend and
  echoed in tool_start, instead of session_id alone, so a stale or
  concurrent confirmation can no longer resolve the wrong call.
- The approval slot is registered before tool_start is yielded, closing
  the race where a fast click or an auto "Always allow" could reach the
  backend before the waiter existed.
- The frontend resolves with the same session id the request was sent
  with (plus the approval_id), fixing the new-thread mismatch where the
  confirmation targeted a different session than the blocked stream.
- The confirm endpoint returns {resolved}; the UI keeps the buttons and
  shows a retry hint until the backend confirms a match, instead of
  hiding them on a failed or mistargeted post.
- The gate runs after the disabled-tool and duplicate-call checks, so a
  call that will not execute is not put up for approval. A denied call is
  still excluded from duplicate detection, so re-issuing and approving it
  works.
- "Always allow" is scoped per session to match the backend gate.

Add backend tests for the approval registry, the SSE no-deadlock
handshake, and the loop integration (allow, deny, disabled, duplicate,
re-issue after deny).
@danielhanchen danielhanchen self-requested a review as a code owner May 31, 2026 05:32
@danielhanchen

Copy link
Copy Markdown
Member

Thanks for this, the opt-in confirmation gate is a great addition. I reviewed it end to end and pushed a commit on top (cfca72ce) that fixes a feature-breaking gap and hardens the handshake. Summary below so the changes are clear.

Main issue: built-in tools never showed the controls

The Allow / Always allow / Deny buttons only lived in ToolFallback, but the built-in tools render with their own components, registered by name in thread.tsx:

web_search -> WebSearchToolUI
python -> PythonToolUI
terminal -> TerminalToolUI
code_execution -> CodeExecutionToolUI
image_generation -> ImageGenerationToolUI
Fallback -> ToolFallback

Those custom components only import the ToolFallback* layout pieces, not the confirmation buttons, so for exactly the tools the PR advertises (search, python, terminal, code) the buttons never appeared. The backend still paused after tool_start, so the request hung until the 1 hour timeout. Only MCP tools, which fall through to ToolFallback, actually worked.

Fix: a shared ToolConfirmationControls is now rendered under every tool card by wrapping each registered component (and the fallback) in thread.tsx, so the gate applies uniformly.

Handshake hardening

While fixing the above I also addressed a few correctness gaps in the gate:

  • Per-call id instead of session-only key. The gate now keys on an approval_id minted by the backend and echoed in tool_start, rather than session_id alone. A stale or concurrent confirmation can no longer resolve a different pending call.
  • Register before announcing. The approval slot is created before tool_start is yielded, closing the race where a fast click or an auto "Always allow" could reach /tool-confirm before the waiter existed (it would have returned resolved: false and then hung).
  • Correct session target. The frontend resolved with the global activeThreadId at click time, which can differ from the session_id the request was sent under (new threads, compare panes, switching chats). It now resolves with the same resolvedThreadId the request used, plus the approval_id.
  • Fail visible, not silent. /tool-confirm returns {resolved}; the UI keeps the buttons (with a short retry hint) until the backend confirms a match, instead of hiding them on a failed or mistargeted post.
  • Gate only what runs. The wait now happens after the disabled-tool and duplicate-call checks, so a call that would be short-circuited anyway is not put up for approval. A denied call is still kept out of duplicate detection, so re-issuing and approving the same call works.
  • Session-scoped "Always allow." It was a global Set, so allowing a tool in one chat auto-approved it in unrelated chats. It is now keyed per session to match the backend.

What I did not change (worth a follow-up)

External-provider hosted tools and the Anthropic /v1/messages path do not have a pre-execution approval channel (the provider runs the tool before Studio can pause it), so I did not wire confirm_tool_calls into those paths. The awaiting_confirmation marker means their tool cards correctly show no approval buttons. Real confirmation there would need a provider-side intercept and is out of scope here.

Validation

  • New backend tests: test_tool_approvals.py (approval registry concurrency, incl. the resolve-before-wait race), test_tool_confirm_stream.py (real uvicorn server, proves tool_start reaches the client and /tool-confirm is served while the stream thread is blocked, no deadlock), test_tool_confirm_loop.py (drives the real safetensors loop: allow executes once, deny feeds back the rejection, disabled/duplicate are not prompted, deny then re-issue then approve runs).
  • test_safetensors_tool_loop.py (43) and the openai tool-result/model-validation tests still pass.
  • Frontend tsc -b typecheck and vite build both pass.

One note for whoever merges: an automated check flagged a large "revert" against main, but that is just the branch being a few commits behind. The diff touches none of those files, so a normal merge will not revert anything. Rebasing or merging main will clear the warning.

@oobabooga

Copy link
Copy Markdown
Member Author

I have reviewed the additional changes and can confirm they harden the implementation and look correct to me.

I also tested the updated code manually and it worked fine. Good to merge from my side.

@oobabooga

Copy link
Copy Markdown
Member Author

Since the confirmation buttons apply to all tools, I propose moving them out of the MCP section and into the Tools section. My latest commit in the PR does that:

image

@Datta0

Datta0 commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator
image

there seems to be a small issue where if I disable confirm tool calls while a chat session is running, it doesn't disable it completely, but it doesn't show the pop-up for allowance as well. I had to manually expand the end tool calls dropdown where it showed me the tool call and the pop-up.

Screen.Recording.2026-06-01.at.3.26.39.PM.mov

@oobabooga

Copy link
Copy Markdown
Member Author

This should be resolved now after the latest commit @Datta0, it was a bug specific to turns with multiple tool calls, which render in a collapsed group by default. Test:

image

About the mid-streaming toggle change: the toggle value is passed once on send, so changing it during streaming has no effect, similar to changing a slider like temperature mid-generation. I'd keep this as-is rather than add a warning to the tooltip for an edge case.

@Datta0

Datta0 commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator
image yeah its better now . Ignore the "switching while session is going on" thing. its just how I did it, need not be the only way to rep

But I think we can benefit from a warning popup saying that disabling mid session ain't gonna do much

oobabooga and others added 2 commits June 9, 2026 01:03
# Conflicts:
#	studio/backend/core/inference/orchestrator.py
#	studio/backend/core/inference/safetensors_agentic.py
#	studio/backend/routes/inference.py
#	studio/frontend/src/components/assistant-ui/tool-group.tsx
@wasimysaid wasimysaid self-assigned this Jun 11, 2026
@wasimysaid

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 67fb3b9e21

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/routes/inference.py
Comment thread studio/frontend/src/features/chat/api/chat-adapter.ts
Comment thread studio/backend/routes/inference.py Outdated
Comment thread studio/frontend/src/features/chat/chat-settings-sheet.tsx Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c53302ebf3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/core/inference/llama_cpp.py
Comment thread studio/backend/models/inference.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08810f4f77

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/state/tool_approvals.py
Comment thread studio/backend/routes/inference.py
wasimysaid and others added 5 commits June 11, 2026 17:16
resolve_tool_decision accepted a second confirmation for the same approval_id
and overwrote slot["decision"] in the window before the waiter reads it and
pops the slot, so a duplicate or out-of-order POST could flip an Allow to Deny
(and returned a misleading resolved:true). Reject once the slot's event is
already set so the first decision wins. Adds a regression test.
@danielhanchen

Copy link
Copy Markdown
Member

One small follow-up on the confirmation gate: the recorded decision was not immutable. resolve_tool_decision accepted a second confirmation for the same approval_id and overwrote slot["decision"] in the window before the waiter reads it and pops the slot, so a duplicate or out-of-order /tool-confirm POST could flip an Allow to Deny (and returned a misleading resolved: true). Pushed a commit that rejects a confirmation once the slot's event is already set, so the first decision wins, with a regression test.

The other review points I checked are already handled on the current branch (the confirm_tool_calls rejection on the provider-routed / non-streaming / Anthropic paths, the pending-approval cleanup when the stream is closed at the prompt, and the settings copy), so this is the only change.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ba6a670e92

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1126 to +1131
setToolConfirmation: (toolCallId, approvalId, sessionId, autoAllowKey) =>
set((state) => ({
toolConfirmations: {
...state.toolConfirmations,
[toolCallId]: { approvalId, sessionId, autoAllowKey },
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope pending confirmations by thread

When two local streams are active at once, this global map can overwrite one pending confirmation with another that uses the same toolCallId; the safetensors loop starts every run at call_0, and GGUF/model-emitted ids are not scoped either. In compare panes or a background chat plus the active chat, two gated call_0 tool starts will leave only the later approvalId here, so clicking Allow/Deny on the first card can resolve the wrong backend approval or fail while the first generation remains blocked. Key this by the same per-run/thread scope you already pass as autoAllowKey (or by approvalId) rather than by toolCallId alone.

Useful? React with 👍 / 👎.

rag_scope: Optional[dict] = None,
seed: Optional[int] = None,
disable_parallel_tool_use: bool = False,
confirm_tool_calls: bool = False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate RAG autoinject before it searches

When confirm_tool_calls is true and Docs/RAG autoinject is enabled (the default auto path for small or unknown-size models), the loop still calls build_rag_autoinject before any of the new confirmation logic below; that helper performs search_for_autoinject immediately and only then emits a search_knowledge_base tool_start/tool_end without an approval_id. In that RAG-autoinject scenario, a local Studio tool runs despite the user enabling confirmation, so the autoinject path needs to be skipped, rejected, or routed through the same approval gate before retrieval.

Useful? React with 👍 / 👎.

@wasimysaid

Copy link
Copy Markdown
Collaborator

Pushed 99887cbea with the remaining confirmation fixes.

Summary:

  • Skip forced RAG autoinject when confirm_tool_calls is enabled, so retrieval only happens through the normal gated search_knowledge_base tool path.
  • Scope pending frontend confirmations by approval id and thread scope while preserving backend tool-end matching, so concurrent runs with repeated tool ids do not overwrite each other.
  • Added GGUF and safetensors regression coverage for the RAG autoinject bypass.

Validation:

  • python -m pytest studio/backend/tests/test_tool_approvals.py studio/backend/tests/test_tool_confirm_loop.py studio/backend/tests/test_safetensors_tool_loop.py::TestGuardrails::test_confirm_tool_calls_close_after_prompt_cleans_slot studio/backend/tests/test_safetensors_tool_loop.py::TestGuardrails::test_confirm_tool_calls_skips_rag_autoinject studio/backend/tests/test_llama_cpp_tool_loop.py::test_confirm_tool_calls_close_after_prompt_cleans_gguf_slot studio/backend/tests/test_llama_cpp_tool_loop.py::test_confirm_tool_calls_skips_gguf_rag_autoinject studio/backend/tests/test_openai_tool_passthrough.py::TestChatCompletionRequestToolFields::test_confirm_tool_calls_requires_streaming_for_safetensors_tools studio/backend/tests/test_openai_tool_passthrough.py::TestGgufVisionToolRouting::test_confirm_tool_calls_requires_streaming_for_gguf_tools studio/backend/tests/test_anthropic_messages.py::TestAnthropicMessagesToolRouting::test_confirm_tool_calls_rejected_for_server_tools
  • npm run typecheck in studio/frontend
  • python -m py_compile for the changed backend files and tests
  • git diff --check

@wasimysaid wasimysaid merged commit 7f2986a into unslothai:main Jun 12, 2026
2 of 30 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4cb057670

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 2568 to +2571
const id =
(toolEvent.tool_call_id as string) ||
`${toolEvent.tool_name}_${Date.now()}`;
awaitingConfirmation && approvalId
? `${toolConfirmationScopeId}:${approvalId}`
: backendToolCallId ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse provisional render_html card for confirmation

When render_html is detected early, the backend can already emit a provisional tool_start with the backend tool_call_id and no approval fields; in that scenario this branch assigns the real confirmed start a new ${scope}:${approvalId} id instead of the existing backend id. The result is two UI cards for one render_html call: the provisional card stays running forever while the second card gets the confirmation and completion. Either update the existing provisional card by backend id or suppress provisional starts while confirmations are enabled.

Useful? React with 👍 / 👎.

danielhanchen added a commit that referenced this pull request Jun 15, 2026
Bring the Bypass Permissions feature up to date with main (262 commits).
main landed the tool-call-confirmation base (#5869) and evolved it, so the
conflicts were resolved by taking main's version of every touched file and
re-applying the bypass deltas on top:

- tools.py: bypass env scrubber (_build_bypass_env, secret/cred-location
  classifiers), _bypass_preexec, /proc fail-closed hardening, and the
  disable_sandbox path in execute_tool/_python_exec/_bash_exec.
- safetensors_agentic.py / llama_cpp.py: thread bypass_permissions and gate
  needs_confirm with 'and not bypass_permissions' (main simplified the gate to
  bool(confirm_tool_calls)).
- routes/inference.py, models/inference.py, orchestrator.py: precedence
  (confirm and not bypass) plus the request fields and loop wiring.
- frontend: bypassPermissions store state, chat-adapter payload, the settings
  toggle + warning dialog, and the red composer badge in both composers.
- tests: fakes accept rag_scope (main added it); GGUF AST test matches main's
  needs_confirm name.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants