diff --git a/strix/agents/factory.py b/strix/agents/factory.py index f8a412439..bc664a5d5 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -717,6 +717,7 @@ def build_strix_agent( is_whitebox: bool = False, is_diff_scoped: bool = False, interactive: bool = False, + parallel_tool_calls: bool = False, chat_completions_tools: bool = False, strict_tool_schemas: bool = True, system_prompt_context: dict[str, Any] | None = None, @@ -751,6 +752,7 @@ def build_strix_agent( is_root=is_root, is_diff_scoped=is_diff_scoped, interactive=interactive, + parallel_tool_calls=parallel_tool_calls, system_prompt_context=system_prompt_context, supports_images=supports_images, report_tool=report_tool.name, @@ -813,6 +815,7 @@ def make_child_factory( is_whitebox: bool = False, is_diff_scoped: bool = False, interactive: bool = False, + parallel_tool_calls: bool = False, chat_completions_tools: bool = False, strict_tool_schemas: bool = True, system_prompt_context: dict[str, Any] | None = None, @@ -835,6 +838,7 @@ def make_child_factory( is_whitebox=is_whitebox, is_diff_scoped=is_diff_scoped, interactive=interactive, + parallel_tool_calls=parallel_tool_calls, chat_completions_tools=chat_completions_tools, strict_tool_schemas=strict_tool_schemas, system_prompt_context=system_prompt_context, diff --git a/strix/agents/prompt.py b/strix/agents/prompt.py index afbf56b94..c20b75a85 100644 --- a/strix/agents/prompt.py +++ b/strix/agents/prompt.py @@ -83,6 +83,7 @@ def render_system_prompt( is_root: bool = False, is_diff_scoped: bool = False, interactive: bool = False, + parallel_tool_calls: bool = False, system_prompt_context: dict[str, Any] | None = None, include_scope: bool = True, supports_images: bool = True, @@ -131,6 +132,7 @@ def render_system_prompt( requested_skill_names=[name for name in skill_content if name not in shared], available_skills=get_available_skills(), interactive=interactive, + parallel_tool_calls=parallel_tool_calls, is_root=is_root, system_prompt_context=system_prompt_context or {}, include_scope=include_scope, diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index dd39ea4fe..e75540e00 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -44,7 +44,7 @@ INTERACTIVE BEHAVIOR: - If you end a turn on plain text and the nudge arrives, your words already reached the user. Do not repeat them: call wait_for_user. - You can narrate while you work — plain text is shown to the user as you go. Narrating is free; wait_for_user is specifically the act of WAITING for the user, so do not call it just to give a status update. - Respond naturally when the user asks questions or gives instructions. -- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and wait_for_user only when you genuinely need the user. +- While actively working on a task, every turn should carry {% if parallel_tool_calls %}a tool call (independent calls can go in the same turn){% else %}exactly one tool call{% endif %} — use think to plan, the appropriate tool to act, and wait_for_user only when you genuinely need the user. - Never loop through think or other tools just to prepare, polish, confirm, or announce an answer. Once you know the answer, write it and call wait_for_user. {% else %} AUTONOMOUS BEHAVIOR: @@ -55,6 +55,9 @@ AUTONOMOUS BEHAVIOR: - There is no user attached to this run, so there is nobody to ask and nothing to yield to. If there is nothing left to execute: do NOT send filler/repetitive text — either call wait_for_agents (only if you are genuinely expecting another agent to message you) 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 waiting on another agent, use wait_for_agents; when done, use agent_finish (subagents) or finish_scan (root) - A text-only turn does nothing: it neither ends the run nor yields — it just wastes a turn and forces a retry. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY way to terminate, and the report flows through them. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead. +{%- if parallel_tool_calls %} +- Read-only calls can go together in one turn: when several file reads or searches don't depend on each other's output, issue them as separate tool calls in the same turn rather than one per turn. Anything else — writing files, running commands that change state, sending requests, creating or messaging agents, reporting, or finishing — goes alone in its own turn. +{%- endif %} {% endif %} diff --git a/strix/config/settings.py b/strix/config/settings.py index 22e6c596a..eb61a793d 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -65,6 +65,10 @@ class LlmSettings(BaseSettings): default=False, alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE", ) + parallel_tool_calls: bool = Field( + default=False, + alias="STRIX_PARALLEL_TOOL_CALLS", + ) prompt_cache: bool = Field( default=True, alias="STRIX_PROMPT_CACHE", diff --git a/strix/core/execution.py b/strix/core/execution.py index 83c42f333..6ee732e59 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -1009,7 +1009,7 @@ async def _append_tool_required_message( message = ( "Your previous message ended a turn without a tool call. Plain text never ends " "execution and never hands control to the user: it is shown to the user, and the " - "run continues. Continue immediately and call exactly one tool. " + "run continues. Continue immediately and call a tool. " "If you have nothing to do until the user replies, call wait_for_user; your " "text already reached them, so do not repeat it. " "If you are blocked waiting for another agent, call wait_for_agents. " @@ -1021,7 +1021,7 @@ async def _append_tool_required_message( message = ( "Your previous response ended the autonomous run without a lifecycle tool " "call. That is invalid in non-interactive mode; plain text final answers are " - "ignored. Continue immediately and call exactly one tool. " + "ignored. Continue immediately and call a tool. " f"If your work is complete, call {finish_tool}. " "If you are blocked waiting for another agent, call wait_for_agents. " "Otherwise use the appropriate execution or planning tool. " diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 7bb2b72c7..6dbe468a3 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -247,6 +247,7 @@ def make_model_settings( *, model_name: str, force_required_tool_choice: bool = False, + parallel_tool_calls: bool = False, request_timeout: float | None = None, prompt_cache: bool = True, extra_headers: dict[str, str] | None = None, @@ -255,7 +256,7 @@ def make_model_settings( ) -> ModelSettings: headers = _request_headers(model_name, extra_headers) model_settings = ModelSettings( - parallel_tool_calls=False if has_tools else None, + parallel_tool_calls=parallel_tool_calls if has_tools else None, retry=DEFAULT_MODEL_RETRY, include_usage=True, extra_args=request_timeout_extra_args(request_timeout), diff --git a/strix/core/runner.py b/strix/core/runner.py index ca4c1e799..106be7551 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -176,6 +176,7 @@ def _compose_root_instructions_override( is_whitebox: bool, is_diff_scoped: bool, interactive: bool, + parallel_tool_calls: bool, system_prompt_context: dict[str, Any], supports_images: bool, report_tool: str, @@ -190,6 +191,7 @@ def _compose_root_instructions_override( is_root=True, is_diff_scoped=is_diff_scoped, interactive=interactive, + parallel_tool_calls=parallel_tool_calls, system_prompt_context=system_prompt_context, include_scope=False, supports_images=supports_images, @@ -413,6 +415,7 @@ async def run_strix_scan( settings.llm.reasoning_effort, model_name=resolved_model, force_required_tool_choice=settings.llm.force_required_tool_choice, + parallel_tool_calls=settings.llm.parallel_tool_calls, request_timeout=settings.llm.timeout, prompt_cache=settings.llm.prompt_cache, extra_headers=settings.llm.extra_headers, @@ -508,6 +511,7 @@ async def run_strix_scan( is_whitebox=is_whitebox, is_diff_scoped=is_diff_scoped, interactive=interactive, + parallel_tool_calls=settings.llm.parallel_tool_calls, system_prompt_context=root_context, supports_images=supports_images, report_tool=report_tool.name, @@ -521,6 +525,7 @@ async def run_strix_scan( is_whitebox=is_whitebox, is_diff_scoped=is_diff_scoped, interactive=interactive, + parallel_tool_calls=settings.llm.parallel_tool_calls, chat_completions_tools=chat_completions_tools, strict_tool_schemas=strict_tool_schemas, system_prompt_context=root_context, @@ -544,6 +549,7 @@ async def run_strix_scan( is_whitebox=is_whitebox, is_diff_scoped=is_diff_scoped, interactive=interactive, + parallel_tool_calls=settings.llm.parallel_tool_calls, chat_completions_tools=chat_completions_tools, strict_tool_schemas=strict_tool_schemas, system_prompt_context=scope_context, diff --git a/tests/test_inputs.py b/tests/test_inputs.py index f54d37177..34bd65c78 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -314,6 +314,12 @@ def test_make_model_settings_disables_parallel_tool_calls_by_default() -> None: assert make_model_settings("none", model_name="gpt-4o").parallel_tool_calls is False +def test_make_model_settings_enables_parallel_tool_calls_when_requested() -> None: + settings = make_model_settings("none", model_name="gpt-4o", parallel_tool_calls=True) + + assert settings.parallel_tool_calls is True + + def test_make_model_settings_omits_parallel_tool_calls_without_tools() -> None: settings = make_model_settings("none", model_name="gpt-4o", has_tools=False) diff --git a/tests/test_runner_interrupt.py b/tests/test_runner_interrupt.py index b6271407b..7a47c6111 100644 --- a/tests/test_runner_interrupt.py +++ b/tests/test_runner_interrupt.py @@ -25,6 +25,7 @@ def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, + parallel_tool_calls=False, timeout=300, prompt_cache=True, extra_headers=None, diff --git a/tests/test_runner_mcp.py b/tests/test_runner_mcp.py index 74f73c36b..39ea01ff6 100644 --- a/tests/test_runner_mcp.py +++ b/tests/test_runner_mcp.py @@ -41,6 +41,7 @@ def _settings() -> Any: model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, + parallel_tool_calls=False, timeout=300, prompt_cache=True, extra_headers=None, diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py index d6bad9aef..72de0750e 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -39,6 +39,7 @@ async def test_persistent_rate_limit_stops_gracefully( model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, + parallel_tool_calls=False, timeout=300, prompt_cache=True, extra_headers=None, diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index 8c2e2047c..a4942ebc8 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -61,6 +61,7 @@ def _patch_engine_scaffold( model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, + parallel_tool_calls=False, timeout=300, prompt_cache=True, extra_headers=None, @@ -285,6 +286,19 @@ async def test_unknown_tool_calls_are_returned_to_the_model( assert captured["run_config"].tool_not_found_behavior == "return_error_to_model" +@pytest.mark.parametrize("interactive", [False, True]) +def test_parallel_tool_calls_prompt_is_opt_in(interactive: bool) -> None: + default = render_system_prompt(include_scope=False, interactive=interactive) + parallel = render_system_prompt( + include_scope=False, interactive=interactive, parallel_tool_calls=True + ) + + assert "Read-only calls can go together" not in default + assert ("Read-only calls can go together" in parallel) is not interactive + assert ("exactly one tool call" in default) is interactive + assert "exactly one tool call" not in parallel + + def test_scope_is_rendered_once_at_the_end_of_the_prompt() -> None: prompt = render_system_prompt( system_prompt_context={ diff --git a/tests/test_runner_teardown.py b/tests/test_runner_teardown.py index e3c436960..511451bc1 100644 --- a/tests/test_runner_teardown.py +++ b/tests/test_runner_teardown.py @@ -49,6 +49,7 @@ def _settings() -> Any: model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, + parallel_tool_calls=False, timeout=300, prompt_cache=True, extra_headers=None,