Skip to content

Commit b2bd821

Browse files
authored
fix: #3359 preserve local approval rejection reasons (#3360)
1 parent 8715a05 commit b2bd821

4 files changed

Lines changed: 90 additions & 4 deletions

File tree

src/agents/run_internal/tool_execution.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1091,7 +1091,12 @@ async def resolve_approval_status(
10911091
if decision_result.get("approve") is True:
10921092
context_wrapper.approve_tool(approval_item)
10931093
elif decision_result.get("approve") is False:
1094-
context_wrapper.reject_tool(approval_item)
1094+
reason = decision_result.get("reason")
1095+
rejection_message = reason if isinstance(reason, str) and reason else None
1096+
context_wrapper.reject_tool(
1097+
approval_item,
1098+
rejection_message=rejection_message,
1099+
)
10951100
approval_status = context_wrapper.get_approval_status(
10961101
tool_name,
10971102
call_id,

tests/test_apply_patch_tool.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,9 @@ async def test_apply_patch_tool_on_approval_callback_auto_rejects() -> None:
384384

385385
# Should return rejection output
386386
assert isinstance(result, ToolCallOutputItem)
387-
assert HITL_REJECTION_MSG in result.output
387+
assert result.output == "Not allowed"
388+
raw_item = cast(dict[str, Any], result.raw_item)
389+
assert raw_item["output"] == "Not allowed"
388390
assert len(editor.operations) == 0 # Should not have executed
389391

390392

tests/test_custom_tool.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
from openai.types.responses import ResponseCustomToolCall
55

66
from agents import Agent, CustomTool, RunConfig, RunContextWrapper
7-
from agents.items import ToolCallOutputItem
7+
from agents.items import ToolApprovalItem, ToolCallOutputItem
88
from agents.lifecycle import RunHooks
99
from agents.run_internal.run_steps import ToolRunCustom
1010
from agents.run_internal.tool_actions import CustomToolAction
11+
from agents.tool import CustomToolOnApprovalFunctionResult
1112
from agents.tool_context import ToolContext
1213

1314

@@ -47,3 +48,47 @@ async def invoke(ctx: ToolContext[Any], raw_input: str) -> str:
4748
"call_id": "call_custom",
4849
"output": "HELLO",
4950
}
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_custom_tool_on_approval_callback_auto_rejects_with_reason() -> None:
55+
async def invoke(_ctx: ToolContext[Any], _raw_input: str) -> str:
56+
raise AssertionError("rejected custom tool should not execute")
57+
58+
async def on_approval(
59+
_context: RunContextWrapper[Any], _approval_item: ToolApprovalItem
60+
) -> CustomToolOnApprovalFunctionResult:
61+
return {"approve": False, "reason": "Not allowed"}
62+
63+
tool = CustomTool(
64+
name="raw_editor",
65+
description="Edit raw text.",
66+
on_invoke_tool=invoke,
67+
format={"type": "text"},
68+
needs_approval=True,
69+
on_approval=on_approval,
70+
)
71+
agent = Agent(name="custom-agent", tools=[tool])
72+
tool_call = ResponseCustomToolCall(
73+
type="custom_tool_call",
74+
name="raw_editor",
75+
call_id="call_custom",
76+
input="hello",
77+
)
78+
79+
result = await CustomToolAction.execute(
80+
agent=agent,
81+
call=ToolRunCustom(tool_call=tool_call, custom_tool=tool),
82+
hooks=RunHooks[Any](),
83+
context_wrapper=RunContextWrapper(context=None),
84+
config=RunConfig(),
85+
)
86+
87+
assert isinstance(result, ToolCallOutputItem)
88+
assert result.output == "Not allowed"
89+
raw_item = cast(dict[str, Any], result.raw_item)
90+
assert raw_item == {
91+
"type": "custom_tool_call_output",
92+
"call_id": "call_custom",
93+
"output": "Not allowed",
94+
}

tests/test_shell_tool.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from agents.items import ToolApprovalItem, ToolCallOutputItem
2222
from agents.run_internal.run_loop import ShellAction, ToolRunShellCall, execute_shell_calls
23+
from agents.tool import ShellOnApprovalFunctionResult
2324

2425
from .testing_processor import SPAN_PROCESSOR_TESTING
2526
from .utils.hitl import (
@@ -776,4 +777,37 @@ async def test_shell_tool_on_approval_callback_auto_rejects() -> None:
776777

777778
# Should return rejection output
778779
assert isinstance(result, ToolCallOutputItem)
779-
assert HITL_REJECTION_MSG in result.output
780+
assert result.output == "Not allowed"
781+
raw_item = cast(dict[str, Any], result.raw_item)
782+
assert raw_item["output"][0]["stderr"] == "Not allowed"
783+
784+
785+
@pytest.mark.asyncio
786+
async def test_shell_tool_on_approval_empty_reason_uses_default_rejection() -> None:
787+
"""Test that empty rejection reasons do not suppress the default message."""
788+
789+
async def on_approval(
790+
_context: RunContextWrapper[Any], _approval_item: ToolApprovalItem
791+
) -> ShellOnApprovalFunctionResult:
792+
return {"approve": False, "reason": ""}
793+
794+
shell_tool = ShellTool(
795+
executor=lambda request: "output",
796+
needs_approval=require_approval,
797+
on_approval=on_approval,
798+
)
799+
800+
tool_run = ToolRunShellCall(tool_call=_shell_call(), shell_tool=shell_tool)
801+
agent = Agent(name="shell-agent", tools=[shell_tool])
802+
context_wrapper: RunContextWrapper[Any] = make_context_wrapper()
803+
804+
result = await ShellAction.execute(
805+
agent=agent,
806+
call=tool_run,
807+
hooks=RunHooks[Any](),
808+
context_wrapper=context_wrapper,
809+
config=RunConfig(),
810+
)
811+
812+
assert isinstance(result, ToolCallOutputItem)
813+
assert result.output == HITL_REJECTION_MSG

0 commit comments

Comments
 (0)