diff --git a/skills/penetration-testing-with-strix/SKILL.md b/skills/penetration-testing-with-strix/SKILL.md index 475601c7..afd56cea 100644 --- a/skills/penetration-testing-with-strix/SKILL.md +++ b/skills/penetration-testing-with-strix/SKILL.md @@ -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. | diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 61af684d..3b9dd616 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -375,11 +375,15 @@ def _configure_filesystem_tools( ) -def _make_filesystem_configurator(*, chat_completions: bool, strict_schemas: bool) -> Any: +def _make_filesystem_configurator( + *, chat_completions: bool, strict_schemas: bool, supports_images: bool = True +) -> Any: def configure(toolset: Any) -> None: _configure_filesystem_tools( toolset, chat_completions=chat_completions, strict_schemas=strict_schemas ) + if not supports_images: + toolset.view_image.is_enabled = False return configure @@ -517,6 +521,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 @@ -668,6 +674,7 @@ def build_strix_agent( system_prompt_context: dict[str, Any] | None = None, extra_tools: Sequence[Tool] | None = None, instructions_override: str | None = None, + supports_images: bool = True, ) -> SandboxAgent[Any]: """Build a SandboxAgent for either root or child use. @@ -692,6 +699,7 @@ def build_strix_agent( is_diff_scoped=is_diff_scoped, interactive=interactive, system_prompt_context=system_prompt_context, + supports_images=supports_images, ) agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])] @@ -731,6 +739,7 @@ def build_strix_agent( configure_tools=_make_filesystem_configurator( chat_completions=chat_completions_tools, strict_schemas=strict_tool_schemas, + supports_images=supports_images, ), ), Shell( @@ -752,6 +761,7 @@ def make_child_factory( chat_completions_tools: bool = False, strict_tool_schemas: bool = True, system_prompt_context: dict[str, Any] | None = None, + supports_images: bool = True, ) -> Any: """Return the runner-owned builder used by ``spawn_child_agent``. @@ -772,6 +782,7 @@ def make_child_factory( chat_completions_tools=chat_completions_tools, strict_tool_schemas=strict_tool_schemas, system_prompt_context=system_prompt_context, + supports_images=supports_images, ) return _factory diff --git a/strix/agents/prompt.py b/strix/agents/prompt.py index cf53ca67..c45831aa 100644 --- a/strix/agents/prompt.py +++ b/strix/agents/prompt.py @@ -85,6 +85,7 @@ def render_system_prompt( interactive: bool = False, system_prompt_context: dict[str, Any] | None = None, include_scope: bool = True, + supports_images: bool = True, ) -> str: """Render the system prompt. Returns empty string on template failure. @@ -119,7 +120,7 @@ def render_system_prompt( is_root=is_root, is_diff_scoped=is_diff_scoped, ) - skill_content = load_skills(skills_to_load) + skill_content = load_skills(skills_to_load, supports_images=supports_images) env.globals["get_skill"] = lambda name: skill_content.get(name, "") # Skills every agent of this kind loads come first, so siblings share them diff --git a/strix/config/models.py b/strix/config/models.py index 4ce6dc86..b2d6f9d0 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -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/"): diff --git a/strix/core/runner.py b/strix/core/runner.py index ada35e79..55784d21 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -23,6 +23,7 @@ from strix.config.models import ( StrixProvider, configure_sdk_api_route, configure_sdk_model_defaults, + model_supports_images, supports_strict_tool_schemas, uses_chat_completions_tool_schema, ) @@ -153,6 +154,7 @@ def _compose_root_instructions_override( is_diff_scoped: bool, interactive: bool, system_prompt_context: dict[str, Any], + supports_images: bool, ) -> str | None: if root_instructions_override is None: return None @@ -166,6 +168,7 @@ def _compose_root_instructions_override( interactive=interactive, system_prompt_context=system_prompt_context, include_scope=False, + supports_images=supports_images, ) return ( f"{base_instructions}\n\n" @@ -265,6 +268,9 @@ async def run_strix_scan( strict_tool_schemas = supports_strict_tool_schemas(resolved_model) if not strict_tool_schemas: logger.info("Sending non-strict tool schemas: %s caps strict tools", resolved_model) + supports_images = model_supports_images(resolved_model) + if not supports_images: + logger.info("Leaving out image tools: %s does not accept images", resolved_model) if budget_policy not in ("stop", "pause"): raise ValueError(f"unknown budget_policy: {budget_policy!r}") @@ -468,6 +474,7 @@ async def run_strix_scan( is_diff_scoped=is_diff_scoped, interactive=interactive, system_prompt_context=root_context, + supports_images=supports_images, ) root_agent = build_strix_agent( @@ -482,6 +489,7 @@ async def run_strix_scan( strict_tool_schemas=strict_tool_schemas, system_prompt_context=root_context, instructions_override=root_instructions, + supports_images=supports_images, ) if not is_resume: @@ -501,6 +509,7 @@ async def run_strix_scan( chat_completions_tools=chat_completions_tools, strict_tool_schemas=strict_tool_schemas, system_prompt_context=scope_context, + supports_images=supports_images, ) async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]: @@ -528,6 +537,7 @@ async def run_strix_scan( "spawn_child_agent": spawn_child_agent, "scan_targets": build_scan_targets(scan_config), "max_context_images": settings.runtime.max_context_images, + "supports_images": supports_images, } root_session = open_agent_session(root_id, agents_db) @@ -592,16 +602,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, " diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index a121a2af..c9b6951b 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -299,6 +299,7 @@ Strix Cloud: ) parser.add_argument( + "-r", "--resume", type=str, nargs="?", diff --git a/strix/interface/resume_picker.py b/strix/interface/resume_picker.py index bd9e6268..fb1e1796 100644 --- a/strix/interface/resume_picker.py +++ b/strix/interface/resume_picker.py @@ -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(" ") diff --git a/strix/interface/tui/internal/app/model.go b/strix/interface/tui/internal/app/model.go index ee1e9100..bae3cb7e 100644 --- a/strix/interface/tui/internal/app/model.go +++ b/strix/interface/tui/internal/app/model.go @@ -90,6 +90,7 @@ type Model struct { client *Client width, height int snapshot protocol.Snapshot + windowTitle string input textarea.Model viewport viewport.Model viewportContent string diff --git a/strix/interface/tui/internal/app/wire.go b/strix/interface/tui/internal/app/wire.go index 1536110b..72f02a38 100644 --- a/strix/interface/tui/internal/app/wire.go +++ b/strix/interface/tui/internal/app/wire.go @@ -2,10 +2,13 @@ package app import ( "encoding/json" + "fmt" + "os" "strings" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" "github.com/usestrix/strix/tui/internal/protocol" "github.com/usestrix/strix/tui/internal/render" ) @@ -56,6 +59,10 @@ func (m *Model) handleEnvelope(envelope protocol.Envelope) tea.Cmd { // resize (not just refresh): status-row visibility changes the chat height. m.resizeViewport() m.resizeVulnerabilityViewport() + if title := windowTitle(m.snapshot.Targets); title != m.windowTitle { + m.windowTitle = title + return setTerminalTitle(title) + } case "collection_bootstrap": return m.handleCollectionBootstrap(envelope.Payload) case "collection_delta": @@ -118,6 +125,26 @@ func (m *Model) handleEnvelope(envelope protocol.Envelope) tea.Cmd { return nil } +// tea.SetWindowTitle only sends OSC 2 (window title); iTerm tabs show the OSC 1 +// icon name, which shells usually set, so send OSC 0 to set both. +func setTerminalTitle(title string) tea.Cmd { + return func() tea.Msg { + _, _ = os.Stdout.WriteString(ansi.SetIconNameWindowTitle(title)) + return nil + } +} + +func windowTitle(targets []string) string { + switch len(targets) { + case 0: + return "strix" + case 1: + return targets[0] + default: + return fmt.Sprintf("%s +%d", targets[0], len(targets)-1) + } +} + func (m *Model) consumeMessages(messages []protocol.Message, setupMode bool) { if m.seenMessages == nil { m.seenMessages = map[string]bool{} diff --git a/strix/report/runs.py b/strix/report/runs.py index 1c46115c..ae27a60f 100644 --- a/strix/report/runs.py +++ b/strix/report/runs.py @@ -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 = [ diff --git a/strix/skills/__init__.py b/strix/skills/__init__.py index db5ae3e4..bc2d3b40 100644 --- a/strix/skills/__init__.py +++ b/strix/skills/__init__.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import TypeGuard import yaml +from jinja2 import Environment, StrictUndefined from strix.utils.resource_paths import get_strix_resource_path @@ -13,6 +14,14 @@ from strix.utils.resource_paths import get_strix_resource_path logger = logging.getLogger(__name__) _FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(?P.*?)\n---\s*\n", re.DOTALL) +# Only skills with `template: jinja` in their frontmatter are rendered; the rest hold +# literal `{{ }}` payloads. +_SKILL_TEMPLATES = Environment( + autoescape=False, # noqa: S701 - prompts, not HTML + undefined=StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, +) _INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination", "analysis"}) _ROOT_SKILL_CATEGORY = "root" @@ -261,7 +270,7 @@ def _candidate_skill_files(skill_name: str) -> list[Path]: return _bare_skill_files(skill_name) -def load_skills(skill_names: list[str]) -> dict[str, str]: +def load_skills(skill_names: list[str], *, supports_images: bool = True) -> dict[str, str]: """Load skill markdown bodies (frontmatter stripped) by name. Skill files live at ``strix/skills//.md`` (or any @@ -291,7 +300,11 @@ def load_skills(skill_names: list[str]) -> dict[str, str]: continue var_name = skill_name.split("/")[-1] - _, skill_body = _parse_skill_content(content, file_path) + metadata, skill_body = _parse_skill_content(content, file_path) + if metadata.get("template") == "jinja": + skill_body = _SKILL_TEMPLATES.from_string(skill_body).render( + supports_images=supports_images + ) skill_content[var_name] = skill_body logger.debug("Loaded skill: %s -> %s", skill_name, var_name) _track_skill_loaded(var_name, file_path) diff --git a/strix/skills/tooling/agent_browser.md b/strix/skills/tooling/agent_browser.md index a254bfaf..efc59cdd 100644 --- a/strix/skills/tooling/agent_browser.md +++ b/strix/skills/tooling/agent_browser.md @@ -1,6 +1,7 @@ --- name: agent_browser description: agent-browser CLI for headless Chrome via shell. Snapshot-and-ref workflow, click/fill/extract, screenshots, multi-tab, multi-session, network mocking. Pre-installed in the sandbox; invoke via exec_command. +template: jinja --- @@ -260,8 +261,9 @@ only for simple expressions. ### Screenshot -`agent-browser screenshot` writes a PNG to disk in the sandbox. The -shell command alone does **not** put the image into your context — +`agent-browser screenshot` writes a PNG to disk in the sandbox. +{% if supports_images %} +The shell command alone does **not** put the image into your context — chain it with the SDK ``view_image`` tool to actually see it: ```bash @@ -275,6 +277,12 @@ the full path on stdout — pass that to ``view_image``). If you need a specific filename, keep it inside that directory or a sibling hidden dir under ``/workspace``. Never write screenshots to ``/tmp`` — ``view_image`` rejects anything outside the workspace root. +{% else %} +You are running on a text-only model and cannot view images. Screenshots +are only useful to you as a saved file (for example, login evidence). +Drive the page from `snapshot -i` refs, `eval` for any DOM/JS state you +need to read, and `text @ref` / `get text` for content extraction. +{% endif %} ```bash agent-browser screenshot # path printed on stdout @@ -283,6 +291,7 @@ agent-browser screenshot --full # full scroll height agent-browser screenshot --annotate # numbered labels + legend keyed to snapshot refs ``` +{% if supports_images %} `--annotate` is designed for multimodal models: each label `[N]` maps to ref `@eN`. Take the annotated screenshot, then ``view_image`` it, and you can correlate visual layout with snapshot refs. @@ -294,10 +303,10 @@ layout questions, captchas, custom widgets where the accessibility tree is incomplete). If ``view_image`` errors back at you (rejected image, "vision not -supported", or similar), you are running on a text-only model — stop -calling it and stop taking screenshots. Drive the page entirely from -`snapshot -i` refs, `eval` for any DOM/JS state you need to read, and -`text @ref` / `get text` for content extraction. +supported", or similar), you can drive the page from `snapshot -i` +refs, `eval` for any DOM/JS state you need to read, and `text @ref` +or `get text` for content extraction. +{% endif %} ### Handle multiple pages via tabs diff --git a/strix/telemetry/logging.py b/strix/telemetry/logging.py index 670b2236..40391cc1 100644 --- a/strix/telemetry/logging.py +++ b/strix/telemetry/logging.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +import functools import logging import os import sys @@ -78,6 +79,16 @@ class _StdoutQuietFilter(logging.Filter): ) +class _PtyThresholdFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return "PTY process count reached warning threshold" not in record.getMessage() + + +@functools.cache # install once; tests reset via ``cache_clear()`` +def _silence_pty_threshold_warning() -> None: + logging.getLogger("agents.sandbox.sandboxes.docker").addFilter(_PtyThresholdFilter()) + + def configure_dependency_logging() -> None: """Quiet dependency logging/warnings that obscure Strix scan logs.""" litellm = sys.modules.get("litellm") @@ -89,6 +100,7 @@ def configure_dependency_logging() -> None: logging.getLogger("asyncio").propagate = False warnings.filterwarnings("ignore", category=RuntimeWarning, module="asyncio") _route_unraisable_to_log() + _silence_pty_threshold_warning() def _route_unraisable_to_log() -> None: diff --git a/strix/tools/load_skill/tool.py b/strix/tools/load_skill/tool.py index 3ef6f9ec..83f70f8d 100644 --- a/strix/tools/load_skill/tool.py +++ b/strix/tools/load_skill/tool.py @@ -24,12 +24,12 @@ async def load_skill(ctx: RunContextWrapper, skills: list[str]) -> str: Max 5. Names match the bare files under ``strix/skills//.md``. """ - del ctx requested = list(skills or []) err = validate_requested_skills(requested) if err: return f"load_skill: {err}" - contents = load_skills(requested) + context = ctx.context if isinstance(ctx.context, dict) else {} + contents = load_skills(requested, supports_images=context.get("supports_images", True)) if not contents: return "load_skill: no content loaded for requested skills." sections = [f"## Skill: {name}\n\n{body}" for name, body in contents.items()] diff --git a/strix/tools/mcp/agent_tools.py b/strix/tools/mcp/agent_tools.py index ab068c64..7fa2c392 100644 --- a/strix/tools/mcp/agent_tools.py +++ b/strix/tools/mcp/agent_tools.py @@ -288,9 +288,11 @@ async def call_mcp( "Call search_mcp_tools, then get_mcp_tool_schema." ) session = await entry.ensure_connected() + context = ctx.context if isinstance(ctx.context, dict) else {} return await session.dispatch( tool, arguments or {}, label=namespaced_tool_name(connection, tool), result_transform=entry.result_transform, + supports_images=context.get("supports_images", True), ) diff --git a/strix/tools/mcp/client.py b/strix/tools/mcp/client.py index c0de7fab..4982ffcc 100644 --- a/strix/tools/mcp/client.py +++ b/strix/tools/mcp/client.py @@ -183,7 +183,9 @@ def _build_server(config: McpConnectionConfig) -> BuiltMcpServer: ) -def _mcp_result_to_tool_output(server: MCPServer, result: Any) -> Any: +def _mcp_result_to_tool_output( + server: MCPServer, result: Any, *, supports_images: bool = True +) -> Any: """Serialize a ``CallToolResult`` to a tool output, mirroring the agents SDK. This reproduces the serialization in ``agents.mcp.util.MCPUtil.invoke_mcp_tool`` @@ -199,6 +201,13 @@ def _mcp_result_to_tool_output(server: MCPServer, result: Any) -> Any: for item in result.content: if item.type == "text": outputs.append({"type": "text", "text": item.text}) + elif item.type == "image" and not supports_images: + outputs.append( + { + "type": "text", + "text": f"[{item.mimeType} image omitted: this model cannot view images]", + } + ) elif item.type == "image": outputs.append( {"type": "image", "image_url": f"data:{item.mimeType};base64,{item.data}"} @@ -217,6 +226,7 @@ async def dispatch_mcp_call( *, label: str, result_transform: ResultTransform | None = None, + supports_images: bool = True, ) -> Any: """Run one MCP tool call and convert its result to a tool output. @@ -235,7 +245,7 @@ async def dispatch_mcp_call( result = await server.call_tool(tool_name, arguments) if result_transform is not None: return result_transform(label, result.model_dump(mode="json")) - tool_output = _mcp_result_to_tool_output(server, result) + tool_output = _mcp_result_to_tool_output(server, result, supports_images=supports_images) if getattr(result, "isError", False): return _errored_tool_output(tool_output) return tool_output diff --git a/strix/tools/mcp/session.py b/strix/tools/mcp/session.py index f90a4f87..9cf85408 100644 --- a/strix/tools/mcp/session.py +++ b/strix/tools/mcp/session.py @@ -365,6 +365,7 @@ class SupervisedMcpSession: *, label: str, result_transform: ResultTransform | None = None, + supports_images: bool = True, ) -> Any: """Run one tool call with bounded retries for transient session failures. @@ -382,6 +383,7 @@ class SupervisedMcpSession: arguments, label=label, result_transform=result_transform, + supports_images=supports_images, ) outcome = await self._run_job(job, phase="call") diff --git a/tests/test_agent_factory_shell.py b/tests/test_agent_factory_shell.py index 6bef1211..0109bf52 100644 --- a/tests/test_agent_factory_shell.py +++ b/tests/test_agent_factory_shell.py @@ -115,3 +115,15 @@ def test_function_tools_are_result_bounded() -> None: by_name = {t.name: t for t in agent.tools} assert getattr(by_name["think"], "_strix_bounded", False) is True + + +@pytest.mark.parametrize(("supports_images", "has_view_image"), [(True, True), (False, False)]) +def test_view_image_is_left_out_for_text_only_models( + supports_images: bool, has_view_image: bool +) -> None: + agent = factory.build_strix_agent(is_root=True, supports_images=supports_images) + filesystem = agent.capabilities[0] + filesystem.bind(cast("Any", object())) + + view_image = next(tool for tool in filesystem.tools() if tool.name == "view_image") + assert cast("FunctionTool", view_image).is_enabled is has_view_image diff --git a/tests/test_agent_tool_registration.py b/tests/test_agent_tool_registration.py index 2cea8685..489fea70 100644 --- a/tests/test_agent_tool_registration.py +++ b/tests/test_agent_tool_registration.py @@ -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 diff --git a/tests/test_cli_resume_picker.py b/tests/test_cli_resume_picker.py index ed0aeb05..4d00380f 100644 --- a/tests/test_cli_resume_picker.py +++ b/tests/test_cli_resume_picker.py @@ -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() diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py index 54f49f54..97d78893 100644 --- a/tests/test_mcp_client.py +++ b/tests/test_mcp_client.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any import pytest from agents.mcp import MCPServer, MCPServerStdio, MCPServerStreamableHttp from agents.tool_context import ToolContext -from mcp.types import CallToolResult, TextContent +from mcp.types import CallToolResult, ImageContent, TextContent from mcp.types import Tool as MCPTool from pydantic import ValidationError @@ -884,6 +884,40 @@ async def test_call_mcp_flags_an_errored_result_failed_for_the_tui() -> None: assert out == {"type": "text", "text": "boom:read_file", "success": False} +@pytest.mark.asyncio +async def test_call_mcp_replaces_images_for_a_text_only_model() -> None: + class _ImageServer(FakeMCPServer): + async def call_tool( + self, + tool_name: str, + arguments: dict[str, Any] | None, + meta: dict[str, Any] | None = None, + ) -> CallToolResult: + return CallToolResult( + content=[ + TextContent(type="text", text="shot"), + ImageContent(type="image", data="aGk=", mimeType="image/png"), + ] + ) + + registry = McpRegistry() + registry.add( + name="fs", server=_ImageServer("fs", [_mcp_tool("shot")]), purpose=None, tool_count=1 + ) + ctx = _ctx(registry) + args = json.dumps({"connection": "fs", "tool": "shot"}) + + out = await call_mcp.on_invoke_tool(ctx, args) + assert out[1] == {"type": "image", "image_url": "data:image/png;base64,aGk="} + + ctx.context["supports_images"] = False + out = await call_mcp.on_invoke_tool(ctx, args) + assert out == [ + {"type": "text", "text": "shot"}, + {"type": "text", "text": "[image/png image omitted: this model cannot view images]"}, + ] + + # --- generic MCP tools are the only MCP surface every agent gets ------------- diff --git a/tests/test_models.py b/tests/test_models.py index 82cf8ac4..aafbaf40 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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 diff --git a/tests/test_pty_warning_filter.py b/tests/test_pty_warning_filter.py new file mode 100644 index 00000000..7cd14fca --- /dev/null +++ b/tests/test_pty_warning_filter.py @@ -0,0 +1,41 @@ +import logging +from collections.abc import Iterator + +import pytest + +from strix.telemetry import logging as tlog + + +_SDK_LOGGER = "agents.sandbox.sandboxes.docker" + + +@pytest.fixture +def sdk_logger() -> Iterator[logging.Logger]: + logger = logging.getLogger(_SDK_LOGGER) + original = list(logger.filters) + logger.filters.clear() + tlog._silence_pty_threshold_warning.cache_clear() + yield logger + logger.filters[:] = original + tlog._silence_pty_threshold_warning.cache_clear() + + +def _record(msg: str, *args: object) -> logging.LogRecord: + return logging.LogRecord(_SDK_LOGGER, logging.WARNING, __file__, 0, msg, args, None) + + +def test_installed_filter_drops_pty_threshold_warning(sdk_logger: logging.Logger) -> None: + tlog._silence_pty_threshold_warning() + record = _record("PTY process count reached warning threshold: %s active sessions", 62) + assert not sdk_logger.filter(record) + + +def test_installed_filter_keeps_other_warnings(sdk_logger: logging.Logger) -> None: + tlog._silence_pty_threshold_warning() + assert sdk_logger.filter(_record("something else went wrong")) + + +def test_install_is_idempotent(sdk_logger: logging.Logger) -> None: + tlog._silence_pty_threshold_warning() + tlog._silence_pty_threshold_warning() + assert len(sdk_logger.filters) == 1 diff --git a/tests/test_resume_picker.py b/tests/test_resume_picker.py index 1999150f..31bbf9ec 100644 --- a/tests/test_resume_picker.py +++ b/tests/test_resume_picker.py @@ -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 ") diff --git a/tests/test_run_summaries.py b/tests/test_run_summaries.py index 30e6db29..2a238014 100644 --- a/tests/test_run_summaries.py +++ b/tests/test_run_summaries.py @@ -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", + ] diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index 0e24908d..631c9271 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -287,6 +287,15 @@ def test_requested_skills_follow_the_shared_prefix() -> None: assert "" in xss.split("")[1] +def test_text_only_prompt_drops_screenshot_guidance() -> None: + assert "view_image" in render_system_prompt(include_scope=False) + + prompt = render_system_prompt(include_scope=False, supports_images=False) + assert "view_image" not in prompt + assert "text-only model and cannot view images" in prompt + assert "