Skip to content

Commit 0928afa

Browse files
mjschockclaude
andcommitted
feat: integrate HITL approval checking into run execution loop
This commit integrates the human-in-the-loop infrastructure into the actual run execution flow, making tool approval functional. **Changes:** 1. **NextStepInterruption Type** (_run_impl.py:205-210) - Added NextStepInterruption dataclass - Includes interruptions list (ToolApprovalItems) - Added to NextStep union type 2. **ProcessedResponse Enhancement** (_run_impl.py:167-192) - Added interruptions field - Added has_interruptions() method 3. **Tool Approval Checking** (_run_impl.py:773-848) - Check needs_approval before tool execution - Support dynamic approval functions - If approval needed: * Check approval status via context * If None: Create ToolApprovalItem, return for interruption * If False: Return rejection message * If True: Continue with execution 4. **Interruption Handling** (_run_impl.py:311-333) - After tool execution, check for ToolApprovalItems - If found, create NextStepInterruption and return immediately - Prevents execution of remaining tools when approval pending **Flow:** Tool Call → Check needs_approval → Check approval status → If None: Create interruption, pause run → User approves/rejects → Resume run → If approved: Execute tool If rejected: Return rejection message **Remaining Work:** - Update Runner.run() to accept RunState - Handle interruptions in result creation - Add tests - Add documentation/examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2925b42 commit 0928afa

File tree

1 file changed

+99
-3
lines changed

1 file changed

+99
-3
lines changed

src/agents/_run_impl.py

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ class ProcessedResponse:
172172
local_shell_calls: list[ToolRunLocalShellCall]
173173
tools_used: list[str] # Names of all tools used, including hosted tools
174174
mcp_approval_requests: list[ToolRunMCPApprovalRequest] # Only requests with callbacks
175+
interruptions: list[RunItem] # Tool approval items awaiting user decision
175176

176177
def has_tools_or_approvals_to_run(self) -> bool:
177178
# Handoffs, functions and computer actions need local processing
@@ -186,6 +187,10 @@ def has_tools_or_approvals_to_run(self) -> bool:
186187
]
187188
)
188189

190+
def has_interruptions(self) -> bool:
191+
"""Check if there are tool calls awaiting approval."""
192+
return len(self.interruptions) > 0
193+
189194

190195
@dataclass
191196
class NextStepHandoff:
@@ -202,6 +207,14 @@ class NextStepRunAgain:
202207
pass
203208

204209

210+
@dataclass
211+
class NextStepInterruption:
212+
"""Represents an interruption in the agent run due to tool approval requests."""
213+
214+
interruptions: list[RunItem]
215+
"""The list of tool calls (ToolApprovalItem) awaiting approval."""
216+
217+
205218
@dataclass
206219
class SingleStepResult:
207220
original_input: str | list[TResponseInputItem]
@@ -217,7 +230,7 @@ class SingleStepResult:
217230
new_step_items: list[RunItem]
218231
"""Items generated during this current step."""
219232

220-
next_step: NextStepHandoff | NextStepFinalOutput | NextStepRunAgain
233+
next_step: NextStepHandoff | NextStepFinalOutput | NextStepRunAgain | NextStepInterruption
221234
"""The next step to take."""
222235

223236
tool_input_guardrail_results: list[ToolInputGuardrailResult]
@@ -295,7 +308,31 @@ async def execute_tools_and_side_effects(
295308
config=run_config,
296309
),
297310
)
298-
new_step_items.extend([result.run_item for result in function_results])
311+
# Check for tool approval interruptions before adding items
312+
from .items import ToolApprovalItem
313+
314+
interruptions: list[RunItem] = []
315+
approved_function_results = []
316+
for result in function_results:
317+
if isinstance(result.run_item, ToolApprovalItem):
318+
interruptions.append(result.run_item)
319+
else:
320+
approved_function_results.append(result)
321+
322+
# If there are interruptions, return immediately without executing remaining tools
323+
if interruptions:
324+
# Return the interruption step
325+
return SingleStepResult(
326+
original_input=original_input,
327+
model_response=new_response,
328+
pre_step_items=pre_step_items,
329+
new_step_items=interruptions,
330+
next_step=NextStepInterruption(interruptions=interruptions),
331+
tool_input_guardrail_results=tool_input_guardrail_results,
332+
tool_output_guardrail_results=tool_output_guardrail_results,
333+
)
334+
335+
new_step_items.extend([result.run_item for result in approved_function_results])
299336
new_step_items.extend(computer_results)
300337
new_step_items.extend(local_shell_results)
301338

@@ -583,6 +620,7 @@ def process_model_response(
583620
local_shell_calls=local_shell_calls,
584621
tools_used=tools_used,
585622
mcp_approval_requests=mcp_approval_requests,
623+
interruptions=[], # Will be populated after tool execution
586624
)
587625

588626
@classmethod
@@ -762,7 +800,65 @@ async def run_single_tool(
762800
if config.trace_include_sensitive_data:
763801
span_fn.span_data.input = tool_call.arguments
764802
try:
765-
# 1) Run input tool guardrails, if any
803+
# 1) Check if tool needs approval
804+
needs_approval_result = func_tool.needs_approval
805+
if callable(needs_approval_result):
806+
# Parse arguments for dynamic approval check
807+
import json
808+
809+
try:
810+
parsed_args = (
811+
json.loads(tool_call.arguments) if tool_call.arguments else {}
812+
)
813+
except json.JSONDecodeError:
814+
parsed_args = {}
815+
needs_approval_result = await needs_approval_result(
816+
context_wrapper, parsed_args, tool_call.call_id
817+
)
818+
819+
if needs_approval_result:
820+
# Check if tool has been approved/rejected
821+
approval_status = context_wrapper.is_tool_approved(
822+
func_tool.name, tool_call.call_id
823+
)
824+
825+
if approval_status is None:
826+
# Not yet decided - need to interrupt for approval
827+
from .items import ToolApprovalItem
828+
829+
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
830+
return FunctionToolResult(
831+
tool=func_tool, output=None, run_item=approval_item
832+
)
833+
834+
if approval_status is False:
835+
# Rejected - return rejection message
836+
rejection_msg = "Tool execution was not approved."
837+
span_fn.set_error(
838+
SpanError(
839+
message=rejection_msg,
840+
data={
841+
"tool_name": func_tool.name,
842+
"error": (
843+
f"Tool execution for {tool_call.call_id} "
844+
"was manually rejected by user."
845+
),
846+
},
847+
)
848+
)
849+
result = rejection_msg
850+
span_fn.span_data.output = result
851+
return FunctionToolResult(
852+
tool=func_tool,
853+
output=result,
854+
run_item=ToolCallOutputItem(
855+
output=result,
856+
raw_item=ItemHelpers.tool_call_output_item(tool_call, result),
857+
agent=agent,
858+
),
859+
)
860+
861+
# 2) Run input tool guardrails, if any
766862
rejected_message = await cls._execute_input_guardrails(
767863
func_tool=func_tool,
768864
tool_context=tool_context,

0 commit comments

Comments
 (0)