test_failure_contract.py 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. from __future__ import annotations
  2. import json
  3. import logging
  4. import pytest
  5. from agent import FailureDetail, FailureDisposition, ToolExecutionError, ToolResult
  6. from agent.tools.registry import ToolRegistry
  7. def _schema(name: str) -> dict:
  8. return {
  9. "type": "function",
  10. "function": {
  11. "name": name,
  12. "description": name,
  13. "parameters": {"type": "object", "properties": {}},
  14. },
  15. }
  16. def test_failure_detail_is_bounded_json_safe_and_round_trips() -> None:
  17. failure = FailureDetail(
  18. code="INPUT_SCOPE_MISMATCH",
  19. message="x" * 3_000,
  20. disposition=FailureDisposition.REPLAN_TASK,
  21. source_tool="save_candidate",
  22. details={"task_id": "task-1", "scope": ["paragraph", 3]},
  23. )
  24. assert len(failure.message) == 2_000
  25. assert FailureDetail.from_dict(failure.to_dict()) == failure
  26. assert failure.fingerprint() == FailureDetail(
  27. code="INPUT_SCOPE_MISMATCH",
  28. message="different wording",
  29. disposition=FailureDisposition.REPLAN_TASK,
  30. source_tool="save_candidate",
  31. details={"task_id": "task-2", "scope": ["paragraph", 3]},
  32. ).fingerprint()
  33. def test_failure_detail_rejects_non_json_details() -> None:
  34. with pytest.raises(ValueError, match="JSON values"):
  35. FailureDetail(
  36. code="INVALID",
  37. message="invalid",
  38. disposition=FailureDisposition.RETRY_CALL,
  39. details={"value": object()},
  40. )
  41. def test_tool_result_exposes_structured_failure_to_model() -> None:
  42. failure = FailureDetail(
  43. code="REJECTED",
  44. message="retry with another value",
  45. disposition=FailureDisposition.RETRY_CALL,
  46. source_tool="sample",
  47. )
  48. result = ToolResult(title="rejected", output="context", failure=failure)
  49. payload = json.loads(result.to_llm_message())
  50. assert payload["failure"] == failure.to_dict()
  51. assert payload["output"] == "context"
  52. assert ToolExecutionError(failure).failure == failure
  53. @pytest.mark.asyncio
  54. async def test_registry_preserves_expected_failure_without_traceback(caplog) -> None:
  55. registry = ToolRegistry()
  56. async def sample() -> str:
  57. raise ToolExecutionError(
  58. FailureDetail(
  59. code="EXPECTED_REJECTION",
  60. message="change the contract",
  61. disposition=FailureDisposition.REPLAN_TASK,
  62. )
  63. )
  64. registry.register(sample, schema=_schema("sample"))
  65. with caplog.at_level(logging.WARNING):
  66. result = await registry.execute("sample", {})
  67. assert result["_control"]["failure"]["code"] == "EXPECTED_REJECTION"
  68. assert result["_control"]["failure"]["source_tool"] == "sample"
  69. assert not any(record.exc_info for record in caplog.records)
  70. assert registry.get_stats("sample")["sample"]["failure_count"] == 1
  71. @pytest.mark.asyncio
  72. async def test_registry_hides_unexpected_error_but_logs_traceback(caplog) -> None:
  73. registry = ToolRegistry()
  74. async def sample() -> str:
  75. raise RuntimeError("secret implementation detail")
  76. registry.register(sample, schema=_schema("sample"))
  77. with caplog.at_level(logging.ERROR):
  78. result = await registry.execute("sample", {})
  79. failure = result["_control"]["failure"]
  80. assert failure["code"] == "UNEXPECTED_TOOL_ERROR"
  81. assert "secret implementation detail" not in result["text"]
  82. assert any(record.exc_info for record in caplog.records)