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:
Ahmed Allam
2026-08-04 06:14:54 +03:00
parent 6f70b6f319
commit 4a455b1e62
7 changed files with 194 additions and 27 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ repos:
fastapi, fastapi,
pytest, pytest,
hatchling, hatchling,
"openai-agents[litellm]==0.14.6", "openai-agents[litellm]>=0.19.0,<0.20",
] ]
args: [--install-types, --non-interactive] args: [--install-types, --non-interactive]
+3 -2
View File
@@ -33,8 +33,8 @@ classifiers = [
"Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.14",
] ]
dependencies = [ dependencies = [
"openai-agents[litellm]==0.14.6", "openai-agents[litellm]>=0.19.0,<0.20",
"openai>=2.26.0,<2.45", "openai>=2.45.0,<3",
"litellm", "litellm",
"pydantic>=2.11.3", "pydantic>=2.11.3",
"pydantic-settings>=2.13.0", "pydantic-settings>=2.13.0",
@@ -233,6 +233,7 @@ ignore = [
"strix/interface/auth_cli.py" = ["N802"] "strix/interface/auth_cli.py" = ["N802"]
"tests/test_codex_streaming.py" = ["N802"] "tests/test_codex_streaming.py" = ["N802"]
"tests/test_disable_streaming.py" = ["N802"] "tests/test_disable_streaming.py" = ["N802"]
"tests/test_unknown_tool_recovery.py" = ["N802"]
"tests/test_report_pdf.py" = ["S105", "S106"] "tests/test_report_pdf.py" = ["S105", "S106"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a # Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf. # circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
+3
View File
@@ -268,6 +268,9 @@ async def run_strix_scan(
model_settings=model_settings, model_settings=model_settings,
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]), sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
trace_include_sensitive_data=False, trace_include_sensitive_data=False,
# A hallucinated tool name is a recoverable model mistake, not a scan-ending
# error: hand it back as a tool result so the agent can correct itself.
tool_not_found_behavior="return_error_to_model",
) )
hooks = ReportUsageHooks( hooks = ReportUsageHooks(
model=resolved_model, model=resolved_model,
+2 -1
View File
@@ -8,6 +8,7 @@ from typing import Any
import httpx import httpx
import pytest import pytest
from agents import ModelSettings
from openai import RateLimitError from openai import RateLimitError
import strix.tools.notes.tools as notes_tools 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_root_task", lambda _scan_config: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "") 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, "build_strix_agent", lambda **_kwargs: object())
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object()) monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object()) monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
+22 -2
View File
@@ -11,6 +11,7 @@ from typing import Any
import httpx import httpx
import pytest import pytest
from agents import ModelSettings
from openai import RateLimitError from openai import RateLimitError
import strix.tools.notes.tools as notes_tools 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_root_task", lambda _scan_config: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: scope_context) 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] = {} 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, "make_child_factory", lambda **_kwargs: lambda **_k: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: 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() raise _make_rate_limit_error()
monkeypatch.setattr(runner, "run_agent_loop", _raise_rate_limit) 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"] kwargs = captured["kwargs"]
assert kwargs["instructions_override"] is None assert kwargs["instructions_override"] is None
assert kwargs["system_prompt_context"] == {"scope": "built-in"} 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"
+155
View File
@@ -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
Generated
+8 -21
View File
@@ -1384,7 +1384,7 @@ wheels = [
[[package]] [[package]]
name = "openai" name = "openai"
version = "2.44.0" version = "2.53.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@@ -1396,14 +1396,14 @@ dependencies = [
{ name = "tqdm" }, { name = "tqdm" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" } sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" }, { url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" },
] ]
[[package]] [[package]]
name = "openai-agents" name = "openai-agents"
version = "0.14.6" version = "0.19.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "griffelib" }, { name = "griffelib" },
@@ -1411,13 +1411,12 @@ dependencies = [
{ name = "openai" }, { name = "openai" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "requests" }, { name = "requests" },
{ name = "types-requests" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/4f859d13ba5eea5fe5a3166ffeed04bd04d478ccf3187da6acebb17ba2a7/openai_agents-0.14.6.tar.gz", hash = "sha256:e9d16b835f73be4c5e3798694f90d7a62efcade931e59416bc7462c850e15705", size = 5311175, upload-time = "2026-04-25T02:32:00.897Z" } sdist = { url = "https://files.pythonhosted.org/packages/ba/6c/8fa83cb23d2fe864b284cb45acf895d72a2f6e9827cc684dd4ef0d02d414/openai_agents-0.19.0.tar.gz", hash = "sha256:1d519d6966834e5c04160caec3a2549190e92ce50cdeb22fac5e17e67b8b98b2", size = 5620718, upload-time = "2026-07-27T22:49:26.615Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/96/b49d04e860c79699814289c273e88066ce97a50686172b5733b7458da062/openai_agents-0.14.6-py3-none-any.whl", hash = "sha256:fdd3fb459892c8af5d0b522908b544e96f6217c7254ba55e966424493b43c1ed", size = 816112, upload-time = "2026-04-25T02:31:58.976Z" }, { url = "https://files.pythonhosted.org/packages/3b/3a/bac1aa3405c0f11b4334ac999e881d08fa40fad1f3b7229a1ca222ada489/openai_agents-0.19.0-py3-none-any.whl", hash = "sha256:25392cff993eca7c75b0679ec8d0111faef20c517222c0193111097d0f70db7a", size = 928463, upload-time = "2026-07-27T22:49:24.405Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -2426,8 +2425,8 @@ requires-dist = [
{ name = "docker", specifier = ">=7.1.0" }, { name = "docker", specifier = ">=7.1.0" },
{ name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" }, { name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" },
{ name = "litellm" }, { name = "litellm" },
{ name = "openai", specifier = ">=2.26.0,<2.45" }, { name = "openai", specifier = ">=2.45.0,<3" },
{ name = "openai-agents", extras = ["litellm"], specifier = "==0.14.6" }, { name = "openai-agents", extras = ["litellm"], specifier = ">=0.19.0,<0.20" },
{ name = "pydantic", specifier = ">=2.11.3" }, { name = "pydantic", specifier = ">=2.11.3" },
{ name = "pydantic-settings", specifier = ">=2.13.0" }, { name = "pydantic-settings", specifier = ">=2.13.0" },
{ name = "pypdf", specifier = ">=5.0" }, { name = "pypdf", specifier = ">=5.0" },
@@ -2550,18 +2549,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
] ]
[[package]]
name = "types-requests"
version = "2.33.0.20260518"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"