From 5fe4cb0c551a202c8fd7d60333bc30ba6a9af491 Mon Sep 17 00:00:00 2001 From: Ahmed Allam <49919286+0xallam@users.noreply.github.com> Date: Thu, 28 May 2026 11:55:10 -0700 Subject: [PATCH] fix: reasoning models reject tool_choice=required; bump to 1.0.2 (closes #503, #505) (#508) --- pyproject.toml | 2 +- strix/agents/prompts/system_prompt.jinja | 1 + strix/core/inputs.py | 10 ++++++++-- strix/core/runner.py | 23 ++++++++++++++++++++++- uv.lock | 2 +- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6999aafb..ce97bbc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "strix-agent" -version = "1.0.1" +version = "1.0.2" description = "Open-source AI Hackers for your apps" readme = "README.md" license = "Apache-2.0" diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 773d6a2a..4a2f55c2 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -43,6 +43,7 @@ AUTONOMOUS BEHAVIOR: - NEVER send an empty or blank message. If you have no content to output or need to wait (for user input, subagent results, or any other reason), you MUST call the wait_for_message tool (or another appropriate tool) instead of emitting an empty response. - If there is nothing to execute and no user query to answer any more: do NOT send filler/repetitive text — either call wait_for_message or finish your work (subagents: agent_finish; root: finish_scan) - While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If idle, use wait_for_message; when done, use agent_finish (subagents) or finish_scan (root) +- A text-only turn — even one — IMMEDIATELY ends the scan/run with no report written. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY valid way to terminate. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead — the report and termination signal both flow through it. {% endif %} diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 225fc1c8..0913726f 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -109,13 +109,19 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]: def make_model_settings( reasoning_effort: ReasoningEffort | None, ) -> ModelSettings: + # Anthropic + DeepSeek thinking reject ``tool_choice="required"`` outright + # when reasoning is enabled; OpenAI o-series accepts both but doesn't need + # the safety net. When reasoning is on we let the model self-select tools + # and rely on the system prompt + the ``_finish_tool_use_behavior`` callback + # to keep the loop converging on a lifecycle tool. + use_reasoning = reasoning_effort is not None and reasoning_effort != "none" model_settings = ModelSettings( parallel_tool_calls=False, - tool_choice="required", + tool_choice=None if use_reasoning else "required", retry=DEFAULT_MODEL_RETRY, include_usage=True, ) - if reasoning_effort is not None: + if use_reasoning: model_settings = model_settings.resolve( ModelSettings(reasoning=Reasoning(effort=reasoning_effort)), ) diff --git a/strix/core/runner.py b/strix/core/runner.py index 0e6d6583..7592c8fa 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -261,7 +261,7 @@ async def run_strix_scan( async with coordinator._lock: root_status = coordinator.statuses.get(root_id) - return await run_agent_loop( + result = await run_agent_loop( agent=root_agent, initial_input=initial_input, run_config=run_config, @@ -275,6 +275,27 @@ async def run_strix_scan( event_sink=event_sink, hooks=hooks, ) + if not interactive and result is not None: + final = getattr(result, "final_output", None) + scan_completed = False + if isinstance(final, str): + try: + parsed = json.loads(final) + scan_completed = bool(isinstance(parsed, dict) and parsed.get("scan_completed")) + except (ValueError, TypeError): + scan_completed = False + elif isinstance(final, dict): + scan_completed = bool(final.get("scan_completed")) + if not scan_completed: + logger.error( + "Scan %s ended without calling finish_scan. The agent " + "emitted a text-only turn instead of a lifecycle tool call, " + "so no executive report was written. Final output (first " + "300 chars): %r", + scan_id, + str(final)[:300], + ) + return result # noqa: TRY300 except BaseException: logger.exception("Strix scan %s failed", scan_id) if root_id is not None: diff --git a/uv.lock b/uv.lock index 7c13141d..88f6e799 100644 --- a/uv.lock +++ b/uv.lock @@ -2035,7 +2035,7 @@ wheels = [ [[package]] name = "strix-agent" -version = "1.0.1" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "caido-sdk-client" },