diff --git a/EvoScientist/middleware/recoverable_tools.py b/EvoScientist/middleware/recoverable_tools.py index cff25f8..da5f274 100644 --- a/EvoScientist/middleware/recoverable_tools.py +++ b/EvoScientist/middleware/recoverable_tools.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: _READ_ONLY_PREFIXES = ("read_", "get_", "list_", "search_", "find_", "check_") _READ_ONLY_NAMES = { + "tavily_search", "web_search", "glob", "grep", @@ -136,6 +137,7 @@ class RecoverableToolEffectMiddleware(AgentMiddleware): tool_call_id = str(tool_call.get("id") or "") arguments = tool_call.get("args") or {} request_hash = _hash(arguments) + effect_class = _effect_class(tool_name) checkpoint_ns = str(metadata.get("checkpoint_ns") or metadata.get("langgraph_checkpoint_ns") or "") task_path = ":".join( str(metadata.get(name) or "") @@ -160,7 +162,7 @@ class RecoverableToolEffectMiddleware(AgentMiddleware): "task_path": task_path, "tool_call_id": tool_call_id, "tool_name": tool_name, - "effect_class": _effect_class(tool_name), + "effect_class": effect_class, "request_hash": request_hash, }, ) @@ -233,6 +235,8 @@ class RecoverableToolEffectMiddleware(AgentMiddleware): tool_name, effect_id, ) + if effect_class != "read_only": + raise EvoRuntimeError("TOOL_EFFECT_TERMINAL_UNAVAILABLE") from None return ToolMessage( content="TOOL_EFFECT_TERMINAL_UNAVAILABLE", tool_call_id=tool_call_id, diff --git a/tests/test_recoverable_tools.py b/tests/test_recoverable_tools.py index 4ad0e60..19522bc 100644 --- a/tests/test_recoverable_tools.py +++ b/tests/test_recoverable_tools.py @@ -11,6 +11,10 @@ from EvoScientist.llm.contracts import EvoRuntimeError from EvoScientist.middleware import recoverable_tools +def test_tavily_search_is_classified_read_only(): + assert recoverable_tools._effect_class("tavily_search") == "read_only" + + def test_tool_effect_context_prefers_dedicated_grant(monkeypatch): monkeypatch.setattr( "langgraph.config.get_config", @@ -220,3 +224,44 @@ async def test_terminal_callback_semantic_error_remains_fail_closed(monkeypatch) ) assert exc_info.value.code == "RUN_FENCE_LOST" + + +@pytest.mark.asyncio +async def test_non_idempotent_terminal_transport_failure_is_fail_closed(monkeypatch): + request = SimpleNamespace( + tool_call={"id": "tool-4", "name": "send_message", "args": {"text": "hello"}} + ) + monkeypatch.setattr( + recoverable_tools, + "_context", + lambda: ( + { + "gateway_url": "http://gateway", + "run_id": "run-1", + "envelope_signature": "signature", + }, + {}, + ), + ) + + async def fake_post(_proxy, phase, _payload): + if phase == "prepare": + return {"action": "execute", "fencing_token": 10} + raise httpx.ConnectTimeout("terminal callback timed out") + + async def handler(_request): + return ToolMessage( + content="message sent", + tool_call_id="tool-4", + name="send_message", + ) + + monkeypatch.setattr(recoverable_tools, "_post", fake_post) + + with pytest.raises(EvoRuntimeError) as exc_info: + await recoverable_tools.RecoverableToolEffectMiddleware().awrap_tool_call( + cast(Any, request), + handler, + ) + + assert exc_info.value.code == "TOOL_EFFECT_TERMINAL_UNAVAILABLE"