441 lines
15 KiB
Python
441 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
|
|
from agents import (
|
|
Agent,
|
|
ApplyPatchTool,
|
|
RunConfig,
|
|
RunContextWrapper,
|
|
RunHooks,
|
|
set_tracing_disabled,
|
|
trace,
|
|
)
|
|
from agents.editor import ApplyPatchOperation, ApplyPatchResult
|
|
from agents.items import ToolApprovalItem, ToolCallOutputItem
|
|
from agents.run_internal.run_loop import ApplyPatchAction, ToolRunApplyPatchCall
|
|
|
|
from .testing_processor import SPAN_PROCESSOR_TESTING
|
|
from .utils.hitl import (
|
|
HITL_REJECTION_MSG,
|
|
make_context_wrapper,
|
|
make_on_approval_callback,
|
|
reject_tool_call,
|
|
require_approval,
|
|
)
|
|
|
|
|
|
def _get_function_span(tool_name: str) -> dict[str, Any]:
|
|
for span in SPAN_PROCESSOR_TESTING.get_ordered_spans(including_empty=True):
|
|
exported = span.export()
|
|
if not exported:
|
|
continue
|
|
span_data = exported.get("span_data")
|
|
if not isinstance(span_data, dict):
|
|
continue
|
|
if span_data.get("type") == "function" and span_data.get("name") == tool_name:
|
|
return exported
|
|
raise AssertionError(f"Function span for tool '{tool_name}' not found")
|
|
|
|
|
|
def _call(call_id: str, operation: dict[str, Any]) -> DummyApplyPatchCall:
|
|
return DummyApplyPatchCall(type="apply_patch_call", call_id=call_id, operation=operation)
|
|
|
|
|
|
def build_apply_patch_call(
|
|
tool: ApplyPatchTool,
|
|
call_id: str,
|
|
operation: dict[str, Any],
|
|
*,
|
|
context_wrapper: RunContextWrapper[Any] | None = None,
|
|
) -> tuple[Agent[Any], RunContextWrapper[Any], ToolRunApplyPatchCall]:
|
|
ctx = context_wrapper or make_context_wrapper()
|
|
agent = Agent(name="patcher", tools=[tool])
|
|
tool_run = ToolRunApplyPatchCall(tool_call=_call(call_id, operation), apply_patch_tool=tool)
|
|
return agent, ctx, tool_run
|
|
|
|
|
|
@dataclass
|
|
class DummyApplyPatchCall:
|
|
type: str
|
|
call_id: str
|
|
operation: dict[str, Any]
|
|
|
|
|
|
class RecordingEditor:
|
|
def __init__(self) -> None:
|
|
self.operations: list[ApplyPatchOperation] = []
|
|
|
|
def create_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
|
self.operations.append(operation)
|
|
return ApplyPatchResult(output=f"Created {operation.path}")
|
|
|
|
def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
|
self.operations.append(operation)
|
|
return ApplyPatchResult(status="completed", output=f"Updated {operation.path}")
|
|
|
|
def delete_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
|
self.operations.append(operation)
|
|
return ApplyPatchResult(output=f"Deleted {operation.path}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_tool_success() -> None:
|
|
editor = RecordingEditor()
|
|
tool = ApplyPatchTool(editor=editor)
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool, "call_apply", {"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"}
|
|
)
|
|
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
assert "Updated tasks.md" in result.output
|
|
raw_item = cast(dict[str, Any], result.raw_item)
|
|
assert raw_item["type"] == "apply_patch_call_output"
|
|
assert raw_item["status"] == "completed"
|
|
assert raw_item["call_id"] == "call_apply"
|
|
assert editor.operations[0].type == "update_file"
|
|
assert editor.operations[0].ctx_wrapper is context_wrapper
|
|
assert isinstance(raw_item["output"], str)
|
|
assert raw_item["output"].startswith("Updated tasks.md")
|
|
input_payload = result.to_input_item()
|
|
assert isinstance(input_payload, dict)
|
|
payload_dict = cast(dict[str, Any], input_payload)
|
|
assert payload_dict["type"] == "apply_patch_call_output"
|
|
assert payload_dict["status"] == "completed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_tool_failure() -> None:
|
|
class ExplodingEditor(RecordingEditor):
|
|
def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
|
raise RuntimeError("boom")
|
|
|
|
tool = ApplyPatchTool(editor=ExplodingEditor())
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool, "call_apply_fail", {"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"}
|
|
)
|
|
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
assert "boom" in result.output
|
|
raw_item = cast(dict[str, Any], result.raw_item)
|
|
assert raw_item["status"] == "failed"
|
|
assert isinstance(raw_item.get("output"), str)
|
|
input_payload = result.to_input_item()
|
|
assert isinstance(input_payload, dict)
|
|
payload_dict = cast(dict[str, Any], input_payload)
|
|
assert payload_dict["type"] == "apply_patch_call_output"
|
|
assert payload_dict["status"] == "failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_tool_emits_function_span() -> None:
|
|
editor = RecordingEditor()
|
|
tool = ApplyPatchTool(editor=editor)
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool, "call_apply_trace", {"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"}
|
|
)
|
|
|
|
set_tracing_disabled(False)
|
|
with trace("apply-patch-span-test"):
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
function_span = _get_function_span(tool.name)
|
|
span_data = cast(dict[str, Any], function_span["span_data"])
|
|
assert "tasks.md" in cast(str, span_data.get("input", ""))
|
|
assert "Updated tasks.md" in cast(str, span_data.get("output", ""))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_tool_redacts_span_error_when_sensitive_data_disabled() -> None:
|
|
secret_error = "patch secret output"
|
|
|
|
class ExplodingEditor(RecordingEditor):
|
|
def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
|
raise RuntimeError(secret_error)
|
|
|
|
tool = ApplyPatchTool(editor=ExplodingEditor())
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool,
|
|
"call_apply_trace_redacted",
|
|
{"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"},
|
|
)
|
|
|
|
set_tracing_disabled(False)
|
|
with trace("apply-patch-span-redaction-test"):
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(trace_include_sensitive_data=False),
|
|
)
|
|
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
function_span = _get_function_span(tool.name)
|
|
assert function_span.get("error") == {
|
|
"message": "Error running tool",
|
|
"data": {
|
|
"tool_name": tool.name,
|
|
"error": "Tool execution failed. Error details are redacted.",
|
|
},
|
|
}
|
|
assert secret_error not in json.dumps(function_span)
|
|
span_data = cast(dict[str, Any], function_span["span_data"])
|
|
assert span_data.get("input") is None
|
|
assert span_data.get("output") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_tool_accepts_mapping_call() -> None:
|
|
editor = RecordingEditor()
|
|
tool = ApplyPatchTool(editor=editor)
|
|
tool_call: dict[str, Any] = {
|
|
"type": "apply_patch_call",
|
|
"call_id": "call_mapping",
|
|
"operation": {
|
|
"type": "create_file",
|
|
"path": "notes.md",
|
|
"diff": "+hello\n",
|
|
},
|
|
}
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool,
|
|
"call_mapping",
|
|
tool_call["operation"],
|
|
context_wrapper=RunContextWrapper(context=None),
|
|
)
|
|
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
raw_item = cast(dict[str, Any], result.raw_item)
|
|
assert raw_item["call_id"] == "call_mapping"
|
|
assert editor.operations[0].path == "notes.md"
|
|
assert editor.operations[0].ctx_wrapper is context_wrapper
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_tool_needs_approval_returns_approval_item() -> None:
|
|
"""Test that apply_patch tool with needs_approval=True returns ToolApprovalItem."""
|
|
|
|
editor = RecordingEditor()
|
|
tool = ApplyPatchTool(editor=editor, needs_approval=require_approval)
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool, "call_apply", {"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"}
|
|
)
|
|
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert isinstance(result, ToolApprovalItem)
|
|
assert result.tool_name == "apply_patch"
|
|
assert result.name == "apply_patch"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_tool_needs_approval_rejected_returns_rejection() -> None:
|
|
"""Test that apply_patch tool with needs_approval that is rejected returns rejection output."""
|
|
|
|
editor = RecordingEditor()
|
|
tool = ApplyPatchTool(editor=editor, needs_approval=require_approval)
|
|
tool_call = _call("call_apply", {"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"})
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool, "call_apply", tool_call.operation, context_wrapper=make_context_wrapper()
|
|
)
|
|
|
|
# Pre-reject the tool call
|
|
reject_tool_call(context_wrapper, agent, cast(dict[str, Any], tool_call), "apply_patch")
|
|
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
assert HITL_REJECTION_MSG in result.output
|
|
raw_item = cast(dict[str, Any], result.raw_item)
|
|
assert raw_item["type"] == "apply_patch_call_output"
|
|
assert raw_item["status"] == "failed"
|
|
assert raw_item["output"] == HITL_REJECTION_MSG
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_rejection_uses_run_level_formatter() -> None:
|
|
"""Apply patch approval rejection should use the run-level formatter message."""
|
|
|
|
editor = RecordingEditor()
|
|
tool = ApplyPatchTool(
|
|
editor=editor,
|
|
needs_approval=require_approval,
|
|
)
|
|
tool_call = _call("call_apply", {"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"})
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool, "call_apply", tool_call.operation, context_wrapper=make_context_wrapper()
|
|
)
|
|
|
|
reject_tool_call(context_wrapper, agent, cast(dict[str, Any], tool_call), "apply_patch")
|
|
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(
|
|
tool_error_formatter=lambda args: f"{args.tool_name} denied ({args.call_id})"
|
|
),
|
|
)
|
|
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
assert result.output == "apply_patch denied (call_apply)"
|
|
raw_item = cast(dict[str, Any], result.raw_item)
|
|
assert raw_item["output"] == "apply_patch denied (call_apply)"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_tool_on_approval_callback_auto_approves() -> None:
|
|
"""Test that apply_patch tool on_approval callback can auto-approve."""
|
|
|
|
editor = RecordingEditor()
|
|
tool = ApplyPatchTool(
|
|
editor=editor,
|
|
needs_approval=require_approval,
|
|
on_approval=make_on_approval_callback(approve=True),
|
|
)
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool, "call_apply", {"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"}
|
|
)
|
|
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
# Should execute normally since on_approval auto-approved
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
assert "Updated tasks.md" in result.output
|
|
assert len(editor.operations) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_tool_on_approval_callback_auto_rejects() -> None:
|
|
"""Test that apply_patch tool on_approval callback can auto-reject."""
|
|
|
|
editor = RecordingEditor()
|
|
tool = ApplyPatchTool(
|
|
editor=editor,
|
|
needs_approval=require_approval,
|
|
on_approval=make_on_approval_callback(approve=False, reason="Not allowed"),
|
|
)
|
|
agent, context_wrapper, tool_run = build_apply_patch_call(
|
|
tool, "call_apply", {"type": "update_file", "path": "tasks.md", "diff": "-a\n+b\n"}
|
|
)
|
|
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
# Should return rejection output
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
assert result.output == "Not allowed"
|
|
raw_item = cast(dict[str, Any], result.raw_item)
|
|
assert raw_item["output"] == "Not allowed"
|
|
assert len(editor.operations) == 0 # Should not have executed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_patch_failed_status_not_overwritten_by_later_completed_op() -> None:
|
|
"""If any operation reports `failed`, the overall apply_patch status must remain `failed`,
|
|
even when subsequent operations succeed."""
|
|
|
|
class MixedStatusEditor(RecordingEditor):
|
|
def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
|
self.operations.append(operation)
|
|
return ApplyPatchResult(status="failed", output=f"Failed {operation.path}")
|
|
|
|
def create_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
|
self.operations.append(operation)
|
|
return ApplyPatchResult(status="completed", output=f"Created {operation.path}")
|
|
|
|
@dataclass
|
|
class MultiOpCall:
|
|
type: str
|
|
call_id: str
|
|
operations: list[dict[str, Any]]
|
|
|
|
editor = MixedStatusEditor()
|
|
tool = ApplyPatchTool(editor=editor)
|
|
multi_call = MultiOpCall(
|
|
type="apply_patch_call",
|
|
call_id="call_multi",
|
|
operations=[
|
|
{"type": "update_file", "path": "a.md", "diff": "-x\n+y\n"},
|
|
{"type": "create_file", "path": "b.md", "diff": "+hi\n"},
|
|
],
|
|
)
|
|
agent = Agent(name="patcher", tools=[tool])
|
|
context_wrapper = make_context_wrapper()
|
|
tool_run = ToolRunApplyPatchCall(tool_call=multi_call, apply_patch_tool=tool)
|
|
|
|
result = await ApplyPatchAction.execute(
|
|
agent=agent,
|
|
call=tool_run,
|
|
hooks=RunHooks[Any](),
|
|
context_wrapper=context_wrapper,
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert isinstance(result, ToolCallOutputItem)
|
|
raw_item = cast(dict[str, Any], result.raw_item)
|
|
# The first op failed; the second succeeded. Overall status must reflect the failure.
|
|
assert raw_item["status"] == "failed"
|
|
assert "Failed a.md" in result.output
|
|
assert "Created b.md" in result.output
|