From 239a9db4a27a3cd7fc6ce0d8fc392a00e492cfe7 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 24 Jul 2026 00:26:25 -0500 Subject: [PATCH 1/2] fix(approvals): consult stored status before needs_approval on execute Resume and sticky decisions were re-evaluating dynamic needs_approval checkers before reading resolved approval status, so a checker that raises or changes result could abort an already-approved tool call. --- src/agents/realtime/session.py | 14 +- src/agents/run_internal/tool_actions.py | 215 ++++++++++++++-------- src/agents/run_internal/tool_execution.py | 160 ++++++++-------- tests/mcp/test_mcp_approval.py | 5 +- tests/test_hitl_error_scenarios.py | 48 +++++ 5 files changed, 280 insertions(+), 162 deletions(-) diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 66dc72be5b..5f7ae50e3f 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -607,12 +607,8 @@ async def _maybe_request_tool_approval( tool_lookup_key=tool_lookup_key, ) - needs_approval = await self._function_needs_approval(function_tool, tool_call) - if self._closing or self._closed: - return None - if not needs_approval: - return True - + # Resolved approve/reject decisions are authoritative; do not re-await + # needs_approval for a call whose status is already stored. approval_status = self._context_wrapper.get_approval_status( function_tool.name, tool_call.call_id, @@ -624,6 +620,12 @@ async def _maybe_request_tool_approval( if approval_status is False: return False + needs_approval = await self._function_needs_approval(function_tool, tool_call) + if self._closing or self._closed: + return None + if not needs_approval: + return True + if self._pre_approval_tool_input_guardrails_enabled(): rejected_message = await self._run_tool_input_guardrails( tool=function_tool, diff --git a/src/agents/run_internal/tool_actions.py b/src/agents/run_internal/tool_actions.py index 0421c15c43..d6a2f3c1ed 100644 --- a/src/agents/run_internal/tool_actions.py +++ b/src/agents/run_internal/tool_actions.py @@ -454,37 +454,59 @@ async def _run_call(span: Any | None) -> RunItem: dataclasses.asdict(shell_call.action) ) - needs_approval_result = await evaluate_needs_approval_setting( - shell_tool.needs_approval, context_wrapper, shell_call.action, shell_call.call_id + # Prefer stored approve/reject status over re-evaluating needs_approval. + approval_status = context_wrapper.get_approval_status( + shell_tool.name, shell_call.call_id ) - - if needs_approval_result: - approval_status, approval_item = await resolve_approval_status( + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=config, + tool_type="shell", tool_name=shell_tool.name, call_id=shell_call.call_id, - raw_item=call.tool_call, - agent=agent, - context_wrapper=context_wrapper, - on_approval=shell_tool.on_approval, + ) + return shell_rejection_item( + agent, + shell_call.call_id, + tool_call=call.tool_call, + rejection_message=rejection_message, ) - if approval_status is False: - rejection_message = await resolve_approval_rejection_message( - context_wrapper=context_wrapper, - run_config=config, - tool_type="shell", + if approval_status is None: + needs_approval_result = await evaluate_needs_approval_setting( + shell_tool.needs_approval, + context_wrapper, + shell_call.action, + shell_call.call_id, + ) + if needs_approval_result: + approval_status, approval_item = await resolve_approval_status( tool_name=shell_tool.name, call_id=shell_call.call_id, - ) - return shell_rejection_item( - agent, - shell_call.call_id, - tool_call=call.tool_call, - rejection_message=rejection_message, + raw_item=call.tool_call, + agent=agent, + context_wrapper=context_wrapper, + on_approval=shell_tool.on_approval, ) - if approval_status is not True: - return approval_item + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=config, + tool_type="shell", + tool_name=shell_tool.name, + call_id=shell_call.call_id, + ) + return shell_rejection_item( + agent, + shell_call.call_id, + tool_call=call.tool_call, + rejection_message=rejection_message, + ) + + if approval_status is not True: + return approval_item await asyncio.gather( hooks.on_tool_start(context_wrapper, agent, shell_tool), @@ -649,41 +671,65 @@ async def _run_call(span: Any | None) -> RunItem: if span and config.trace_include_sensitive_data: span.span_data.input = tool_input - needs_approval_result = await evaluate_needs_approval_setting( - custom_tool.runtime_needs_approval(), context_wrapper, tool_input, call_id - ) - - if needs_approval_result: - approval_status, approval_item = await resolve_approval_status( + # Prefer stored approve/reject status over re-evaluating needs_approval. + approval_status = context_wrapper.get_approval_status(custom_tool.name, call_id) + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=config, + tool_type="custom", tool_name=custom_tool.name, call_id=call_id, - raw_item=call.tool_call, - agent=agent, - context_wrapper=context_wrapper, - on_approval=custom_tool.runtime_on_approval(), + ) + return cls._tool_output_item( + agent, + call_id, + rejection_message, + raw_item=cls._raw_tool_output_item( + call_id, + rejection_message, + tool_call=call.tool_call, + ), ) - if approval_status is False: - rejection_message = await resolve_approval_rejection_message( - context_wrapper=context_wrapper, - run_config=config, - tool_type="custom", + if approval_status is None: + needs_approval_result = await evaluate_needs_approval_setting( + custom_tool.runtime_needs_approval(), + context_wrapper, + tool_input, + call_id, + ) + if needs_approval_result: + approval_status, approval_item = await resolve_approval_status( tool_name=custom_tool.name, call_id=call_id, + raw_item=call.tool_call, + agent=agent, + context_wrapper=context_wrapper, + on_approval=custom_tool.runtime_on_approval(), ) - return cls._tool_output_item( - agent, - call_id, - rejection_message, - raw_item=cls._raw_tool_output_item( + + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=config, + tool_type="custom", + tool_name=custom_tool.name, + call_id=call_id, + ) + return cls._tool_output_item( + agent, call_id, rejection_message, - tool_call=call.tool_call, - ), - ) + raw_item=cls._raw_tool_output_item( + call_id, + rejection_message, + tool_call=call.tool_call, + ), + ) - if approval_status is not True: - return approval_item + if approval_status is not True: + return approval_item await asyncio.gather( hooks.on_tool_start(tool_context, agent, custom_tool), @@ -830,42 +876,61 @@ async def _run_call(span: Any | None) -> RunItem: ] ) - needs_approval_result = False - for operation in operations: - if await evaluate_needs_approval_setting( - apply_patch_tool.needs_approval, context_wrapper, operation, call_id - ): - needs_approval_result = True - break - - if needs_approval_result: - approval_status, approval_item = await resolve_approval_status( + # Prefer stored approve/reject status over re-evaluating needs_approval. + approval_status = context_wrapper.get_approval_status(apply_patch_tool.name, call_id) + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=config, + tool_type="apply_patch", tool_name=apply_patch_tool.name, call_id=call_id, - raw_item=call.tool_call, - agent=agent, - context_wrapper=context_wrapper, - on_approval=apply_patch_tool.on_approval, + ) + return apply_patch_rejection_item( + agent, + call_id, + tool_call=call.tool_call, + output_type="apply_patch_call_output", + rejection_message=rejection_message, ) - if approval_status is False: - rejection_message = await resolve_approval_rejection_message( - context_wrapper=context_wrapper, - run_config=config, - tool_type="apply_patch", + if approval_status is None: + needs_approval_result = False + for operation in operations: + if await evaluate_needs_approval_setting( + apply_patch_tool.needs_approval, context_wrapper, operation, call_id + ): + needs_approval_result = True + break + + if needs_approval_result: + approval_status, approval_item = await resolve_approval_status( tool_name=apply_patch_tool.name, call_id=call_id, + raw_item=call.tool_call, + agent=agent, + context_wrapper=context_wrapper, + on_approval=apply_patch_tool.on_approval, ) - return apply_patch_rejection_item( - agent, - call_id, - tool_call=call.tool_call, - output_type="apply_patch_call_output", - rejection_message=rejection_message, - ) - if approval_status is not True: - return approval_item + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=context_wrapper, + run_config=config, + tool_type="apply_patch", + tool_name=apply_patch_tool.name, + call_id=call_id, + ) + return apply_patch_rejection_item( + agent, + call_id, + tool_call=call.tool_call, + output_type="apply_patch_call_output", + rejection_message=rejection_message, + ) + + if approval_status is not True: + return approval_item await asyncio.gather( hooks.on_tool_start(context_wrapper, agent, apply_patch_tool), diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py index 3eea93d8d6..4eff762cd5 100644 --- a/src/agents/run_internal/tool_execution.py +++ b/src/agents/run_internal/tool_execution.py @@ -1683,14 +1683,9 @@ async def _maybe_execute_tool_approval( raw_tool_call: ResponseFunctionToolCall, span_fn: Span[Any], ) -> Any | None: - needs_approval_result = await function_needs_approval( - func_tool, - self.context_wrapper, - tool_call, - ) - if not needs_approval_result: - return None - + # Resolved approve/reject decisions are authoritative. Check stored status + # before awaiting needs_approval so sticky/resume decisions cannot be undone + # by a checker that raises or returns a different result. tool_namespace = get_tool_call_namespace(raw_tool_call) if tool_namespace is None and is_deferred_top_level_function_tool(func_tool): tool_namespace = func_tool.name @@ -1703,88 +1698,95 @@ async def _maybe_execute_tool_approval( tool_namespace=tool_namespace, tool_lookup_key=tool_lookup_key, ) - if approval_status is None: - if self._should_run_pre_approval_tool_input_guardrails(): - tool_context_namespace = get_tool_call_namespace(raw_tool_call) - if tool_context_namespace is None: - tool_context_namespace = get_tool_call_namespace(tool_call) - tool_context = ToolContext.from_agent_context( - self.context_wrapper, - tool_call.call_id, - tool_call=raw_tool_call, - tool_namespace=tool_context_namespace, - agent=self.public_agent, - run_config=self.config, - ) - rejected_message = await _execute_tool_input_guardrails( - func_tool=func_tool, - tool_context=tool_context, - agent=self.public_agent, - tool_input_guardrail_results=self.tool_input_guardrail_results, - ) - if rejected_message is not None: - return FunctionToolResult( - tool=func_tool, - output=rejected_message, - run_item=function_rejection_item( - self.public_agent, - tool_call, - rejection_message=rejected_message, - output_json_schema=func_tool.output_json_schema, - scope_id=self.tool_state_scope_id, - tool_origin=get_function_tool_origin(func_tool), - ), - ) - approval_item = ToolApprovalItem( - agent=self.public_agent, - raw_item=raw_tool_call, - tool_name=func_tool.name, + if approval_status is True: + return None + if approval_status is False: + rejection_message = await resolve_approval_rejection_message( + context_wrapper=self.context_wrapper, + run_config=self.config, + tool_type="function", + tool_name=tool_trace_name(func_tool.name, tool_namespace) or func_tool.name, + call_id=tool_call.call_id, tool_namespace=tool_namespace, - tool_origin=get_function_tool_origin(func_tool), tool_lookup_key=tool_lookup_key, - _allow_bare_name_alias=should_allow_bare_name_approval_alias( - func_tool, - self.available_function_tools, + ) + span_fn.set_error( + SpanError( + message=rejection_message, + data={ + "tool_name": func_tool.name, + "error": ( + f"Tool execution for {tool_call.call_id} was manually rejected by user." + ), + }, + ) + ) + span_fn.span_data.output = rejection_message + return FunctionToolResult( + tool=func_tool, + output=rejection_message, + run_item=function_rejection_item( + self.public_agent, + tool_call, + rejection_message=rejection_message, + output_json_schema=func_tool.output_json_schema, + scope_id=self.tool_state_scope_id, + tool_origin=get_function_tool_origin(func_tool), ), ) - return FunctionToolResult(tool=func_tool, output=None, run_item=approval_item) - if approval_status is not False: + needs_approval_result = await function_needs_approval( + func_tool, + self.context_wrapper, + tool_call, + ) + if not needs_approval_result: return None - rejection_message = await resolve_approval_rejection_message( - context_wrapper=self.context_wrapper, - run_config=self.config, - tool_type="function", - tool_name=tool_trace_name(func_tool.name, tool_namespace) or func_tool.name, - call_id=tool_call.call_id, + if self._should_run_pre_approval_tool_input_guardrails(): + tool_context_namespace = get_tool_call_namespace(raw_tool_call) + if tool_context_namespace is None: + tool_context_namespace = get_tool_call_namespace(tool_call) + tool_context = ToolContext.from_agent_context( + self.context_wrapper, + tool_call.call_id, + tool_call=raw_tool_call, + tool_namespace=tool_context_namespace, + agent=self.public_agent, + run_config=self.config, + ) + rejected_message = await _execute_tool_input_guardrails( + func_tool=func_tool, + tool_context=tool_context, + agent=self.public_agent, + tool_input_guardrail_results=self.tool_input_guardrail_results, + ) + if rejected_message is not None: + return FunctionToolResult( + tool=func_tool, + output=rejected_message, + run_item=function_rejection_item( + self.public_agent, + tool_call, + rejection_message=rejected_message, + output_json_schema=func_tool.output_json_schema, + scope_id=self.tool_state_scope_id, + tool_origin=get_function_tool_origin(func_tool), + ), + ) + approval_item = ToolApprovalItem( + agent=self.public_agent, + raw_item=raw_tool_call, + tool_name=func_tool.name, tool_namespace=tool_namespace, + tool_origin=get_function_tool_origin(func_tool), tool_lookup_key=tool_lookup_key, - ) - span_fn.set_error( - SpanError( - message=rejection_message, - data={ - "tool_name": func_tool.name, - "error": ( - f"Tool execution for {tool_call.call_id} was manually rejected by user." - ), - }, - ) - ) - span_fn.span_data.output = rejection_message - return FunctionToolResult( - tool=func_tool, - output=rejection_message, - run_item=function_rejection_item( - self.public_agent, - tool_call, - rejection_message=rejection_message, - output_json_schema=func_tool.output_json_schema, - scope_id=self.tool_state_scope_id, - tool_origin=get_function_tool_origin(func_tool), + _allow_bare_name_alias=should_allow_bare_name_approval_alias( + func_tool, + self.available_function_tools, ), ) + return FunctionToolResult(tool=func_tool, output=None, run_item=approval_item) async def _execute_single_tool_body( self, diff --git a/tests/mcp/test_mcp_approval.py b/tests/mcp/test_mcp_approval.py index 791fa71c24..38ff6f6b15 100644 --- a/tests/mcp/test_mcp_approval.py +++ b/tests/mcp/test_mcp_approval.py @@ -191,7 +191,8 @@ def require_approval( assert not second.interruptions, "safe should bypass approval via callable policy" assert second.final_output == "safe done" - assert seen == ["guarded", "guarded", "safe"] + # Resume must not re-invoke the callable once approval status is resolved. + assert seen == ["guarded", "safe"] @pytest.mark.asyncio @@ -235,8 +236,8 @@ async def require_approval( assert not second.interruptions, "run context should be able to skip approval" assert second.final_output == "no approval path" + # Resume must not re-await the callable once approval status is resolved. assert seen_contexts == [ - {"needs_approval": True}, {"needs_approval": True}, {"needs_approval": False}, ] diff --git a/tests/test_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index 23d4002c1d..3dffe8a6c4 100644 --- a/tests/test_hitl_error_scenarios.py +++ b/tests/test_hitl_error_scenarios.py @@ -1393,6 +1393,54 @@ async def _record_rejection( assert rejections == ["rejected-call"] +@pytest.mark.asyncio +async def test_execute_path_skips_needs_approval_checker_when_status_resolved() -> None: + """Resolved approvals must short-circuit needs_approval on the execute path. + + Planning helpers already skip the checker once status is True/False (#3229/#3259). + Resume still re-enters tool execution, which previously re-awaited needs_approval + before reading stored status — so a checker that raises after the first interrupt + aborted an already-approved tool call. + """ + checker_calls: list[str] = [] + + async def needs_approval(_ctx: Any, _args: dict[str, Any], call_id: str) -> bool: + checker_calls.append(call_id) + if len(checker_calls) > 1: + raise AssertionError( + f"needs_approval must not run after approval is resolved; calls={checker_calls}" + ) + return True + + @function_tool(needs_approval=needs_approval) + async def sensitive(value: str) -> str: + return f"ran:{value}" + + model = FakeModel() + agent = Agent(name="agent", model=model, tools=[sensitive]) + model.add_multiple_turn_outputs( + [ + [make_function_tool_call(sensitive.name, call_id="call-1", arguments='{"value":"x"}')], + [get_text_message("done")], + ] + ) + + first = await Runner.run(agent, "hello") + assert len(first.interruptions) == 1 + assert checker_calls == ["call-1"] + + state = first.to_state() + state.approve(first.interruptions[0]) + resumed = await Runner.run(agent, state) + + assert resumed.final_output == "done" + assert checker_calls == ["call-1"] + assert any( + isinstance(item, ToolCallOutputItem) and item.output == "ran:x" + for item in resumed.new_items + ) + + @pytest.mark.asyncio async def test_collect_runs_by_approval_skips_checker_when_status_resolved() -> None: """Approved/rejected shell calls must not invoke needs_approval_checker. From 9326e53b93b52ef1190db373e1ec3399b9450dc6 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 24 Jul 2026 00:43:59 -0500 Subject: [PATCH 2/2] test(approvals): cover sticky reject on all execute paths Add parameterized Runner regressions for function/shell/custom/apply_patch and a Realtime case where always_reject wins over a later needs_approval False result without re-entering the checker or running the executor. --- tests/realtime/test_session.py | 50 +++++++++ tests/test_hitl_error_scenarios.py | 160 ++++++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 1 deletion(-) diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 3211f2358d..86d5ec5f84 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -3210,6 +3210,56 @@ async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: ] assert tool_calls == [] + @pytest.mark.asyncio + async def test_sticky_reject_takes_precedence_when_needs_approval_returns_false( + self, mock_model + ): + """Sticky always_reject must win even when needs_approval would return False.""" + checker_calls: list[str] = [] + tool_calls: list[str] = [] + + async def needs_approval(_ctx: Any, _params: dict[str, Any], call_id: str) -> bool: + checker_calls.append(call_id) + # First call interrupts; later calls would skip approval without sticky status. + return call_id == "call_reject_first" + + async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str: + tool_calls.append("called") + return "should-not-run" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + agent = RealtimeAgent(name="agent", tools=[tool]) + session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False}) + + first_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call_reject_first", arguments="{}" + ) + second_call = RealtimeModelToolCallEvent( + name=tool.name, call_id="call_reject_second", arguments="{}" + ) + + await session._handle_tool_call(first_call) + await session.reject_tool_call( + first_call.call_id, + always=True, + rejection_message="sticky rejection", + ) + await session._handle_tool_call(second_call) + + assert session._pending_tool_calls == {} + assert [output for _call, output, _start in mock_model.sent_tool_outputs] == [ + "sticky rejection", + "sticky rejection", + ] + assert tool_calls == [] + assert checker_calls == ["call_reject_first"] + @pytest.mark.asyncio async def test_function_tool_exception_handling( self, mock_model, mock_agent, mock_function_tool diff --git a/tests/test_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index 3dffe8a6c4..b0c3a01eb2 100644 --- a/tests/test_hitl_error_scenarios.py +++ b/tests/test_hitl_error_scenarios.py @@ -6,7 +6,11 @@ from typing import Any, Optional, cast import pytest -from openai.types.responses import ResponseComputerToolCall, ResponseFunctionToolCall +from openai.types.responses import ( + ResponseComputerToolCall, + ResponseCustomToolCall, + ResponseFunctionToolCall, +) from openai.types.responses.response_computer_tool_call import ActionScreenshot from openai.types.responses.response_input_param import ( ComputerCallOutput, @@ -18,6 +22,7 @@ Agent, ApplyPatchTool, ComputerTool, + CustomTool, LocalShellTool, Runner, RunResult, @@ -43,9 +48,11 @@ from agents.run_internal import run_loop from agents.run_internal.agent_bindings import bind_execution_agent, bind_public_agent from agents.run_internal.run_loop import ( + ApplyPatchAction, NextStepInterruption, NextStepRunAgain, ProcessedResponse, + ShellAction, ToolRunApplyPatchCall, ToolRunComputerAction, ToolRunFunction, @@ -53,6 +60,9 @@ ToolRunShellCall, extract_tool_call_id, ) +from agents.run_internal.run_steps import ToolRunCustom +from agents.run_internal.tool_actions import CustomToolAction +from agents.run_internal.tool_execution import execute_function_tool_calls from agents.run_internal.tool_planning import ( _collect_runs_by_approval, _select_function_tool_runs_for_resume, @@ -1441,6 +1451,154 @@ async def sensitive(value: str) -> str: ) +@pytest.mark.parametrize("tool_kind", ["function", "shell", "custom", "apply_patch"]) +@pytest.mark.asyncio +async def test_sticky_reject_takes_precedence_when_needs_approval_returns_false( + tool_kind: str, +) -> None: + """Sticky always_reject must win even when needs_approval would return False. + + Each Runner execute path previously consulted the dynamic checker before stored + status. A checker returning False then executed the tool despite always_reject. + """ + checker_calls: list[str] = [] + executed: list[str] = [] + context_wrapper = make_context_wrapper() + + async def allow_without_approval(_ctx: Any, _payload: Any, call_id: str) -> bool: + checker_calls.append(call_id) + return False + + if tool_kind == "function": + + @function_tool(needs_approval=allow_without_approval) + async def sensitive() -> str: + executed.append("function") + return "should-not-run" + + agent = Agent(name="agent", tools=[sensitive]) + prior_function_call = make_function_tool_call(sensitive.name, call_id="call-prior") + context_wrapper.reject_tool( + ToolApprovalItem(agent=agent, raw_item=prior_function_call), + always_reject=True, + ) + next_function_call = make_function_tool_call(sensitive.name, call_id="call-next") + function_results, _, _ = await execute_function_tool_calls( + bindings=bind_public_agent(agent), + tool_runs=[ToolRunFunction(tool_call=next_function_call, function_tool=sensitive)], + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + assert [result.output for result in function_results] == [HITL_REJECTION_MSG] + elif tool_kind == "shell": + + def shell_executor(_req: Any) -> str: + executed.append("shell") + return "should-not-run" + + shell_tool = ShellTool( + executor=shell_executor, + needs_approval=allow_without_approval, + ) + agent = Agent(name="agent", tools=[shell_tool]) + prior_shell_call = cast(dict[str, Any], make_shell_call("call-prior")) + context_wrapper.reject_tool( + ToolApprovalItem( + agent=agent, + raw_item=prior_shell_call, + tool_name=shell_tool.name, + ), + always_reject=True, + ) + next_shell_call = cast(dict[str, Any], make_shell_call("call-next")) + result = await ShellAction.execute( + agent=agent, + call=ToolRunShellCall(tool_call=next_shell_call, shell_tool=shell_tool), + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + assert isinstance(result, ToolCallOutputItem) + assert HITL_REJECTION_MSG in str(result.output) + elif tool_kind == "custom": + + async def invoke_custom(_ctx: Any, _raw: str) -> str: + executed.append("custom") + return "should-not-run" + + custom_tool = CustomTool( + name="raw_editor", + description="Edit raw text.", + on_invoke_tool=invoke_custom, + format={"type": "text"}, + needs_approval=allow_without_approval, + ) + agent = Agent(name="agent", tools=[custom_tool]) + prior_custom_call = ResponseCustomToolCall( + type="custom_tool_call", + name=custom_tool.name, + call_id="call-prior", + input="prior", + ) + context_wrapper.reject_tool( + ToolApprovalItem( + agent=agent, + raw_item=cast(Any, prior_custom_call), + tool_name=custom_tool.name, + ), + always_reject=True, + ) + next_custom_call = ResponseCustomToolCall( + type="custom_tool_call", + name=custom_tool.name, + call_id="call-next", + input="next", + ) + result = await CustomToolAction.execute( + agent=agent, + call=ToolRunCustom(tool_call=next_custom_call, custom_tool=custom_tool), + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + assert isinstance(result, ToolCallOutputItem) + assert result.output == HITL_REJECTION_MSG + else: + editor = RecordingEditor() + apply_patch_tool = ApplyPatchTool( + editor=editor, + needs_approval=allow_without_approval, + ) + agent = Agent(name="agent", tools=[apply_patch_tool]) + prior_patch_call = cast(dict[str, Any], make_apply_patch_dict("call-prior")) + context_wrapper.reject_tool( + ToolApprovalItem( + agent=agent, + raw_item=prior_patch_call, + tool_name=apply_patch_tool.name, + ), + always_reject=True, + ) + next_patch_call = cast(dict[str, Any], make_apply_patch_dict("call-next")) + result = await ApplyPatchAction.execute( + agent=agent, + call=ToolRunApplyPatchCall( + tool_call=next_patch_call, + apply_patch_tool=apply_patch_tool, + ), + hooks=RunHooks(), + context_wrapper=context_wrapper, + config=RunConfig(), + ) + assert isinstance(result, ToolCallOutputItem) + assert HITL_REJECTION_MSG in str(result.output) + assert editor.operations == [] + + assert executed == [] + assert checker_calls == [] + + @pytest.mark.asyncio async def test_collect_runs_by_approval_skips_checker_when_status_resolved() -> None: """Approved/rejected shell calls must not invoke needs_approval_checker.