Skip to content

Commit b9bcf1f

Browse files
mjschockclaude
andcommitted
feat: implement human-in-the-loop (HITL) approval infrastructure
This commit adds the foundational components for human-in-the-loop functionality in the Python OpenAI Agents SDK, matching the TypeScript implementation. **Completed Components:** 1. **Tool Approval Field** (tool.py) - Added `needs_approval` field to FunctionTool - Supports boolean or callable (dynamic approval) - Updated function_tool() decorator 2. **ToolApprovalItem Class** (items.py) - New item type for tool calls requiring approval - Added to RunItem union type 3. **Approval Tracking** (run_context.py) - Created ApprovalRecord class - Added approval infrastructure to RunContextWrapper - Methods: is_tool_approved(), approve_tool(), reject_tool() - Supports individual and permanent approvals/rejections 4. **RunState Class** (run_state.py) - NEW FILE - Complete serialization/deserialization support - approve() and reject() methods - get_interruptions() method - Agent map building for name resolution - 567 lines of serialization logic 5. **Interruptions Support** (result.py) - Added interruptions field to RunResultBase - Will contain ToolApprovalItem instances when paused 6. **NextStepInterruption** (run_state.py) - New step type for representing interruptions **Remaining Work:** 1. Add NextStepInterruption to NextStep union in _run_impl.py 2. Implement tool approval checking in run execution 3. Update run methods to accept RunState 4. Add comprehensive tests 5. Update documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0c4f2b9 commit b9bcf1f

File tree

6 files changed

+735
-1
lines changed

6 files changed

+735
-1
lines changed

src/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
ModelResponse,
4444
ReasoningItem,
4545
RunItem,
46+
ToolApprovalItem,
4647
ToolCallItem,
4748
ToolCallOutputItem,
4849
TResponseInputItem,
@@ -60,6 +61,7 @@
6061
from .result import RunResult, RunResultStreaming
6162
from .run import RunConfig, Runner
6263
from .run_context import RunContextWrapper, TContext
64+
from .run_state import NextStepInterruption, RunState
6365
from .stream_events import (
6466
AgentUpdatedStreamEvent,
6567
RawResponsesStreamEvent,

src/agents/items.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,21 @@ class MCPApprovalResponseItem(RunItemBase[McpApprovalResponse]):
212212
type: Literal["mcp_approval_response_item"] = "mcp_approval_response_item"
213213

214214

215+
@dataclass
216+
class ToolApprovalItem(RunItemBase[ResponseFunctionToolCall]):
217+
"""Represents a tool call that requires approval before execution.
218+
219+
When a tool has `needs_approval=True`, the run will be interrupted and this item will be
220+
added to the interruptions list. You can then approve or reject the tool call using
221+
RunState.approve() or RunState.reject() and resume the run.
222+
"""
223+
224+
raw_item: ResponseFunctionToolCall
225+
"""The raw function tool call that requires approval."""
226+
227+
type: Literal["tool_approval_item"] = "tool_approval_item"
228+
229+
215230
RunItem: TypeAlias = Union[
216231
MessageOutputItem,
217232
HandoffCallItem,
@@ -222,6 +237,7 @@ class MCPApprovalResponseItem(RunItemBase[McpApprovalResponse]):
222237
MCPListToolsItem,
223238
MCPApprovalRequestItem,
224239
MCPApprovalResponseItem,
240+
ToolApprovalItem,
225241
]
226242
"""An item generated by an agent."""
227243

src/agents/result.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ class RunResultBase(abc.ABC):
6969
context_wrapper: RunContextWrapper[Any]
7070
"""The context wrapper for the agent run."""
7171

72+
interruptions: list[RunItem]
73+
"""Any interruptions (e.g., tool approval requests) that occurred during the run.
74+
If non-empty, the run was paused waiting for user action (e.g., approve/reject tool calls).
75+
"""
76+
7277
@property
7378
@abc.abstractmethod
7479
def last_agent(self) -> Agent[Any]:

src/agents/run_context.py

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
1+
from __future__ import annotations
2+
13
from dataclasses import dataclass, field
2-
from typing import Any, Generic
4+
from typing import TYPE_CHECKING, Any, Generic
35

46
from typing_extensions import TypeVar
57

68
from .usage import Usage
79

10+
if TYPE_CHECKING:
11+
from .items import ToolApprovalItem
12+
813
TContext = TypeVar("TContext", default=Any)
914

1015

16+
class ApprovalRecord:
17+
"""Tracks approval/rejection state for a tool."""
18+
19+
approved: bool | list[str]
20+
"""Either True (always approved), False (never approved), or a list of approved call IDs."""
21+
22+
rejected: bool | list[str]
23+
"""Either True (always rejected), False (never rejected), or a list of rejected call IDs."""
24+
25+
def __init__(self):
26+
self.approved = []
27+
self.rejected = []
28+
29+
1130
@dataclass
1231
class RunContextWrapper(Generic[TContext]):
1332
"""This wraps the context object that you passed to `Runner.run()`. It also contains
@@ -24,3 +43,116 @@ class RunContextWrapper(Generic[TContext]):
2443
"""The usage of the agent run so far. For streamed responses, the usage will be stale until the
2544
last chunk of the stream is processed.
2645
"""
46+
47+
_approvals: dict[str, ApprovalRecord] = field(default_factory=dict)
48+
"""Internal tracking of tool approval/rejection decisions."""
49+
50+
def is_tool_approved(self, tool_name: str, call_id: str) -> bool | None:
51+
"""Check if a tool call has been approved.
52+
53+
Args:
54+
tool_name: The name of the tool being called.
55+
call_id: The ID of the specific tool call.
56+
57+
Returns:
58+
True if approved, False if rejected, None if not yet decided.
59+
"""
60+
approval_entry = self._approvals.get(tool_name)
61+
if not approval_entry:
62+
return None
63+
64+
# Check for permanent approval/rejection
65+
if approval_entry.approved is True and approval_entry.rejected is True:
66+
# Approval takes precedence
67+
return True
68+
69+
if approval_entry.approved is True:
70+
return True
71+
72+
if approval_entry.rejected is True:
73+
return False
74+
75+
# Check for individual call approval/rejection
76+
individual_approval = (
77+
call_id in approval_entry.approved
78+
if isinstance(approval_entry.approved, list)
79+
else False
80+
)
81+
individual_rejection = (
82+
call_id in approval_entry.rejected
83+
if isinstance(approval_entry.rejected, list)
84+
else False
85+
)
86+
87+
if individual_approval and individual_rejection:
88+
# Approval takes precedence
89+
return True
90+
91+
if individual_approval:
92+
return True
93+
94+
if individual_rejection:
95+
return False
96+
97+
return None
98+
99+
def approve_tool(self, approval_item: ToolApprovalItem, always_approve: bool = False) -> None:
100+
"""Approve a tool call.
101+
102+
Args:
103+
approval_item: The tool approval item to approve.
104+
always_approve: If True, always approve this tool (for all future calls).
105+
"""
106+
tool_name = approval_item.raw_item.name
107+
call_id = approval_item.raw_item.call_id
108+
109+
if always_approve:
110+
approval_entry = ApprovalRecord()
111+
approval_entry.approved = True
112+
approval_entry.rejected = []
113+
self._approvals[tool_name] = approval_entry
114+
return
115+
116+
if tool_name not in self._approvals:
117+
self._approvals[tool_name] = ApprovalRecord()
118+
119+
approval_entry = self._approvals[tool_name]
120+
if isinstance(approval_entry.approved, list):
121+
approval_entry.approved.append(call_id)
122+
123+
def reject_tool(self, approval_item: ToolApprovalItem, always_reject: bool = False) -> None:
124+
"""Reject a tool call.
125+
126+
Args:
127+
approval_item: The tool approval item to reject.
128+
always_reject: If True, always reject this tool (for all future calls).
129+
"""
130+
tool_name = approval_item.raw_item.name
131+
call_id = approval_item.raw_item.call_id
132+
133+
if always_reject:
134+
approval_entry = ApprovalRecord()
135+
approval_entry.approved = False
136+
approval_entry.rejected = True
137+
self._approvals[tool_name] = approval_entry
138+
return
139+
140+
if tool_name not in self._approvals:
141+
self._approvals[tool_name] = ApprovalRecord()
142+
143+
approval_entry = self._approvals[tool_name]
144+
if isinstance(approval_entry.rejected, list):
145+
approval_entry.rejected.append(call_id)
146+
147+
def _rebuild_approvals(self, approvals: dict[str, dict[str, Any]]) -> None:
148+
"""Rebuild approvals from serialized state (for RunState deserialization).
149+
150+
Args:
151+
approvals: Dictionary mapping tool names to approval records.
152+
"""
153+
self._approvals = {}
154+
for tool_name, record_dict in approvals.items():
155+
record = ApprovalRecord()
156+
record.approved = record_dict.get("approved", [])
157+
record.rejected = record_dict.get("rejected", [])
158+
self._approvals[tool_name] = record

0 commit comments

Comments
 (0)