Skip to content

Commit 12cc15c

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 <[email protected]>
1 parent 6768f5d commit 12cc15c

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
@@ -197,6 +197,7 @@ class ProcessedResponse:
197197
apply_patch_calls: list[ToolRunApplyPatchCall]
198198
tools_used: list[str] # Names of all tools used, including hosted tools
199199
mcp_approval_requests: list[ToolRunMCPApprovalRequest] # Only requests with callbacks
200+
interruptions: list[RunItem] # Tool approval items awaiting user decision
200201

201202
def has_tools_or_approvals_to_run(self) -> bool:
202203
# Handoffs, functions and computer actions need local processing
@@ -213,6 +214,10 @@ def has_tools_or_approvals_to_run(self) -> bool:
213214
]
214215
)
215216

217+
def has_interruptions(self) -> bool:
218+
"""Check if there are tool calls awaiting approval."""
219+
return len(self.interruptions) > 0
220+
216221

217222
@dataclass
218223
class NextStepHandoff:
@@ -229,6 +234,14 @@ class NextStepRunAgain:
229234
pass
230235

231236

237+
@dataclass
238+
class NextStepInterruption:
239+
"""Represents an interruption in the agent run due to tool approval requests."""
240+
241+
interruptions: list[RunItem]
242+
"""The list of tool calls (ToolApprovalItem) awaiting approval."""
243+
244+
232245
@dataclass
233246
class SingleStepResult:
234247
original_input: str | list[TResponseInputItem]
@@ -244,7 +257,7 @@ class SingleStepResult:
244257
new_step_items: list[RunItem]
245258
"""Items generated during this current step."""
246259

247-
next_step: NextStepHandoff | NextStepFinalOutput | NextStepRunAgain
260+
next_step: NextStepHandoff | NextStepFinalOutput | NextStepRunAgain | NextStepInterruption
248261
"""The next step to take."""
249262

250263
tool_input_guardrail_results: list[ToolInputGuardrailResult]
@@ -339,7 +352,31 @@ async def execute_tools_and_side_effects(
339352
config=run_config,
340353
),
341354
)
342-
new_step_items.extend([result.run_item for result in function_results])
355+
# Check for tool approval interruptions before adding items
356+
from .items import ToolApprovalItem
357+
358+
interruptions: list[RunItem] = []
359+
approved_function_results = []
360+
for result in function_results:
361+
if isinstance(result.run_item, ToolApprovalItem):
362+
interruptions.append(result.run_item)
363+
else:
364+
approved_function_results.append(result)
365+
366+
# If there are interruptions, return immediately without executing remaining tools
367+
if interruptions:
368+
# Return the interruption step
369+
return SingleStepResult(
370+
original_input=original_input,
371+
model_response=new_response,
372+
pre_step_items=pre_step_items,
373+
new_step_items=interruptions,
374+
next_step=NextStepInterruption(interruptions=interruptions),
375+
tool_input_guardrail_results=tool_input_guardrail_results,
376+
tool_output_guardrail_results=tool_output_guardrail_results,
377+
)
378+
379+
new_step_items.extend([result.run_item for result in approved_function_results])
343380
new_step_items.extend(computer_results)
344381
new_step_items.extend(shell_results)
345382
new_step_items.extend(apply_patch_results)
@@ -751,6 +788,7 @@ def process_model_response(
751788
apply_patch_calls=apply_patch_calls,
752789
tools_used=tools_used,
753790
mcp_approval_requests=mcp_approval_requests,
791+
interruptions=[], # Will be populated after tool execution
754792
)
755793

756794
@classmethod
@@ -930,7 +968,65 @@ async def run_single_tool(
930968
if config.trace_include_sensitive_data:
931969
span_fn.span_data.input = tool_call.arguments
932970
try:
933-
# 1) Run input tool guardrails, if any
971+
# 1) Check if tool needs approval
972+
needs_approval_result = func_tool.needs_approval
973+
if callable(needs_approval_result):
974+
# Parse arguments for dynamic approval check
975+
import json
976+
977+
try:
978+
parsed_args = (
979+
json.loads(tool_call.arguments) if tool_call.arguments else {}
980+
)
981+
except json.JSONDecodeError:
982+
parsed_args = {}
983+
needs_approval_result = await needs_approval_result(
984+
context_wrapper, parsed_args, tool_call.call_id
985+
)
986+
987+
if needs_approval_result:
988+
# Check if tool has been approved/rejected
989+
approval_status = context_wrapper.is_tool_approved(
990+
func_tool.name, tool_call.call_id
991+
)
992+
993+
if approval_status is None:
994+
# Not yet decided - need to interrupt for approval
995+
from .items import ToolApprovalItem
996+
997+
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
998+
return FunctionToolResult(
999+
tool=func_tool, output=None, run_item=approval_item
1000+
)
1001+
1002+
if approval_status is False:
1003+
# Rejected - return rejection message
1004+
rejection_msg = "Tool execution was not approved."
1005+
span_fn.set_error(
1006+
SpanError(
1007+
message=rejection_msg,
1008+
data={
1009+
"tool_name": func_tool.name,
1010+
"error": (
1011+
f"Tool execution for {tool_call.call_id} "
1012+
"was manually rejected by user."
1013+
),
1014+
},
1015+
)
1016+
)
1017+
result = rejection_msg
1018+
span_fn.span_data.output = result
1019+
return FunctionToolResult(
1020+
tool=func_tool,
1021+
output=result,
1022+
run_item=ToolCallOutputItem(
1023+
output=result,
1024+
raw_item=ItemHelpers.tool_call_output_item(tool_call, result),
1025+
agent=agent,
1026+
),
1027+
)
1028+
1029+
# 2) Run input tool guardrails, if any
9341030
rejected_message = await cls._execute_input_guardrails(
9351031
func_tool=func_tool,
9361032
tool_context=tool_context,

0 commit comments

Comments
 (0)