diff --git a/strix/interface/main.py b/strix/interface/main.py index bc88da673..37a6de5b4 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -629,6 +629,7 @@ def main() -> None: # noqa: PLR0912, PLR0915 finally: tracer = get_global_tracer() if tracer: + tracer.cleanup() posthog.end(tracer, exit_reason=exit_reason) results_path = Path("strix_runs") / args.run_name diff --git a/strix/llm/llm.py b/strix/llm/llm.py index 5e6a01f73..b0b6b22dd 100644 --- a/strix/llm/llm.py +++ b/strix/llm/llm.py @@ -239,7 +239,9 @@ def _prepare_messages(self, conversation_history: list[dict[str, Any]]) -> list[ conversation_history.extend(compressed) messages.extend(compressed) - if messages[-1].get("role") == "assistant" and not self.config.interactive: + if messages[-1].get("role") == "assistant" and ( + not self.config.interactive or self._is_anthropic() + ): messages.append({"role": "user", "content": "Continue the task."}) if self._is_anthropic() and self.config.enable_prompt_caching: diff --git a/strix/tools/finish/finish_actions.py b/strix/tools/finish/finish_actions.py index 79f48e79b..6a3037257 100644 --- a/strix/tools/finish/finish_actions.py +++ b/strix/tools/finish/finish_actions.py @@ -99,19 +99,30 @@ def finish_scan( if active_agents_error: return active_agents_error - validation_errors = [] - - if not executive_summary or not executive_summary.strip(): - validation_errors.append("Executive summary cannot be empty") - if not methodology or not methodology.strip(): - validation_errors.append("Methodology cannot be empty") - if not technical_analysis or not technical_analysis.strip(): - validation_errors.append("Technical analysis cannot be empty") - if not recommendations or not recommendations.strip(): - validation_errors.append("Recommendations cannot be empty") - - if validation_errors: - return {"success": False, "message": "Validation failed", "errors": validation_errors} + _NOT_PROVIDED = "[Not provided by model]" + placeholder_fields = [] + if not (executive_summary or "").strip(): + placeholder_fields.append("executive_summary") + if not (methodology or "").strip(): + placeholder_fields.append("methodology") + if not (technical_analysis or "").strip(): + placeholder_fields.append("technical_analysis") + if not (recommendations or "").strip(): + placeholder_fields.append("recommendations") + + executive_summary = (executive_summary or "").strip() or _NOT_PROVIDED + methodology = (methodology or "").strip() or _NOT_PROVIDED + technical_analysis = (technical_analysis or "").strip() or _NOT_PROVIDED + recommendations = (recommendations or "").strip() or _NOT_PROVIDED + + if placeholder_fields: + import logging + + logging.warning( + "finish_scan: model omitted required field(s) %s; " + "saving partial report with placeholder text", + placeholder_fields, + ) try: from strix.telemetry.tracer import get_global_tracer diff --git a/tests/llm/test_prepare_messages.py b/tests/llm/test_prepare_messages.py new file mode 100644 index 000000000..2566f8ee9 --- /dev/null +++ b/tests/llm/test_prepare_messages.py @@ -0,0 +1,50 @@ +"""Tests for LLM._prepare_messages trailing-assistant-message handling.""" +from strix.llm.config import LLMConfig +from strix.llm.llm import LLM + + +def _make_llm(monkeypatch, model_name: str, interactive: bool) -> LLM: + monkeypatch.setenv("STRIX_LLM", model_name) + config = LLMConfig(model_name=model_name, interactive=interactive, enable_prompt_caching=False) + return LLM(config, agent_name=None) + + +def _history_ending_with_assistant() -> list[dict]: + return [ + {"role": "user", "content": "Scan this target."}, + {"role": "assistant", "content": "I found a vulnerability."}, + ] + + +def test_non_interactive_anthropic_adds_user_message(monkeypatch) -> None: + """Non-interactive mode always appends a user message when history ends with assistant.""" + llm = _make_llm(monkeypatch, "claude-sonnet-4-6", interactive=False) + history = _history_ending_with_assistant() + messages = llm._prepare_messages(history) + assert messages[-1]["role"] == "user" + assert messages[-1]["content"] == "Continue the task." + + +def test_interactive_anthropic_adds_user_message(monkeypatch) -> None: + """Interactive mode with Anthropic model must also append a user message. + + Anthropic API rejects messages where the last entry has role 'assistant' + (no assistant prefill support). This should hold regardless of interactive mode. + """ + llm = _make_llm(monkeypatch, "claude-sonnet-4-6", interactive=True) + history = _history_ending_with_assistant() + messages = llm._prepare_messages(history) + assert messages[-1]["role"] == "user" + assert messages[-1]["content"] == "Continue the task." + + +def test_interactive_non_anthropic_does_not_add_user_message(monkeypatch) -> None: + """Interactive mode with a non-Anthropic model keeps the trailing assistant message. + + Non-Anthropic models may support assistant prefill; in interactive mode the + caller (TUI) is responsible for appending the next user message. + """ + llm = _make_llm(monkeypatch, "openai/gpt-5.4", interactive=True) + history = _history_ending_with_assistant() + messages = llm._prepare_messages(history) + assert messages[-1]["role"] == "assistant"