| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105 |
- from __future__ import annotations
- import json
- import logging
- import pytest
- from agent import FailureDetail, FailureDisposition, ToolExecutionError, ToolResult
- from agent.tools.registry import ToolRegistry
- def _schema(name: str) -> dict:
- return {
- "type": "function",
- "function": {
- "name": name,
- "description": name,
- "parameters": {"type": "object", "properties": {}},
- },
- }
- def test_failure_detail_is_bounded_json_safe_and_round_trips() -> None:
- failure = FailureDetail(
- code="INPUT_SCOPE_MISMATCH",
- message="x" * 3_000,
- disposition=FailureDisposition.REPLAN_TASK,
- source_tool="save_candidate",
- details={"task_id": "task-1", "scope": ["paragraph", 3]},
- )
- assert len(failure.message) == 2_000
- assert FailureDetail.from_dict(failure.to_dict()) == failure
- assert failure.fingerprint() == FailureDetail(
- code="INPUT_SCOPE_MISMATCH",
- message="different wording",
- disposition=FailureDisposition.REPLAN_TASK,
- source_tool="save_candidate",
- details={"task_id": "task-2", "scope": ["paragraph", 3]},
- ).fingerprint()
- def test_failure_detail_rejects_non_json_details() -> None:
- with pytest.raises(ValueError, match="JSON values"):
- FailureDetail(
- code="INVALID",
- message="invalid",
- disposition=FailureDisposition.RETRY_CALL,
- details={"value": object()},
- )
- def test_tool_result_exposes_structured_failure_to_model() -> None:
- failure = FailureDetail(
- code="REJECTED",
- message="retry with another value",
- disposition=FailureDisposition.RETRY_CALL,
- source_tool="sample",
- )
- result = ToolResult(title="rejected", output="context", failure=failure)
- payload = json.loads(result.to_llm_message())
- assert payload["failure"] == failure.to_dict()
- assert payload["output"] == "context"
- assert ToolExecutionError(failure).failure == failure
- @pytest.mark.asyncio
- async def test_registry_preserves_expected_failure_without_traceback(caplog) -> None:
- registry = ToolRegistry()
- async def sample() -> str:
- raise ToolExecutionError(
- FailureDetail(
- code="EXPECTED_REJECTION",
- message="change the contract",
- disposition=FailureDisposition.REPLAN_TASK,
- )
- )
- registry.register(sample, schema=_schema("sample"))
- with caplog.at_level(logging.WARNING):
- result = await registry.execute("sample", {})
- assert result["_control"]["failure"]["code"] == "EXPECTED_REJECTION"
- assert result["_control"]["failure"]["source_tool"] == "sample"
- assert not any(record.exc_info for record in caplog.records)
- assert registry.get_stats("sample")["sample"]["failure_count"] == 1
- @pytest.mark.asyncio
- async def test_registry_hides_unexpected_error_but_logs_traceback(caplog) -> None:
- registry = ToolRegistry()
- async def sample() -> str:
- raise RuntimeError("secret implementation detail")
- registry.register(sample, schema=_schema("sample"))
- with caplog.at_level(logging.ERROR):
- result = await registry.execute("sample", {})
- failure = result["_control"]["failure"]
- assert failure["code"] == "UNEXPECTED_TOOL_ERROR"
- assert "secret implementation detail" not in result["text"]
- assert any(record.exc_info for record in caplog.records)
|