Compare commits

..
Author SHA1 Message Date
Ian 1f30c47e0e Revert the Vercel AI Gateway image-rejection guess
It also matched errors about one image form, e.g. Groq's
'Non-image file content parts' functionality not supported.
2026-10-07 17:15:07 -04:00
Ian 887ec1727f fix(execution): guess at Vercel AI Gateway's image rejection 2026-10-07 16:54:31 -04:00
Ian 8b0ab9ddb2 fix(execution): match DeepSeek's image rejection 2026-10-07 16:48:45 -04:00
Ian 9c44bba118 fix(execution): match only replies that mean the model takes no images 2026-10-07 16:48:45 -04:00
Ian b88bfd27b6 fix(execution): only strip images on image-rejection errors
The image-strip recovery fired on any 400/404/422, wiping a session's screenshots and replaying the turn on unrelated errors (e.g. an upstream incomplete-tool-call 400, a context overflow). Require an image-related message too.
2026-10-07 16:48:45 -04:00
Ian 0a6818f243 chore: fix the ruff and bandit failures on main 2026-10-07 16:48:35 -04:00
Ian 52ba4ab266 feat(llm): scrub images from every request to a text-only model
Replace images at the MCP call before any result_transform, give load_skill the
same agent_browser screenshot swap as the system prompt, and add a
call_model_input_filter that turns any image left in the input into a tool error
while chaining any filter already set.
2026-10-07 16:48:27 -04:00
32 changed files with 92 additions and 1179 deletions

No files matched your search

-12
View File
@@ -94,18 +94,6 @@ strix --target ./app-directory
> [!NOTE]
> First run automatically pulls the sandbox Docker image. Results are saved to `strix_runs/<run-name>`
### Updating
```bash
# Self-update the standalone binary to the latest release
strix --update
# ...or re-run the installer
curl -sSL https://strix.ai/install | bash
```
Installed with pipx, uv, or pip? `strix --update` prints the matching upgrade command instead (`pipx upgrade strix-agent`, `uv tool upgrade strix-agent`, `pip install --upgrade strix-agent`). Strix also shows a notice with the right command when a new version is available.
---
## Ways to Run Strix
+1 -3
View File
@@ -675,7 +675,6 @@ def build_strix_agent(
extra_tools: Sequence[Tool] | None = None,
instructions_override: str | None = None,
supports_images: bool = True,
finish_tool: Tool = finish_scan,
) -> SandboxAgent[Any]:
"""Build a SandboxAgent for either root or child use.
@@ -688,7 +687,6 @@ def build_strix_agent(
registered via ``register_agent_tools``.
instructions_override: Use this verbatim as the system prompt instead
of rendering the built-in scan prompt.
finish_tool: The tool that ends the run, given to the root agent.
"""
if instructions_override is not None:
instructions = instructions_override
@@ -709,7 +707,7 @@ def build_strix_agent(
# Yielding to the user is only meaningful when one is attached.
agent_tools.append(wait_for_user)
if is_root:
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_tool]
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan]
else:
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
_ensure_unique_tool_names(tools)
+1 -1
View File
@@ -21,7 +21,7 @@ MCP CONNECTIONS (available this run):
{% if system_prompt_context.mcp_connections %}
- Connected this run (search one to find relevant tools):
{% for connection in system_prompt_context.mcp_connections %}
- {{ connection.name }}{% if connection.state == "unavailable" %} (unavailable right now){% elif connection.tool_count is not none %} ({{ connection.tool_count }} tools){% endif %}{% if connection.purpose %}: {{ connection.purpose }}{% endif %}
- {{ connection.name }} ({{ connection.tool_count }} tools){% if connection.purpose %}: {{ connection.purpose }}{% endif %}
{% endfor %}
{% endif %}
- Reach for a connection whenever the target itself cannot give you information a connection could: its database schema and access policies, real deployment or infrastructure configuration, known issues or prior findings, or server logs. In those cases call list_mcps early to see what is available, and prefer a connection's authoritative data over inferring from the target's responses. Do not wait to be told a connection exists.
+16 -96
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
import contextlib
import inspect
import json
import logging
import os
import time
@@ -263,8 +262,6 @@ class _TurnGuardModel(Model):
not covered by the request timeout, which resets on any byte (keepalives
included). ``LLM_STREAM_IDLE_TIMEOUT`` bounds the gap between events so the
turn fails instead of hanging, and the existing retry path replays it.
``LLM_STREAM_FIRST_EVENT_TIMEOUT`` and ``LLM_STREAM_TOTAL_TIMEOUT`` bound
the first event and the whole stream.
"""
def __init__(
@@ -272,15 +269,11 @@ class _TurnGuardModel(Model):
inner: Model,
*,
max_tool_calls_per_turn: int = 0,
stream_idle_timeout: float | None = None,
first_event_timeout: float | None = None,
total_timeout: float | None = None,
stream_idle_timeout: float = 0.0,
) -> None:
self._inner = inner
self._max_tool_calls_per_turn = max_tool_calls_per_turn
self._stream_idle_timeout = stream_idle_timeout
self._first_event_timeout = first_event_timeout
self._total_timeout = total_timeout
def _limiter(self) -> TurnToolCallLimiter:
return TurnToolCallLimiter(self._max_tool_calls_per_turn)
@@ -367,12 +360,7 @@ class _TurnGuardModel(Model):
conversation_id=conversation_id,
prompt=prompt,
)
async for event in _with_timeouts(
stream,
idle=self._stream_idle_timeout,
first_event=self._first_event_timeout,
total=self._total_timeout,
):
async for event in _with_idle_timeout(stream, self._stream_idle_timeout):
guarded = _guard_event(event, rewriter, limiter)
if guarded is not None:
yield guarded
@@ -406,68 +394,28 @@ async def _aclose(stream: AsyncIterator[TResponseStreamEvent]) -> None:
await stream.aclose()
async def _with_timeouts(
stream: AsyncIterator[TResponseStreamEvent],
*,
idle: float | None = None,
first_event: float | None = None,
total: float | None = None,
async def _with_idle_timeout(
stream: AsyncIterator[TResponseStreamEvent], timeout: float
) -> AsyncIterator[TResponseStreamEvent]:
"""Bound the first event, the gap between events and the whole stream; None is no bound."""
if timeout <= 0:
async for event in stream:
yield event
return
iterator = stream.__aiter__()
started = time.monotonic()
while True:
limits: list[tuple[float, float, str]] = []
# Wait until the soonest limit expires: first-event (else idle) until the
# first event arrives, then idle; plus whatever is left of the total.
if first_event is not None:
limits.append((first_event, first_event, "stream_first_event_timeout"))
elif idle is not None:
limits.append((idle, idle, "stream_idle_timeout"))
if total is not None:
left = max(0.0, started + total - time.monotonic())
limits.append((left, total, "stream_total_timeout"))
try:
if limits:
event = await _next_event(iterator, *min(limits))
else:
event = await iterator.__anext__()
event = await asyncio.wait_for(iterator.__anext__(), timeout)
except StopAsyncIteration:
return
except TimeoutError as exc:
except TimeoutError:
await _aclose(stream)
logger.warning("%s; abandoning the turn", exc)
raise
first_event = None
message = f"model stream produced no event for {timeout:.0f}s"
logger.warning("%s; abandoning the turn", message)
raise TimeoutError(message) from None
yield event
async def _next_event(
iterator: AsyncIterator[TResponseStreamEvent], wait: float, limit: float, reason: str
) -> TResponseStreamEvent:
# asyncio.timeout() cancels without a message, so the request log could not
# tell this from a shutdown; this is asyncio.timeout() with a message.
task = asyncio.current_task()
assert task is not None
cancelling = task.cancelling()
expired = False
def expire() -> None:
nonlocal expired
expired = True
task.cancel(msg=f"{request_log.CANCEL_REASON_PREFIX}{reason}")
handle = asyncio.get_running_loop().call_later(wait, expire)
try:
return await iterator.__anext__()
except BaseException as exc:
if expired and task.uncancel() <= cancelling and isinstance(exc, asyncio.CancelledError):
raise TimeoutError(f"model stream hit {reason} ({limit:.0f}s)") from None
raise
finally:
handle.cancel()
def _guard_event(
event: TResponseStreamEvent, rewriter: TurnCallIdRewriter, limiter: TurnToolCallLimiter
) -> TResponseStreamEvent | None:
@@ -602,10 +550,7 @@ class StrixProvider(MultiProvider):
def get_model(self, model_name: str | None) -> Model:
llm = load_settings().llm
slug = codex.subscription_model(model_name)
# The settings use 0 for no bound.
idle_timeout = float(llm.stream_idle_timeout) or None
first_event_timeout = float(llm.stream_first_event_timeout) or None
total_timeout = float(llm.stream_total_timeout) or None
idle_timeout = float(llm.stream_idle_timeout)
if slug:
# The ChatGPT subscription backend is always streamed; it has no
# non-streaming mode to fall back to, so LLM_DISABLE_STREAMING
@@ -646,13 +591,11 @@ class StrixProvider(MultiProvider):
# The wrapper emits its single event only once the whole request
# is done, so an idle gap is meaningless here; the request
# timeout bounds it instead.
idle_timeout = first_event_timeout = total_timeout = None
idle_timeout = 0.0
return _TurnGuardModel(
model,
max_tool_calls_per_turn=llm.max_tool_calls_per_turn,
stream_idle_timeout=idle_timeout,
first_event_timeout=first_event_timeout,
total_timeout=total_timeout,
)
@@ -802,11 +745,6 @@ def _install_openrouter_stream_cost_capture() -> None:
class _StrixOpenRouterStreamingHandler(OpenRouterChatCompletionStreamingHandler):
def chunk_parser(self, chunk: dict[str, Any]) -> Any:
# Before parsing: LiteLLM raises on an error chunk and drops its
# top-level ``provider``.
request_log.record_upstream_provider(
chunk.get("provider"), _openrouter_error_type(chunk.get("error"))
)
stream = super().chunk_parser(chunk)
usage = chunk.get("usage")
response_id = chunk.get("id") or getattr(stream, "id", None)
@@ -825,22 +763,12 @@ def _install_openrouter_stream_cost_capture() -> None:
json_mode=json_mode,
)
def get_error_class(self, error_message: str, status_code: int, headers: Any) -> Any:
# A non-2xx reply names the provider in ``error.metadata``.
with contextlib.suppress(Exception):
error = json.loads(error_message)["error"]
request_log.record_upstream_provider(
error["metadata"].get("provider_name"), _openrouter_error_type(error)
)
return super().get_error_class(error_message, status_code, headers)
def transform_response(self, *args: Any, **kwargs: Any) -> Any:
# Non-streamed replies (LLM_DISABLE_STREAMING) skip the chunk parser.
response = super().transform_response(*args, **kwargs)
raw_response = kwargs.get("raw_response", args[1] if len(args) > 1 else None)
with contextlib.suppress(Exception):
body = raw_response.json() # type: ignore[union-attr]
request_log.record_upstream_provider(body.get("provider"))
if body.get("usage"):
record_openrouter_provider(body.get("provider"), body["usage"])
return response
@@ -861,14 +789,6 @@ def _install_openrouter_stream_cost_capture() -> None:
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
def _openrouter_error_type(error: object) -> object:
if isinstance(error, dict):
metadata = error.get("metadata")
if isinstance(metadata, dict):
return metadata.get("error_type")
return None
OPENROUTER_ATTRIBUTION_HEADERS = {
"HTTP-Referer": "https://strix.ai",
"X-Title": "Strix",
-4
View File
@@ -79,10 +79,6 @@ class LlmSettings(BaseSettings):
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
preflight_timeout: int = Field(default=30, ge=1, alias="LLM_PREFLIGHT_TIMEOUT")
stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT")
stream_first_event_timeout: int = Field(
default=120, ge=0, alias="LLM_STREAM_FIRST_EVENT_TIMEOUT"
)
stream_total_timeout: int = Field(default=600, ge=0, alias="LLM_STREAM_TOTAL_TIMEOUT")
max_tool_calls_per_turn: int = Field(
default=32,
ge=0,
-1
View File
@@ -71,7 +71,6 @@ class AgentCoordinator:
self._lock = asyncio.Lock()
self._snapshot_path: Path | None = None
self.is_shutting_down = False
self.root_finish_tool = "finish_scan"
self._budget_stopped = False
self._reserve_stopped = False
self._budget_paused = False
+9 -10
View File
@@ -66,8 +66,7 @@ _IMAGE_REJECTION = re.compile(
r"|is not a multimodal model|at most 0 image\(s\)" # vLLM
r"|does not support image input" # LiteLLM's Fireworks check
r"|doesn't support the image field" # Bedrock Converse
r"|unknown variant `image_url`" # DeepSeek, e.g. via Vercel AI Gateway
r"|'[^']*image[^']*' functionality not supported", # Vercel AI Gateway (AI SDK), unverified
r"|unknown variant `image_url`", # DeepSeek, e.g. via Vercel AI Gateway
re.IGNORECASE,
)
_MAX_COMPACTIONS_PER_CYCLE = 2
@@ -184,7 +183,9 @@ def _is_transient_model_error(exc: BaseException) -> bool:
return True
code = _model_error_status_code(exc)
if code is not None:
return code >= 400 and code not in (401, 402, 403, 404)
import litellm
return bool(litellm._should_retry(code))
return isinstance(exc, APIError)
@@ -302,7 +303,7 @@ async def _run_agent_loop(
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
if reserve_stopped and start_parked and interactive and context.get("parent_id") is None:
await coordinator.send(agent_id, _reserve_notice(coordinator.root_finish_tool))
await coordinator.send(agent_id, _reserve_notice())
if not (start_parked and interactive):
with contextlib.suppress(BudgetPausedError):
@@ -642,7 +643,6 @@ async def _run_until_lifecycle(
input_data = await _append_tool_required_message(
session=session,
context=context,
root_finish_tool=coordinator.root_finish_tool,
attempt=recoveries,
limit=recovery_limit,
interactive=interactive,
@@ -989,9 +989,8 @@ async def _append_tool_required_message(
limit: int,
interactive: bool,
silent_yield: bool = False,
root_finish_tool: str = "finish_scan",
) -> list[dict[str, str]]:
finish_tool = root_finish_tool if context.get("parent_id") is None else "agent_finish"
finish_tool = "finish_scan" if context.get("parent_id") is None else "agent_finish"
if silent_yield:
message = (
"You called wait_for_user without having written anything to the user since "
@@ -1109,7 +1108,7 @@ async def notify_parent_on_terminal(
)
def _reserve_notice(finish_tool: str) -> dict[str, Any]:
def _reserve_notice() -> dict[str, Any]:
return {
"from": "system",
"type": "budget_reserve_stop",
@@ -1119,7 +1118,7 @@ def _reserve_notice(finish_tool: str) -> dict[str, Any]:
"sub-agent is being force-stopped as soon as its in-flight turn completes, and "
"none will send a completion report. Their confirmed vulnerabilities are "
"already filed as they were found. Do not wait on any sub-agents and do not "
f"spawn new ones — wrap up now and call {finish_tool}."
"spawn new ones — wrap up now and call finish_scan."
),
}
@@ -1128,7 +1127,7 @@ async def _notify_root_on_budget_reserve(coordinator: AgentCoordinator) -> None:
root = await coordinator.claim_reserve_notification()
if root is None:
return
await coordinator.send(root, _reserve_notice(coordinator.root_finish_tool))
await coordinator.send(root, _reserve_notice())
async def _notify_parent_on_exit(
+5 -8
View File
@@ -104,16 +104,16 @@ _ROOT_DIRECTIVES: tuple[str, ...] = (
(
"As the root agent, begin planning your wind-down of the whole scan: avoid "
"starting large new lines of investigation, and keep your required objectives on "
"track so you can call {finish_tool} comfortably before the limit."
"track so you can call finish_scan comfortably before the limit."
),
(
"As the root agent, prioritize wrapping up the whole scan now: stop opening new "
"lines of investigation, close out only what is essential, and move toward calling "
"{finish_tool} to compile and deliver the final report."
"finish_scan to compile and deliver the final report."
),
(
"As the root agent, STOP all other work on the whole scan and finish immediately: "
"secure your findings and call {finish_tool} now — anything left unfinished when the "
"secure your findings and call finish_scan now — anything left unfinished when the "
"limit is hit is discarded."
),
)
@@ -138,11 +138,8 @@ _SUBAGENT_DIRECTIVES: tuple[str, ...] = (
def _wrapup_directive(context: RunContextWrapper[dict[str, Any]], stage: int) -> str:
is_root = context.context.get("parent_id") is None
if not is_root:
return _SUBAGENT_DIRECTIVES[stage]
coordinator = coordinator_from_context(context.context)
finish_tool = coordinator.root_finish_tool if coordinator is not None else "finish_scan"
return _ROOT_DIRECTIVES[stage].format(finish_tool=finish_tool)
directives = _ROOT_DIRECTIVES if is_root else _SUBAGENT_DIRECTIVES
return directives[stage]
def _urgency(stage: int) -> str:
+10 -29
View File
@@ -49,7 +49,6 @@ from strix.report.state import get_global_report_state
from strix.runtime import session_manager
from strix.telemetry import set_scan_phase
from strix.telemetry.logging import set_scan_id, setup_scan_logging
from strix.tools.finish.tool import finish_scan
from strix.tools.output_store import (
WORKSPACE_SPILL_DIR,
configure_spill_writer,
@@ -59,7 +58,6 @@ from strix.tools.output_store import (
if TYPE_CHECKING:
from agents.memory import SQLiteSession
from agents.result import RunResultBase
from agents.tool import Tool
from strix.runtime.status import StatusSink
from strix.tools.mcp import (
@@ -70,8 +68,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_MCP_PROMPT_WARMUP_TIMEOUT_SECONDS = 30.0
StreamEventSink = Callable[[str, Any], None]
# Receives the run's MCP connection roster as a list of non-secret status dicts
@@ -97,19 +93,6 @@ def _mcp_roster_payload(registry: McpRegistry) -> list[dict[str, Any]]:
]
def _mcp_prompt_roster(registry: McpRegistry) -> list[dict[str, Any]]:
"""The MCP roster rendered in agent prompts, with only verified counts."""
return [
{
"name": summary.name,
"purpose": summary.purpose,
"tool_count": summary.tool_count if summary.state == "catalog_ready" else None,
"state": summary.state,
}
for summary in registry.summaries()
]
def _record_mcp_connections(connection_names: list[str]) -> None:
"""Record which MCP servers this run configured, for the interfaces.
@@ -217,7 +200,6 @@ async def run_strix_scan(
status_sink: StatusSink | None = None,
mcp_connection_requests: list[McpConnectionRequest] | None = None,
mcp_status_sink: McpStatusSink | None = None,
root_finish_tool: Tool = finish_scan,
) -> RunResultBase | None:
"""Run or resume one Strix scan against a sandbox.
@@ -240,7 +222,6 @@ async def run_strix_scan(
command-line default) it reads ``~/.strix/mcp-servers.json`` itself. Either
way the engine does the connecting, so the caller passes inert configs plus
metadata and never live sessions.
``root_finish_tool`` is the tool the root agent ends the run with.
"""
def report(phase: str) -> None:
@@ -297,7 +278,6 @@ async def run_strix_scan(
coordinator = AgentCoordinator()
coordinator.set_snapshot_path(agents_path)
coordinator.set_budget_policy(budget_policy)
coordinator.root_finish_tool = root_finish_tool.name
from strix.tools.coverage.tools import hydrate_coverage_from_disk
from strix.tools.notes.tools import hydrate_notes_from_disk
@@ -458,13 +438,19 @@ async def run_strix_scan(
_record_mcp_connections(mcp_registry.names())
report(
f"MCP: configured {len(mcp_registry)} connection(s); "
"connecting and listing tools"
"warming them in the background"
)
scope_context["mcp_available"] = True
scope_context["mcp_connections"] = _mcp_prompt_roster(mcp_registry)
scope_context["mcp_connections"] = [
{
"name": summary.name,
"purpose": summary.purpose,
"tool_count": summary.tool_count,
}
for summary in mcp_registry.summaries()
]
def _emit_mcp_status() -> None:
scope_context["mcp_connections"] = _mcp_prompt_roster(mcp_registry)
roster = _mcp_roster_payload(mcp_registry)
_persist_mcp_status(roster)
if mcp_status_sink is not None:
@@ -475,11 +461,7 @@ async def run_strix_scan(
mcp_registry.set_status_sink(_emit_mcp_status)
_emit_mcp_status()
warmup_task = mcp_registry.start_warmup(max_concurrency=6)
await asyncio.wait(
{warmup_task},
timeout=_MCP_PROMPT_WARMUP_TIMEOUT_SECONDS,
)
mcp_registry.start_warmup(max_concurrency=6)
except Exception:
logger.exception("Failed to configure user MCP servers; continuing without them")
@@ -508,7 +490,6 @@ async def run_strix_scan(
system_prompt_context=root_context,
instructions_override=root_instructions,
supports_images=supports_images,
finish_tool=root_finish_tool,
)
if not is_resume:
+2 -2
View File
@@ -66,7 +66,7 @@ func panelFocus(panel sidebarPanel) (focusMode, bool) {
func (m Model) sidebarPanels() []panelRect {
statsHeight, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights()
top := m.sidebarTopHeight()
top := m.viewerHeight()
rects := []panelRect{{panelAgents, top, agentHeight}}
top += agentHeight
if vulnHeight > 0 {
@@ -83,7 +83,7 @@ func (m Model) sidebarPanels() []panelRect {
func (m Model) sidebarGap() int {
statsHeight, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights()
return max(0, m.height-m.sidebarTopHeight()-agentHeight-vulnHeight-mcpHeight-statsHeight)
return max(0, m.height-m.viewerHeight()-agentHeight-vulnHeight-mcpHeight-statsHeight)
}
func (m Model) panelAt(y int) (panelRect, bool) {
@@ -355,77 +355,3 @@ func TestLonePanelHasNoControls(t *testing.T) {
t.Fatalf("controls should return once a second panel exists")
}
}
func TestTargetsBoxSitsUnderViewer(t *testing.T) {
m := panelsModel(t)
m.snapshot.Targets = []string{"https://example.com", "./app"}
m.snapshot.TargetCount = 2
m.resizeViewport()
_, sidebarWidth, _, _ := m.layout()
rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n")
if len(rows) != m.height {
t.Fatalf("sidebar renders %d rows, want %d", len(rows), m.height)
}
top := m.viewerHeight()
for i, want := range []string{"Targets 2", "▸ https://example.com", "▸ ./app"} {
if !strings.Contains(rows[top+1+i], want) {
t.Fatalf("row %d missing %q:\n%s", top+1+i, want, strings.Join(rows, "\n"))
}
}
if !strings.Contains(rows[panelRectOf(t, m, panelAgents).top+1], "Agents") {
t.Fatalf("agents panel not where its rect says:\n%s", strings.Join(rows, "\n"))
}
}
func TestTargetsBoxDropsOnShortTerminal(t *testing.T) {
m := panelsModel(t)
m.height = 19
m.snapshot.Targets = []string{"a", "b", "c", "d", "e"}
m.snapshot.TargetCount = 5
m.resizeViewport()
_, sidebarWidth, _, _ := m.layout()
if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got > m.height {
t.Fatalf("sidebar renders %d rows on a %d-row terminal", got, m.height)
}
}
// The targets box only takes rows the agent tree would otherwise grow into, so
// the stats panel keeps the Caido URL and the other panels keep their heights.
func TestTargetsBoxNeverSqueezesPanels(t *testing.T) {
for height := 19; height <= 40; height++ {
m := panelsModel(t)
m.height = height
m.snapshot.Usage = map[string]any{"total_tokens": 1234, "cost": 0.5}
m.snapshot.CaidoURL = "http://127.0.0.1:8080"
m.resizeViewport()
stats, vuln, mcp, _ := m.sidebarHeights()
m.snapshot.Targets = []string{"a", "b", "c", "d", "e"}
m.snapshot.TargetCount = 5
m.resizeViewport()
gotStats, gotVuln, gotMcp, _ := m.sidebarHeights()
if gotStats != stats || gotVuln != vuln || gotMcp != mcp {
t.Fatalf("height %d: targets squeezed panels: stats %d->%d findings %d->%d mcp %d->%d",
height, stats, gotStats, vuln, gotVuln, mcp, gotMcp)
}
_, sidebarWidth, _, _ := m.layout()
if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got != m.height {
t.Fatalf("height %d: sidebar renders %d rows", height, got)
}
}
}
func TestTargetsViewCountsTheTargetsItCannotShow(t *testing.T) {
m := panelsModel(t)
m.snapshot.Targets = []string{"a", "b", "c", "d", "e"}
m.snapshot.TargetCount = 5
for rows, want := range map[int]string{
2: "Targets 5\n+5 more",
3: "Targets 5\n▸ a\n+4 more",
6: "Targets 5\n▸ a\n▸ b\n▸ c\n▸ d\n+1 more",
9: "Targets 5\n▸ a\n▸ b\n▸ c\n▸ d\n+1 more",
} {
if got := ansi.Strip(m.targetsView(20, rows)); got != want {
t.Fatalf("%d rows:\n%s\nwant:\n%s", rows, got, want)
}
}
}
+6 -13
View File
@@ -421,7 +421,7 @@ func (m Model) setupComposer(width int) string {
inner := max(1, width-4)
body := m.highlightInputSelection(m.input.View())
body += "\n\n" + m.setupSummaryView(inner)
if targets := m.targetsView(inner, maxTargetRows); targets != "" {
if targets := m.setupTargetsView(inner); targets != "" {
body += "\n" + targets
}
return lipgloss.NewStyle().Width(max(1, width-2)).Padding(0, 1).
@@ -454,22 +454,15 @@ func (m Model) setupSummaryView(width int) string {
return truncate(strings.Join(chips, render.Dim().Render(" · ")), max(1, width))
}
// maxTargetRows fits the header, four targets and the count of the rest.
const maxTargetRows = 6
// targetsView lists what the scan is pointed at, once anything is queued, in at
// most maxRows rows. Targets that do not fit are counted on the last row.
func (m Model) targetsView(width, maxRows int) string {
if len(m.snapshot.Targets) == 0 || maxRows < 2 {
// setupTargetsView lists what the scan is pointed at, once anything is queued.
func (m Model) setupTargetsView(width int) string {
if len(m.snapshot.Targets) == 0 {
return ""
}
const visible = 4
total := max(m.snapshot.TargetCount, len(m.snapshot.Targets))
visible := min(len(m.snapshot.Targets), maxTargetRows-2, maxRows-1)
if visible < total {
visible = min(visible, maxRows-2)
}
rows := []string{render.Bold(green).Render("Targets") + render.Dim().Render(fmt.Sprintf(" %d", total))}
for _, target := range m.snapshot.Targets[:visible] {
for _, target := range m.snapshot.Targets[:min(visible, len(m.snapshot.Targets))] {
rows = append(rows, render.Col(dim).Render("▸ ")+render.Col(white).Render(truncate(target, max(1, width-2))))
}
if hidden := total - visible; hidden > 0 {
-35
View File
@@ -542,15 +542,6 @@ func (m Model) sidebarView(width, height int) string {
BorderForeground(dark).
Padding(0, 1).
Render(m.viewerBox(width - 4))}
if targetsHeight := m.targetsBoxHeight(); targetsHeight > 0 {
parts = append(parts, lipgloss.NewStyle().
Width(width-2).
Height(targetsHeight-2).
Border(lipgloss.RoundedBorder()).
BorderForeground(dark).
Padding(0, 1).
Render(m.targetsView(width-4, targetsHeight-2)))
}
agents := ""
if agentHeight > 1 {
agentRows := max(1, agentHeight-4)
@@ -601,13 +592,6 @@ func (m Model) sidebarView(width, height int) string {
}
func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight int) {
_, statsHeight, vulnHeight, mcpHeight, agentHeight = m.sidebarLayout()
return
}
// sidebarLayout sizes the targets box and the panels. The targets box only
// takes rows no panel needs, so it never squeezes the panels below it.
func (m Model) sidebarLayout() (targetsHeight, statsHeight, vulnHeight, mcpHeight, agentHeight int) {
// Measure the stats panel the way its box will render it: a long model name
// wraps inside the sidebar, and counting only its newlines would size the
// box short and push the whole frame past the bottom of the terminal.
@@ -644,14 +628,6 @@ func (m Model) sidebarLayout() (targetsHeight, statsHeight, vulnHeight, mcpHeigh
}
}
spare := max(0, m.height-m.viewerHeight()-statsHeight-vulnHeight-mcpHeight-agentHeight)
// The box needs its border and two rows: the header and a target or the
// count of hidden ones. A zoomed panel keeps the whole sidebar.
if targets := m.targetsView(m.sidebarInnerWidth(), maxTargetRows); targets != "" && m.zoomedPanel == panelNone {
if fit := min(lipgloss.Height(targets)+2, spare); fit >= 4 {
targetsHeight = fit
spare -= fit
}
}
switch {
case m.zoomedPanel == panelFindings && vulnHeight > 1:
vulnHeight += spare
@@ -667,17 +643,6 @@ func (m Model) viewerHeight() int {
return strings.Count(m.viewerView(m.viewerContentWidth()), "\n") + 3
}
// sidebarTopHeight is the rows above the panels: the viewer box and, when the
// run has targets, the targets box under it.
func (m Model) sidebarTopHeight() int {
return m.viewerHeight() + m.targetsBoxHeight()
}
func (m Model) targetsBoxHeight() int {
targetsHeight, _, _, _, _ := m.sidebarLayout()
return targetsHeight
}
func (m Model) sidebarInnerWidth() int {
_, sidebarWidth, _, _ := m.layout()
if sidebarWidth == 0 {
+4 -47
View File
@@ -159,33 +159,11 @@ class HttpReply:
status_code: int | None = None
headers: dict[str, str] | None = None
upstream_provider: str | None = None
upstream_error_type: str | None = None
_http_reply: ContextVar[HttpReply | None] = ContextVar("strix_llm_http_reply", default=None)
def record_upstream_provider(provider: object, error_type: object = None) -> None:
"""Remember which provider behind a gateway served the attempt in flight."""
reply = _http_reply.get()
if reply is None:
return
if isinstance(provider, str) and provider:
reply.upstream_provider = provider
if isinstance(error_type, str) and error_type:
reply.upstream_error_type = error_type
def _upstream_details(reply: HttpReply | None) -> list[tuple[str, object]]:
if reply is None:
return []
return [
("upstream_provider", reply.upstream_provider),
("upstream_error_type", reply.upstream_error_type),
]
async def record_http_reply(response: Response) -> None:
"""httpx ``response`` event hook: remember the reply for the attempt in flight.
@@ -794,7 +772,6 @@ def _litellm_success(
("request", _litellm_request_details(kwargs)),
("response", _litellm_response_details(response)),
("litellm", _litellm_hidden_details(hidden, slo)),
*_upstream_details(_http_reply.get()),
),
)
@@ -844,7 +821,6 @@ def _litellm_failure(
("request", _litellm_request_details(kwargs)),
("error", error or None),
("litellm", _litellm_hidden_details(hidden, slo)),
*_upstream_details(_http_reply.get()),
),
)
@@ -918,18 +894,15 @@ def _observe_sdk_shared_http_client() -> None:
def _log_line_sink(event: LlmRequestEvent) -> None:
details = event.details or {}
level = logging.DEBUG if event.outcome == "success" else logging.WARNING
logger.log(
level,
"llm_request route=%s provider=%s upstream=%s upstream_error=%s model=%s host=%s "
"outcome=%s status=%s request_id=%s response_id=%s stream=%s duration_ms=%d ttft_ms=%s "
"llm_request route=%s provider=%s model=%s host=%s outcome=%s status=%s "
"request_id=%s response_id=%s stream=%s duration_ms=%d ttft_ms=%s "
"req_bytes=%s res_bytes=%s finish=%s "
"in=%s out=%s cached=%s cost=%s agent=%s attempt=%d%s",
event.route,
event.provider or "-",
details.get("upstream_provider") or "-",
details.get("upstream_error_type") or "-",
event.model,
event.api_host or "-",
event.outcome,
@@ -1097,7 +1070,6 @@ class RequestLoggingModel(Model):
details=merge_details(
("request", request.details),
("response", _openai_response_details(response, raw_response)),
*_upstream_details(reply),
),
)
status, request_id = _openai_error_fields(exc)
@@ -1110,14 +1082,13 @@ class RequestLoggingModel(Model):
outcome="error",
status_code=status,
provider_request_id=request_id or base.provider_request_id,
error_type=_cancel_reason(exc) or type(exc).__name__,
error_type=type(exc).__name__,
error_message=_abandonment_message(exc) or clean_error_message(exc),
response_bytes=_exception_body_size(exc, status),
response_headers=headers_from_response(error_headers) or base.response_headers,
details=merge_details(
("request", request.details),
("error", _exception_details(exc)),
*_upstream_details(reply),
),
)
@@ -1347,23 +1318,9 @@ def _is_abandonment(exc: BaseException | None) -> bool:
return isinstance(exc, asyncio.CancelledError | GeneratorExit)
# Prefixes the reason a stream timeout gives when it cancels an attempt.
CANCEL_REASON_PREFIX = "strix:"
def _cancel_reason(exc: BaseException) -> str | None:
"""The stream timeout that cancelled the attempt, e.g. ``stream_idle_timeout``."""
if isinstance(exc, asyncio.CancelledError) and exc.args:
message = str(exc.args[0])
if message.startswith(CANCEL_REASON_PREFIX):
return message.removeprefix(CANCEL_REASON_PREFIX)
return None
def _abandonment_message(exc: BaseException) -> str | None:
if isinstance(exc, asyncio.CancelledError):
reason = f": {exc.args[0]}" if exc.args else ""
return f"attempt cancelled before the reply was consumed{reason}"
return "attempt cancelled before the reply was consumed (stream idle timeout or shutdown)"
if isinstance(exc, GeneratorExit):
return "stream closed by the caller before it finished"
return None
+3 -14
View File
@@ -115,8 +115,7 @@ not just "upgrade <vulnerable pkg> to <fixed>".
### Usage / reachability analysis (required for every dependency CVE)
For every CVE you are about to report, run a static usage analysis and record
the result in the structured `reachability` + `reachability_evidence` fields,
and report every usage site you find as a `code_locations` entry (see Reporting).
the result in the structured `reachability` + `reachability_evidence` fields.
The level is an **evidence ladder, never an exploitability verdict** — claim
only what you proved, and cite the proof. It never changes severity (that is
`advisory_cvss` alone); it exists so the reader can prioritize.
@@ -221,18 +220,8 @@ findings and rejects empty PoC fields):
GHSA, look up the mapped CVE; if there is genuinely no CVE, do not report it
with this tool.
- There are no PoC fields — `create_dependency_report` does not take
`poc_description` / `poc_script_code`. The proof lives in `description` and
`technical_analysis` (scanner output + advisory).
- **You MUST report usage sites in `code_locations`.** Add one entry for each
import of the package and each call of an affected symbol that your usage
analysis found. Every `file:line` you cite in `reachability_evidence` must
also be a `code_locations` entry. Also add the line in `manifest_path` that
pins the vulnerable version. Each entry needs the repo-relative `file`, the
exact `start_line` / `end_line`, the verbatim `snippet` at those lines, and a
short `label` (e.g. "imports js-yaml", "calls yaml.load on CLI input"). Read
the real file to confirm the lines; never guess. When the package is
`not_imported`, the manifest line is the only entry. If an entry carries
`fix_before` / `fix_after`, also fill `fix_verification`.
`poc_description` / `poc_script_code` / `code_locations`. The proof lives in
`description` and `technical_analysis` (scanner output + advisory).
- **Always fill the structured dependency fields** (they power the dedicated
dependency-report card; do not leave them only in free-text):
- `package_name` — `PkgName` (required).
+1 -2
View File
@@ -683,8 +683,7 @@ async def agent_finish(
{
"success": False,
"error": (
"agent_finish is for subagents. Root/main agents must call "
f"{coordinator.root_finish_tool} instead"
"agent_finish is for subagents. Root/main agents must call finish_scan instead"
),
},
ensure_ascii=False,
+3 -24
View File
@@ -24,7 +24,6 @@ automatically.
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING, Any
@@ -47,7 +46,6 @@ def _registry_from_ctx(ctx: RunContextWrapper) -> McpRegistry | None:
_NO_CONNECTIONS = "No MCP connections are configured for this run."
_MCP_DISCOVERY_WAIT_SECONDS = 30.0
def _unknown_connection(connection: str, registry: McpRegistry) -> str:
@@ -63,13 +61,11 @@ def _format_tool(tool: MCPTool) -> str:
@function_tool(timeout=60)
async def list_mcps(ctx: RunContextWrapper) -> dict[str, Any]:
"""List available MCP connections, loading each connection's catalog on first use.
"""List the MCP connections available this run, so you can discover them.
Read-only. Returns one entry per connection with its ``id`` (the exact name
you pass to the other MCP tools), ``name``, ``description``, and
``tool_count`` (null while the catalog is unknown). Waits at most 30 seconds
for catalogs; unfinished catalogs keep loading in the background.
The response does not include tool schemas. Call
``tool_count``. The response does not include tool schemas. Call
``search_mcp_tools`` next. Then call ``get_mcp_tool_schema`` for one selected
tool before you call ``call_mcp``. Returns an empty ``connections`` list when
the run has no MCP connections.
@@ -77,23 +73,6 @@ async def list_mcps(ctx: RunContextWrapper) -> dict[str, Any]:
registry = _registry_from_ctx(ctx)
if registry is None or not registry:
return {"connections": []}
pending = [
entry
for name in registry.names()
if (entry := registry.get(name)) is not None
and entry.state != "catalog_ready"
and (entry.session is None or not entry.session.is_dead)
]
if pending:
await asyncio.wait(
{
asyncio.gather(
*(entry.ensure_catalog() for entry in pending),
return_exceptions=True,
)
},
timeout=_MCP_DISCOVERY_WAIT_SECONDS,
)
dead_by_name = {status.name: status.dead for status in registry.statuses()}
return {
"connections": [
@@ -101,7 +80,7 @@ async def list_mcps(ctx: RunContextWrapper) -> dict[str, Any]:
"id": summary.name,
"name": summary.name,
"description": summary.purpose,
"tool_count": summary.tool_count if summary.state == "catalog_ready" else None,
"tool_count": summary.tool_count,
"dead": dead_by_name.get(summary.name, False),
"state": summary.state,
}
+5 -5
View File
@@ -432,19 +432,19 @@ class McpRegistry:
entry.set_status_sink(sink)
def start_warmup(self, *, max_concurrency: int = 6) -> asyncio.Task[None]:
"""Connect and list every configured entry with a fixed concurrency bound."""
"""Connect every configured entry in the background with a fixed bound."""
if self._warmup_task is not None:
return self._warmup_task
async def warm() -> None:
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def load_catalog(entry: McpConnectionEntry) -> None:
async def connect(entry: McpConnectionEntry) -> None:
async with semaphore:
with contextlib.suppress(Exception):
await entry.ensure_catalog()
with contextlib.suppress(McpConnectionUnavailableError):
await entry.ensure_connected()
await asyncio.gather(*(load_catalog(entry) for entry in self._entries.values()))
await asyncio.gather(*(connect(entry) for entry in self._entries.values()))
self._warmup_task = asyncio.create_task(warm(), name="mcp-warmup")
return self._warmup_task
+1 -6
View File
@@ -13,7 +13,6 @@ from datetime import datetime
from typing import TYPE_CHECKING, Any, Literal
from agents import RunContextWrapper, function_tool
from caido_sdk_client import BaseError
from strix.runtime.caido_handle import CaidoBootstrapHandle
from strix.tools.nullish import clean_optional
@@ -125,11 +124,7 @@ def _no_client() -> str:
def _err(name: str, exc: Exception) -> str:
# Caido-side failures (timeouts, bad filters, ...) are returned to the model, not our bugs.
if isinstance(exc, BaseError):
logger.warning("%s failed", name, exc_info=True)
else:
logger.exception("%s failed", name)
logger.exception("%s failed", name)
return json.dumps(
{"success": False, "error": f"{name} failed: {exc}"},
ensure_ascii=False,
+1 -36
View File
@@ -1883,7 +1883,7 @@ def _build_dependency_evidence(
return evidence
async def _do_create_dependency( # noqa: PLR0912, PLR0915
async def _do_create_dependency( # noqa: PLR0912
*,
title: str,
description: str,
@@ -1905,8 +1905,6 @@ async def _do_create_dependency( # noqa: PLR0912, PLR0915
manifest_path: str | None = None,
reachability: str = "unknown",
reachability_evidence: str | None = None,
code_locations: list[dict[str, Any]] | None = None,
fix_verification: str | None = None,
contextual_cvss_breakdown: dict[str, str] | None = None,
contextual_cvss_reasoning: str | None = None,
agent_id: str | None = None,
@@ -1949,16 +1947,6 @@ async def _do_create_dependency( # noqa: PLR0912, PLR0915
if manifest_err:
errors.append(manifest_err)
parsed_locations = _normalize_code_locations(code_locations)
if parsed_locations:
errors.extend(_validate_code_locations(parsed_locations))
elif code_locations:
errors.append(
"code_locations were dropped as unusable - every location needs a relative "
"'file' and an integer 'start_line'"
)
errors.extend(_validate_fix_verification(parsed_locations, fix_verification))
reachability = (reachability or "unknown").strip().lower()
if reachability not in _VALID_REACHABILITY:
errors.append(
@@ -2065,8 +2053,6 @@ async def _do_create_dependency( # noqa: PLR0912, PLR0915
cvss=cvss_score if advisory_cvss is not None else None,
cve=parsed_cve,
cwe=cwe,
code_locations=parsed_locations,
fix_verification=fix_verification,
finding_class="dependency_cve",
dependency_metadata=dependency_metadata,
agent_id=agent_id if isinstance(agent_id, str) else None,
@@ -2121,8 +2107,6 @@ async def create_dependency_report(
dependency_path: str | None = None,
reachability: str = "unknown",
reachability_evidence: str | None = None,
code_locations: list[dict[str, Any]] | None = None,
fix_verification: str | None = None,
contextual_cvss_breakdown: dict[str, str] | None = None,
contextual_cvss_reasoning: str | None = None,
) -> str:
@@ -2233,23 +2217,6 @@ async def create_dependency_report(
is off in production), and say who controls the input. State
it plainly when no entry point reaches the sink — that is the
most useful result a reader can get.
code_locations: Where application code imports or calls the package —
the evidence behind your ``reachability`` claim. List of dicts,
same shape as ``create_vulnerability_report``::
{"file": "src/api/client.ts", "start_line": 14, "end_line": 14,
"snippet": "const x = require('pkg')", "label": "imports it"}
Cite the repo-relative ``file`` and the exact 1-based
``start_line`` where ``snippet`` actually sits — verify against
the real file, never guess. A location may also propose
the fix itself: ``fix_before`` (verbatim current code, e.g. the
manifest pin line) + ``fix_after`` (same lines with the fixed
version) — that combination requires ``fix_verification``.
fix_verification: Required whenever any ``code_locations`` entry
carries ``fix_after``. Same bar as a normal finding: security
closure, bypass review, preserved behavior, and how each was
checked.
contextual_cvss_breakdown: **Required.** Full CVSS v3.1 rating of this
CVE **in this codebase** — the same 8-metric object as
``create_vulnerability_report``'s ``cvss_breakdown``:
@@ -2310,8 +2277,6 @@ async def create_dependency_report(
manifest_path=manifest_path,
reachability=reachability,
reachability_evidence=reachability_evidence,
code_locations=code_locations,
fix_verification=fix_verification,
contextual_cvss_breakdown=contextual_cvss_breakdown,
contextual_cvss_reasoning=contextual_cvss_reasoning,
agent_id=agent_id,
-9
View File
@@ -59,15 +59,6 @@ def test_registered_tools_appear_before_lifecycle_tool() -> None:
assert child_names[-2:] == ["extra", "agent_finish"]
def test_root_ends_with_the_given_finish_tool() -> None:
root = factory.build_strix_agent(is_root=True, finish_tool=_tool("finish_pr_review"))
root_names = [t.name for t in root.tools]
assert root_names[-1] == "finish_pr_review"
assert "finish_scan" not in root_names
def test_per_call_extra_tools_stack_with_registry() -> None:
factory.register_agent_tools(_tool("registered"))
-65
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import uuid
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
@@ -390,67 +389,3 @@ def test_openrouter_request_carries_agent_session_id() -> None:
assert body()["session_id"] == session_id
finally:
request_log.reset_call_context(token)
def _openrouter_config() -> Any:
_install_openrouter_stream_cost_capture()
config = ProviderConfigManager.get_provider_chat_config(
model="z-ai/glm-5.3", provider=LlmProviders.OPENROUTER
)
assert config is not None
return config
def test_openrouter_records_upstream_of_stream_and_mid_stream_error() -> None:
handler = _openrouter_config().get_model_response_iterator(
streaming_response=iter([]), sync_stream=True
)
reply = request_log.HttpReply()
token = request_log._http_reply.set(reply)
try:
handler.chunk_parser(
{
"id": "gen-a",
"created": 1,
"model": "z-ai/glm-5.3",
"provider": "Relace",
"choices": [{"index": 0, "delta": {"content": "x"}}],
}
)
assert reply.upstream_provider == "Relace"
assert reply.upstream_error_type is None
with pytest.raises(Exception, match="incomplete tool call"):
handler.chunk_parser(
{
"id": "gen-a",
"provider": "Relace",
"error": {
"code": 400,
"message": "Generation stopped with an incomplete tool call.",
"metadata": {"error_type": "invalid_request"},
},
}
)
finally:
request_log._http_reply.reset(token)
assert reply.upstream_provider == "Relace"
assert reply.upstream_error_type == "invalid_request"
def test_openrouter_records_upstream_of_pre_stream_error() -> None:
body = {
"error": {
"message": "Provider returned error",
"code": 400,
"metadata": {"raw": "...", "provider_name": "InferenceNet"},
}
}
reply = request_log.HttpReply()
token = request_log._http_reply.set(reply)
try:
_openrouter_config().get_error_class(json.dumps(body), 400, {})
_openrouter_config().get_error_class("<html>502 Bad Gateway</html>", 502, {})
finally:
request_log._http_reply.reset(token)
assert reply.upstream_provider == "InferenceNet"
assert reply.upstream_error_type is None
-36
View File
@@ -132,27 +132,6 @@ async def test_reserve_stop_notifies_root_once(monkeypatch: pytest.MonkeyPatch)
assert "finish_scan" in str(message["content"])
@pytest.mark.asyncio
async def test_reserve_notice_names_the_root_finish_tool(monkeypatch: pytest.MonkeyPatch) -> None:
coordinator = AgentCoordinator()
coordinator.root_finish_tool = "finish_pr_review"
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child-a", "recon", parent_id="root")
sent: list[dict[str, Any]] = []
async def _record(_target_agent_id: str, message: dict[str, Any]) -> bool:
sent.append(message)
return True
monkeypatch.setattr(coordinator, "send", _record)
await _notify_root_on_budget_reserve(coordinator)
assert "call finish_pr_review" in str(sent[0]["content"])
assert "finish_scan" not in str(sent[0]["content"])
@pytest.mark.asyncio
async def test_concurrent_reserve_claims_yield_single_root() -> None:
coordinator = AgentCoordinator()
@@ -1316,21 +1295,6 @@ async def test_interactive_nudge_offers_waiting_without_repeating() -> None:
assert "do not repeat it" in items[0]["content"]
@pytest.mark.asyncio
async def test_nudge_names_the_root_finish_tool() -> None:
items = await execution._append_tool_required_message(
session=None,
context={"parent_id": None},
attempt=1,
limit=3,
interactive=False,
root_finish_tool="finish_pr_review",
)
assert "call finish_pr_review" in items[0]["content"]
assert "finish_scan" not in items[0]["content"]
def _cycle_with_items(
coordinator: AgentCoordinator,
agent_id: str,
+8 -7
View File
@@ -79,13 +79,12 @@ def test_content_guardrail_is_not_retried() -> None:
assert execution._is_transient_model_error(guardrail) is False
def test_client_errors_are_transient() -> None:
def test_client_errors_are_not_transient() -> None:
bad_request = BadRequestError(
"bad", response=httpx.Response(400, request=_request()), body=None
)
assert execution._is_transient_model_error(bad_request) is True
for status in (401, 402, 403, 404):
assert execution._is_transient_model_error(_status_error(status)) is False
assert execution._is_transient_model_error(bad_request) is False
assert execution._is_transient_model_error(_status_error(404)) is False
assert execution._is_transient_model_error(ValueError("nope")) is False
@@ -168,8 +167,11 @@ async def test_run_cycle_gives_up_after_max_retries(
async def test_run_cycle_does_not_retry_permanent_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
streams = [_FakeStream(exc=ValueError("nope")), _FakeStream()]
with pytest.raises(ValueError, match="nope"):
bad_request = BadRequestError(
"bad", response=httpx.Response(400, request=_request()), body=None
)
streams = [_FakeStream(exc=bad_request), _FakeStream()]
with pytest.raises(BadRequestError):
await _run_once(monkeypatch, streams)
@@ -187,7 +189,6 @@ async def test_run_cycle_does_not_retry_permanent_error(
(400, "my-model is not a multimodal model", True),
(400, "This model doesn't support the image field for user messages.", True),
(422, "messages[3]: unknown variant `image_url`, expected `text`", True),
(400, "'Image URLs in user messages' functionality not supported.", True),
(
400,
"OpenRouterException: Message: This model's maximum context length is 1048576 "
-16
View File
@@ -7,7 +7,6 @@ from unittest.mock import MagicMock, patch
import pytest
from strix.core.agents import AgentCoordinator
from strix.core.hooks import (
BudgetExceededError,
BudgetPausedError,
@@ -336,21 +335,6 @@ async def test_budget_warning_root_directive_distinct_from_subagent() -> None:
assert "confirmed" in sub
@pytest.mark.asyncio
async def test_root_directive_names_the_coordinators_finish_tool() -> None:
hooks = ReportUsageHooks(model="test-model", max_turns=100)
coordinator = AgentCoordinator()
coordinator.root_finish_tool = "finish_pr_review"
ctx = _make_warn_context(requests=85, parent_id=None)
ctx.context["coordinator"] = coordinator
items: list[Any] = []
await hooks.on_llm_start(ctx, MagicMock(), None, items)
assert "finish_pr_review" in items[0]["content"]
assert "finish_scan" not in items[0]["content"]
@pytest.mark.parametrize("parent_id", [None, "root-1"])
@pytest.mark.asyncio
async def test_turn_warning_directive_escalates_per_stage(parent_id: str | None) -> None:
-21
View File
@@ -394,30 +394,9 @@ def test_log_line_sink_formats_without_content(caplog: pytest.LogCaptureFixture)
assert "request_id=req_line01" in line
assert "status=400" in line
assert "provider=anthropic" in line
assert "upstream=- upstream_error=-" in line
assert "SECRET PROMPT" not in line
def test_log_line_names_upstream_recorded_during_attempt(
caplog: pytest.LogCaptureFixture,
) -> None:
exc = _anthropic_error(400, ANTHROPIC_BLOCK_BODY, {})
token = request_log._http_reply.set(request_log.HttpReply())
try:
request_log.record_upstream_provider("Together", "provider_unavailable")
event = request_log.event_from_litellm(
_anthropic_kwargs(exc), None, None, None, outcome="error"
)
finally:
request_log._http_reply.reset(token)
assert event.details is not None
assert event.details["upstream_provider"] == "Together"
with caplog.at_level(logging.DEBUG, logger="strix.llm.request_log"):
request_log._log_line_sink(event)
line = caplog.records[-1].getMessage()
assert "upstream=Together upstream_error=provider_unavailable" in line
# --------------------------------------------------------------------------- #
# sizes, timing, finish reason, free-form headers and details #
# --------------------------------------------------------------------------- #
+5 -226
View File
@@ -43,7 +43,6 @@ from strix.tools.mcp import (
resolve_mcp_call,
search_mcp_tools,
)
from strix.tools.mcp import agent_tools as mcp_agent_tools
from strix.tools.mcp import client as mcp_client
from strix.tools.mcp import registry as mcp_registry_mod
from strix.tools.mcp import session as mcp_session_mod
@@ -422,18 +421,8 @@ def test_registry_summaries() -> None:
@pytest.mark.asyncio
async def test_list_mcps_returns_connections_with_ids_and_descriptions() -> None:
registry = McpRegistry()
registry.add(
name="fs",
server=FakeMCPServer("fs", [_mcp_tool("read_file"), _mcp_tool("write_file")]),
purpose="local files",
tool_count=2,
)
registry.add(
name="db",
server=FakeMCPServer("db", [_mcp_tool("query")]),
purpose=None,
tool_count=1,
)
registry.add(name="fs", server=FakeMCPServer("fs", []), purpose="local files", tool_count=2)
registry.add(name="db", server=FakeMCPServer("db", []), purpose=None, tool_count=1)
out = await list_mcps.on_invoke_tool(_ctx(registry), "{}")
@@ -448,7 +437,7 @@ async def test_list_mcps_returns_connections_with_ids_and_descriptions() -> None
"description": "local files",
"tool_count": 2,
"dead": False,
"state": "catalog_ready",
"state": "connected",
},
{
"id": "db",
@@ -456,143 +445,10 @@ async def test_list_mcps_returns_connections_with_ids_and_descriptions() -> None
"description": None,
"tool_count": 1,
"dead": False,
"state": "catalog_ready",
"state": "connected",
},
]
}
await registry.close()
@pytest.mark.asyncio
async def test_list_mcps_loads_catalog_for_unwarmed_connection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
server = FakeMCPServer("fs", [_mcp_tool("read_file"), _mcp_tool("write_file")])
monkeypatch.setattr(
mcp_client,
"_build_server",
lambda _config: _built_server(server),
)
registry = McpRegistry()
entry = registry.register(McpConnectionRequest(config=_config("fs", None)))
out = await list_mcps.on_invoke_tool(_ctx(registry), "{}")
assert entry.state == "catalog_ready"
assert entry.tool_count == 2
assert out["connections"][0]["tool_count"] == 2
assert out["connections"][0]["state"] == "catalog_ready"
await registry.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("slow_stage", ["connect", "catalog"])
@pytest.mark.parametrize("warmup", [False, True])
async def test_list_mcps_returns_healthy_connections_while_slow_catalog_keeps_loading(
monkeypatch: pytest.MonkeyPatch,
slow_stage: str,
warmup: bool,
) -> None:
release = asyncio.Event()
started = asyncio.Event()
catalog_calls = 0
class _SlowServer(FakeMCPServer):
async def connect(self) -> None:
if slow_stage == "connect":
started.set()
await release.wait()
async def list_tools(self, run_context: Any = None, agent: Any = None) -> list[MCPTool]:
nonlocal catalog_calls
catalog_calls += 1
if slow_stage == "catalog":
started.set()
await release.wait()
return await super().list_tools(run_context, agent)
servers = {
"healthy": FakeMCPServer("healthy", [_mcp_tool("read_file")]),
"slow": _SlowServer("slow", [_mcp_tool("query")]),
}
monkeypatch.setattr(
mcp_client, "_build_server", lambda config: _built_server(servers[config.name])
)
monkeypatch.setattr(mcp_agent_tools, "_MCP_DISCOVERY_WAIT_SECONDS", 0.01)
registry = McpRegistry()
for name in servers:
registry.register(McpConnectionRequest(config=_config(name, None)))
if warmup:
registry.start_warmup()
entry = registry.get("slow")
assert entry is not None
try:
out = await asyncio.wait_for(list_mcps.on_invoke_tool(_ctx(registry), "{}"), timeout=1)
assert started.is_set()
assert out["connections"][0]["tool_count"] == 1
assert out["connections"][0]["state"] == "catalog_ready"
assert out["connections"][1]["tool_count"] is None
assert out["connections"][1]["state"] in {"connecting", "catalog_loading"}
assert entry._catalog_task is not None
assert not entry._catalog_task.done()
repeated = await asyncio.wait_for(list_mcps.on_invoke_tool(_ctx(registry), "{}"), timeout=1)
assert repeated == out
release.set()
await asyncio.wait_for(entry.ensure_catalog(), timeout=1)
finished = await list_mcps.on_invoke_tool(_ctx(registry), "{}")
assert finished["connections"][1]["tool_count"] == 1
assert finished["connections"][1]["state"] == "catalog_ready"
assert catalog_calls == 1
finally:
release.set()
await registry.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("fail_after_deadline", [False, True])
async def test_list_mcps_retains_and_cleans_up_background_catalog_tasks(
monkeypatch: pytest.MonkeyPatch,
fail_after_deadline: bool,
) -> None:
release = asyncio.Event()
finished = asyncio.Event()
loop_errors: list[dict[str, Any]] = []
async def load_catalog() -> list[MCPTool]:
await release.wait()
raise RuntimeError("catalog unavailable")
registry = McpRegistry()
entry = registry.add(
name="blocked", server=FakeMCPServer("blocked", []), purpose=None, tool_count=0
)
assert entry.session is not None
monkeypatch.setattr(entry.session, "list_tools", load_catalog)
monkeypatch.setattr(mcp_agent_tools, "_MCP_DISCOVERY_WAIT_SECONDS", 0.01)
loop = asyncio.get_running_loop()
previous_handler = loop.get_exception_handler()
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
try:
out = await asyncio.wait_for(list_mcps.on_invoke_tool(_ctx(registry), "{}"), timeout=1)
assert out["connections"][0]["tool_count"] is None
assert entry._catalog_task is not None
assert not entry._catalog_task.done()
if fail_after_deadline:
entry._catalog_task.add_done_callback(lambda _task: finished.set())
release.set()
await asyncio.wait_for(finished.wait(), timeout=1)
assert entry.state == "connected"
# Drop the finished task without awaiting it; its error must already be consumed.
entry._catalog_task = None
else:
task = entry._catalog_task
await registry.close()
assert task.cancelled()
assert loop_errors == []
finally:
await registry.close()
loop.set_exception_handler(previous_handler)
@pytest.mark.asyncio
@@ -877,37 +733,7 @@ async def test_registry_warmup_bounds_parallel_connections(
release.set()
await warmup
assert [summary.state for summary in registry.summaries()] == ["catalog_ready"] * 4
assert [summary.tool_count for summary in registry.summaries()] == [1] * 4
await registry.close()
@pytest.mark.asyncio
async def test_registry_warmup_swallows_catalog_listing_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FailingListServer(FakeMCPServer):
async def list_tools(
self,
run_context: Any = None,
agent: Any = None,
) -> list[MCPTool]:
raise RuntimeError("catalog unavailable")
monkeypatch.setattr(
mcp_client,
"_build_server",
lambda _config: _built_server(_FailingListServer("db", [])),
)
registry = McpRegistry()
entry = registry.register(McpConnectionRequest(config=_config("db", None)))
warmup = registry.start_warmup()
await warmup
assert warmup.done()
assert entry.state == "connected"
assert entry.tool_count == 0
assert [summary.state for summary in registry.summaries()] == ["connected"] * 4
await registry.close()
@@ -1186,53 +1012,6 @@ def test_prompt_renders_named_connection_inventory() -> None:
assert "read the app's schema" in prompt
def test_prompt_omits_unloaded_tool_count() -> None:
prompt = render_system_prompt(
system_prompt_context={
"mcp_available": True,
"mcp_connections": [
{"name": "pending_conn", "purpose": None, "tool_count": None, "state": "connected"}
],
}
)
connection_line = next(line for line in prompt.splitlines() if "pending_conn" in line)
assert "(0 tools)" not in connection_line
assert "tools)" not in connection_line
def test_prompt_renders_loaded_tool_count() -> None:
prompt = render_system_prompt(
system_prompt_context={
"mcp_available": True,
"mcp_connections": [
{"name": "ready_conn", "purpose": None, "tool_count": 8, "state": "catalog_ready"}
],
}
)
assert "ready_conn (8 tools)" in prompt
def test_prompt_renders_unavailable_connection_without_tool_count() -> None:
prompt = render_system_prompt(
system_prompt_context={
"mcp_available": True,
"mcp_connections": [
{
"name": "unavailable_conn",
"purpose": None,
"tool_count": None,
"state": "unavailable",
}
],
}
)
assert "unavailable_conn (unavailable right now)" in prompt
assert "unavailable_conn (0 tools)" not in prompt
def test_prompt_inventory_is_gated_on_availability() -> None:
"""The block is gated on ``mcp_available``; an ``mcp_connections`` payload
without it renders nothing, so a stale or spoofed list cannot leak names."""
-9
View File
@@ -13,7 +13,6 @@ from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
import pytest
from caido_sdk_client.errors import OperationUserError
from strix.runtime.caido_handle import CaidoBootstrapHandle
from strix.tools.proxy import caido_api, tools
@@ -253,11 +252,3 @@ async def test_existing_request_ids_queries_current_project(
assert existing == {"1042"}
assert looked_up == ["1042", "1088"]
def test_err_logs_caido_errors_as_warnings(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level("WARNING", logger="strix.tools.proxy.tools"):
tools._err("list_requests", OperationUserError(""))
[record] = caplog.records
assert record.levelname == "WARNING"
-159
View File
@@ -408,165 +408,6 @@ async def test_dependency_report_records_transitive_chain(report_state: ReportSt
)
async def test_dependency_report_persists_code_locations(report_state: ReportState) -> None:
"""Usage-site locations file onto a dependency finding like any other."""
result = await _do_create_dependency(
title="CVE-2021-23337 in lodash 4.17.20",
description="Command injection via template.",
target="repo/package.json",
cve="CVE-2021-23337",
package_name="lodash",
installed_version="4.17.20",
impact="Arbitrary command execution.",
remediation_steps="Upgrade to 4.17.21.",
assumptions="Assumes the template sink is reachable.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="4.17.21",
cwe="CWE-94",
advisory_cvss=7.2,
technical_analysis=None,
fix_effort="trivial",
reachability="vulnerable_symbol_used",
reachability_evidence=_DEP_EVIDENCE,
code_locations=[
{
"file": "src/render.ts",
"start_line": 14,
"end_line": 14,
"snippet": "const merge = require('lodash').merge",
"label": "imports the vulnerable package",
}
],
contextual_cvss_breakdown=_DEP_CONTEXT,
contextual_cvss_reasoning=_DEP_REASONING,
)
assert result["success"] is True
locs = report_state.vulnerability_reports[0]["code_locations"]
assert locs[0]["file"] == "src/render.ts"
assert locs[0]["label"] == "imports the vulnerable package"
async def test_dependency_report_fix_locations_require_verification(
report_state: ReportState,
) -> None:
"""A location proposing a fix still requires fix_verification."""
result = await _do_create_dependency(
title="CVE-2021-23337 in lodash 4.17.20",
description="Command injection via template.",
target="repo/package.json",
cve="CVE-2021-23337",
package_name="lodash",
installed_version="4.17.20",
impact="Arbitrary command execution.",
remediation_steps="Upgrade to 4.17.21.",
assumptions="Assumes the template sink is reachable.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="4.17.21",
cwe="CWE-94",
advisory_cvss=7.2,
technical_analysis=None,
fix_effort="trivial",
reachability="imported",
reachability_evidence=_DEP_EVIDENCE,
code_locations=[
{
"file": "package-lock.json",
"start_line": 10,
"end_line": 10,
"fix_before": '"lodash": "4.17.20"',
"fix_after": '"lodash": "4.17.21"',
}
],
contextual_cvss_breakdown=_DEP_CONTEXT,
contextual_cvss_reasoning=_DEP_REASONING,
)
assert result["success"] is False
assert any("fix_verification" in e for e in result["errors"])
assert not report_state.vulnerability_reports
async def test_dependency_report_fix_locations_persist_with_verification(
report_state: ReportState,
) -> None:
"""A fix-pair location files when fix_verification is supplied, and both
fields persist on the stored report."""
result = await _do_create_dependency(
title="CVE-2021-23337 in lodash 4.17.20",
description="Command injection via template.",
target="repo/package.json",
cve="CVE-2021-23337",
package_name="lodash",
installed_version="4.17.20",
impact="Arbitrary command execution.",
remediation_steps="Upgrade to 4.17.21.",
assumptions="Assumes the template sink is reachable.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="4.17.21",
cwe="CWE-94",
advisory_cvss=7.2,
technical_analysis=None,
fix_effort="trivial",
reachability="imported",
reachability_evidence=_DEP_EVIDENCE,
code_locations=[
{
"file": "package-lock.json",
"start_line": 10,
"end_line": 10,
"fix_before": '"lodash": "4.17.20"',
"fix_after": '"lodash": "4.17.21"',
}
],
fix_verification="Bump verified: lockfile parses and the sink is unreachable.",
contextual_cvss_breakdown=_DEP_CONTEXT,
contextual_cvss_reasoning=_DEP_REASONING,
)
assert result["success"] is True
report = report_state.vulnerability_reports[0]
loc = report["code_locations"][0]
assert loc["fix_before"] == '"lodash": "4.17.20"'
assert loc["fix_after"] == '"lodash": "4.17.21"'
assert report["fix_verification"] == (
"Bump verified: lockfile parses and the sink is unreachable."
)
async def test_dependency_report_rejects_unusable_locations(
report_state: ReportState,
) -> None:
"""A supplied list that normalizes to nothing errors instead of filing
silently without the requested usage evidence."""
result = await _do_create_dependency(
title="CVE-2021-23337 in lodash 4.17.20",
description="Command injection via template.",
target="repo/package.json",
cve="CVE-2021-23337",
package_name="lodash",
installed_version="4.17.20",
impact="Arbitrary command execution.",
remediation_steps="Upgrade to 4.17.21.",
assumptions="Assumes the template sink is reachable.",
package_ecosystem="npm",
manifest_path="package-lock.json",
fixed_version="4.17.21",
cwe="CWE-94",
advisory_cvss=7.2,
technical_analysis=None,
fix_effort="trivial",
reachability="imported",
reachability_evidence=_DEP_EVIDENCE,
code_locations=[{"file": "/abs/path.ts", "snippet": "require('lodash')"}],
contextual_cvss_breakdown=_DEP_CONTEXT,
contextual_cvss_reasoning=_DEP_REASONING,
)
assert result["success"] is False
assert any("dropped as unusable" in e for e in result["errors"])
assert not report_state.vulnerability_reports
async def test_dependency_report_omits_blank_chain_fields(report_state: ReportState) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
-125
View File
@@ -8,7 +8,6 @@ routine does the connecting.
from __future__ import annotations
import importlib
import types
from typing import Any
@@ -18,21 +17,10 @@ from agents import ModelSettings
import strix.tools.mcp as mcp_pkg
import strix.tools.notes.tools as notes_tools
import strix.tools.todo.tools as todo_tools
from strix.agents import factory
from strix.core import runner
from strix.core.agents import AgentCoordinator
from strix.runtime import session_manager
from strix.tools.mcp import McpConnectionConfig, McpConnectionRequest
from strix.tools.mcp import client as mcp_client
_test_mcp_client = importlib.import_module("tests.test_mcp_client")
FakeMCPServer: Any = _test_mcp_client.FakeMCPServer
_mcp_tool: Any = _test_mcp_client._mcp_tool
def _built_server(server: Any) -> Any:
return mcp_client.BuiltMcpServer(server, None)
def _settings() -> Any:
@@ -74,11 +62,6 @@ def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
monkeypatch.setattr(runner, "build_strix_agent", lambda **_k: object())
monkeypatch.setattr(runner, "make_child_factory", lambda **_k: lambda **_kk: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
monkeypatch.setattr(
mcp_client,
"_build_server",
lambda config: _built_server(FakeMCPServer(config.name, [])),
)
async def _run_agent_loop(**_kwargs: Any) -> None:
return None
@@ -173,11 +156,6 @@ async def test_roster_is_persisted_even_without_a_status_sink(
"load_user_mcp_configs",
lambda: [McpConnectionConfig(name="local_fs", transport="stdio", command="npx")],
)
monkeypatch.setattr(
mcp_client,
"_build_server",
lambda _config: _built_server(FakeMCPServer("local_fs", [])),
)
persisted: list[list[dict[str, Any]]] = []
@@ -204,106 +182,3 @@ async def test_roster_is_persisted_even_without_a_status_sink(
"state": "configured",
}
]
@pytest.mark.asyncio
async def test_warmup_listing_failure_does_not_interrupt_scan(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
_wire_runner(monkeypatch, tmp_path)
server = FakeMCPServer("db", [])
async def _failing_list_tools(
run_context: Any = None,
agent: Any = None,
) -> list[Any]:
del run_context, agent
raise RuntimeError("catalog unavailable")
server.list_tools = _failing_list_tools
monkeypatch.setattr(
mcp_client,
"_build_server",
lambda _config: _built_server(server),
)
root_builds: list[dict[str, Any]] = []
monkeypatch.setattr(runner, "build_strix_agent", lambda **kwargs: root_builds.append(kwargs))
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-mcp-list-failure",
image="img",
coordinator=AgentCoordinator(),
mcp_connection_requests=[
McpConnectionRequest(
config=McpConnectionConfig(name="db", url="https://mcp.example.com")
)
],
)
assert len(root_builds) == 1
@pytest.mark.asyncio
async def test_warmed_tool_counts_reach_root_and_child_agent_builds(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
_wire_runner(monkeypatch, tmp_path)
server = FakeMCPServer("db", [_mcp_tool(f"tool_{index}") for index in range(3)])
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(server))
root_builds: list[dict[str, Any]] = []
def _build_root(**kwargs: Any) -> object:
root_builds.append(kwargs)
return object()
monkeypatch.setattr(
runner,
"build_strix_agent",
_build_root,
)
child_builds: list[dict[str, Any]] = []
def _build_child(**kwargs: Any) -> object:
child_builds.append(kwargs)
return object()
monkeypatch.setattr(
factory,
"build_strix_agent",
_build_child,
)
real_make_child_factory = factory.make_child_factory
child_factory_capture: dict[str, Any] = {}
def _make_child_factory(**kwargs: Any) -> Any:
child_factory_capture["context"] = kwargs["system_prompt_context"]
child_factory_capture["factory"] = real_make_child_factory(**kwargs)
return child_factory_capture["factory"]
monkeypatch.setattr(runner, "make_child_factory", _make_child_factory)
request = McpConnectionRequest(
config=McpConnectionConfig(name="db", url="https://mcp.example.com", notes="database")
)
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-mcp-prompt-counts",
image="img",
coordinator=AgentCoordinator(),
mcp_connection_requests=[request],
)
root_context = root_builds[0]["system_prompt_context"]
assert root_context["mcp_connections"][0]["tool_count"] == 3
assert child_factory_capture["context"] is root_context
assert child_factory_capture["context"]["mcp_connections"][0]["tool_count"] == 3
child_factory_capture["factory"](name="child", skills=[])
assert child_builds[0]["system_prompt_context"] is child_factory_capture["context"]
assert child_builds[0]["system_prompt_context"]["mcp_connections"][0]["tool_count"] == 3
+1 -17
View File
@@ -6,7 +6,6 @@ flow through to the root agent's ``build_strix_agent`` call.
from __future__ import annotations
import importlib
import os
import types
from typing import Any
@@ -28,11 +27,6 @@ from strix.core.inputs import make_model_settings
from strix.runtime import session_manager
from strix.tools.load_skill.tool import load_skill
from strix.tools.mcp import BearerAuth, McpConnectionConfig, McpConnectionRequest
from strix.tools.mcp import client as mcp_client
_test_mcp_client = importlib.import_module("tests.test_mcp_client")
FakeMCPServer: Any = _test_mcp_client.FakeMCPServer
def _make_rate_limit_error() -> RateLimitError:
@@ -90,11 +84,6 @@ def _patch_engine_scaffold(
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: scope_context)
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: ModelSettings())
monkeypatch.setattr(
mcp_client,
"_build_server",
lambda config: mcp_client.BuiltMcpServer(FakeMCPServer(config.name, []), None),
)
captured: dict[str, Any] = {}
@@ -233,12 +222,7 @@ async def test_mcp_available_flag_set_when_a_connection_attaches(
assert kwargs["system_prompt_context"]["mcp_available"] is True
# The named inventory names each connected server for the prompt.
assert kwargs["system_prompt_context"]["mcp_connections"] == [
{
"name": "fs",
"purpose": "local files",
"tool_count": 0,
"state": "catalog_ready",
}
{"name": "fs", "purpose": "local files", "tool_count": 0}
]
+10 -67
View File
@@ -23,8 +23,7 @@ from openai import AsyncOpenAI
from strix.config import loader
from strix.config.loader import load_settings
from strix.config.models import StrixProvider, _TurnGuardModel, _with_timeouts
from strix.llm import request_log
from strix.config.models import StrixProvider, _TurnGuardModel, _with_idle_timeout
if TYPE_CHECKING:
@@ -79,21 +78,9 @@ def stalling_gateway() -> Iterator[str]:
server.server_close()
class _ServedByRelace(OpenAIChatCompletionsModel):
async def stream_response(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
request_log.record_upstream_provider("Relace")
async for event in super().stream_response(*args, **kwargs):
yield event
def _stream(base_url: str, *, idle_timeout: float | None) -> AsyncIterator[Any]:
def _stream(base_url: str, *, idle_timeout: float) -> AsyncIterator[Any]:
client = AsyncOpenAI(api_key="tok", base_url=base_url, max_retries=0, timeout=_STALL_SECONDS)
inner: Model = request_log.RequestLoggingModel(
_ServedByRelace(model="gw-model", openai_client=client),
model_name="gw-model",
provider="openai",
base_url=base_url,
)
inner: Model = OpenAIChatCompletionsModel(model="gw-model", openai_client=client)
guarded = _TurnGuardModel(inner, stream_idle_timeout=idle_timeout)
return guarded.stream_response(
None,
@@ -109,7 +96,7 @@ def _stream(base_url: str, *, idle_timeout: float | None) -> AsyncIterator[Any]:
)
async def _drain(base_url: str, *, idle_timeout: float | None) -> list[Any]:
async def _drain(base_url: str, *, idle_timeout: float) -> list[Any]:
return [event async for event in _stream(base_url, idle_timeout=idle_timeout)]
@@ -118,31 +105,16 @@ async def test_stalled_stream_hangs_without_the_watchdog(stalling_gateway: str)
# Repro: tokens arrive, then nothing. Un-watched, the turn just sits there;
# the request timeout is far away and would reset on any keepalive byte.
with pytest.raises(TimeoutError):
await asyncio.wait_for(_drain(stalling_gateway, idle_timeout=None), timeout=2)
await asyncio.wait_for(_drain(stalling_gateway, idle_timeout=0), timeout=2)
@pytest.mark.asyncio
async def test_stalled_stream_is_abandoned_by_the_watchdog(stalling_gateway: str) -> None:
logged: list[request_log.LlmRequestEvent] = []
request_log.register_sink(logged.append)
started = time.monotonic()
try:
with pytest.raises(TimeoutError, match="stream_idle_timeout"):
await _drain(stalling_gateway, idle_timeout=1)
finally:
request_log.unregister_sink(logged.append)
with pytest.raises(TimeoutError, match="produced no event"):
await _drain(stalling_gateway, idle_timeout=1)
assert time.monotonic() - started < _STALL_SECONDS
# The request log can tell our timeout from a shutdown.
assert [
(e.error_type, (e.details or {}).get("upstream_provider"), e.error_message) for e in logged
] == [
(
"stream_idle_timeout",
"Relace",
"attempt cancelled before the reply was consumed: strix:stream_idle_timeout",
)
]
@pytest.mark.asyncio
@@ -152,39 +124,14 @@ async def test_events_keep_flowing_while_the_stream_is_alive() -> None:
await asyncio.sleep(0.05)
yield f"event-{i}"
seen: list[Any] = [event async for event in _with_timeouts(_live(), idle=1.0)]
seen: list[Any] = [event async for event in _with_idle_timeout(_live(), 1.0)]
assert seen == [f"event-{i}" for i in range(5)]
@pytest.mark.asyncio
async def test_first_event_and_whole_stream_are_bounded() -> None:
async def _slow_start() -> AsyncIterator[Any]:
await asyncio.sleep(_STALL_SECONDS)
yield "late"
async def _endless() -> AsyncIterator[Any]:
while True:
await asyncio.sleep(0.05)
yield "event"
with pytest.raises(TimeoutError, match="stream_first_event_timeout"):
[event async for event in _with_timeouts(_slow_start(), idle=5, first_event=0.2)]
with pytest.raises(TimeoutError, match="stream_idle_timeout"):
[event async for event in _with_timeouts(_slow_start(), idle=0.2)]
with pytest.raises(TimeoutError, match="stream_total_timeout"):
[event async for event in _with_timeouts(_endless(), idle=5, total=0.3)]
@pytest.fixture
def _reset_settings(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
for key in (
"STRIX_LLM",
"LLM_DISABLE_STREAMING",
"LLM_STREAM_IDLE_TIMEOUT",
"LLM_STREAM_FIRST_EVENT_TIMEOUT",
"LLM_STREAM_TOTAL_TIMEOUT",
):
for key in ("STRIX_LLM", "LLM_DISABLE_STREAMING", "LLM_STREAM_IDLE_TIMEOUT"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(loader, "_cached", None)
monkeypatch.setattr(loader, "_override", None)
@@ -204,15 +151,11 @@ def test_idle_timeout_is_configurable(
) -> None:
monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: _DummyModel())
monkeypatch.setenv("LLM_STREAM_IDLE_TIMEOUT", "45")
monkeypatch.setenv("LLM_STREAM_FIRST_EVENT_TIMEOUT", "30")
monkeypatch.setenv("LLM_STREAM_TOTAL_TIMEOUT", "400")
load_settings()
model = StrixProvider().get_model("openai/gpt-4o-mini")
assert isinstance(model, _TurnGuardModel)
assert model._stream_idle_timeout == 45
assert model._first_event_timeout == 30
assert model._total_timeout == 400
def test_idle_timeout_is_off_without_streaming(
@@ -227,4 +170,4 @@ def test_idle_timeout_is_off_without_streaming(
model = StrixProvider().get_model("openai/gpt-4o-mini")
assert isinstance(model, _TurnGuardModel)
assert model._stream_idle_timeout is model._first_event_timeout is model._total_timeout is None
assert model._stream_idle_timeout == 0