Files
openai--openai-agents-python/tests/utils/hitl.py
T
2026-07-13 12:39:17 +08:00

494 lines
15 KiB
Python

from __future__ import annotations
from collections.abc import Awaitable, Callable, Iterable, Sequence
from dataclasses import dataclass
from typing import Any, cast
from openai.types.responses import ResponseFunctionToolCall
from agents import Agent, Runner, RunResult, RunResultStreaming
from agents.items import ToolApprovalItem, ToolCallOutputItem, TResponseOutputItem
from agents.run_context import RunContextWrapper
from agents.run_internal.run_loop import NextStepInterruption, SingleStepResult
from agents.run_state import RunState as RunStateClass
from ..fake_model import FakeModel
HITL_REJECTION_MSG = "Tool execution was not approved."
@dataclass
class ApprovalScenario:
"""Container for approval-driven tool scenarios."""
tool: Any
raw_call: TResponseOutputItem
final_output: TResponseOutputItem
assert_result: Callable[[RunResult], None]
@dataclass
class PendingScenario:
"""Container for scenarios with pending approvals."""
tool: Any
raw_call: TResponseOutputItem
assert_result: Callable[[RunResult], None] | None = None
async def roundtrip_interruptions_via_run(
agent: Agent[Any],
model: FakeModel,
raw_call: Any,
*,
user_input: str = "test",
) -> list[ToolApprovalItem]:
"""Run once with a tool call, serialize state, and deserialize it."""
model.set_next_output([raw_call])
result = await Runner.run(agent, user_input)
assert result.interruptions, "expected an interruption"
state = result.to_state()
deserialized_state = await RunStateClass.from_json(agent, state.to_json())
return deserialized_state.get_interruptions()
async def assert_roundtrip_tool_name(
agent: Agent[Any],
model: FakeModel,
raw_call: TResponseOutputItem,
expected_tool_name: str,
*,
user_input: str,
) -> None:
"""Assert that deserialized interruptions keep the tool name intact."""
interruptions = await roundtrip_interruptions_via_run(
agent, model, raw_call, user_input=user_input
)
assert interruptions, "Interruptions should be preserved after deserialization"
assert interruptions[0].tool_name == expected_tool_name, (
f"{expected_tool_name} tool approval should be preserved, not converted to function"
)
def make_state_with_interruptions(
agent: Agent[Any],
interruptions: list[ToolApprovalItem],
*,
original_input: str = "test",
max_turns: int = 10,
) -> RunStateClass[Any, Agent[Any]]:
"""Create a RunState primed with interruptions."""
context = make_context_wrapper()
state = RunStateClass(
context=context,
original_input=original_input,
starting_agent=agent,
max_turns=max_turns,
)
state._current_step = NextStepInterruption(interruptions=interruptions)
return state
async def assert_tool_output_roundtrip(
agent: Agent[Any],
raw_output: Any,
expected_type: str,
*,
output: Any = "command output",
) -> None:
"""Ensure tool outputs keep their type through serialization and deserialization."""
context = make_context_wrapper()
state = RunStateClass(context=context, original_input="test", starting_agent=agent, max_turns=3)
state._generated_items = [
ToolCallOutputItem(
agent=agent,
raw_item=raw_output,
output=output,
)
]
json_data = state.to_json()
generated_items_json = json_data.get("generated_items", [])
assert len(generated_items_json) == 1, f"{expected_type} item should be serialized"
serialized_type = generated_items_json[0].get("raw_item", {}).get("type")
assert serialized_type == expected_type, (
f"Expected {expected_type} in serialized JSON, but got {serialized_type}. "
"Serialization should not coerce tool outputs."
)
deserialized_state = await RunStateClass.from_json(agent, json_data)
assert len(deserialized_state._generated_items) == 1, (
f"{expected_type} item should be deserialized."
)
deserialized_item = deserialized_state._generated_items[0]
assert isinstance(deserialized_item, ToolCallOutputItem)
raw_item = deserialized_item.raw_item
output_type = raw_item.get("type") if isinstance(raw_item, dict) else raw_item.type
assert output_type == expected_type, (
f"Expected {expected_type}, but got {output_type}. "
"Serialization should preserve the tool output type."
)
async def run_and_resume(
agent: Agent[Any],
model: Any,
raw_call: Any,
*,
user_input: str,
) -> RunResult:
"""Run once, then resume from the produced state."""
model.set_next_output([raw_call])
first = await Runner.run(agent, user_input)
return await Runner.run(agent, first.to_state())
def approve_first_interruption(
result: Any,
*,
always_approve: bool = False,
) -> RunStateClass[Any, Agent[Any]]:
"""Approve the first interruption on the result and return the updated state."""
assert getattr(result, "interruptions", None), "expected an approval interruption"
state = cast(RunStateClass[Any, Agent[Any]], result.to_state())
state.approve(result.interruptions[0], always_approve=always_approve)
return state
async def resume_after_first_approval(
agent: Agent[Any],
result: Any,
*,
always_approve: bool = False,
) -> RunResult:
"""Approve the first interruption and resume the run."""
state = approve_first_interruption(result, always_approve=always_approve)
return await Runner.run(agent, state)
async def resume_streamed_after_first_approval(
agent: Agent[Any],
result: Any,
*,
always_approve: bool = False,
) -> RunResultStreaming:
"""Approve the first interruption and resume a streamed run to completion."""
state = approve_first_interruption(result, always_approve=always_approve)
resumed = Runner.run_streamed(agent, state)
await consume_stream(resumed)
return resumed
async def run_and_resume_after_approval(
agent: Agent[Any],
model: Any,
raw_call: Any,
final_output: Any,
*,
user_input: str,
) -> RunResult:
"""Run, approve the first interruption, and resume."""
model.set_next_output([raw_call])
first = await Runner.run(agent, user_input)
state = approve_first_interruption(first, always_approve=True)
model.set_next_output([final_output])
return await Runner.run(agent, state)
def collect_tool_outputs(
items: Iterable[Any],
*,
output_type: str,
) -> list[ToolCallOutputItem]:
"""Return ToolCallOutputItems matching a raw_item type."""
return [
item
for item in items
if isinstance(item, ToolCallOutputItem)
and isinstance(item.raw_item, dict)
and item.raw_item.get("type") == output_type
]
async def consume_stream(result: Any) -> None:
"""Drain all stream events to completion."""
async for _ in result.stream_events():
pass
def assert_single_approval_interruption(
result: SingleStepResult,
*,
tool_name: str | None = None,
) -> ToolApprovalItem:
"""Assert the result contains exactly one approval interruption and return it."""
assert isinstance(result.next_step, NextStepInterruption)
assert len(result.next_step.interruptions) == 1
interruption = result.next_step.interruptions[0]
assert isinstance(interruption, ToolApprovalItem)
if tool_name:
assert interruption.tool_name == tool_name
return interruption
async def require_approval(
_ctx: Any | None = None, _params: Any = None, _call_id: str | None = None
) -> bool:
"""Approval helper that always requires a HITL decision."""
return True
class RecordingEditor:
"""Editor that records operations for testing."""
def __init__(self) -> None:
self.operations: list[Any] = []
def create_file(self, operation: Any) -> Any:
self.operations.append(operation)
return {"output": f"Created {operation.path}", "status": "completed"}
def update_file(self, operation: Any) -> Any:
self.operations.append(operation)
return {"output": f"Updated {operation.path}", "status": "completed"}
def delete_file(self, operation: Any) -> Any:
self.operations.append(operation)
return {"output": f"Deleted {operation.path}", "status": "completed"}
def make_shell_call(
call_id: str,
*,
id_value: str | None = None,
commands: list[str] | None = None,
status: str = "in_progress",
) -> TResponseOutputItem:
"""Build a shell_call payload with optional overrides."""
return cast(
TResponseOutputItem,
{
"type": "shell_call",
"id": id_value or call_id,
"call_id": call_id,
"status": status,
"action": {"type": "exec", "commands": commands or ["echo test"], "timeout_ms": 1000},
},
)
def make_apply_patch_dict(call_id: str, diff: str = "-a\n+b\n") -> TResponseOutputItem:
"""Create an apply_patch_call dict payload."""
return cast(
TResponseOutputItem,
{
"type": "apply_patch_call",
"call_id": call_id,
"operation": {"type": "update_file", "path": "test.md", "diff": diff},
},
)
def make_function_tool_call(
name: str,
*,
call_id: str = "call-1",
arguments: str = "{}",
namespace: str | None = None,
) -> ResponseFunctionToolCall:
"""Create a ResponseFunctionToolCall for HITL scenarios."""
if namespace is None:
return ResponseFunctionToolCall(
type="function_call",
name=name,
call_id=call_id,
arguments=arguments,
)
return ResponseFunctionToolCall(
type="function_call",
name=name,
call_id=call_id,
arguments=arguments,
namespace=namespace,
)
def queue_function_call_and_text(
model: FakeModel,
function_call: TResponseOutputItem,
*,
first_turn_extra: Sequence[TResponseOutputItem] | None = None,
followup: Sequence[TResponseOutputItem] | None = None,
) -> None:
"""Queue a function call turn followed by a follow-up turn on the fake model."""
raw_type = (
function_call.get("type")
if isinstance(function_call, dict)
else getattr(function_call, "type", None)
)
assert raw_type == "function_call", "queue_function_call_and_text expects a function call item"
model.add_multiple_turn_outputs(
[
[function_call, *(first_turn_extra or [])],
list(followup or []),
]
)
async def run_and_resume_with_mutation(
agent: Agent[Any],
model: Any,
turn_outputs: Sequence[Sequence[Any]],
*,
user_input: str,
mutate_state: Callable[[RunStateClass[Any, Agent[Any]], ToolApprovalItem], None] | None = None,
) -> tuple[RunResult, RunResult]:
"""Run until interruption, optionally mutate state, then resume."""
model.add_multiple_turn_outputs(turn_outputs)
first = await Runner.run(agent, input=user_input)
assert first.interruptions, "expected an approval interruption"
state = first.to_state()
if mutate_state and first.interruptions:
mutate_state(state, first.interruptions[0])
resumed = await Runner.run(agent, input=state)
return first, resumed
async def assert_pending_resume(
tool: Any,
model: Any,
raw_call: TResponseOutputItem,
*,
user_input: str,
output_type: str,
) -> RunResult:
"""Run, resume, and assert pending approvals stay pending."""
agent = make_agent(model=model, tools=[tool])
resumed = await run_and_resume(agent, model, raw_call, user_input=user_input)
assert resumed.interruptions, "pending approval should remain after resuming"
assert any(
isinstance(item, ToolApprovalItem) and item.tool_name == tool.name
for item in resumed.interruptions
)
assert not collect_tool_outputs(resumed.new_items, output_type=output_type), (
f"{output_type} should not execute without approval"
)
return resumed
def make_mcp_raw_item(
*,
call_id: str = "call_mcp_1",
include_provider_data: bool = True,
tool_name: str = "test_mcp_tool",
provider_data: dict[str, Any] | None = None,
include_name: bool = True,
use_call_id: bool = True,
) -> dict[str, Any]:
"""Build a hosted MCP tool call payload for approvals."""
raw_item: dict[str, Any] = {"type": "hosted_tool_call"}
if include_name:
raw_item["name"] = tool_name
if include_provider_data:
if use_call_id:
raw_item["call_id"] = call_id
else:
raw_item["id"] = call_id
raw_item["provider_data"] = provider_data or {
"type": "mcp_approval_request",
"id": "req-1",
"server_label": "test_server",
}
else:
raw_item["id"] = call_id
return raw_item
def make_mcp_approval_item(
agent: Agent[Any],
*,
call_id: str = "call_mcp_1",
include_provider_data: bool = True,
tool_name: str | None = "test_mcp_tool",
provider_data: dict[str, Any] | None = None,
include_name: bool = True,
use_call_id: bool = True,
) -> ToolApprovalItem:
"""Create a ToolApprovalItem for MCP or hosted tool calls."""
raw_item = make_mcp_raw_item(
call_id=call_id,
include_provider_data=include_provider_data,
tool_name=tool_name or "unknown_mcp_tool",
provider_data=provider_data,
include_name=include_name,
use_call_id=use_call_id,
)
return ToolApprovalItem(agent=agent, raw_item=raw_item, tool_name=tool_name)
def make_context_wrapper() -> RunContextWrapper[dict[str, Any]]:
"""Create an empty RunContextWrapper for HITL tests."""
return RunContextWrapper(context={})
def make_agent(
*,
model: Any | None = None,
tools: Sequence[Any] | None = None,
name: str = "TestAgent",
) -> Agent[Any]:
"""Build a test Agent with optional model and tools."""
return Agent(name=name, model=model, tools=list(tools or []))
def make_model_and_agent(
*,
tools: Sequence[Any] | None = None,
name: str = "TestAgent",
) -> tuple[FakeModel, Agent[Any]]:
"""Build a FakeModel with a paired Agent for HITL tests."""
model = FakeModel()
agent = make_agent(model=model, tools=tools, name=name)
return model, agent
def reject_tool_call(
context_wrapper: RunContextWrapper[Any],
agent: Agent[Any],
raw_item: Any,
tool_name: str,
*,
rejection_message: str | None = None,
) -> ToolApprovalItem:
"""Reject a tool call in the context and return the approval item used."""
approval_item = ToolApprovalItem(agent=agent, raw_item=raw_item, tool_name=tool_name)
context_wrapper.reject_tool(approval_item, rejection_message=rejection_message)
return approval_item
def make_on_approval_callback(
approve: bool,
*,
reason: str | None = None,
) -> Callable[[RunContextWrapper[Any], ToolApprovalItem], Awaitable[Any]]:
"""Build an on_approval callback that always approves or rejects."""
async def on_approval(
_ctx: RunContextWrapper[Any], _approval_item: ToolApprovalItem
) -> dict[str, Any]:
payload: dict[str, Any] = {"approve": approve}
if reason:
payload["reason"] = reason
return payload
return on_approval