mirror of
https://github.com/usestrix/strix.git
synced 2026-10-15 04:18:10 +00:00
Merge remote-tracking branch 'origin/main' into pr-1446
This commit is contained in:
commit
6a7f86b06b
28 changed files with 348 additions and 40 deletions
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/"):
|
||||
|
|
|
|||
|
|
@ -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, "
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ Strix Cloud:
|
|||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--resume",
|
||||
type=str,
|
||||
nargs="?",
|
||||
|
|
|
|||
|
|
@ -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(" ")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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<body>.*?)\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/<category>/<name>.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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ async def load_skill(ctx: RunContextWrapper, skills: list[str]) -> str:
|
|||
Max 5. Names match the bare files under
|
||||
``strix/skills/<category>/<name>.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()]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
41
tests/test_pty_warning_filter.py
Normal file
41
tests/test_pty_warning_filter.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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 ")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -287,6 +287,15 @@ def test_requested_skills_follow_the_shared_prefix() -> None:
|
|||
assert "<xss>" in xss.split("<cache_point>")[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 "<!--" not in prompt
|
||||
|
||||
|
||||
def test_scope_is_sent_as_its_own_system_message_on_cache_point_routes() -> None:
|
||||
settings = make_model_settings(None, model_name="anthropic/claude-sonnet-5-5")
|
||||
prompt = render_system_prompt(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,17 @@ def test_registered_dir_adds_new_skill(tmp_path: Path) -> None:
|
|||
assert load_skills(["widget"]) == {"widget": "widget body"}
|
||||
|
||||
|
||||
def test_only_jinja_skills_are_rendered(tmp_path: Path) -> None:
|
||||
body = "{% if supports_images %}\nlook\n{% else %}\nread\n{% endif %}\n"
|
||||
_write_skill(tmp_path, "extra", "plain", body)
|
||||
_write_skill(tmp_path, "extra", "templated", f"---\ntemplate: jinja\n---\n{body}")
|
||||
register_skill_dir(tmp_path)
|
||||
|
||||
assert load_skills(["plain"]) == {"plain": body}
|
||||
assert load_skills(["templated"]) == {"templated": "look\n"}
|
||||
assert load_skills(["templated"], supports_images=False) == {"templated": "read\n"}
|
||||
|
||||
|
||||
def test_available_skill_includes_frontmatter_description(tmp_path: Path) -> None:
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue