mirror of
https://github.com/usestrix/strix.git
synced 2026-08-21 10:48:59 +02:00
fix(core): recover from hallucinated tool names instead of ending the scan
A tool call for a name Strix does not register raised ModelBehaviorError from the SDK turn resolver, which nothing retries: the root agent's raise tore down the whole scan and a sub-agent died before its status was set. Opt into the SDK's tool_not_found_behavior="return_error_to_model" so the unknown call comes back as a tool result and the agent self-corrects. The setting landed in openai-agents 0.19.0, which requires openai>=2.45, so both pins move.
This commit is contained in:
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from agents import ModelSettings
|
||||
from openai import RateLimitError
|
||||
|
||||
import strix.tools.notes.tools as notes_tools
|
||||
@@ -64,7 +65,7 @@ async def test_persistent_rate_limit_stops_gracefully(
|
||||
|
||||
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
|
||||
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "")
|
||||
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object())
|
||||
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: ModelSettings())
|
||||
monkeypatch.setattr(runner, "build_strix_agent", lambda **_kwargs: object())
|
||||
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
|
||||
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from agents import ModelSettings
|
||||
from openai import RateLimitError
|
||||
|
||||
import strix.tools.notes.tools as notes_tools
|
||||
@@ -74,7 +75,7 @@ def _patch_engine_scaffold(
|
||||
|
||||
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
|
||||
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: scope_context)
|
||||
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object())
|
||||
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: ModelSettings())
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
@@ -87,7 +88,8 @@ def _patch_engine_scaffold(
|
||||
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
|
||||
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
|
||||
|
||||
async def _raise_rate_limit(*_args: Any, **_kwargs: Any) -> None:
|
||||
async def _raise_rate_limit(*_args: Any, **kwargs: Any) -> None:
|
||||
captured["run_config"] = kwargs.get("run_config")
|
||||
raise _make_rate_limit_error()
|
||||
|
||||
monkeypatch.setattr(runner, "run_agent_loop", _raise_rate_limit)
|
||||
@@ -176,3 +178,21 @@ async def test_root_prompt_options_default_to_none(
|
||||
kwargs = captured["kwargs"]
|
||||
assert kwargs["instructions_override"] is None
|
||||
assert kwargs["system_prompt_context"] == {"scope": "built-in"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_tool_calls_are_returned_to_the_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
"""A hallucinated tool name must not end the scan."""
|
||||
captured = _patch_engine_scaffold(monkeypatch, tmp_path, {})
|
||||
|
||||
await runner.run_strix_scan(
|
||||
scan_config={"targets": [], "scan_mode": "deep"},
|
||||
scan_id="scan-unknown-tool",
|
||||
image="img",
|
||||
coordinator=AgentCoordinator(),
|
||||
)
|
||||
|
||||
assert captured["run_config"].tool_not_found_behavior == "return_error_to_model"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for surviving a hallucinated tool name.
|
||||
|
||||
Models regularly invent tool names that Strix does not register (``read_file``
|
||||
is a common one, borrowed from other agent frameworks). The SDK's default is to
|
||||
raise ``ModelBehaviorError``, which ends the whole run: nothing in Strix retries
|
||||
it, so one bad token discards a scan. The runner therefore opts into
|
||||
``tool_not_found_behavior="return_error_to_model"`` so the unknown call comes
|
||||
back as a tool result and the agent corrects itself on the next turn.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from agents import Agent, Runner, function_tool
|
||||
from agents.exceptions import ModelBehaviorError
|
||||
from agents.models.interface import Model, ModelProvider
|
||||
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
||||
from agents.run import RunConfig
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from strix.config.models import _NonStreamingModel
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
_TURNS: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def _unknown_tool_call_completion() -> dict[str, Any]:
|
||||
return {
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gw-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": '{"path": "/etc/passwd"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
|
||||
}
|
||||
|
||||
|
||||
def _text_completion(text: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": "chatcmpl-2",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gw-model",
|
||||
"choices": [
|
||||
{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": text}}
|
||||
],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
|
||||
}
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""Calls an unregistered tool on turn 1, then answers on turn 2."""
|
||||
|
||||
def log_message(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
_TURNS.append(json.loads(self.rfile.read(length) or b"{}"))
|
||||
completion = (
|
||||
_unknown_tool_call_completion() if len(_TURNS) == 1 else _text_completion("recovered")
|
||||
)
|
||||
payload = json.dumps(completion).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_url() -> Iterator[str]:
|
||||
_TURNS.clear()
|
||||
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}/v1"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _agent() -> Agent[Any]:
|
||||
@function_tool
|
||||
def real_tool(n: int) -> str:
|
||||
return f"did {n}"
|
||||
|
||||
return Agent(name="Strix", instructions="test", tools=[real_tool], model="gw-model")
|
||||
|
||||
|
||||
def _run_config(base_url: str, **kwargs: Any) -> RunConfig:
|
||||
class _Provider(ModelProvider):
|
||||
def get_model(self, model_name: str | None) -> Model: # noqa: ARG002
|
||||
client = AsyncOpenAI(api_key="tok", base_url=base_url)
|
||||
return _NonStreamingModel(OpenAIChatCompletionsModel("gw-model", openai_client=client))
|
||||
|
||||
return RunConfig(model_provider=_Provider(), **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_tool_call_is_returned_to_the_model(gateway_url: str) -> None:
|
||||
result = Runner.run_streamed(
|
||||
_agent(),
|
||||
input="go",
|
||||
run_config=_run_config(gateway_url, tool_not_found_behavior="return_error_to_model"),
|
||||
)
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
|
||||
assert result.final_output == "recovered"
|
||||
# The second turn carries the error back to the model as a tool result.
|
||||
tool_results = [
|
||||
item
|
||||
for item in _TURNS[1]["messages"]
|
||||
if item.get("role") == "tool" and item.get("tool_call_id") == "call_1"
|
||||
]
|
||||
assert tool_results
|
||||
assert "read_file" in str(tool_results[0]["content"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_tool_call_kills_the_run_without_the_setting(gateway_url: str) -> None:
|
||||
result = Runner.run_streamed(_agent(), input="go", run_config=_run_config(gateway_url))
|
||||
with pytest.raises(ModelBehaviorError, match="read_file"):
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
Reference in New Issue
Block a user