test_failure_contract.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. from __future__ import annotations
  2. import json
  3. import logging
  4. import pytest
  5. from agent import (
  6. AgentRunner,
  7. FailureDetail,
  8. FailureDisposition,
  9. RunConfig,
  10. ToolExecutionError,
  11. ToolResult,
  12. )
  13. from agent.trace.store import FileSystemTraceStore
  14. from agent.tools.registry import ToolRegistry
  15. def _schema(name: str) -> dict:
  16. return {
  17. "type": "function",
  18. "function": {
  19. "name": name,
  20. "description": name,
  21. "parameters": {"type": "object", "properties": {}},
  22. },
  23. }
  24. def test_failure_detail_is_bounded_json_safe_and_round_trips() -> None:
  25. failure = FailureDetail(
  26. code="INPUT_SCOPE_MISMATCH",
  27. message="x" * 3_000,
  28. disposition=FailureDisposition.REPLAN_TASK,
  29. source_tool="save_candidate",
  30. details={"task_id": "task-1", "scope": ["paragraph", 3]},
  31. )
  32. assert len(failure.message) == 2_000
  33. assert FailureDetail.from_dict(failure.to_dict()) == failure
  34. assert failure.fingerprint() == FailureDetail(
  35. code="INPUT_SCOPE_MISMATCH",
  36. message="different wording",
  37. disposition=FailureDisposition.REPLAN_TASK,
  38. source_tool="save_candidate",
  39. details={"task_id": "task-2", "scope": ["paragraph", 3]},
  40. ).fingerprint()
  41. def test_failure_detail_rejects_non_json_details() -> None:
  42. with pytest.raises(ValueError, match="JSON values"):
  43. FailureDetail(
  44. code="INVALID",
  45. message="invalid",
  46. disposition=FailureDisposition.RETRY_CALL,
  47. details={"value": object()},
  48. )
  49. def test_tool_result_exposes_structured_failure_to_model() -> None:
  50. failure = FailureDetail(
  51. code="REJECTED",
  52. message="retry with another value",
  53. disposition=FailureDisposition.RETRY_CALL,
  54. source_tool="sample",
  55. )
  56. result = ToolResult(title="rejected", output="context", failure=failure)
  57. payload = json.loads(result.to_llm_message())
  58. assert payload["failure"] == failure.to_dict()
  59. assert payload["output"] == "context"
  60. assert ToolExecutionError(failure).failure == failure
  61. @pytest.mark.asyncio
  62. async def test_registry_preserves_expected_failure_without_traceback(caplog) -> None:
  63. registry = ToolRegistry()
  64. async def sample() -> str:
  65. raise ToolExecutionError(
  66. FailureDetail(
  67. code="EXPECTED_REJECTION",
  68. message="change the contract",
  69. disposition=FailureDisposition.REPLAN_TASK,
  70. )
  71. )
  72. registry.register(sample, schema=_schema("sample"))
  73. with caplog.at_level(logging.WARNING):
  74. result = await registry.execute("sample", {})
  75. assert result["_control"]["failure"]["code"] == "EXPECTED_REJECTION"
  76. assert result["_control"]["failure"]["source_tool"] == "sample"
  77. assert not any(record.exc_info for record in caplog.records)
  78. assert registry.get_stats("sample")["sample"]["failure_count"] == 1
  79. @pytest.mark.asyncio
  80. async def test_registry_hides_unexpected_error_but_logs_traceback(caplog) -> None:
  81. registry = ToolRegistry()
  82. async def sample() -> str:
  83. raise RuntimeError("secret implementation detail")
  84. registry.register(sample, schema=_schema("sample"))
  85. with caplog.at_level(logging.ERROR):
  86. result = await registry.execute("sample", {})
  87. failure = result["_control"]["failure"]
  88. assert failure["code"] == "UNEXPECTED_TOOL_ERROR"
  89. assert "secret implementation detail" not in result["text"]
  90. assert any(record.exc_info for record in caplog.records)
  91. @pytest.mark.asyncio
  92. @pytest.mark.parametrize("parallel", [False, True])
  93. async def test_runner_hides_unexpected_errors_in_both_tool_paths(
  94. tmp_path,
  95. caplog,
  96. parallel,
  97. ) -> None:
  98. registry = ToolRegistry()
  99. async def sample() -> str:
  100. return "unused"
  101. registry.register(sample, schema=_schema("sample"))
  102. async def llm_call(**_kwargs):
  103. return {
  104. "content": "",
  105. "tool_calls": [{
  106. "id": "call-sample",
  107. "type": "function",
  108. "function": {"name": "sample", "arguments": "{}"},
  109. }],
  110. "finish_reason": "tool_calls",
  111. }
  112. runner = AgentRunner(
  113. trace_store=FileSystemTraceStore(str(tmp_path)),
  114. tool_registry=registry,
  115. llm_call=llm_call,
  116. )
  117. async def broken_executor(*_args, **_kwargs):
  118. raise RuntimeError("private framework traceback")
  119. runner._execute_authorized_tool = broken_executor # type: ignore[method-assign]
  120. from agent.tools.builtin.knowledge import KnowledgeConfig
  121. with caplog.at_level(logging.ERROR):
  122. result = await runner.run_result(
  123. [{"role": "user", "content": "run"}],
  124. RunConfig(
  125. max_iterations=2,
  126. tools=["sample"],
  127. tool_groups=[],
  128. parallel_tool_execution=parallel,
  129. knowledge=KnowledgeConfig(
  130. enable_extraction=False,
  131. enable_completion_extraction=False,
  132. enable_injection=False,
  133. ),
  134. ),
  135. )
  136. assert result["status"] == "failed"
  137. assert result["failure"]["code"] == "UNEXPECTED_TOOL_ERROR"
  138. messages = await runner.trace_store.get_trace_messages(result["trace_id"])
  139. assert "private framework traceback" not in "\n".join(str(item.content) for item in messages)
  140. assert any(record.exc_info for record in caplog.records)