Compare commits

..
Author SHA1 Message Date
Ian a6666c5848 style: drop a trailing space in the image-support docstring 2026-10-07 15:42:20 -04:00
Ian 49eb38b775 refactor(llm): read image support from litellm's catalog, assuming it for unknown models 2026-10-07 15:33:59 -04:00
Ian 4d1803a72c feat(llm): detect whether a model accepts image input
Add model_supports_images(): OpenRouter models are checked against architecture.input_modalities from OpenRouter's public model list (fetched once per process), others against LiteLLM's supports_vision. Unknown models and lookup failures count as image-capable. Not wired in yet.
2026-10-06 21:33:14 -04:00
Ian f1386cad37 feat(cli): add -r for --resume and a title column to the resume picker 2026-10-06 07:51:00 +03:00
ian-at-strix d6db4f1f89 Stop false "ended without calling finish_scan" error on PR reviews (#1449)
* fix: don't log missing finish_scan when a PR review finished with finish_pr_review

* Updated comment
2026-10-05 19:58:20 -04:00
ian-at-strix 03d2c6c168 fix: stop the run after finish_pr_review (#1451) 2026-10-05 16:25:33 -04:00
13 changed files with 130 additions and 25 deletions

No files matched your search

@@ -95,7 +95,7 @@ Key flags:
| `--max-budget USD` | Hard LLM spend cap; scan wraps up cleanly at the limit. |
| `--max-turns N` | Per-agent turn cap (default 500). |
| `--fail-on SEVERITY` | Headless only: exit `2` only for findings at or above `critical`/`high`/`medium`/`low`/`info`. Default: any finding. |
| `--resume RUN_NAME` | Resume a prior run from `strix_runs/`, with its agent history and targets. Cannot be combined with `-t`. |
| `-r`, `--resume RUN_NAME` | Resume a prior run from `strix_runs/`, with its agent history and targets. Cannot be combined with `-t`. |
| `--scope-mode` | For code targets: `auto` (diff-scope in CI/headless), `diff` (force changed files only), `full` (whole tree). |
| `--diff-base REF` | Branch or commit that `diff` scope compares against. Defaults to the repo's default branch. |
+2
View File
@@ -517,6 +517,8 @@ def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool:
completion_key = "agent_completed"
elif tool_name == "finish_scan":
completion_key = "scan_completed"
elif tool_name == "finish_pr_review": # registered by strix-pro
completion_key = "review_completed"
else:
return False
+6
View File
@@ -905,6 +905,12 @@ def model_supports_reasoning(model_name: str) -> bool:
return bool(entry and entry.get("supports_reasoning"))
def model_supports_images(model_name: str) -> bool:
"""Return whether the model accepts image input. Assume yes until proven otherwise."""
entry = _catalog_entry(model_name)
return entry is None or bool(entry.get("supports_vision"))
def _bare_openai_name(model_name: str) -> str:
name = model_name.strip().lower()
for prefix in ("litellm/", "any-llm/", "openai/"):
+4 -10
View File
@@ -592,16 +592,10 @@ async def run_strix_scan(
)
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:
# Lifecycle tools mark the root completed.
async with coordinator._lock:
root_completed = coordinator.statuses.get(root_id) == "completed"
if not root_completed:
logger.error(
"Scan %s ended without calling finish_scan. The agent "
"emitted a text-only turn instead of a lifecycle tool call, "
+1
View File
@@ -299,6 +299,7 @@ Strix Cloud:
)
parser.add_argument(
"-r",
"--resume",
type=str,
nargs="?",
+18 -11
View File
@@ -175,6 +175,7 @@ def filter_runs(runs: list[RunSummary], needle: str) -> list[RunSummary]:
for run in runs
if needle in run.run_name.lower()
or needle in run.target.lower()
or needle in run.title.lower()
or needle in run.status.lower()
]
@@ -215,16 +216,18 @@ class ResumePicker:
def _visible(self) -> int:
return max(3, min(len(self.rows), _MAX_VISIBLE, self.console.height - _CHROME_LINES))
def _columns(self) -> tuple[int, int, int]:
def _columns(self) -> tuple[int, int, int, int]:
width = max(40, self.console.width - 1)
status_width = max(len("status"), *(len(_status_text(run)) for run in self.runs))
run_width = min(_MAX_RUN_WIDTH, max(len("run"), *(len(run.run_name) for run in self.runs)))
fixed = len(_CURSOR) + _STARTED_WIDTH + _FINDINGS_WIDTH + status_width + 4 * 2
fixed = len(_CURSOR) + _STARTED_WIDTH + _FINDINGS_WIDTH + status_width + 5 * 2
target_width = width - fixed - run_width
if target_width < _MIN_TARGET_WIDTH:
run_width = max(8, run_width + target_width - _MIN_TARGET_WIDTH)
if target_width < 2 * _MIN_TARGET_WIDTH:
run_width = max(8, run_width + target_width - 2 * _MIN_TARGET_WIDTH)
target_width = width - fixed - run_width
return max(_MIN_TARGET_WIDTH, target_width), status_width, run_width
title_width = max(_MIN_TARGET_WIDTH, target_width // 2)
target_width = max(_MIN_TARGET_WIDTH, target_width - title_width)
return title_width, target_width, status_width, run_width
def _scroll(self) -> range:
rows = self.rows
@@ -250,7 +253,7 @@ class ResumePicker:
title.append(self.filter)
header = Text(
" " * len(_CURSOR)
+ self._cells("started", "target", "status", "findings", "run", widths),
+ self._cells("started", "title", "target", "status", "findings", "run", widths),
style="dim",
)
lines = [title, Text(), header]
@@ -276,16 +279,18 @@ class ResumePicker:
@staticmethod
def _cells(
started: str,
title: str,
target: str,
status: str,
findings: str,
run: str,
widths: tuple[int, int, int],
widths: tuple[int, int, int, int],
) -> str:
target_width, status_width, run_width = widths
title_width, target_width, status_width, run_width = widths
return " ".join(
[
_fit(started, _STARTED_WIDTH),
_fit(title, title_width),
_fit(target, target_width),
_fit(status, status_width),
_fit(findings, _FINDINGS_WIDTH),
@@ -293,8 +298,8 @@ class ResumePicker:
]
)
def _row(self, run: RunSummary, selected: bool, widths: tuple[int, int, int]) -> Text:
target_width, status_width, run_width = widths
def _row(self, run: RunSummary, selected: bool, widths: tuple[int, int, int, int]) -> Text:
title_width, target_width, status_width, run_width = widths
primary = "bold" if selected else ""
muted = "" if selected else "dim"
status_style = _STATUS_STYLES.get(run.status, "")
@@ -304,7 +309,9 @@ class ResumePicker:
line.append(_CURSOR if selected else " " * len(_CURSOR), style=_GREEN)
line.append(_fit(relative_time(run.started_at, self.now), _STARTED_WIDTH), style=muted)
line.append(" ")
line.append(_fit(run.target, target_width), style=primary)
line.append(_fit(run.title, title_width), style=primary)
line.append(" ")
line.append(_fit(run.target, target_width), style=muted)
line.append(" ")
line.append(_fit(_status_text(run), status_width), style=status_style)
line.append(" ")
+16
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
@@ -22,6 +23,7 @@ class RunSummary:
status: str
findings: int
resumable: bool
title: str = ""
def list_run_summaries(*, cwd: Path | None = None) -> list[RunSummary]:
@@ -55,9 +57,23 @@ def _summarize(run_dir: Path, record: Any) -> RunSummary:
status=str(record.get("status") or "unknown"),
findings=len(findings) if isinstance(findings, list) else 0,
resumable=(runtime_state_dir(run_dir) / "agents.json").is_file(),
title=_title(record, findings),
)
_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
def _title(record: dict[str, Any], findings: Any) -> str:
"""The instruction's first line, else the most severe finding's title, shortened."""
instruction = str(record.get("user_instruction") or record.get("instruction") or "").strip()
if instruction:
return instruction.splitlines()[0]
found = [f for f in findings if isinstance(f, dict)] if isinstance(findings, list) else []
worst = min(found, key=lambda f: _SEVERITY_RANK.get(str(f.get("severity")), 4), default={})
return re.split(r" via | on | \(", str(worst.get("title") or ""))[0]
def _describe_target(record: dict[str, Any]) -> str:
targets = record.get("targets_info")
originals = [
+13
View File
@@ -128,3 +128,16 @@ def test_disabling_strict_leaves_shared_tools_untouched() -> None:
agent = factory.build_strix_agent(is_root=True)
assert any(t.strict_json_schema for t in agent.tools if isinstance(t, FunctionTool))
@pytest.mark.parametrize(
("tool_name", "output", "completed"),
[
("finish_pr_review", '{"success": true, "review_completed": true}', True),
("finish_pr_review", '{"success": false, "error": "agents_still_active"}', False),
("finish_scan", '{"success": true, "scan_completed": true}', True),
("think", '{"success": true, "review_completed": true}', False),
],
)
def test_lifecycle_tool_completed(tool_name: str, output: str, completed: bool) -> None:
assert factory._lifecycle_tool_completed(tool_name, output) is completed
+5 -2
View File
@@ -41,10 +41,13 @@ def _write_run(base: Path, name: str, *, state: bool = True) -> None:
(run_dir / ".state" / "agents.json").write_text("{}", encoding="utf-8")
def test_bare_resume_defers_to_the_picker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("flag", ["--resume", "-r"])
def test_bare_resume_defers_to_the_picker(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, flag: str
) -> None:
monkeypatch.chdir(tmp_path)
_write_run(tmp_path / "strix_runs", "example-com_1111")
monkeypatch.setattr(sys, "argv", ["strix", "--resume"])
monkeypatch.setattr(sys, "argv", ["strix", flag])
args = cli_main.parse_arguments()
+9
View File
@@ -16,6 +16,7 @@ from strix.config.models import (
_NonStreamingModel,
_TurnGuardModel,
configure_sdk_model_defaults,
model_supports_images,
request_timeout_extra_args,
resolve_api_type,
routes_through_litellm,
@@ -202,3 +203,11 @@ def test_configure_sdk_api_route_follows_the_given_model(
models.configure_sdk_api_route("my-private-model", settings)
assert routes == ["responses", "chat_completions"]
def test_image_support_follows_the_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(litellm.model_cost, "acme-text", {})
monkeypatch.setitem(litellm.model_cost, "acme-vision", {"supports_vision": True})
assert model_supports_images("acme-text") is False
assert model_supports_images("litellm/openai/acme-vision") is True
assert model_supports_images("acme-unknown-model") is True
+5 -1
View File
@@ -38,6 +38,7 @@ def _run(
status: str = "completed",
findings: int = 0,
resumable: bool = True,
title: str = "",
) -> RunSummary:
started = (NOW - timedelta(minutes=minutes_ago)).isoformat()
return RunSummary(
@@ -48,6 +49,7 @@ def _run(
status=status,
findings=findings,
resumable=resumable,
title=title,
)
@@ -62,6 +64,7 @@ RUNS = [
minutes_ago=60 * 26,
status="stopped",
findings=7,
title="SQL injection",
),
_run(
"strix_41c0",
@@ -127,7 +130,8 @@ def test_render_lists_every_run_with_its_metadata() -> None:
for run in RUNS:
assert run.run_name in text
assert "12 min ago" in text
assert "https://juice-shop.herokuapp.com" in text
assert "https://juice-shop" in text
assert "SQL injection" in text
assert "interrupted" in text
assert "failed · no state" in text
assert text.splitlines()[3].startswith(" \u276f ")
+22
View File
@@ -139,3 +139,25 @@ def test_blank_instruction_does_not_break_the_listing(
[summary] = list_run_summaries()
assert summary.target == ""
def test_title_is_the_instruction_else_the_most_severe_finding(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
_write_run(
base, "instructed_1111", {"user_instruction": "focus on auth\nand billing"}, modified=2
)
_write_run(base, "found_2222", {}, modified=1)
(base / "found_2222" / "vulnerabilities.json").write_text(
json.dumps(
[
{"title": "Verbose errors", "severity": "low"},
{"title": "SQL injection via id on /login", "severity": "critical"},
]
),
encoding="utf-8",
)
assert [run.title for run in list_run_summaries(cwd=tmp_path)] == [
"focus on auth",
"SQL injection",
]
+28
View File
@@ -91,3 +91,31 @@ async def test_a_live_child_is_settled_before_sessions_close(
task = child_task["t"]
assert task.done(), "the child task was left running past scan teardown"
assert task.cancelled(), "the child was not cancelled cleanly on a finish"
@pytest.mark.parametrize(("root_status", "logged"), [("completed", False), ("stopped", True)])
@pytest.mark.asyncio
async def test_missing_finish_is_logged_only_when_root_did_not_complete(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
caplog: pytest.LogCaptureFixture,
root_status: str,
logged: bool,
) -> None:
_wire_runner(monkeypatch, tmp_path)
coordinator = AgentCoordinator()
async def _root_ends_with_text(**kwargs: Any) -> Any:
await coordinator.set_status(kwargs["agent_id"], root_status)
return types.SimpleNamespace(final_output="The review is complete.")
monkeypatch.setattr(runner, "run_agent_loop", _root_ends_with_text)
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-test",
image="img",
coordinator=coordinator,
)
assert ("ended without calling finish_scan" in caplog.text) is logged