Compare commits

..
Author SHA1 Message Date
Ian f565cece23 fix(compaction): chain any input filter already set on the run config
The image scrub for text-only models also sets call_model_input_filter; replacing it dropped the scrub.
2026-10-06 21:49:28 -04:00
Ian 94adabd67e fix(compaction): rerun without the budget check when compaction can't fit the session 2026-10-06 21:33:32 -04:00
Ian 3a69a45f86 fix(compaction): stop the run and compact before a request would overrun the context window 2026-10-06 21:33:31 -04:00
Ian e011253ebc test(compaction): drop comment restating the test name 2026-10-06 21:33:30 -04:00
Ian 5227960e99 fix(compaction): fall back to reasoning text when the summary has none
A reasoning model can spend the whole summary output budget thinking and return only a reasoning item. _extract_text read only output messages, so compaction logged 'summary returned no content' and the agent failed. Use the reasoning text, with a warning, when there is no output text.
2026-10-06 21:33:30 -04:00
72 changed files with 484 additions and 3434 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
-18
View File
@@ -33,18 +33,6 @@ Configure Strix using environment variables or a config file.
the LiteLLM and native OpenAI routing paths.
</ParamField>
<ParamField path="LLM_EXTRA_BODY" type="string">
Extra fields merged into the JSON body of every request to the main model, as a JSON object
(e.g. `{"service_tier":"flex","safety_identifier":"9f86d081..."}`). Use it
for provider parameters Strix doesn't expose, such as OpenAI's `service_tier`
or `safety_identifier`. Applies to both the LiteLLM and native OpenAI routing
paths. Fields are not validated, so the provider or gateway must accept them;
unknown fields are usually rejected. Extra fields can override fields Strix
sends and break requests, so don't set request-shaping fields such as `model`, `messages`, `input`,
`tools` or `stream`, and use `STRIX_REASONING_EFFORT` rather than
`reasoning_effort`.
</ParamField>
<ParamField path="LLM_TIMEOUT" default="300" type="integer">
Request timeout in seconds for LLM calls.
</ParamField>
@@ -100,12 +88,6 @@ affecting the agents that do the actual testing.
inherits `LLM_EXTRA_HEADERS`; set this when its endpoint needs custom headers.
</ParamField>
<ParamField path="DEDUPE_LLM_EXTRA_BODY" type="string">
Optional JSON object of extra request-body fields sent on every
deduplication-model request. A dedicated dedupe model never inherits
`LLM_EXTRA_BODY`; set this when its endpoint needs them too.
</ParamField>
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
Reasoning effort for the deduplication model. Defaults to the model's own
baseline when unset.
-15
View File
@@ -68,21 +68,6 @@ export LLM_API_KEY="your-bearer-token" # sent as Authorization: Bearer
export LLM_EXTRA_HEADERS='{"X-Feature-Key":"value","X-Tenant":"acme"}'
```
### Extra request-body fields
Some provider parameters go in the request body rather than a header, and
Strix has no dedicated setting for them. Set them with `LLM_EXTRA_BODY` as a
JSON object — they are merged into the body of every request to the model. For example, to
use OpenAI's cheaper flex processing and attribute requests to a user with a
safety identifier (a hash, never a raw email):
```bash
export LLM_EXTRA_BODY='{"service_tier":"flex","safety_identifier":"9f86d081884c7d65"}'
```
Fields aren't validated, so use only fields your provider accepts, and don't
set fields Strix already sends, such as `model`, `messages` or `stream`.
For endpoints behind a private CA, point Strix at your certificate bundle with
the standard `SSL_CERT_FILE=/path/to/ca-bundle.pem` — never disable TLS
verification against a real endpoint.
+3 -62
View File
@@ -13,13 +13,11 @@ from agents.agent import ToolsToFinalOutputResult
from agents.sandbox import SandboxAgent
from agents.sandbox.capabilities import Filesystem, Shell
from agents.sandbox.errors import InvalidManifestPathError
from agents.sandbox.session.pty_types import PTY_YIELD_TIME_MS_MAX
from agents.tool import CustomTool, FunctionTool, Tool
from pydantic import ValidationError
from strix.agents.prompt import render_system_prompt
from strix.config import load_settings
from strix.config.tool_call_arguments import describe_malformed_arguments
from strix.tools.agents_graph.tools import (
agent_finish,
create_agent,
@@ -269,10 +267,6 @@ def _with_coerced_arguments(tool: FunctionTool) -> FunctionTool:
nullish = tool.name.startswith(_QUERY_TOOL_PREFIXES)
async def invoke(ctx: Any, raw_input: str) -> Any:
malformed = describe_malformed_arguments(tool.name, raw_input)
if malformed is not None:
logger.debug("Tool %s got malformed arguments; asking the model to re-issue", tool.name)
return malformed
return await invoke_tool(ctx, _coerce_arguments(raw_input, schema, nullish=nullish))
tool.on_invoke_tool = invoke
@@ -381,15 +375,11 @@ def _configure_filesystem_tools(
)
def _make_filesystem_configurator(
*, chat_completions: bool, strict_schemas: bool, supports_images: bool = True
) -> Any:
def _make_filesystem_configurator(*, chat_completions: bool, strict_schemas: bool) -> 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
@@ -442,47 +432,8 @@ def _apply_shell_output_cap(parsed: dict[str, Any]) -> None:
)
_EXEC_YIELD_CAP_S = PTY_YIELD_TIME_MS_MAX // 1000
_EXEC_COMMAND_DESCRIPTION_NOTE = (
f"The wait for output is capped at {_EXEC_YIELD_CAP_S} s whatever yield_time_ms says. "
"A non-TTY command still running at the yield cannot be read later (write_stdin fails "
"on it and its output is lost): run anything longer in the background with its output "
"redirected to a file (`cmd > /tmp/out.log 2>&1 &`, then read the log), or start it with "
'tty=true and poll with write_stdin(session_id=<id>, chars="").'
)
_RUNNING_NON_TTY_NOTICE = (
"Note: this non-TTY process cannot be polled and its later output is lost. Rerun it in "
"the background (`cmd > /tmp/out.log 2>&1 &`, then read the log) or with tty=true."
)
_PROCESS_RUNNING_RE = re.compile(r"^Process running with session ID \d+$", re.MULTILINE)
_OUTPUT_HEADER_RE = re.compile(r"^Output:\n", re.MULTILINE)
def _annotate_running_non_tty(parsed: dict[str, Any], result: Any) -> Any:
if parsed.get("tty") or not isinstance(result, str):
return result
header = _OUTPUT_HEADER_RE.search(result)
head_end = len(result) if header is None else header.start()
if _PROCESS_RUNNING_RE.search(result, 0, head_end) is None:
return result
notice = _RUNNING_NON_TTY_NOTICE
requested = parsed.get("yield_time_ms")
if isinstance(requested, int | float) and requested > PTY_YIELD_TIME_MS_MAX:
notice = (
f"Note: yield_time_ms={int(requested)} was capped at {PTY_YIELD_TIME_MS_MAX} "
f"({_EXEC_YIELD_CAP_S} s). " + notice.removeprefix("Note: ")
)
if header is None:
return f"{result}\n{notice}"
return f"{result[: header.end()]}{notice}\n{result[header.end() :]}"
def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
invoke_tool = tool.on_invoke_tool
tool.description = f"{tool.description.rstrip()} {_EXEC_COMMAND_DESCRIPTION_NOTE}"
async def invoke(ctx: Any, raw_input: str) -> Any:
try:
@@ -495,7 +446,7 @@ def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
_apply_shell_output_cap(parsed)
raw_input = json.dumps(parsed)
try:
result = await invoke_tool(ctx, raw_input)
return await invoke_tool(ctx, raw_input)
except ValidationError as exc:
return _format_validation_error(tool.name, exc)
except InvalidManifestPathError as exc:
@@ -505,9 +456,6 @@ def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
"(or omitted to use the turn's cwd). "
f"Got: {rel!r}."
)
if isinstance(parsed, dict):
return _annotate_running_non_tty(parsed, result)
return result
tool.on_invoke_tool = invoke
return tool
@@ -722,8 +670,6 @@ 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,
finish_tool: Tool = finish_scan,
) -> SandboxAgent[Any]:
"""Build a SandboxAgent for either root or child use.
@@ -736,7 +682,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
@@ -749,7 +694,6 @@ 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 [])]
@@ -757,7 +701,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)
@@ -789,7 +733,6 @@ def build_strix_agent(
configure_tools=_make_filesystem_configurator(
chat_completions=chat_completions_tools,
strict_schemas=strict_tool_schemas,
supports_images=supports_images,
),
),
Shell(
@@ -811,7 +754,6 @@ 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``.
@@ -832,7 +774,6 @@ 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
+1 -2
View File
@@ -85,7 +85,6 @@ 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.
@@ -120,7 +119,7 @@ def render_system_prompt(
is_root=is_root,
is_diff_scoped=is_diff_scoped,
)
skill_content = load_skills(skills_to_load, supports_images=supports_images)
skill_content = load_skills(skills_to_load)
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
# Skills every agent of this kind loads come first, so siblings share them
+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.
+1 -2
View File
@@ -44,7 +44,7 @@ INTERACTIVE BEHAVIOR:
- If you end a turn on plain text and the nudge arrives, your words already reached the user. Do not repeat them: call wait_for_user.
- You can narrate while you work — plain text is shown to the user as you go. Narrating is free; wait_for_user is specifically the act of WAITING for the user, so do not call it just to give a status update.
- Respond naturally when the user asks questions or gives instructions.
- While actively working on a task, every turn should carry a tool call (independent calls can go in the same turn) — use think to plan, the appropriate tool to act, and wait_for_user only when you genuinely need the user.
- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and wait_for_user only when you genuinely need the user.
- Never loop through think or other tools just to prepare, polish, confirm, or announce an answer. Once you know the answer, write it and call wait_for_user.
{% else %}
AUTONOMOUS BEHAVIOR:
@@ -55,7 +55,6 @@ AUTONOMOUS BEHAVIOR:
- There is no user attached to this run, so there is nobody to ask and nothing to yield to. If there is nothing left to execute: do NOT send filler/repetitive text — either call wait_for_agents (only if you are genuinely expecting another agent to message you) or finish your work (subagents: agent_finish; root: finish_scan)
- While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If waiting on another agent, use wait_for_agents; when done, use agent_finish (subagents) or finish_scan (root)
- A text-only turn does nothing: it neither ends the run nor yields — it just wastes a turn and forces a retry. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY way to terminate, and the report flows through them. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead.
- Read-only calls can go together in one turn: when several file reads or searches don't depend on each other's output, issue them as separate tool calls in the same turn rather than one per turn. Anything else — writing files, running commands that change state, sending requests, creating or messaging agents, reporting, or finishing — goes alone in its own turn.
{% endif %}
</communication_rules>
+2 -26
View File
@@ -6,8 +6,7 @@ import json
import logging
import os
from pathlib import Path
from types import UnionType
from typing import TYPE_CHECKING, Any, Union, get_args, get_origin
from typing import TYPE_CHECKING, Any
from pydantic import AliasChoices, BaseModel
@@ -127,36 +126,13 @@ def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]:
continue # env wins under some alias; skip the JSON file for this field
for alias in aliases:
if alias in env_block_upper:
sub_data[fname] = _decode_saved_value(finfo, env_block_upper[alias])
sub_data[fname] = env_block_upper[alias]
break
if sub_data:
nested[sub_name] = sub_data
return nested
def _decode_saved_value(finfo: FieldInfo, value: Any) -> Any:
"""Decode a JSON string saved for a dict field, as pydantic does for env vars.
``persist_current`` saves env values verbatim, so a dict setting such as
``LLM_EXTRA_HEADERS`` lands in the file as JSON text. Values that are
already objects, or aren't valid JSON, are left for validation to judge.
"""
if not isinstance(value, str) or not _is_dict_field(finfo):
return value
try:
return json.loads(value)
except ValueError:
return value
def _is_dict_field(finfo: FieldInfo) -> bool:
annotation = finfo.annotation
candidates = (
get_args(annotation) if get_origin(annotation) in (Union, UnionType) else (annotation,)
)
return any(c is dict or get_origin(c) is dict for c in candidates)
def _first_alias_value(aliases: list[str], source: Mapping[str, Any]) -> Any | None:
return next((source[alias] for alias in aliases if alias in source), None)
+18 -115
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
import contextlib
import inspect
import json
import logging
import os
import time
@@ -41,7 +40,6 @@ from openai.types.shared import Reasoning
from strix.agents.prompt import CACHE_POINT
from strix.config import codex
from strix.config.loader import load_settings
from strix.config.tool_call_arguments import repair_input
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input
from strix.config.tool_call_limits import TurnToolCallLimiter
from strix.llm import request_log
@@ -256,12 +254,6 @@ class _TurnGuardModel(Model):
Ids that collide with the history are rewritten before the turn is
recorded, and already-corrupted histories are repaired on the way out.
Tool-call arguments: a turn whose ``arguments`` are not valid JSON fails
that one call with a parse error, but the raw string is recorded and
strict providers then reject every request that replays it. Such
arguments are rewritten to a valid JSON object on the way out (see
:mod:`strix.config.tool_call_arguments`).
Tool-call volume: a degenerate response can queue hundreds of calls that
the run loop then honours one by one. Only the first
``LLM_MAX_TOOL_CALLS_PER_TURN`` calls of a response are kept.
@@ -270,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__(
@@ -279,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)
@@ -320,7 +306,7 @@ class _TurnGuardModel(Model):
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> ModelResponse:
sanitized = _sanitize_input(input)
sanitized = dedupe_input(input)
rewriter = TurnCallIdRewriter(sanitized)
system_instructions, sanitized = _split_cached_prefix(
system_instructions, sanitized, model_settings
@@ -356,7 +342,7 @@ class _TurnGuardModel(Model):
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> AsyncIterator[TResponseStreamEvent]:
sanitized = _sanitize_input(input)
sanitized = dedupe_input(input)
rewriter = TurnCallIdRewriter(sanitized)
system_instructions, sanitized = _split_cached_prefix(
system_instructions, sanitized, model_settings
@@ -374,22 +360,13 @@ 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
self._log_dropped(limiter)
def _sanitize_input(model_input: str | list[TResponseInputItem]) -> str | list[Any]:
return repair_input(dedupe_input(model_input))
def _split_cached_prefix(
system_instructions: str | None,
model_input: str | list[Any],
@@ -417,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:
@@ -613,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
@@ -657,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,
)
@@ -813,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)
@@ -836,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
@@ -872,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",
@@ -996,12 +905,6 @@ 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/"):
+1 -15
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any, Literal
from typing import Literal
from pydantic import AliasChoices, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -55,11 +55,6 @@ class LlmSettings(BaseSettings):
alias="LLM_EXTRA_HEADERS",
repr=False,
)
extra_body: dict[str, Any] | None = Field(
default=None,
alias="LLM_EXTRA_BODY",
repr=False,
)
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
force_required_tool_choice: bool = Field(
default=False,
@@ -84,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,
@@ -115,11 +106,6 @@ class DedupeSettings(BaseSettings):
alias="DEDUPE_LLM_EXTRA_HEADERS",
repr=False,
)
extra_body: dict[str, Any] | None = Field(
default=None,
alias="DEDUPE_LLM_EXTRA_BODY",
repr=False,
)
@field_validator("reasoning_effort", mode="before")
@classmethod
-100
View File
@@ -1,100 +0,0 @@
"""Keep replayed tool-call arguments valid JSON.
A model occasionally emits a tool call whose ``arguments`` string is not a
JSON object (truncated, unbalanced, empty). The call itself fails safely: the
tool reports the parse error and the run continues. But the raw string is
recorded in the session as-is, and strict OpenAI-compatible servers (vLLM,
SGLang, ...) validate every assistant tool call in the request, so from then
on each turn is rejected with ``Assistant tool call function.arguments must
be valid JSON`` and the agent can never recover. Rewriting the replayed
arguments to a JSON object that carries the original text keeps the history
valid while the model still sees what it sent.
"""
from __future__ import annotations
import json
from typing import Any
from openai.types.responses import ResponseFunctionToolCall
MALFORMED_ARGUMENTS_KEY = "malformed_arguments"
def _reject_constant(name: str) -> Any:
raise ValueError(f"non-standard JSON constant {name}")
def _loads_strict(text: str) -> Any:
try:
return json.loads(text, parse_constant=_reject_constant)
except RecursionError as exc:
raise ValueError("arguments are nested too deeply to parse") from exc
def describe_malformed_arguments(tool_name: str, arguments: str) -> str | None:
"""Return a model-facing recovery message if ``arguments`` is not a JSON object.
A tool call whose arguments do not parse is almost always one the stream cut
off (the server flushed a prefix of the JSON, or the client closed early),
so instead of the SDK's generic parse-error result the model is told the
call never ran and must be re-issued whole.
"""
if not arguments.strip():
return None
try:
parsed = _loads_strict(arguments)
except ValueError as exc:
detail = str(exc)
else:
if isinstance(parsed, dict):
return None
detail = f"expected a JSON object, got {type(parsed).__name__}"
return (
f"{tool_name}: the tool call was not executed because its arguments were "
f"truncated or otherwise not valid JSON ({detail}). The response was likely "
"cut off mid-call. Re-issue the call with complete, valid JSON arguments."
)
def repair_arguments(arguments: object) -> str | None:
"""Return replacement arguments a strict server accepts, or ``None`` if already valid."""
if not isinstance(arguments, str) or not arguments.strip():
return "{}"
try:
parsed = _loads_strict(arguments)
except ValueError:
parsed = None
if isinstance(parsed, dict):
return None
return json.dumps({MALFORMED_ARGUMENTS_KEY: arguments}, ensure_ascii=False)
def repair_history_arguments(items: list[Any]) -> tuple[list[Any], bool]:
"""Rewrite function calls in a conversation history whose arguments are not a JSON object."""
rebuilt: list[Any] = []
changed = False
for item in items:
if isinstance(item, dict):
if item.get("type") == "function_call":
repaired = repair_arguments(item.get("arguments"))
if repaired is not None:
item = {**item, "arguments": repaired} # noqa: PLW2901
changed = True
elif isinstance(item, ResponseFunctionToolCall):
repaired = repair_arguments(item.arguments)
if repaired is not None:
item = item.model_copy(update={"arguments": repaired}) # noqa: PLW2901
changed = True
rebuilt.append(item)
return rebuilt, changed
def repair_input(model_input: str | list[Any]) -> str | list[Any]:
if isinstance(model_input, str):
return model_input
rebuilt, changed = repair_history_arguments(model_input)
return rebuilt if changed else model_input
+3 -20
View File
@@ -71,11 +71,9 @@ 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
self._operator_paused = False
self._resume_epoch = 0
self._budget_policy: BudgetPolicy = "stop"
self._extend_budget: Callable[[], None] | None = None
@@ -106,10 +104,6 @@ class AgentCoordinator:
def budget_paused(self) -> bool:
return self._budget_paused
@property
def operator_paused(self) -> bool:
return self._operator_paused
@property
def resume_epoch(self) -> int:
"""Bumped by every ``resume_budget``; an agent parks against the value it read."""
@@ -155,7 +149,6 @@ class AgentCoordinator:
"""
async with self._lock:
self._budget_paused = True
self._operator_paused = True
logger.info("scan paused by the operator")
await self._maybe_snapshot()
@@ -171,7 +164,6 @@ class AgentCoordinator:
self._set_budget_limit(max_budget_usd)
async with self._lock:
self._budget_paused = False
self._operator_paused = False
self._resume_epoch += 1
woken = [aid for aid, status in self.statuses.items() if status == "budget_paused"]
for aid in woken:
@@ -239,7 +231,7 @@ class AgentCoordinator:
self._budget_stopped = budget_stopped
self._reserve_stopped = reserve_stopped
if not budget_paused:
self._budget_paused = self._operator_paused
self._budget_paused = False
for aid, status in self.statuses.items():
if status == "budget_paused":
self.statuses[aid] = "waiting"
@@ -431,12 +423,7 @@ class AgentCoordinator:
unknown, or it is terminal and its loop does not park for wake-ups.
"""
from_user = message.get("from") == "user"
if (
from_user
and self._budget_paused
and not self._operator_paused
and self._budget_policy != "pause"
):
if from_user and self._budget_paused and self._budget_policy != "pause":
await self.resume_from_budget_pause(exclude=target_agent_id)
async with self._lock:
if target_agent_id not in self.statuses:
@@ -459,9 +446,7 @@ class AgentCoordinator:
self.recovery_counts.pop(target_agent_id, None)
self.idle_resume_counts.pop(target_agent_id, None)
self._parent_notified.discard(target_agent_id)
held = self._operator_paused and self.statuses[target_agent_id] == "budget_paused"
if not held:
self.statuses[target_agent_id] = "waiting"
self.statuses[target_agent_id] = "waiting"
runtime.wake.set()
stream = runtime.stream
interrupt_on_message = runtime.interrupt_on_message
@@ -654,7 +639,6 @@ class AgentCoordinator:
"budget_stopped": self._budget_stopped,
"reserve_stopped": self._reserve_stopped,
"budget_paused": self._budget_paused,
"operator_paused": self._operator_paused,
}
async def restore(self, snap: dict[str, Any]) -> None:
@@ -677,7 +661,6 @@ class AgentCoordinator:
self._budget_stopped = bool(snap.get("budget_stopped", False))
self._reserve_stopped = bool(snap.get("reserve_stopped", False))
self._budget_paused = bool(snap.get("budget_paused", False))
self._operator_paused = bool(snap.get("operator_paused", False))
for aid in self.statuses:
self.runtimes.setdefault(aid, AgentRuntime())
+82 -50
View File
@@ -6,7 +6,6 @@ import asyncio
import contextlib
import inspect
import logging
import re
import uuid
from collections.abc import Callable
from dataclasses import replace
@@ -33,12 +32,16 @@ from strix.core.sessions import (
enforce_image_budget,
open_agent_session,
replace_session_items,
scrub_images_from_items,
seed_initial_input,
strip_all_images_from_session,
)
from strix.llm import request_log
from strix.llm.compaction import is_context_overflow, maybe_compact
from strix.llm.compaction import (
CompactionNeededError,
is_context_overflow,
maybe_compact,
needs_compaction,
)
if TYPE_CHECKING:
@@ -58,18 +61,6 @@ logger = logging.getLogger(__name__)
StreamEventSink = Callable[[str, Any], None]
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
# Replies meaning "this model takes no images", not image errors in general: a
# context overflow that counts "image/vision expansion" must not match.
_IMAGE_REJECTION = re.compile(
r"no endpoints found that support image input" # OpenRouter
r"|image_url is only supported by certain models" # OpenAI
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
re.IGNORECASE,
)
_MAX_COMPACTIONS_PER_CYCLE = 2
@@ -137,26 +128,49 @@ async def _compact_session(
)
_TEXT_ONLY_IMAGE_TEXT = "[error: this model cannot view images; use `snapshot -i` instead]"
async def _session_needs_compaction(agent: Any, session: Session, run_config: RunConfig) -> bool:
model = _run_config_model(run_config)
if model is None:
return False
return await asyncio.to_thread(
needs_compaction,
model,
_agent_instructions(agent),
_agent_tools_text(agent),
list(await session.get_items()),
)
def _with_image_scrub(run_config: RunConfig, context: dict[str, Any]) -> RunConfig:
if context.get("supports_images", True):
def _with_compaction_check(run_config: RunConfig) -> RunConfig:
"""Stop the run before a model request that would overrun the context window so
the cycle can compact and resume: the SDK builds each request from its in-memory
history, so compacting the session mid-run would not shrink it."""
model = _run_config_model(run_config)
if model is None:
return run_config
# Chain any filter already set; it sees the scrubbed input.
inner = run_config.call_model_input_filter
first_call = True
async def _scrub(data: CallModelData[Any]) -> ModelInputData:
model_data = replace(
data.model_data,
input=scrub_images_from_items(data.model_data.input, text=_TEXT_ONLY_IMAGE_TEXT),
)
if inner is None:
return model_data
result = inner(replace(data, model_data=model_data))
return await result if inspect.isawaitable(result) else result
async def _check(data: CallModelData[Any]) -> ModelInputData:
nonlocal first_call
model_data = data.model_data
if inner is not None:
result = inner(data)
model_data = await result if inspect.isawaitable(result) else result
# Skip the first call: the cycle compacts right before the run, and the run's
# new input is only persisted after this filter, so stopping here would lose it.
if not first_call and await asyncio.to_thread(
needs_compaction,
model,
model_data.instructions or "",
_agent_tools_text(data.agent),
model_data.input,
):
raise CompactionNeededError(model)
first_call = False
return model_data
return replace(run_config, call_model_input_filter=_scrub)
return replace(run_config, call_model_input_filter=_check)
_MAX_TRANSIENT_MODEL_RETRIES = 5
@@ -169,12 +183,6 @@ def _model_error_status_code(exc: BaseException) -> int | None:
return code if isinstance(code, int) else None
def _is_image_rejection(exc: BaseException) -> bool:
return _model_error_status_code(exc) in _INPUT_REJECTION_CODES and bool(
_IMAGE_REJECTION.search(str(exc))
)
def _is_transient_model_error(exc: BaseException) -> bool:
if codex.is_content_guardrail_error(exc):
return False
@@ -184,7 +192,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 +312,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):
@@ -599,7 +609,7 @@ async def _run_until_lifecycle(
hooks=hooks,
)
except BudgetPausedError as exc:
if coordinator.budget_policy != "pause" and not exc.operator_pause:
if coordinator.budget_policy != "pause":
raise
# The agent parked right before an LLM call; everything up to that
# point is already in its session. Once resumed, the same call goes
@@ -612,7 +622,6 @@ async def _run_until_lifecycle(
# Stopped while parked (operator stop or a parent's stop_agent).
await coordinator.reset_recovery(agent_id)
return result
await coordinator.consume_pending(agent_id)
if session is not None:
input_data = []
continue
@@ -643,7 +652,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,
@@ -770,6 +778,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
image_strips = 0
compactions = 0
model_retries = 0
check_context = session is not None
request_log.set_retry_attempt(0)
while True:
stream: Any = None
@@ -792,7 +801,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
stream = Runner.run_streamed(
agent,
input=input_data,
run_config=_with_image_scrub(run_config, context),
run_config=_with_compaction_check(run_config) if check_context else run_config,
context=context,
max_turns=max_turns,
session=session,
@@ -832,7 +841,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
await coordinator.detach_stream(agent_id, stream)
except BudgetPausedError as exc:
logger.info("agent %s paused at the scan budget limit: %s", agent_id, exc)
if coordinator.budget_policy == "pause" or exc.operator_pause:
if coordinator.budget_policy == "pause":
await coordinator.park_for_budget(agent_id)
else:
await coordinator.pause_for_budget(agent_id)
@@ -850,7 +859,11 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
await coordinator.trigger_budget_stop()
raise
except Exception as exc:
if image_strips < 3 and session is not None and _is_image_rejection(exc):
if (
image_strips < 3
and session is not None
and getattr(exc, "status_code", None) in _INPUT_REJECTION_CODES
):
try:
stripped = await strip_all_images_from_session(session)
except Exception:
@@ -865,6 +878,26 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
)
input_data = []
continue
if session is not None and isinstance(exc, CompactionNeededError):
# Rerun without the check if compaction can't bring the session under
# the budget; a real overflow then falls to the capped recovery below.
try:
compacted = await _compact_session(agent, session, run_config, force=True)
check_context = compacted and not await _session_needs_compaction(
agent, session, run_config
)
except Exception:
logger.exception("context budget compaction failed for %s", agent_id)
check_context = False
logger.info(
"%s hit the context budget; %s",
agent_id,
"compacted and resuming"
if check_context
else "rerunning without the budget check",
)
input_data = []
continue
if (
compactions < _MAX_COMPACTIONS_PER_CYCLE
and session is not None
@@ -990,9 +1023,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 "
@@ -1004,7 +1036,7 @@ async def _append_tool_required_message(
message = (
"Your previous message ended a turn without a tool call. Plain text never ends "
"execution and never hands control to the user: it is shown to the user, and the "
"run continues. Continue immediately and call a tool. "
"run continues. Continue immediately and call exactly one tool. "
"If you have nothing to do until the user replies, call wait_for_user; your "
"text already reached them, so do not repeat it. "
"If you are blocked waiting for another agent, call wait_for_agents. "
@@ -1016,7 +1048,7 @@ async def _append_tool_required_message(
message = (
"Your previous response ended the autonomous run without a lifecycle tool "
"call. That is invalid in non-interactive mode; plain text final answers are "
"ignored. Continue immediately and call a tool. "
"ignored. Continue immediately and call exactly one tool. "
f"If your work is complete, call {finish_tool}. "
"If you are blocked waiting for another agent, call wait_for_agents. "
"Otherwise use the appropriate execution or planning tool. "
@@ -1110,7 +1142,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",
@@ -1120,7 +1152,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."
),
}
@@ -1129,7 +1161,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(
+6 -22
View File
@@ -65,12 +65,9 @@ class BudgetPausedError(RuntimeError):
decided to park; the agent waits for a resume newer than that.
"""
def __init__(
self, message: str, *, resume_epoch: int = 0, operator_pause: bool = False
) -> None:
def __init__(self, message: str, *, resume_epoch: int = 0) -> None:
super().__init__(message)
self.resume_epoch = resume_epoch
self.operator_pause = operator_pause
def _validate_budget(max_budget_usd: float | None) -> None:
@@ -107,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."
),
)
@@ -141,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:
@@ -201,7 +195,6 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
system_prompt: str | None, # noqa: ARG002
input_items: list[TResponseInputItem],
) -> None:
self._pause_if_operator_paused(context)
if self._budget_policy == "pause":
self._pause_if_limited(context)
context.context[LLM_TURN_KEY] = int(context.context.get(LLM_TURN_KEY, 0)) + 1
@@ -211,15 +204,6 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
except Exception:
logger.exception("budget/turn warning injection failed")
def _pause_if_operator_paused(self, context: RunContextWrapper[dict[str, Any]]) -> None:
coordinator = coordinator_from_context(context.context)
if coordinator is not None and coordinator.operator_paused:
raise BudgetPausedError(
"scan paused; waiting for the operator to resume",
resume_epoch=coordinator.resume_epoch,
operator_pause=True,
)
def _pause_if_limited(self, context: RunContextWrapper[dict[str, Any]]) -> None:
"""Park the agent before a paid call when the scan is at its limit or paused.
+5 -13
View File
@@ -250,26 +250,22 @@ def make_model_settings(
request_timeout: float | None = None,
prompt_cache: bool = True,
extra_headers: dict[str, str] | None = None,
extra_body: dict[str, Any] | None = None,
has_tools: bool = True,
) -> ModelSettings:
headers = _request_headers(model_name, extra_headers)
model_settings = ModelSettings(
parallel_tool_calls=True if has_tools else None,
parallel_tool_calls=False if has_tools else None,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(request_timeout),
extra_headers=headers,
extra_body=dict(extra_body) if extra_body else None,
)
if (
reasoning_effort is not None
and reasoning_effort != "none"
and model_supports_reasoning(model_name)
):
model_settings = model_settings.resolve(
_reasoning_settings(reasoning_effort, extra_body),
)
model_settings = model_settings.resolve(_reasoning_settings(reasoning_effort))
if force_required_tool_choice and _accepts_required_tool_choice(model_name):
model_settings = model_settings.resolve(ModelSettings(tool_choice="required"))
@@ -294,9 +290,7 @@ def _request_headers(
return headers or None
def _reasoning_settings(
effort: ReasoningEffort, extra_body: dict[str, Any] | None = None
) -> ModelSettings:
def _reasoning_settings(effort: ReasoningEffort) -> ModelSettings:
"""``max`` is not in the OpenAI SDK's ``Reasoning.effort`` enum, so send it as
a raw body field instead — also keeping it clear of LiteLLM's DeepSeek mapping,
which collapses every ``reasoning_effort`` level to plain thinking-enabled.
@@ -304,13 +298,11 @@ def _reasoning_settings(
It goes in ``extra_body``, the field every model implementation forwards as the
request's ``extra_body``; the same value under ``extra_args`` collides with that
keyword and raises before a request is ever sent. ``resolve`` replaces
``extra_body`` wholesale, so the user's ``LLM_EXTRA_BODY`` fields are carried
over, with ``reasoning_effort`` taking precedence.
keyword and raises before a request is ever sent.
"""
if effort != "max":
return ModelSettings(reasoning=Reasoning(effort=effort))
return ModelSettings(extra_body={**(extra_body or {}), "reasoning_effort": "max"})
return ModelSettings(extra_body={"reasoning_effort": "max"})
def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None:
+13 -43
View File
@@ -8,7 +8,7 @@ import io
import json
import logging
import uuid
from collections.abc import Awaitable, Callable
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -23,7 +23,6 @@ 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,
)
@@ -49,7 +48,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 +57,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 +67,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 +92,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.
@@ -171,7 +153,6 @@ 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
@@ -185,7 +166,6 @@ 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"
@@ -203,7 +183,7 @@ async def run_strix_scan(
scan_id: str | None = None,
image: str,
local_sources: list[dict[str, Any]] | None = None,
extra_files: list[dict[str, Any]] | Awaitable[list[dict[str, Any]] | None] | None = None,
extra_files: list[dict[str, Any]] | None = None,
coordinator: AgentCoordinator | None = None,
interactive: bool = False,
max_turns: int = DEFAULT_MAX_TURNS,
@@ -217,7 +197,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 +219,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:
@@ -287,9 +265,6 @@ 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}")
@@ -297,7 +272,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
@@ -401,7 +375,6 @@ async def run_strix_scan(
request_timeout=settings.llm.timeout,
prompt_cache=settings.llm.prompt_cache,
extra_headers=settings.llm.extra_headers,
extra_body=settings.llm.extra_body,
)
run_config = RunConfig(
model=resolved_model,
@@ -459,13 +432,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:
@@ -476,11 +455,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")
@@ -493,7 +468,6 @@ 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(
@@ -508,8 +482,6 @@ async def run_strix_scan(
strict_tool_schemas=strict_tool_schemas,
system_prompt_context=root_context,
instructions_override=root_instructions,
supports_images=supports_images,
finish_tool=root_finish_tool,
)
if not is_resume:
@@ -529,7 +501,6 @@ 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]:
@@ -557,7 +528,6 @@ 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)
@@ -622,7 +592,7 @@ async def run_strix_scan(
)
if not interactive and result is not None:
final = getattr(result, "final_output", None)
# Lifecycle tools mark the root completed.
# Lifecycle tools mark the root completed.
async with coordinator._lock:
root_completed = coordinator.statuses.get(root_id) == "completed"
if not root_completed:
+2 -2
View File
@@ -199,13 +199,13 @@ async def enforce_image_budget(session: Session, max_images: int) -> bool:
return await _rewrite_session(session, _transform)
def scrub_images_from_items(items: list[Any], *, text: str = _INHERITED_IMAGE_TEXT) -> list[Any]:
def scrub_images_from_items(items: list[Any]) -> list[Any]:
"""Return a copy of ``items`` with every image block replaced by text."""
def _scrub(obj: Any) -> Any:
if isinstance(obj, dict):
if obj.get("type") == "input_image":
return {"type": "input_text", "text": text}
return {"type": "input_text", "text": _INHERITED_IMAGE_TEXT}
return {k: _scrub(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_scrub(v) for v in obj]
+2 -3
View File
@@ -164,13 +164,12 @@ async def warm_up_llm() -> None:
raw_model = dedupe_model
deduper = resolve_dedupe_model(settings.dedupe, dedupe_model)
# A dedicated dedupe model may route to another provider, which must
# never receive the main endpoint's headers or body fields; it has
# its own DEDUPE_LLM_EXTRA_HEADERS and DEDUPE_LLM_EXTRA_BODY.
# never receive the main endpoint's headers; it has its own
# DEDUPE_LLM_EXTRA_HEADERS.
await preflight_request(
deduper,
model_name=dedupe_model,
extra_headers=settings.dedupe.extra_headers,
extra_body=settings.dedupe.extra_body,
timeout=llm.preflight_timeout,
api_base_setting="DEDUPE_LLM_API_BASE",
)
-3
View File
@@ -116,7 +116,6 @@ async def preflight_model_connection(
model,
model_name=model_name,
extra_headers=resolved_settings.llm.extra_headers,
extra_body=resolved_settings.llm.extra_body,
timeout=resolved_settings.llm.preflight_timeout,
api_base_setting="LLM_API_BASE",
)
@@ -127,7 +126,6 @@ async def preflight_request(
*,
model_name: str,
extra_headers: dict[str, str] | None,
extra_body: dict[str, Any] | None,
timeout: int,
api_base_setting: str,
) -> None:
@@ -146,7 +144,6 @@ async def preflight_request(
request_timeout=timeout,
prompt_cache=False,
extra_headers=extra_headers,
extra_body=extra_body,
has_tools=False,
)
try:
@@ -90,7 +90,6 @@ type Model struct {
client *Client
width, height int
snapshot protocol.Snapshot
windowTitle string
input textarea.Model
viewport viewport.Model
viewportContent string
+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 {
-27
View File
@@ -2,13 +2,10 @@ 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"
)
@@ -59,10 +56,6 @@ 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":
@@ -125,26 +118,6 @@ 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{}
+51 -22
View File
@@ -15,7 +15,11 @@ from typing import TYPE_CHECKING, Any
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses import (
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningItem,
)
from strix.config import load_settings
from strix.config.models import StrixProvider
@@ -160,22 +164,22 @@ def _content_text(content: Any) -> str:
return ""
def _truncate(text: str, limit: int) -> str:
return text if len(text) <= limit else f"{text[:limit]}\n[truncated]"
def _truncate(text: str, limit: int | None) -> str:
return text if limit is None or len(text) <= limit else f"{text[:limit]}\n[truncated]"
def _serialize_item(item: Any) -> str:
def _serialize_item(item: Any, max_chars: int | None = _TOOL_OUTPUT_MAX_CHARS) -> str:
if not isinstance(item, dict):
return str(item)
item_type = item.get("type")
role = item.get("role")
if item_type == "function_call":
args = _truncate(str(item.get("arguments", "")), _TOOL_OUTPUT_MAX_CHARS)
args = _truncate(str(item.get("arguments", "")), max_chars)
return f"[tool_call {item.get('name', '?')}] {args}"
if item_type == "function_call_output":
output = item.get("output")
text = output if isinstance(output, str) else _content_text(output)
return f"[tool_result] {_truncate(text, _TOOL_OUTPUT_MAX_CHARS)}"
return f"[tool_result] {_truncate(text, max_chars)}"
if item_type == "reasoning":
return ""
if role or item_type == "message":
@@ -183,8 +187,30 @@ def _serialize_item(item: Any) -> str:
return ""
def _serialize_items(items: list[Any]) -> str:
return "\n".join(s for s in (_serialize_item(item) for item in items) if s)
def _serialize_items(items: list[Any], max_chars: int | None = _TOOL_OUTPUT_MAX_CHARS) -> str:
return "\n".join(s for s in (_serialize_item(item, max_chars) for item in items) if s)
class CompactionNeededError(Exception):
"""The next model request would overrun the model's usable context window."""
def _context_budget(model: str) -> int:
context = load_settings().context
reserve = max(context.compact_buffer_tokens, output_limit(model))
return max(context.keep_tokens, context_window(model) - reserve)
def _request_tokens(model: str, instructions: str, tools_text: str, items: list[Any]) -> int:
text = "\n".join((instructions, tools_text, _serialize_items(items, max_chars=None)))
return count_tokens(model, text)
def needs_compaction(model: str, instructions: str, tools_text: str, items: list[Any]) -> bool:
"""Whether a request carrying ``items`` would overrun ``model``'s context budget."""
if not load_settings().context.auto_compact:
return False
return _request_tokens(model, instructions, tools_text, items) > _context_budget(model)
def _is_tool_call(item: Any) -> bool:
@@ -288,15 +314,22 @@ def _checkpoint_item(summary: str) -> dict[str, Any]:
def _extract_text(response: ModelResponse) -> str:
parts: list[str] = []
reasoning: list[str] = []
for item in response.output:
if not isinstance(item, ResponseOutputMessage):
continue
parts.extend(
chunk.text
for chunk in item.content
if isinstance(chunk, ResponseOutputText) and chunk.text
)
return "".join(parts)
if isinstance(item, ResponseOutputMessage):
parts.extend(
chunk.text
for chunk in item.content
if isinstance(chunk, ResponseOutputText) and chunk.text
)
elif isinstance(item, ResponseReasoningItem):
reasoning.extend(summary.text for summary in item.summary if summary.text)
if parts or not reasoning:
return "".join(parts)
# Reasoning models can spend the whole output budget thinking and return no
# message; their reasoning is a degraded but usable summary.
logger.warning("compaction summary had no output text; using reasoning text")
return "\n".join(reasoning)
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
@@ -307,7 +340,6 @@ async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
request_timeout=llm.timeout,
prompt_cache=False,
extra_headers=llm.extra_headers,
extra_body=llm.extra_body,
has_tools=False,
).resolve(ModelSettings(max_tokens=max_tokens))
try:
@@ -359,11 +391,8 @@ async def maybe_compact(
if len(items) < _MIN_ITEMS_TO_COMPACT:
return False
window = context_window(model)
reserve = max(context.compact_buffer_tokens, output_limit(model))
budget = max(context.keep_tokens, window - reserve)
used = count_tokens(model, "\n".join((instructions, tools_text, _serialize_items(items))))
if not force and used <= budget:
used = _request_tokens(model, instructions, tools_text, items)
if not force and used <= _context_budget(model):
return False
split = _select_split(model, items, context.keep_tokens)
+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
+4 -6
View File
@@ -39,13 +39,11 @@ def _dedupe_model_settings(
model_name=model_name,
force_required_tool_choice=False,
request_timeout=request_timeout,
# The main model's headers and body fields apply only when dedupe falls
# back to the main model; a dedicated dedupe model may route to another
# provider, which must never receive the main endpoint's credentials or
# identifiers. A dedicated model gets its own DEDUPE_LLM_EXTRA_HEADERS
# and DEDUPE_LLM_EXTRA_BODY instead.
# The main model's headers apply only when dedupe falls back to the main
# model; a dedicated dedupe model may route to another provider, which
# must never receive the main endpoint's credentials. A dedicated model
# gets its own DEDUPE_LLM_EXTRA_HEADERS instead.
extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers,
extra_body=dedupe.extra_body if dedupe.model else llm.extra_body,
has_tools=False,
)
-255
View File
@@ -1,255 +0,0 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any
from agents.sandbox.session.manager import Instrumentation
from agents.sandbox.session.sandbox_session import SandboxSession
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
from agents.sandbox.snapshot import NoopSnapshot
if TYPE_CHECKING:
import io
from collections.abc import Sequence
from agents.sandbox.entries import BaseEntry
from agents.sandbox.manifest import Manifest
from agents.sandbox.materialization import MaterializedFile
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.types import ExecResult, ExposedPortEndpoint, User
logger = logging.getLogger(__name__)
class _PendingSessionState(SandboxSessionState):
type: str = "pending"
class DeferredSandboxSession(SandboxSession):
def __init__(
self,
bring_up: asyncio.Task[tuple[Any, BaseSandboxSession]],
*,
manifest: Manifest,
exposed_ports: tuple[int, ...] = (),
) -> None:
self._bring_up = bring_up
self._pending_state: SandboxSessionState = _PendingSessionState(
snapshot=NoopSnapshot(id="pending"),
manifest=manifest,
exposed_ports=exposed_ports,
)
self._pending_limits: Any = None
self._pending_archive_limits: Any = None
self._resolved: BaseSandboxSession | None = None
self._client: Any = None
self._instrumentation: Instrumentation = Instrumentation()
self._seq = 0
@property
def pending(self) -> bool:
return self._resolved is None
@property
def bring_up(self) -> asyncio.Task[tuple[Any, BaseSandboxSession]]:
return self._bring_up
async def resolve(self) -> BaseSandboxSession:
if self._resolved is not None:
return self._resolved
client, session = await asyncio.shield(self._bring_up)
self._adopt(client, session)
return session
def _adopt(self, client: Any, session: BaseSandboxSession) -> None:
if self._resolved is not None:
return
self._client = client
self._resolved = session
self._inner = session
if self._pending_limits is not None:
session._set_concurrency_limits(self._pending_limits)
if self._pending_archive_limits is not None:
session._set_archive_limits(self._pending_archive_limits)
if self._pending_state.manifest != session.state.manifest:
session.state = session.state.model_copy(
update={"manifest": self._pending_state.manifest}
)
@property
def client(self) -> Any:
return self._client
@property
def state(self) -> SandboxSessionState:
if self._resolved is None:
return self._pending_state
return self._resolved.state
@state.setter
def state(self, value: SandboxSessionState) -> None:
if self._resolved is None:
self._pending_state = value
else:
self._resolved.state = value
def _set_concurrency_limits(self, limits: Any) -> None:
if self._resolved is None:
self._pending_limits = limits
else:
self._resolved._set_concurrency_limits(limits)
def _set_archive_limits(self, limits: Any) -> None:
if self._resolved is None:
self._pending_archive_limits = limits
else:
self._resolved._set_archive_limits(limits)
def _manifest_base_dir(self) -> Path:
if self._resolved is None:
return Path.cwd()
return self._resolved._manifest_base_dir()
def normalize_path(self, path: Path | str, *, for_write: bool = False) -> Path:
if self._resolved is None:
return Path(path)
return self._resolved.normalize_path(path, for_write=for_write)
def register_persist_workspace_skip_path(self, path: Path | str) -> Path:
if self._resolved is None:
return Path(path)
return self._resolved.register_persist_workspace_skip_path(path)
def supports_pty(self) -> bool:
return self._resolved.supports_pty() if self._resolved is not None else False
async def running(self) -> bool:
if self._resolved is None:
if not self._bring_up.done():
return True
return not self._bring_up.cancelled() and self._bring_up.exception() is None
return await self._resolved.running()
async def start(self) -> None:
if self._resolved is None:
return
await self._resolved.start()
async def stop(self) -> None:
if self._resolved is None:
return
await self._resolved.stop()
async def shutdown(self) -> None:
if self._resolved is None:
return
await self._resolved.shutdown()
async def aclose(self) -> None:
if self._resolved is None:
return
await self._resolved.aclose()
async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None:
session = await self.resolve()
await session._validate_manifest_application(only_ephemeral=only_ephemeral)
async def apply_manifest(self, *, only_ephemeral: bool = False) -> Any:
session = await self.resolve()
return await session.apply_manifest(only_ephemeral=only_ephemeral)
async def _apply_entry_batch(
self,
entries: Sequence[tuple[Path, BaseEntry]],
*,
base_dir: Path,
) -> list[MaterializedFile]:
del base_dir
session = await self.resolve()
return await session._apply_entry_batch(entries, base_dir=session._manifest_base_dir())
async def exec(self, *command: str | Path, **kwargs: Any) -> ExecResult:
await self.resolve()
return await super().exec(*command, **kwargs)
async def ls(self, *args: Any, **kwargs: Any) -> Any:
await self.resolve()
return await super().ls(*args, **kwargs)
async def rm(self, *args: Any, **kwargs: Any) -> Any:
await self.resolve()
return await super().rm(*args, **kwargs)
async def mkdir(self, *args: Any, **kwargs: Any) -> Any:
await self.resolve()
return await super().mkdir(*args, **kwargs)
async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase:
await self.resolve()
return await super().read(path, user=user)
async def write(self, *args: Any, **kwargs: Any) -> Any:
await self.resolve()
return await super().write(*args, **kwargs)
async def resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
await self.resolve()
return await super().resolve_exposed_port(port)
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
session = await self.resolve()
return await session._resolve_exposed_port(port)
async def pty_exec_start(self, *args: Any, **kwargs: Any) -> Any:
await self.resolve()
return await super().pty_exec_start(*args, **kwargs)
async def pty_write_stdin(self, *args: Any, **kwargs: Any) -> Any:
await self.resolve()
return await super().pty_write_stdin(*args, **kwargs)
async def pty_terminate_all(self) -> None:
if self._resolved is None:
return
await super().pty_terminate_all()
async def persist_workspace(self) -> io.IOBase:
await self.resolve()
return await super().persist_workspace()
async def hydrate_workspace(self, data: io.IOBase) -> None:
await self.resolve()
await super().hydrate_workspace(data)
async def teardown(self) -> None:
if self._resolved is None:
self._bring_up.cancel()
with contextlib.suppress(BaseException):
client, session = await self._bring_up
self._adopt(client, session)
if self._resolved is None:
return
await self._client.delete(self._resolved)
class DeferredSandboxClient:
def __init__(self, session: DeferredSandboxSession) -> None:
self._session = session
def __getattr__(self, name: str) -> Any:
client = self._session.client
if client is None:
raise AttributeError(f"sandbox client unavailable before bring-up finishes ({name})")
return getattr(client, name)
async def delete(self, session: Any) -> None:
if isinstance(session, DeferredSandboxSession):
await session.teardown()
return
real = await self._session.resolve()
await self._session.client.delete(real if session is self._session else session)
+32 -93
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import asyncio
import contextlib
import inspect
import io
import logging
import os
@@ -21,12 +19,9 @@ from strix.config import load_settings
from strix.runtime.backends import backend_supports_bind_mounts, get_backend
from strix.runtime.caido_bootstrap import bootstrap_caido
from strix.runtime.caido_handle import CaidoBootstrapHandle
from strix.runtime.deferred_session import DeferredSandboxClient, DeferredSandboxSession
if TYPE_CHECKING:
from collections.abc import Awaitable
from agents.sandbox.session import BaseSandboxSession
from strix.runtime.status import StatusSink
@@ -260,22 +255,13 @@ def _gitdir_from_pointer(git_file: Path) -> Path | None:
return None
async def _resolve_extra_file_archive(
extra_files: list[dict[str, Any]] | Awaitable[list[dict[str, Any]] | None] | None,
local_sources: list[dict[str, Any]],
) -> bytes | None:
files = await extra_files if inspect.isawaitable(extra_files) else extra_files
return build_extra_file_archive(files, local_sources) if files else None
async def create_or_reuse(
scan_id: str,
*,
image: str,
local_sources: list[dict[str, Any]],
extra_files: list[dict[str, Any]] | Awaitable[list[dict[str, Any]] | None] | None = None,
extra_files: list[dict[str, Any]] | None = None,
status_sink: StatusSink | None = None,
defer: bool = False,
) -> dict[str, Any]:
"""Return the existing session bundle for ``scan_id`` or create a new one.
@@ -285,9 +271,7 @@ async def create_or_reuse(
Each ``extra_files`` entry (``{"workspace_path": "/workspace/<rel>",
"content": bytes | str}``) lands as a regular, agent-writable file at its
``workspace_path`` on every backend: the files are uploaded as one archive
and unpacked inside the sandbox right after bring-up. ``extra_files`` may
also be an awaitable resolving to that list: provisioning starts at once and
the files are awaited only when the sandbox is ready to receive them.
and unpacked inside the sandbox right after bring-up.
"""
def report(phase: str) -> None:
@@ -308,6 +292,9 @@ async def create_or_reuse(
else:
bind_mounts = []
entries = build_manifest_entries(local_sources)
extra_file_archive = (
build_extra_file_archive(extra_files, local_sources) if extra_files else None
)
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
@@ -336,69 +323,41 @@ async def create_or_reuse(
backend_name,
image,
)
report("Starting sandbox container")
client, session = await backend(
image=image,
manifest=manifest,
exposed_ports=(_CONTAINER_CAIDO_PORT,),
bind_mounts=bind_mounts,
)
async def _bring_up() -> tuple[Any, Any]:
report("Starting sandbox container")
client, session = await _create_or_discard(
backend(
image=image,
manifest=manifest,
exposed_ports=(_CONTAINER_CAIDO_PORT,),
bind_mounts=bind_mounts,
)
)
if extra_file_archive is not None:
report("Placing workspace files")
try:
extra_file_archive = await _resolve_extra_file_archive(extra_files, local_sources)
if extra_file_archive is not None:
report("Placing workspace files")
await stage_extra_files(session, extra_file_archive)
await stage_extra_files(session, extra_file_archive)
except BaseException:
await _discard_session(client, session)
raise
return client, session
async def _caido_url(session: Any) -> str:
report("Setting up the proxy")
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
scheme = "https" if caido_endpoint.tls else "http"
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
return host_caido_url
report("Setting up the proxy")
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
scheme = "https" if caido_endpoint.tls else "http"
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
logger.debug("Caido host endpoint resolved: %s", host_caido_url)
def _caido(session: Any, host_caido_url: str) -> Any:
return bootstrap_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
)
if defer:
bring_up = asyncio.create_task(_bring_up(), name=f"sandbox-bring-up-{scan_id}")
deferred = DeferredSandboxSession(
bring_up,
manifest=manifest,
exposed_ports=(_CONTAINER_CAIDO_PORT,),
)
async def _caido_when_up() -> Any:
session = await deferred.resolve()
return await _caido(session, await _caido_url(session))
bundle = {
"client": DeferredSandboxClient(deferred),
"session": deferred,
"caido_client": CaidoBootstrapHandle(
asyncio.create_task(_caido_when_up(), name=f"caido-bootstrap-{scan_id}")
),
}
_SESSION_CACHE[scan_id] = bundle
logger.info("Sandbox session for scan %s deferred and cached", scan_id)
return bundle
client, session = await _bring_up()
host_caido_url = await _caido_url(session)
# The Caido login + project setup polls the guest for a couple of seconds
# and nothing needs the client before the first proxy tool call, so it
# runs concurrently with the rest of scan start; consumers resolve the
# handle at first use (see CaidoBootstrapHandle).
caido_client = CaidoBootstrapHandle(
asyncio.create_task(_caido(session, host_caido_url), name=f"caido-bootstrap-{scan_id}")
asyncio.create_task(
bootstrap_caido(
session,
host_url=host_caido_url,
container_url=container_caido_url,
),
name=f"caido-bootstrap-{scan_id}",
)
)
bundle = {
@@ -411,26 +370,6 @@ async def create_or_reuse(
return bundle
_DISCARD_TASKS: set[asyncio.Task[None]] = set()
async def _create_or_discard(create: Awaitable[tuple[Any, Any]]) -> tuple[Any, Any]:
pending = asyncio.ensure_future(create)
try:
return await asyncio.shield(pending)
except asyncio.CancelledError:
discard = asyncio.create_task(_discard_when_created(pending))
_DISCARD_TASKS.add(discard)
discard.add_done_callback(_DISCARD_TASKS.discard)
raise
async def _discard_when_created(create: asyncio.Future[tuple[Any, Any]]) -> None:
with contextlib.suppress(BaseException):
client, session = await create
await _discard_session(client, session)
async def _discard_session(client: Any, session: Any) -> None:
"""Best-effort teardown of a session that never made it into the cache."""
try:
+2 -15
View File
@@ -6,7 +6,6 @@ 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
@@ -14,14 +13,6 @@ 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 # nosec B701 - 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"
@@ -270,7 +261,7 @@ def _candidate_skill_files(skill_name: str) -> list[Path]:
return _bare_skill_files(skill_name)
def load_skills(skill_names: list[str], *, supports_images: bool = True) -> dict[str, str]:
def load_skills(skill_names: list[str]) -> dict[str, str]:
"""Load skill markdown bodies (frontmatter stripped) by name.
Skill files live at ``strix/skills/<category>/<name>.md`` (or any
@@ -300,11 +291,7 @@ def load_skills(skill_names: list[str], *, supports_images: bool = True) -> dict
continue
var_name = skill_name.split("/")[-1]
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_body = _parse_skill_content(content, file_path)
skill_content[var_name] = skill_body
logger.debug("Loaded skill: %s -> %s", skill_name, var_name)
_track_skill_loaded(var_name, file_path)
+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).
+6 -15
View File
@@ -1,7 +1,6 @@
---
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
---
@@ -261,9 +260,8 @@ only for simple expressions.
### Screenshot
`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 —
`agent-browser screenshot` writes a PNG to disk in the sandbox. 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
@@ -277,12 +275,6 @@ 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
@@ -291,7 +283,6 @@ 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.
@@ -303,10 +294,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 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 %}
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.
### Handle multiple pages via tabs
-12
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import contextlib
import functools
import logging
import os
import sys
@@ -79,16 +78,6 @@ 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")
@@ -100,7 +89,6 @@ 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:
+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,
+2 -2
View File
@@ -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}"
context = ctx.context if isinstance(ctx.context, dict) else {}
contents = load_skills(requested, supports_images=context.get("supports_images", True))
contents = load_skills(requested)
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()]
+3 -26
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,
}
@@ -309,11 +288,9 @@ 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),
)
-16
View File
@@ -32,7 +32,6 @@ from agents.mcp import (
)
from mcp.client.stdio import stdio_client
from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.types import TextContent
from strix.tools.mcp.failures import HttpStatusRecorder
from strix.tools.mcp.session import McpConnectionUnavailableError, SupervisedMcpSession
@@ -184,18 +183,6 @@ def _build_server(config: McpConnectionConfig) -> BuiltMcpServer:
)
def _without_images(result: Any) -> Any:
content = [
TextContent(
type="text", text=f"[{item.mimeType} image omitted: this model cannot view images]"
)
if item.type == "image"
else item
for item in result.content
]
return result.model_copy(update={"content": content})
def _mcp_result_to_tool_output(server: MCPServer, result: Any) -> Any:
"""Serialize a ``CallToolResult`` to a tool output, mirroring the agents SDK.
@@ -230,7 +217,6 @@ 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.
@@ -247,8 +233,6 @@ async def dispatch_mcp_call(
corrupt the content the agent receives).
"""
result = await server.call_tool(tool_name, arguments)
if not supports_images:
result = _without_images(result)
if result_transform is not None:
return result_transform(label, result.model_dump(mode="json"))
tool_output = _mcp_result_to_tool_output(server, result)
+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
-2
View File
@@ -365,7 +365,6 @@ 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.
@@ -383,7 +382,6 @@ class SupervisedMcpSession:
arguments,
label=label,
result_transform=result_transform,
supports_images=supports_images,
)
outcome = await self._run_job(job, phase="call")
+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,
+5 -36
View File
@@ -1876,10 +1876,14 @@ def _build_dependency_evidence(
evidence += f"\n\n**Usage analysis:** {label}."
if reachability_evidence and reachability_evidence.strip():
evidence += f" {reachability_evidence.strip()}"
evidence += (
" This is a prioritization signal from static analysis, not a"
" proof of exploitability or of safety."
)
return evidence
async def _do_create_dependency( # noqa: PLR0912, PLR0915
async def _do_create_dependency( # noqa: PLR0912
*,
title: str,
description: str,
@@ -1901,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,
@@ -1945,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(
@@ -2061,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,
@@ -2117,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:
@@ -2229,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``:
@@ -2306,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,
-100
View File
@@ -80,94 +80,6 @@ async def test_wrap_exec_command_preserves_explicit_shell(shell: str) -> None:
assert json.loads(captured["raw_input"])["shell"] == shell
def _running_exec_tool(result: str) -> FunctionTool:
async def invoke(_ctx: Any, _raw_input: str) -> str:
return result
return FunctionTool(
name="exec_command",
description="Runs a command.",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke,
)
_RUNNING_RESULT = (
"Chunk ID: abc123\nWall time: 30.0012 seconds\nProcess running with session ID 7\nOutput:\n"
)
def test_wrap_exec_command_describes_the_yield_cap() -> None:
wrapped = factory._wrap_exec_command(_running_exec_tool("ok"))
assert wrapped.description.startswith("Runs a command. The wait for output is capped at 30 s")
assert "cannot be read later" in wrapped.description
assert "tty=true" in wrapped.description
@pytest.mark.asyncio
async def test_wrap_exec_command_flags_a_running_non_tty_process() -> None:
wrapped = factory._wrap_exec_command(_running_exec_tool(_RUNNING_RESULT))
result = await wrapped.on_invoke_tool(
cast("Any", None), json.dumps({"cmd": "sleep 60; curl x", "yield_time_ms": 90000})
)
assert result == (
"Chunk ID: abc123\nWall time: 30.0012 seconds\n"
"Process running with session ID 7\nOutput:\n"
"Note: yield_time_ms=90000 was capped at 30000 (30 s). this non-TTY process cannot be "
"polled and its later output is lost. Rerun it in the background "
"(`cmd > /tmp/out.log 2>&1 &`, then read the log) or with tty=true.\n"
)
@pytest.mark.asyncio
async def test_wrap_exec_command_flags_a_running_non_tty_process_within_the_cap() -> None:
wrapped = factory._wrap_exec_command(_running_exec_tool(_RUNNING_RESULT))
result = await wrapped.on_invoke_tool(cast("Any", None), json.dumps({"cmd": "sleep 60"}))
assert "Note: this non-TTY process cannot be polled" in result
assert "was capped" not in result
@pytest.mark.asyncio
async def test_wrap_exec_command_leaves_a_running_tty_process_alone() -> None:
wrapped = factory._wrap_exec_command(_running_exec_tool(_RUNNING_RESULT))
result = await wrapped.on_invoke_tool(
cast("Any", None), json.dumps({"cmd": "python3 -m http.server", "tty": True})
)
assert result == _RUNNING_RESULT
@pytest.mark.asyncio
async def test_wrap_exec_command_leaves_an_exited_process_alone() -> None:
exited = "Chunk ID: abc123\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\nhi"
wrapped = factory._wrap_exec_command(_running_exec_tool(exited))
result = await wrapped.on_invoke_tool(cast("Any", None), json.dumps({"cmd": "echo hi"}))
assert result == exited
@pytest.mark.asyncio
async def test_wrap_exec_command_ignores_the_running_marker_inside_command_output() -> None:
exited = (
"Chunk ID: abc123\nWall time: 0.2 seconds\nProcess exited with code 0\nOutput:\n"
"Process running with session ID 7\nOutput:\n"
)
wrapped = factory._wrap_exec_command(_running_exec_tool(exited))
result = await wrapped.on_invoke_tool(
cast("Any", None), json.dumps({"cmd": "cat transcript.txt"})
)
assert result == exited
@pytest.mark.asyncio
async def test_responses_filesystem_custom_tool_output_is_bounded() -> None:
async def invoke(_ctx: Any, _inp: str) -> str:
@@ -203,15 +115,3 @@ 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
+3 -21
View File
@@ -38,26 +38,11 @@ async def _roundtrip(
_STRING = {"todos": {"type": "string"}}
@pytest.mark.asyncio
async def test_truncated_arguments_short_circuit_with_a_reissue_message() -> None:
captured: dict[str, str] = {}
wrapped = factory._with_coerced_arguments(_capturing_tool(captured, _STRING))
result = await wrapped.on_invoke_tool(cast("Any", None), '{"todos": "a, b')
assert "not executed" in result
assert "Re-issue the call" in result
assert "raw_input" not in captured
_ARRAY = {"tags": {"type": "array", "items": {"type": "string"}}}
_NULLABLE_ARRAY = {
"tags": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}
}
_OBJECT = {"modifications": {"type": "object"}}
OVER_NESTED = '{"a":' + "[" * 100_000
@pytest.mark.asyncio
@@ -151,15 +136,12 @@ async def test_unknown_and_null_arguments_are_untouched() -> None:
@pytest.mark.asyncio
@pytest.mark.parametrize("arguments", ["not json", '{"n": NaN}', OVER_NESTED])
async def test_non_object_payloads_are_reported_instead_of_invoked(arguments: str) -> None:
async def test_non_object_payloads_pass_through_unchanged() -> None:
captured: dict[str, str] = {}
wrapped = factory._with_coerced_arguments(_capturing_tool(captured, _ARRAY))
result = await wrapped.on_invoke_tool(cast("Any", None), arguments)
assert result.startswith("probe: the tool call was not executed")
assert "raw_input" not in captured
assert await wrapped.on_invoke_tool(cast("Any", None), "not json") == "ok"
assert captured["raw_input"] == "not json"
@pytest.mark.asyncio
-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"))
+3 -146
View File
@@ -21,7 +21,7 @@ from strix.core.sessions import open_agent_session
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable
from collections.abc import AsyncIterator, Callable
from pathlib import Path
@@ -31,7 +31,6 @@ _CALL_LATENCY_S = 0.005
class _FakeLedger:
def __init__(self) -> None:
self.on_pause: Callable[[], Awaitable[None]] | None = None
self.cost = 0.0
self.calls: list[str] = []
self.remaining: dict[str, int] = {}
@@ -81,8 +80,6 @@ class _FakeStream:
await self._hooks.on_llm_start(ctx_wrapper, self._agent, None, input_items)
except BudgetPausedError as exc:
self.run_loop_exception = exc
if self._ledger.on_pause is not None:
await self._ledger.on_pause()
return
self._ledger.warned_inputs.append(input_items)
if self._ledger.gate is not None:
@@ -153,16 +150,13 @@ class _Scan:
monkeypatch: pytest.MonkeyPatch,
*,
max_budget_usd: float | None,
budget_policy: str = "pause",
) -> None:
self.ledger = _FakeLedger()
self.hooks = ReportUsageHooks(
model="test-model",
max_budget_usd=max_budget_usd,
budget_policy=budget_policy, # type: ignore[arg-type]
model="test-model", max_budget_usd=max_budget_usd, budget_policy="pause"
)
self.coordinator = AgentCoordinator()
self.coordinator.set_budget_policy(budget_policy) # type: ignore[arg-type]
self.coordinator.set_budget_policy("pause")
self.coordinator.set_budget_limit_setter(self.hooks.set_max_budget_usd)
monkeypatch.setattr(execution, "Runner", _fake_runner(self.ledger, self.coordinator))
monkeypatch.setattr(execution, "_compact_session", _noop_compact)
@@ -544,140 +538,3 @@ async def test_stop_policy_is_unchanged() -> None:
def test_budget_policy_is_validated() -> None:
with pytest.raises(ValueError, match="budget_policy"):
ReportUsageHooks(model="m", budget_policy="later") # type: ignore[arg-type]
def _pause_flags(coordinator: AgentCoordinator) -> tuple[bool, bool]:
return coordinator.budget_paused, coordinator.operator_paused
@pytest.mark.asyncio
async def test_operator_pause_holds_under_the_stop_policy(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
scan = _Scan(tmp_path, monkeypatch, max_budget_usd=None, budget_policy="stop")
agents = ("root", "child-a", "child-b")
with patch("strix.core.hooks.get_global_report_state", return_value=scan.ledger):
await scan.start_root(calls=6)
await scan.start_child("child-a", calls=6)
await scan.start_child("child-b", calls=6)
await _wait_until(lambda: scan.ledger.cost >= 3.0)
await scan.coordinator.pause_budget()
spent_at_pause = scan.ledger.cost
await _wait_until(lambda: _all_parked(scan.coordinator, *agents))
assert _pause_flags(scan.coordinator) == (True, True)
assert all(not task.done() for task in scan.tasks())
await scan.coordinator.send("child-a", {"from": "root", "content": "status?"})
await scan.coordinator.send("root", {"from": "user", "content": "keep going"})
await asyncio.sleep(0.05)
assert _pause_flags(scan.coordinator) == (True, True)
assert scan.ledger.cost == pytest.approx(spent_at_pause)
assert _all_parked(scan.coordinator, *agents)
woken = await scan.coordinator.resume_budget()
assert sorted(woken) == sorted(agents)
assert _pause_flags(scan.coordinator) == (False, False)
await asyncio.wait_for(asyncio.gather(*scan.tasks(), return_exceptions=True), timeout=5.0)
assert scan.ledger.cost == pytest.approx(18.0)
assert {aid: str(s) for aid, s in scan.coordinator.statuses.items()} == {
"root": "completed",
"child-a": "completed",
"child-b": "completed",
}
root_items = await scan.sessions[0].get_items()
assert any("keep going" in str(item) for item in root_items)
for session in scan.sessions:
session.close()
@pytest.mark.asyncio
async def test_a_resume_that_lands_before_the_park_still_continues(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
scan = _Scan(tmp_path, monkeypatch, max_budget_usd=None, budget_policy="stop")
resumed = 0
async def resume_at_once() -> None:
nonlocal resumed
resumed += 1
await scan.coordinator.resume_budget()
scan.ledger.on_pause = resume_at_once
with patch("strix.core.hooks.get_global_report_state", return_value=scan.ledger):
await scan.start_root(calls=6)
await scan.start_child("child-a", calls=6)
await _wait_until(lambda: scan.ledger.cost >= 2.0)
await scan.coordinator.pause_budget()
await asyncio.wait_for(asyncio.gather(*scan.tasks(), return_exceptions=True), timeout=5.0)
assert resumed >= 1
assert _pause_flags(scan.coordinator) == (False, False)
assert scan.ledger.cost == pytest.approx(12.0)
assert {aid: str(s) for aid, s in scan.coordinator.statuses.items()} == {
"root": "completed",
"child-a": "completed",
}
for session in scan.sessions:
session.close()
@pytest.mark.asyncio
async def test_operator_pause_gates_the_stop_policy_hooks() -> None:
coordinator = AgentCoordinator()
hooks = ReportUsageHooks(model="m", max_budget_usd=None)
assert hooks.budget_policy == "stop"
await coordinator.pause_budget()
with pytest.raises(BudgetPausedError):
await hooks.on_llm_start(_ctx(coordinator), MagicMock(), None, [])
await coordinator.resume_budget()
await hooks.on_llm_start(_ctx(coordinator), MagicMock(), None, [])
@pytest.mark.asyncio
async def test_a_user_message_lifts_only_the_limit_pause(tmp_path: Path) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
session = open_agent_session("root", tmp_path / "agents.db")
await coordinator.attach_runtime("root", session=session)
extensions: list[int] = []
coordinator.set_budget_extender(lambda: extensions.append(1))
await coordinator.pause_budget()
await coordinator.park_for_budget("root")
assert coordinator.statuses["root"] == "budget_paused"
await coordinator.send("root", {"from": "user", "content": "keep going"})
assert _pause_flags(coordinator) == (True, True)
assert extensions == []
assert coordinator.pending_counts["root"] == 1
await coordinator.resume_budget()
await coordinator.pause_for_budget("root")
await coordinator.send("root", {"from": "user", "content": "keep going"})
assert _pause_flags(coordinator) == (False, False)
assert extensions == [1]
session.close()
@pytest.mark.asyncio
async def test_snapshot_and_reset_keep_an_operator_pause() -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.pause_budget()
snap = await coordinator.snapshot()
restored = AgentCoordinator()
await restored.restore(snap)
assert _pause_flags(restored) == (True, True)
await restored.reset_budget_stops(budget_stopped=False, reserve_stopped=False)
assert _pause_flags(restored) == (True, True)
await restored.resume_budget()
await restored.pause_for_budget("root")
await restored.reset_budget_stops(budget_stopped=False, reserve_stopped=False)
assert _pause_flags(restored) == (False, False)
assert restored.statuses["root"] == "waiting"
+45 -10
View File
@@ -6,6 +6,8 @@ from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import pytest
from agents.extensions.models.litellm_model import InternalChatCompletionMessage
from agents.models.chatcmpl_converter import Converter
from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
@@ -136,7 +138,9 @@ def test_select_split_handles_parallel_calls(monkeypatch: pytest.MonkeyPatch) ->
assert not _has_orphan_tool_output(items[split:])
def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window: int) -> None:
def _patch_budget(
monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window: int
) -> ContextSettings:
monkeypatch.setattr(compaction, "count_tokens", lambda _m, t: len(t))
monkeypatch.setattr(compaction, "context_window", lambda _m: window)
monkeypatch.setattr(compaction, "output_limit", lambda _m: 0)
@@ -147,11 +151,10 @@ def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window:
context.auto_compact = True
settings = SimpleNamespace(
context=context,
llm=SimpleNamespace(
api_key=None, api_base=None, timeout=1, extra_headers=None, extra_body=None
),
llm=SimpleNamespace(api_key=None, api_base=None, timeout=1, extra_headers=None),
)
monkeypatch.setattr(compaction, "load_settings", lambda: settings)
return context
def _model_response(text: str) -> Any:
@@ -180,6 +183,20 @@ def _patch_summary(
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
def test_needs_compaction_counts_full_tool_outputs(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_budget(monkeypatch, keep_tokens=50, window=10_000)
items = [_call("c1"), _output("c1", "x" * 20_000)]
assert compaction.needs_compaction("m", "", "", items) is True
assert compaction.needs_compaction("m", "", "", _turns(2)) is False
def test_needs_compaction_respects_auto_compact_off(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_budget(monkeypatch, keep_tokens=50, window=10_000).auto_compact = False
assert compaction.needs_compaction("m", "", "", [_output("c1", "x" * 20_000)]) is False
@pytest.mark.asyncio
async def test_maybe_compact_noop_when_within_budget(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_budget(monkeypatch, keep_tokens=50, window=1_000_000)
@@ -230,11 +247,7 @@ async def test_summarize_routes_through_provider_with_settings(
"load_settings",
lambda: SimpleNamespace(
llm=SimpleNamespace(
api_key=None,
api_base=None,
timeout=1,
extra_headers={"X-Feature-Key": "svc"},
extra_body={"service_tier": "flex"},
api_key=None, api_base=None, timeout=1, extra_headers={"X-Feature-Key": "svc"}
)
),
)
@@ -245,10 +258,32 @@ async def test_summarize_routes_through_provider_with_settings(
assert captured["model"] == "litellm/openai/some-model"
settings = captured["model_settings"]
assert settings.extra_headers == {"X-Feature-Key": "svc"}
assert settings.extra_body == {"service_tier": "flex"}
assert settings.max_tokens == 64
@pytest.mark.asyncio
async def test_summarize_falls_back_to_reasoning_when_no_output_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
message = InternalChatCompletionMessage(
role="assistant", content=None, reasoning_content="REASONED SUMMARY"
)
response = SimpleNamespace(output=Converter.message_to_output_items(message))
class FakeModel:
async def get_response(self, **_kwargs: Any) -> Any:
return response
class FakeProvider:
def get_model(self, _model_name: str | None) -> Any:
return FakeModel()
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
assert await compaction._summarize("m", "p", 64) == "REASONED SUMMARY"
def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(compaction, "count_tokens", lambda _m, t: len(t))
text = "x" * 10_000
-57
View File
@@ -37,12 +37,6 @@ _LLM_ENV_KEYS = [
"STRIX_RUNTIME_BACKEND",
# TelemetrySettings
"STRIX_TELEMETRY",
# JSON-object settings
"LLM_EXTRA_HEADERS",
"LLM_EXTRA_BODY",
"STRIX_DEDUPE_MODEL",
"DEDUPE_LLM_EXTRA_HEADERS",
"DEDUPE_LLM_EXTRA_BODY",
]
@@ -412,57 +406,6 @@ def test_persist_current_replaces_corrupt_file(
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"STRIX_LLM": "env-model"}}
@pytest.mark.parametrize(
("env_var", "section", "field"),
[
("LLM_EXTRA_HEADERS", "llm", "extra_headers"),
("LLM_EXTRA_BODY", "llm", "extra_body"),
("DEDUPE_LLM_EXTRA_HEADERS", "dedupe", "extra_headers"),
("DEDUPE_LLM_EXTRA_BODY", "dedupe", "extra_body"),
],
)
def test_persisted_json_object_setting_reloads_without_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, env_var: str, section: str, field: str
) -> None:
loader.apply_config_override(tmp_path / "cli-config.json")
monkeypatch.setenv(env_var, '{"X-Tenant": "acme"}')
loader.load_settings()
loader.persist_current()
monkeypatch.delenv(env_var)
loader.apply_config_override(tmp_path / "cli-config.json")
settings = loader.load_settings()
assert getattr(getattr(settings, section), field) == {"X-Tenant": "acme"}
def test_json_object_setting_saved_as_object_still_loads(tmp_path: Path) -> None:
target = tmp_path / "cli-config.json"
target.write_text(
json.dumps({"env": {"LLM_EXTRA_BODY": {"service_tier": "flex"}}}), encoding="utf-8"
)
loader.apply_config_override(target)
assert loader.load_settings().llm.extra_body == {"service_tier": "flex"}
def test_invalid_saved_json_object_setting_is_rejected(tmp_path: Path) -> None:
target = tmp_path / "cli-config.json"
target.write_text(json.dumps({"env": {"LLM_EXTRA_BODY": "not json"}}), encoding="utf-8")
loader.apply_config_override(target)
with pytest.raises(ValidationError):
loader.load_settings()
def test_saved_string_setting_is_not_json_decoded(tmp_path: Path) -> None:
target = tmp_path / "cli-config.json"
target.write_text(json.dumps({"env": {"STRIX_LLM": '{"not": "decoded"}'}}), encoding="utf-8")
loader.apply_config_override(target)
assert loader.load_settings().llm.model == '{"not": "decoded"}'
def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "cli-config.json"
-66
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import uuid
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
@@ -136,7 +135,6 @@ def test_cost_callback_estimates_cost_with_provider_prefixed_model() -> None:
with (
patch("strix.report.state.get_global_report_state", return_value=report_state),
patch("strix.report.state.resolve_litellm_model", side_effect=lambda model: model),
patch("litellm.completion_cost", side_effect=fake_completion_cost),
):
litellm_cost_callback(kwargs, response)
@@ -391,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
-39
View File
@@ -148,42 +148,3 @@ def test_config_file_loads_dedupe_model(
assert settings.dedupe.reasoning_effort == "minimal"
# Main model stays independent of the dedupe override.
assert settings.llm.model == "openai/root"
def test_dedicated_dedupe_model_uses_own_body_fields_not_main(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("LLM_EXTRA_BODY", json.dumps({"safety_identifier": "main"}))
loader._cached = None
try:
dedupe = DedupeSettings(
STRIX_DEDUPE_MODEL="deepseek/cheap",
DEDUPE_LLM_EXTRA_BODY={"service_tier": "flex"},
)
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
assert settings.extra_body == {"service_tier": "flex"}
finally:
loader._cached = None
def test_dedicated_dedupe_model_gets_no_main_body_fields_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("LLM_EXTRA_BODY", json.dumps({"safety_identifier": "main"}))
loader._cached = None
try:
dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap")
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
assert settings.extra_body is None
finally:
loader._cached = None
def test_fallback_dedupe_inherits_main_body_fields(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LLM_EXTRA_BODY", json.dumps({"safety_identifier": "main"}))
loader._cached = None
try:
settings = _dedupe_model_settings(DedupeSettings(), "openai/main-model", 300)
assert settings.extra_body == {"safety_identifier": "main"}
finally:
loader._cached = None
-316
View File
@@ -1,316 +0,0 @@
from __future__ import annotations
import asyncio
import io
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from agents.run_config import SandboxRunConfig
from agents.sandbox import SandboxAgent
from agents.sandbox.capabilities import Filesystem, Shell
from agents.sandbox.runtime_session_manager import SandboxRuntimeSessionManager
from strix.runtime import session_manager
from strix.runtime.backends import _BACKENDS, _BIND_MOUNT_BACKENDS, register_backend
from strix.runtime.deferred_session import DeferredSandboxClient, DeferredSandboxSession
if TYPE_CHECKING:
from collections.abc import Iterator
@dataclass
class _RuntimeSettings:
backend: str
@dataclass
class _Settings:
runtime: _RuntimeSettings
@dataclass
class _Endpoint:
host: str = "127.0.0.1"
port: int = 8080
tls: bool = False
@dataclass
class _ExecResult:
exit_code: int = 0
stdout: bytes = b""
stderr: bytes = b""
def ok(self) -> bool:
return self.exit_code == 0
class _State:
def __init__(self) -> None:
self.manifest: Any = None
self.session_id = "00000000-0000-4000-8000-000000000001"
def model_copy(self, *, update: dict[str, Any]) -> _State:
copy = _State()
copy.manifest = update.get("manifest", self.manifest)
return copy
class _Session:
def __init__(self) -> None:
self.state = _State()
self.execs: list[tuple[str, ...]] = []
self.writes: list[tuple[Path, bytes]] = []
self.limits: list[Any] = []
def _set_concurrency_limits(self, limits: Any) -> None:
self.limits.append(limits)
def _set_archive_limits(self, limits: Any) -> None:
self.limits.append(limits)
async def running(self) -> bool:
return True
async def resolve_exposed_port(self, _port: int) -> _Endpoint:
return _Endpoint()
async def exec(self, *argv: str, timeout: float | None = None, **_kw: Any) -> _ExecResult:
del timeout
self.execs.append(argv)
return _ExecResult()
async def write(self, path: Path, data: io.IOBase, **_kw: Any) -> None:
self.writes.append((path, data.read()))
class _Client:
def __init__(self) -> None:
self.deleted: list[Any] = []
async def delete(self, session: Any) -> None:
self.deleted.append(session)
async def _no_caido(*_args: Any, **_kwargs: Any) -> None:
return None
class _Backend:
def __init__(self, *, fail: bool = False) -> None:
self.gate = asyncio.Event()
self.calls = 0
self.fail = fail
self.client = _Client()
self.session = _Session()
async def __call__(self, **_kwargs: Any) -> tuple[Any, Any]:
self.calls += 1
await self.gate.wait()
if self.fail:
raise RuntimeError("bring-up exploded")
return self.client, self.session
@pytest.fixture
def backend(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Iterator[_Backend]:
name = f"deferred-{request.node.name}"
fake = _Backend(fail="fail" in request.node.name)
register_backend(name, fake, supports_bind_mounts=True)
monkeypatch.setattr(session_manager, "bootstrap_caido", _no_caido)
monkeypatch.setattr(
session_manager, "load_settings", lambda: _Settings(runtime=_RuntimeSettings(backend=name))
)
yield fake
_BACKENDS.pop(name, None)
_BIND_MOUNT_BACKENDS.discard(name)
async def _settle() -> None:
for _ in range(10):
await asyncio.sleep(0)
async def _create(scan_id: str, **kwargs: Any) -> dict[str, Any]:
return await session_manager.create_or_reuse(
scan_id, image="img", local_sources=[], defer=True, **kwargs
)
@pytest.mark.asyncio
async def test_deferred_bundle_returns_before_the_backend_finishes(backend: _Backend) -> None:
scan_id = "deferred-returns"
try:
bundle = await asyncio.wait_for(_create(scan_id), timeout=1)
session = bundle["session"]
assert isinstance(session, DeferredSandboxSession)
assert isinstance(bundle["client"], DeferredSandboxClient)
assert session.pending
await _settle()
assert backend.calls == 1
assert await session.running()
assert session.state.manifest.environment.value["PYTHONUNBUFFERED"] == "1"
again = await session_manager.create_or_reuse(scan_id, image="img", local_sources=[])
assert again is bundle
assert backend.calls == 1
finally:
await session_manager.cleanup(scan_id)
@pytest.mark.asyncio
async def test_first_io_resolves_the_sandbox_then_delegates(backend: _Backend) -> None:
scan_id = "deferred-io"
try:
bundle = await _create(scan_id)
session = bundle["session"]
session._set_concurrency_limits("limits")
exec_task = asyncio.create_task(session.exec("echo", "hi"))
await asyncio.sleep(0.05)
assert not exec_task.done()
backend.gate.set()
result = await asyncio.wait_for(exec_task, timeout=1)
assert result.ok()
assert backend.session.execs == [("echo", "hi")]
assert not session.pending
assert backend.session.limits == ["limits"]
assert backend.session.state.manifest is session.state.manifest
await session.write(Path("/workspace/x"), io.BytesIO(b"data"))
assert backend.session.writes == [(Path("/workspace/x"), b"data")]
finally:
await session_manager.cleanup(scan_id)
assert backend.client.deleted == [backend.session]
@pytest.mark.asyncio
async def test_failed_bring_up_surfaces_on_first_io(backend: _Backend) -> None:
scan_id = "deferred-fail"
try:
bundle = await _create(scan_id)
session = bundle["session"]
backend.gate.set()
with pytest.raises(RuntimeError, match="bring-up exploded"):
await asyncio.wait_for(session.exec("true"), timeout=1)
assert not await session.running()
with pytest.raises(RuntimeError, match="bring-up exploded"):
await bundle["caido_client"].get()
finally:
await session_manager.cleanup(scan_id)
assert backend.client.deleted == []
@pytest.mark.asyncio
async def test_cleanup_while_pending_cancels_the_bring_up(backend: _Backend) -> None:
scan_id = "deferred-cancel"
bundle = await _create(scan_id)
session = bundle["session"]
await asyncio.wait_for(session_manager.cleanup(scan_id), timeout=1)
assert session.bring_up.cancelled()
assert backend.client.deleted == []
assert session_manager._SESSION_CACHE.get(scan_id) is None
backend.gate.set()
await _settle()
assert backend.client.deleted == [backend.session]
@pytest.mark.asyncio
async def test_cleanup_while_pending_ignores_a_failed_late_bring_up(backend: _Backend) -> None:
scan_id = "deferred-cancel-fail"
await _create(scan_id)
await asyncio.wait_for(session_manager.cleanup(scan_id), timeout=1)
backend.gate.set()
await _settle()
assert backend.client.deleted == []
@pytest.mark.asyncio
async def test_extra_files_are_staged_inside_the_deferred_bring_up(backend: _Backend) -> None:
scan_id = "deferred-extra"
try:
bundle = await _create(
scan_id,
extra_files=[{"workspace_path": "/workspace/.strix/notes.md", "content": "hi"}],
)
assert backend.session.writes == []
backend.gate.set()
await asyncio.wait_for(bundle["session"].resolve(), timeout=1)
assert len(backend.session.writes) == 1
assert backend.session.execs
finally:
await session_manager.cleanup(scan_id)
@pytest.mark.asyncio
async def test_awaitable_extra_files_do_not_delay_the_backend_call(backend: _Backend) -> None:
scan_id = "deferred-extra-awaitable"
files: asyncio.Future[list[dict[str, Any]] | None] = asyncio.get_running_loop().create_future()
try:
bundle = await _create(scan_id, extra_files=files)
backend.gate.set()
await _settle()
assert backend.calls == 1
assert backend.session.writes == []
files.set_result([{"workspace_path": "/workspace/.strix/notes.md", "content": "hi"}])
await asyncio.wait_for(bundle["session"].resolve(), timeout=1)
assert len(backend.session.writes) == 1
finally:
await session_manager.cleanup(scan_id)
@pytest.mark.asyncio
async def test_awaitable_extra_files_resolving_to_none_stage_nothing(backend: _Backend) -> None:
scan_id = "deferred-extra-none"
async def _nothing() -> list[dict[str, Any]] | None:
return None
try:
bundle = await _create(scan_id, extra_files=_nothing())
backend.gate.set()
await asyncio.wait_for(bundle["session"].resolve(), timeout=1)
assert backend.session.writes == []
finally:
await session_manager.cleanup(scan_id)
@pytest.mark.asyncio
async def test_default_path_still_blocks_until_the_sandbox_is_up(backend: _Backend) -> None:
scan_id = "deferred-off"
backend.gate.set()
try:
bundle = await session_manager.create_or_reuse(scan_id, image="img", local_sources=[])
assert bundle["session"] is backend.session
assert bundle["client"] is backend.client
finally:
await session_manager.cleanup(scan_id)
assert backend.client.deleted == [backend.session]
@pytest.mark.asyncio
async def test_sdk_session_prep_does_not_wait_for_the_bring_up(backend: _Backend) -> None:
scan_id = "deferred-sdk-prep"
try:
bundle = await _create(scan_id)
agent = SandboxAgent(
name="root", instructions="x", tools=[], capabilities=[Filesystem(), Shell()]
)
manager = SandboxRuntimeSessionManager(
starting_agent=agent,
sandbox_config=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
run_state=None,
)
session = await asyncio.wait_for(
manager.ensure_session(
agent=agent, capabilities=list(agent.capabilities), is_resumed_state=False
),
timeout=1,
)
assert session is bundle["session"]
assert session.pending
backend.gate.set()
await asyncio.wait_for(session.resolve(), timeout=1)
assert not session.pending
finally:
await session_manager.cleanup(scan_id)
+137 -58
View File
@@ -24,6 +24,7 @@ from strix.core.execution import (
notify_parent_on_terminal,
)
from strix.core.sessions import seed_initial_input
from strix.llm.compaction import CompactionNeededError
from strix.tools.agents_graph.tools import agent_finish, stop_agent
from strix.tools.finish.tool import finish_scan
@@ -132,27 +133,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 +1296,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,
@@ -1572,29 +1537,143 @@ async def test_autonomous_nudge_does_not_offer_the_user() -> None:
@pytest.mark.asyncio
async def test_text_only_filter_scrubs_images_then_chains_existing_filter() -> None:
run_config = RunConfig(model="m")
assert execution._with_image_scrub(run_config, {"supports_images": True}) is run_config
seen: list[ModelInputData] = []
def _inner(data: CallModelData[Any]) -> ModelInputData:
seen.append(data.model_data)
return data.model_data
run_config = RunConfig(model="m", call_model_input_filter=_inner)
scrub: Any = execution._with_image_scrub(run_config, {"supports_images": False})
image = {"type": "input_image", "image_url": "data:image/png;base64,aGk="}
output = {"type": "function_call_output", "call_id": "c1", "output": [image]}
async def test_compaction_check_stops_run_after_first_call_when_over_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(execution, "needs_compaction", lambda *_args: True)
guard: Any = execution._with_compaction_check(RunConfig(model="m")).call_model_input_filter
data = CallModelData(
model_data=ModelInputData(input=[cast("Any", output)], instructions="sys"),
agent=MagicMock(),
model_data=ModelInputData(input=[{"role": "user", "content": "hi"}], instructions="sys"),
agent=MagicMock(tools=[]),
context=None,
)
result = await scrub.call_model_input_filter(data)
assert result is seen[0]
assert result.input[0]["output"] == [
{"type": "input_text", "text": execution._TEXT_ONLY_IMAGE_TEXT}
]
assert data.model_data.input[0]["output"] == [image]
assert await guard(data) is data.model_data
with pytest.raises(CompactionNeededError):
await guard(data)
@pytest.mark.asyncio
async def test_compaction_check_chains_an_existing_input_filter(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: list[Any] = []
monkeypatch.setattr(execution, "needs_compaction", lambda *args: seen.append(args[3]))
filtered = ModelInputData(input=[{"role": "user", "content": "scrubbed"}], instructions="sys")
run_config = RunConfig(model="m", call_model_input_filter=lambda _data: filtered)
check: Any = execution._with_compaction_check(run_config).call_model_input_filter
data = CallModelData(
model_data=ModelInputData(input=[], instructions="sys"),
agent=MagicMock(tools=[]),
context=None,
)
assert await check(data) is filtered
assert await check(data) is filtered
assert seen == [filtered.input]
class _BudgetStream:
def __init__(self, exc: BaseException | None) -> None:
self.run_loop_exception = exc
self.new_items: list[Any] = []
async def stream_events(self) -> Any:
for event in _NO_STREAM_EVENTS:
yield event
@pytest.mark.asyncio
async def test_run_cycle_compacts_and_resumes_on_every_budget_stop(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
stops = execution._MAX_COMPACTIONS_PER_CYCLE + 1
streams = [_BudgetStream(CompactionNeededError("m")) for _ in range(stops)]
streams.append(_BudgetStream(None))
runs: list[dict[str, Any]] = []
def _run_streamed(*_args: Any, **kwargs: Any) -> _BudgetStream:
runs.append(kwargs)
return streams[len(runs) - 1]
forced: list[bool] = []
async def _compact(*_args: Any, force: bool) -> bool:
forced.append(force)
return True
monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _run_streamed)
monkeypatch.setattr(execution, "_compact_session", _compact)
monkeypatch.setattr(execution, "needs_compaction", lambda *_args: False)
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
session = SQLiteSession("root", tmp_path / "agents.db")
result = await execution._run_cycle(
MagicMock(),
coordinator,
"root",
input_data="task",
run_config=RunConfig(model="m"),
context={},
max_turns=5,
session=session,
interactive=True,
event_sink=None,
hooks=None,
)
assert cast("Any", result) is streams[-1]
assert len(runs) == stops + 1
assert forced.count(True) == stops
assert all(run["input"] == [] for run in runs[1:])
assert all(run["run_config"].call_model_input_filter is not None for run in runs)
session.close()
@pytest.mark.parametrize(
("compacted", "still_over"),
[(False, False), (True, True)],
ids=["compaction_failed", "still_over_budget"],
)
@pytest.mark.asyncio
async def test_run_cycle_reruns_unchecked_when_compaction_cannot_fit(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch, compacted: bool, still_over: bool
) -> None:
streams = [_BudgetStream(CompactionNeededError("m")), _BudgetStream(None)]
runs: list[dict[str, Any]] = []
def _run_streamed(*_args: Any, **kwargs: Any) -> _BudgetStream:
runs.append(kwargs)
return streams[len(runs) - 1]
async def _compact(*_args: Any, **_kwargs: Any) -> bool:
return compacted
monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _run_streamed)
monkeypatch.setattr(execution, "_compact_session", _compact)
monkeypatch.setattr(execution, "needs_compaction", lambda *_args: still_over)
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
session = SQLiteSession("root", tmp_path / "agents.db")
result = await execution._run_cycle(
MagicMock(),
coordinator,
"root",
input_data="task",
run_config=RunConfig(model="m"),
context={},
max_turns=5,
session=session,
interactive=True,
event_sink=None,
hooks=None,
)
assert cast("Any", result) is streams[-1]
assert len(runs) == 2
assert runs[0]["run_config"].call_model_input_filter is not None
assert runs[1]["run_config"].call_model_input_filter is None
assert runs[1]["input"] == []
session.close()
+9 -80
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
@@ -110,7 +109,6 @@ def _patch_fast_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
async def _run_once(
monkeypatch: pytest.MonkeyPatch,
streams: list[_FakeStream],
session: Any = None,
) -> Any:
_patch_fast_backoff(monkeypatch)
calls = {"n": 0}
@@ -133,7 +131,7 @@ async def _run_once(
run_config=cast("RunConfig", object()),
context={},
max_turns=5,
session=session,
session=None,
interactive=False,
event_sink=None,
hooks=None,
@@ -168,78 +166,9 @@ 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"):
await _run_once(monkeypatch, streams)
@pytest.mark.parametrize(
("status", "message", "expected"),
[
(
404,
"litellm.NotFoundError: NotFoundError: OpenrouterException - "
'{"error":{"message":"No endpoints found that support image input","code":404,'
'"metadata":{"failed_routing_step":"Filter by Image Support"}}}',
True,
),
(400, "Invalid content type. image_url is only supported by certain models.", True),
(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 "
"tokens, but the request requires 1073484 tokens (942412 input including "
"image/vision expansion + 131072 for the completion). Reduce the input length "
"or max_tokens.",
False,
),
(400, "At most 5 image(s) may be provided in one prompt.", False),
(
400,
"Upstream error from Relace: Generation stopped with an incomplete tool call",
False,
),
(500, "No endpoints found that support image input", False),
],
)
def test_is_image_rejection(status: int, message: str, expected: bool) -> None:
exc = APIStatusError(message, response=httpx.Response(status, request=_request()), body=None)
assert execution._is_image_rejection(exc) is expected
async def _run_with_image_session(
monkeypatch: pytest.MonkeyPatch, streams: list[_FakeStream]
) -> tuple[Any, int, int]:
strips = {"n": 0}
async def _fake_strip(_session: Any) -> bool:
strips["n"] += 1
return True
async def _no_compact(*_args: Any, **_kwargs: Any) -> bool:
return False
monkeypatch.setattr(execution, "strip_all_images_from_session", _fake_strip)
monkeypatch.setattr(execution, "_compact_session", _no_compact)
result, attempts, _coordinator = await _run_once(monkeypatch, streams, session=object())
return result, attempts, strips["n"]
@pytest.mark.asyncio
async def test_run_cycle_strips_images_on_image_rejection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
rejection = APIStatusError(
"No endpoints found that support image input",
response=httpx.Response(status_code=404, request=_request()),
body=None,
bad_request = BadRequestError(
"bad", response=httpx.Response(400, request=_request()), body=None
)
streams = [_FakeStream(exc=rejection), _FakeStream()]
result, attempts, strips = await _run_with_image_session(monkeypatch, streams)
assert result is streams[1]
assert attempts == 2
assert strips == 1
streams = [_FakeStream(exc=bad_request), _FakeStream()]
with pytest.raises(BadRequestError):
await _run_once(monkeypatch, streams)
-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:
+2 -54
View File
@@ -310,8 +310,8 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() ->
assert settings.tool_choice == "required"
def test_make_model_settings_enables_parallel_tool_calls_by_default() -> None:
assert make_model_settings("none", model_name="gpt-4o").parallel_tool_calls is True
def test_make_model_settings_disables_parallel_tool_calls_by_default() -> None:
assert make_model_settings("none", model_name="gpt-4o").parallel_tool_calls is False
def test_make_model_settings_omits_parallel_tool_calls_without_tools() -> None:
@@ -361,58 +361,6 @@ def test_make_model_settings_extra_headers_survive_reasoning_resolve() -> None:
assert settings.extra_headers == {"X-Feature-Key": "svc"}
def test_make_model_settings_sets_extra_body() -> None:
settings = make_model_settings(
"none",
model_name="openai/gpt-5",
extra_body={"service_tier": "flex", "safety_identifier": "user-hash"},
)
assert settings.extra_body == {"service_tier": "flex", "safety_identifier": "user-hash"}
def test_make_model_settings_omits_extra_body_when_unset() -> None:
assert make_model_settings("none", model_name="gpt-4o").extra_body is None
def test_make_model_settings_extra_body_survives_reasoning_resolve() -> None:
settings = make_model_settings(
"high",
model_name="openai/o3",
extra_body={"service_tier": "flex"},
)
assert settings.extra_body == {"service_tier": "flex"}
def test_make_model_settings_extra_body_merges_with_max_reasoning_field() -> None:
settings = make_model_settings(
"max",
model_name="deepseek/deepseek-v4-flash",
extra_body={"safety_identifier": "user-hash"},
)
assert settings.extra_body == {"safety_identifier": "user-hash", "reasoning_effort": "max"}
def test_make_model_settings_max_reasoning_overrides_extra_body_reasoning_field() -> None:
settings = make_model_settings(
"max",
model_name="deepseek/deepseek-v4-flash",
extra_body={"reasoning_effort": "low"},
)
assert settings.extra_body == {"reasoning_effort": "max"}
def test_make_model_settings_does_not_mutate_caller_extra_body() -> None:
extra_body = {"safety_identifier": "user-hash"}
make_model_settings("max", model_name="deepseek/deepseek-v4-flash", extra_body=extra_body)
assert extra_body == {"safety_identifier": "user-hash"}
def test_make_model_settings_timeout_survives_reasoning_resolve() -> None:
# Reasoning is resolved via ModelSettings.resolve(); the timeout in extra_args
# must not be dropped when a reasoning override is merged in.
-97
View File
@@ -1,97 +0,0 @@
"""Tests for LLM_EXTRA_BODY: extra fields merged into every LLM request body."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
import httpx
import litellm
import openai
import pytest
from agents.extensions.models.litellm_model import LitellmModel
from agents.models.interface import ModelTracing
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.models.openai_responses import OpenAIResponsesModel
from strix.config import loader
from strix.config.loader import load_settings
from strix.core.inputs import make_model_settings
if TYPE_CHECKING:
from collections.abc import Iterator
from agents.models.interface import Model
_EXTRA_BODY = {"service_tier": "flex", "safety_identifier": "user-hash"}
@pytest.fixture(autouse=True)
def _reset(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.delenv("LLM_EXTRA_BODY", raising=False)
monkeypatch.setattr(loader, "_cached", None)
monkeypatch.setattr(loader, "_override", None)
yield
def test_extra_body_parsed_from_json_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LLM_EXTRA_BODY", json.dumps({"service_tier": "flex", "n": 1}))
assert load_settings().llm.extra_body == {"service_tier": "flex", "n": 1}
async def _send(model: Model) -> None:
await model.get_response(
system_instructions=None,
input="Reply with just 'OK'.",
model_settings=make_model_settings(None, model_name="gpt-5", extra_body=_EXTRA_BODY),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
def _capturing_client(captured: dict[str, Any]) -> openai.AsyncOpenAI:
def handler(request: httpx.Request) -> httpx.Response:
captured.update(json.loads(request.content))
return httpx.Response(400, json={"error": {"message": "captured"}})
return openai.AsyncOpenAI(
api_key="test",
base_url="https://gateway.example/v1",
max_retries=0,
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("model_cls", [OpenAIChatCompletionsModel, OpenAIResponsesModel])
async def test_extra_body_reaches_native_openai_request_body(model_cls: type) -> None:
captured: dict[str, Any] = {}
with pytest.raises(openai.BadRequestError):
await _send(model_cls(model="gpt-5", openai_client=_capturing_client(captured)))
assert {k: captured.get(k) for k in _EXTRA_BODY} == _EXTRA_BODY
@pytest.mark.asyncio
async def test_extra_body_passed_to_litellm(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
async def fake_acompletion(**kwargs: Any) -> Any:
captured.update(kwargs)
raise RuntimeError("captured")
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
with pytest.raises(RuntimeError, match="captured"):
await _send(LitellmModel(model="openai/gpt-5", base_url="https://gateway.example/v1"))
assert captured["extra_body"] == _EXTRA_BODY
-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 #
# --------------------------------------------------------------------------- #
+6 -272
View File
@@ -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, ImageContent, TextContent
from mcp.types import CallToolResult, TextContent
from mcp.types import Tool as MCPTool
from pydantic import ValidationError
@@ -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()
@@ -1058,51 +884,6 @@ 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]"},
]
transformed: list[Any] = []
registry.add(
name="pro",
server=_ImageServer("pro", [_mcp_tool("shot")]),
purpose=None,
tool_count=1,
result_transform=lambda _label, result: transformed.append(result),
)
await call_mcp.on_invoke_tool(ctx, json.dumps({"connection": "pro", "tool": "shot"}))
assert [block["type"] for block in transformed[0]["content"]] == ["text", "text"]
# --- generic MCP tools are the only MCP surface every agent gets -------------
@@ -1186,53 +967,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
@@ -16,7 +16,6 @@ from strix.config.models import (
_NonStreamingModel,
_TurnGuardModel,
configure_sdk_model_defaults,
model_supports_images,
request_timeout_extra_args,
resolve_api_type,
routes_through_litellm,
@@ -203,11 +202,3 @@ 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
-50
View File
@@ -60,7 +60,6 @@ def test_preflight_request_fails_after_its_own_timeout_with_a_clear_message() ->
model, # type: ignore[arg-type]
model_name="openai/gpt-4o",
extra_headers=None,
extra_body=None,
timeout=1,
api_base_setting="DEDUPE_LLM_API_BASE",
)
@@ -131,52 +130,3 @@ def test_warm_up_checks_the_dedupe_model_with_its_own_headers_and_the_preflight_
"DEDUPE_LLM_API_BASE",
]
assert calls[1][0] is dedupe_model
def test_warm_up_checks_each_model_with_its_own_body_fields(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("STRIX_LLM", "openai/gpt-4o")
monkeypatch.setenv("LLM_API_KEY", "sk-test")
monkeypatch.setenv("LLM_EXTRA_BODY", '{"safety_identifier": "main"}')
monkeypatch.setenv("STRIX_DEDUPE_MODEL", "anthropic/claude-sonnet-4-5")
monkeypatch.setenv("DEDUPE_LLM_EXTRA_BODY", '{"service_tier": "flex"}')
_fresh_settings(monkeypatch, tmp_path)
calls: list[dict[str, Any]] = []
async def record(_model: Any, **kwargs: Any) -> None:
calls.append(kwargs)
monkeypatch.setattr(scan_setup, "preflight_request", record)
monkeypatch.setattr(cli_main, "preflight_request", record)
monkeypatch.setattr("strix.config.models.configure_sdk_model_defaults", lambda _settings: None)
monkeypatch.setattr("strix.report.dedupe.resolve_dedupe_model", lambda _dedupe, _name: object())
asyncio.run(cli_main.warm_up_llm())
assert [kwargs["extra_body"] for kwargs in calls] == [
{"safety_identifier": "main"},
{"service_tier": "flex"},
]
def test_preflight_request_sends_the_extra_body() -> None:
seen: list[Any] = []
class _Recorder:
async def get_response(self, *, model_settings: Any, **_: Any) -> None:
seen.append(model_settings.extra_body)
asyncio.run(
preflight_request(
_Recorder(), # type: ignore[arg-type]
model_name="openai/gpt-4o",
extra_headers=None,
extra_body={"safety_identifier": "main"},
timeout=5,
api_base_setting="LLM_API_BASE",
)
)
assert seen == [{"safety_identifier": "main"}]
-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"
-41
View File
@@ -1,41 +0,0 @@
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
+1 -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",
@@ -663,6 +504,7 @@ async def test_dependency_report_records_reachability(report_state: ReportState)
== "src/render.ts:14 calls `_.template()`."
)
assert "**Usage analysis:**" in report["evidence"]
assert "not a proof of exploitability or of safety" in report["evidence"]
# The level must never influence the rating — that comes from the contextual
# breakdown, or from advisory_cvss when no breakdown applies.
assert report["severity"] == "high"
-4
View File
@@ -263,11 +263,7 @@ def test_narrow_terminals_never_wrap_a_row() -> None:
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX key reader")
def test_posix_key_reader_handles_multibyte_and_arrows() -> None:
tty = pytest.importorskip("tty")
master, slave = os.openpty()
# Without this the pty echoes the writes into an output queue nobody reads,
# and read_raw_key's TCSADRAIN restore blocks forever on macOS.
tty.setcbreak(slave)
try:
with os.fdopen(slave, "r+b", buffering=0) as stream:
os.write(master, "\u00e9".encode())
-1
View File
@@ -28,7 +28,6 @@ def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
timeout=300,
prompt_cache=True,
extra_headers=None,
extra_body=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
-126
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:
@@ -44,7 +32,6 @@ def _settings() -> Any:
timeout=300,
prompt_cache=True,
extra_headers=None,
extra_body=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
@@ -75,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
@@ -174,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]]] = []
@@ -205,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
View File
@@ -42,7 +42,6 @@ async def test_persistent_rate_limit_stops_gracefully(
timeout=300,
prompt_cache=True,
extra_headers=None,
extra_body=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
+1 -43
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
@@ -14,7 +13,6 @@ from typing import Any
import httpx
import pytest
from agents import ModelSettings
from agents.tool_context import ToolContext
from openai import RateLimitError
import strix.tools.mcp as mcp_pkg
@@ -26,13 +24,7 @@ from strix.core import runner
from strix.core.agents import AgentCoordinator
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:
@@ -64,7 +56,6 @@ def _patch_engine_scaffold(
timeout=300,
prompt_cache=True,
extra_headers=None,
extra_body=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
@@ -91,11 +82,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] = {}
@@ -234,12 +220,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}
]
@@ -306,29 +287,6 @@ 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
@pytest.mark.asyncio
async def test_text_only_load_skill_drops_screenshot_guidance() -> None:
ctx = ToolContext(
context={"supports_images": False},
tool_name="load_skill",
tool_call_id="call-1",
tool_arguments="{}",
)
out = await load_skill.on_invoke_tool(ctx, '{"skills": ["agent_browser"]}')
assert "view_image" not in out
assert "text-only model" in out
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(
-1
View File
@@ -52,7 +52,6 @@ def _settings() -> Any:
timeout=300,
prompt_cache=True,
extra_headers=None,
extra_body=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
-11
View File
@@ -70,17 +70,6 @@ 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,
+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
-300
View File
@@ -1,300 +0,0 @@
"""Tests for keeping replayed tool-call arguments valid JSON.
A model that emits a tool call with malformed ``arguments`` fails that one
call, but the raw string is recorded in the session. Strict OpenAI-compatible
servers validate every assistant tool call in the request, so each later turn
is rejected and the agent can never recover. A gateway that validates
arguments the way those servers do proves both the failure and the fix.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING, Any
import pytest
from agents import Agent, Runner, function_tool
from agents.models.interface import Model, ModelProvider
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.run import RunConfig
from openai import AsyncOpenAI, BadRequestError
from openai.types.responses import ResponseFunctionToolCall
from strix.config.models import _NonStreamingModel, _TurnGuardModel
from strix.config.tool_call_arguments import (
MALFORMED_ARGUMENTS_KEY,
_loads_strict,
describe_malformed_arguments,
repair_arguments,
repair_history_arguments,
repair_input,
)
if TYPE_CHECKING:
from collections.abc import Iterator
TRUNCATED = '{"n": 1'
OVER_NESTED = '{"a":' + "[" * 100_000
def _tool_call_completion(arguments: str) -> dict[str, Any]:
return {
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 0,
"model": "gw-model",
"choices": [
{
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "do_thing", "arguments": arguments},
}
],
},
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
}
def _text_completion(text: str) -> dict[str, Any]:
return {
"id": "chatcmpl-2",
"object": "chat.completion",
"created": 0,
"model": "gw-model",
"choices": [
{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": text}}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
_REQUESTS: list[list[dict[str, Any]]] = []
def _assistant_arguments(messages: list[dict[str, Any]]) -> list[str]:
return [
str(call["function"]["arguments"])
for message in messages
for call in message.get("tool_calls") or []
]
def _tool_results(messages: list[dict[str, Any]]) -> list[str]:
return [str(m.get("content")) for m in messages if m.get("role") == "tool"]
class _StrictHandler(BaseHTTPRequestHandler):
"""Gateway that rejects malformed assistant tool-call arguments, like vLLM/SGLang do."""
def log_message(self, *args: Any) -> None:
pass
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
messages = body.get("messages", [])
_REQUESTS.append(messages)
for arguments in _assistant_arguments(messages):
try:
json.loads(arguments)
except ValueError:
self._respond(
400,
{
"object": "error",
"message": "Assistant tool call function.arguments must be valid JSON.",
"type": "BadRequest",
"param": None,
"code": 400,
},
)
return
if len(_REQUESTS) == 1:
self._respond(200, _tool_call_completion(TRUNCATED))
else:
self._respond(200, _text_completion("all done"))
def _respond(self, status: int, payload: dict[str, Any]) -> None:
encoded = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
@pytest.fixture
def strict_gateway() -> Iterator[str]:
_REQUESTS.clear()
server = HTTPServer(("127.0.0.1", 0), _StrictHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}/v1"
finally:
server.shutdown()
server.server_close()
def _model(base_url: str) -> Model:
client = AsyncOpenAI(api_key="tok", base_url=base_url, max_retries=0)
return _NonStreamingModel(OpenAIChatCompletionsModel(model="gw-model", openai_client=client))
async def _run_agent(base_url: str, *, wrap: bool) -> Any:
@function_tool
def do_thing(n: int) -> str:
return f"did {n}"
class _Provider(ModelProvider):
def get_model(self, model_name: str | None) -> Model: # noqa: ARG002
model = _model(base_url)
return _TurnGuardModel(model) if wrap else model
agent = Agent(name="t", instructions="use the tool", tools=[do_thing], model="gw-model")
result = Runner.run_streamed(
agent, input="please", run_config=RunConfig(model_provider=_Provider())
)
async for _ in result.stream_events():
pass
return result
@pytest.mark.asyncio
async def test_malformed_arguments_poison_every_later_turn_without_the_wrapper(
strict_gateway: str,
) -> None:
# Repro: the model truncates a tool call's arguments once. The tool fails
# that call gracefully, but the next request replays the raw string and
# the provider rejects the whole conversation from then on.
with pytest.raises(BadRequestError, match="must be valid JSON"):
await _run_agent(strict_gateway, wrap=False)
assert _assistant_arguments(_REQUESTS[-1]) == [TRUNCATED]
@pytest.mark.asyncio
async def test_malformed_arguments_are_replayed_as_valid_json(strict_gateway: str) -> None:
result = await _run_agent(strict_gateway, wrap=True)
assert result.final_output == "all done"
(arguments,) = _assistant_arguments(_REQUESTS[-1])
assert json.loads(arguments) == {MALFORMED_ARGUMENTS_KEY: TRUNCATED}
(tool_result,) = _tool_results(_REQUESTS[-1])
assert "JSON" in tool_result
@pytest.mark.parametrize(
("arguments", "expected"),
[
('{"cmd": "ls', json.dumps({MALFORMED_ARGUMENTS_KEY: '{"cmd": "ls'})),
("[1, 2]", json.dumps({MALFORMED_ARGUMENTS_KEY: "[1, 2]"})),
("", "{}"),
(" ", "{}"),
(None, "{}"),
],
)
def test_repair_arguments_rewrites_anything_but_a_json_object(
arguments: str | None, expected: str
) -> None:
assert repair_arguments(arguments) == expected
@pytest.mark.parametrize(
"arguments", ["{}", '{"cmd": "ls -la"}', '{"nested": {"a": [1]}}', '{"n": "NaN"}']
)
def test_repair_arguments_leaves_json_objects_alone(arguments: str) -> None:
assert repair_arguments(arguments) is None
@pytest.mark.parametrize("arguments", ['{"n": NaN}', '{"n": Infinity}', '{"n": -Infinity}'])
def test_repair_arguments_wraps_non_standard_json_constants(arguments: str) -> None:
repaired = repair_arguments(arguments)
assert repaired is not None
assert _loads_strict(repaired) == {MALFORMED_ARGUMENTS_KEY: arguments}
def test_repair_arguments_wraps_over_nested_json() -> None:
repaired = repair_arguments(OVER_NESTED)
assert repaired is not None
assert _loads_strict(repaired) == {MALFORMED_ARGUMENTS_KEY: OVER_NESTED}
def test_history_repair_rewrites_only_malformed_calls() -> None:
items = [
{"role": "user", "content": "go"},
{"type": "function_call", "call_id": "a", "name": "x", "arguments": '{"cmd": "ls'},
{"type": "function_call_output", "call_id": "a", "output": "invalid JSON"},
{"type": "function_call", "call_id": "b", "name": "y", "arguments": '{"ok": true}'},
ResponseFunctionToolCall(call_id="c", name="z", arguments="", type="function_call"),
]
rebuilt, changed = repair_history_arguments(items)
assert changed
assert rebuilt[0] is items[0]
assert json.loads(rebuilt[1]["arguments"]) == {MALFORMED_ARGUMENTS_KEY: '{"cmd": "ls'}
assert rebuilt[1]["call_id"] == "a"
assert rebuilt[2] is items[2]
assert rebuilt[3] is items[3]
assert isinstance(rebuilt[4], ResponseFunctionToolCall)
assert rebuilt[4].arguments == "{}"
assert isinstance(items[1], dict)
assert items[1]["arguments"] == '{"cmd": "ls'
def test_repair_input_returns_same_object_when_nothing_changes() -> None:
items = [{"type": "function_call", "call_id": "a", "name": "x", "arguments": "{}"}]
assert repair_input(items) is items
assert repair_input("plain prompt") == "plain prompt"
def test_repair_input_wraps_over_nested_function_call_arguments() -> None:
items = [
{
"type": "function_call",
"call_id": "a",
"name": "x",
"arguments": OVER_NESTED,
}
]
repaired = repair_input(items)
assert isinstance(repaired, list)
assert _loads_strict(repaired[0]["arguments"]) == {MALFORMED_ARGUMENTS_KEY: OVER_NESTED}
@pytest.mark.parametrize(
"arguments",
['{"cmd": "ls -la', "[1, 2]", "null", "not json", '{"n": NaN}', OVER_NESTED],
)
def test_describe_malformed_arguments_tells_the_model_to_reissue(arguments: str) -> None:
message = describe_malformed_arguments("exec_command", arguments)
assert message is not None
assert message.startswith("exec_command: the tool call was not executed")
assert "Re-issue the call" in message
@pytest.mark.parametrize("arguments", ["", " ", "{}", '{"cmd": "ls"}'])
def test_describe_malformed_arguments_accepts_objects_and_empty_input(arguments: str) -> None:
assert describe_malformed_arguments("exec_command", arguments) is None