mirror of
https://github.com/usestrix/strix.git
synced 2026-11-09 18:08:59 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f30c47e0e | ||
|
|
887ec1727f | ||
|
|
8b0ab9ddb2 | ||
|
|
9c44bba118 | ||
|
|
b88bfd27b6 | ||
|
|
0a6818f243 | ||
|
|
52ba4ab266 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -675,7 +675,6 @@ def build_strix_agent(
|
||||
extra_tools: Sequence[Tool] | None = None,
|
||||
instructions_override: str | None = None,
|
||||
supports_images: bool = True,
|
||||
finish_tool: Tool = finish_scan,
|
||||
) -> SandboxAgent[Any]:
|
||||
"""Build a SandboxAgent for either root or child use.
|
||||
|
||||
@@ -688,7 +687,6 @@ def build_strix_agent(
|
||||
registered via ``register_agent_tools``.
|
||||
instructions_override: Use this verbatim as the system prompt instead
|
||||
of rendering the built-in scan prompt.
|
||||
finish_tool: The tool that ends the run, given to the root agent.
|
||||
"""
|
||||
if instructions_override is not None:
|
||||
instructions = instructions_override
|
||||
@@ -709,7 +707,7 @@ def build_strix_agent(
|
||||
# Yielding to the user is only meaningful when one is attached.
|
||||
agent_tools.append(wait_for_user)
|
||||
if is_root:
|
||||
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_tool]
|
||||
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan]
|
||||
else:
|
||||
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
||||
_ensure_unique_tool_names(tools)
|
||||
|
||||
@@ -21,7 +21,7 @@ MCP CONNECTIONS (available this run):
|
||||
{% if system_prompt_context.mcp_connections %}
|
||||
- Connected this run (search one to find relevant tools):
|
||||
{% for connection in system_prompt_context.mcp_connections %}
|
||||
- {{ connection.name }}{% if connection.state == "unavailable" %} (unavailable right now){% elif connection.tool_count is not none %} ({{ connection.tool_count }} tools){% endif %}{% if connection.purpose %}: {{ connection.purpose }}{% endif %}
|
||||
- {{ connection.name }} ({{ connection.tool_count }} tools){% if connection.purpose %}: {{ connection.purpose }}{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
- Reach for a connection whenever the target itself cannot give you information a connection could: its database schema and access policies, real deployment or infrastructure configuration, known issues or prior findings, or server logs. In those cases call list_mcps early to see what is available, and prefer a connection's authoritative data over inferring from the target's responses. Do not wait to be told a connection exists.
|
||||
|
||||
+16
-96
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -263,8 +262,6 @@ class _TurnGuardModel(Model):
|
||||
not covered by the request timeout, which resets on any byte (keepalives
|
||||
included). ``LLM_STREAM_IDLE_TIMEOUT`` bounds the gap between events so the
|
||||
turn fails instead of hanging, and the existing retry path replays it.
|
||||
``LLM_STREAM_FIRST_EVENT_TIMEOUT`` and ``LLM_STREAM_TOTAL_TIMEOUT`` bound
|
||||
the first event and the whole stream.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -272,15 +269,11 @@ class _TurnGuardModel(Model):
|
||||
inner: Model,
|
||||
*,
|
||||
max_tool_calls_per_turn: int = 0,
|
||||
stream_idle_timeout: float | None = None,
|
||||
first_event_timeout: float | None = None,
|
||||
total_timeout: float | None = None,
|
||||
stream_idle_timeout: float = 0.0,
|
||||
) -> None:
|
||||
self._inner = inner
|
||||
self._max_tool_calls_per_turn = max_tool_calls_per_turn
|
||||
self._stream_idle_timeout = stream_idle_timeout
|
||||
self._first_event_timeout = first_event_timeout
|
||||
self._total_timeout = total_timeout
|
||||
|
||||
def _limiter(self) -> TurnToolCallLimiter:
|
||||
return TurnToolCallLimiter(self._max_tool_calls_per_turn)
|
||||
@@ -367,12 +360,7 @@ class _TurnGuardModel(Model):
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
async for event in _with_timeouts(
|
||||
stream,
|
||||
idle=self._stream_idle_timeout,
|
||||
first_event=self._first_event_timeout,
|
||||
total=self._total_timeout,
|
||||
):
|
||||
async for event in _with_idle_timeout(stream, self._stream_idle_timeout):
|
||||
guarded = _guard_event(event, rewriter, limiter)
|
||||
if guarded is not None:
|
||||
yield guarded
|
||||
@@ -406,68 +394,28 @@ async def _aclose(stream: AsyncIterator[TResponseStreamEvent]) -> None:
|
||||
await stream.aclose()
|
||||
|
||||
|
||||
async def _with_timeouts(
|
||||
stream: AsyncIterator[TResponseStreamEvent],
|
||||
*,
|
||||
idle: float | None = None,
|
||||
first_event: float | None = None,
|
||||
total: float | None = None,
|
||||
async def _with_idle_timeout(
|
||||
stream: AsyncIterator[TResponseStreamEvent], timeout: float
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
"""Bound the first event, the gap between events and the whole stream; None is no bound."""
|
||||
if timeout <= 0:
|
||||
async for event in stream:
|
||||
yield event
|
||||
return
|
||||
|
||||
iterator = stream.__aiter__()
|
||||
started = time.monotonic()
|
||||
while True:
|
||||
limits: list[tuple[float, float, str]] = []
|
||||
# Wait until the soonest limit expires: first-event (else idle) until the
|
||||
# first event arrives, then idle; plus whatever is left of the total.
|
||||
if first_event is not None:
|
||||
limits.append((first_event, first_event, "stream_first_event_timeout"))
|
||||
elif idle is not None:
|
||||
limits.append((idle, idle, "stream_idle_timeout"))
|
||||
if total is not None:
|
||||
left = max(0.0, started + total - time.monotonic())
|
||||
limits.append((left, total, "stream_total_timeout"))
|
||||
try:
|
||||
if limits:
|
||||
event = await _next_event(iterator, *min(limits))
|
||||
else:
|
||||
event = await iterator.__anext__()
|
||||
event = await asyncio.wait_for(iterator.__anext__(), timeout)
|
||||
except StopAsyncIteration:
|
||||
return
|
||||
except TimeoutError as exc:
|
||||
except TimeoutError:
|
||||
await _aclose(stream)
|
||||
logger.warning("%s; abandoning the turn", exc)
|
||||
raise
|
||||
first_event = None
|
||||
message = f"model stream produced no event for {timeout:.0f}s"
|
||||
logger.warning("%s; abandoning the turn", message)
|
||||
raise TimeoutError(message) from None
|
||||
yield event
|
||||
|
||||
|
||||
async def _next_event(
|
||||
iterator: AsyncIterator[TResponseStreamEvent], wait: float, limit: float, reason: str
|
||||
) -> TResponseStreamEvent:
|
||||
# asyncio.timeout() cancels without a message, so the request log could not
|
||||
# tell this from a shutdown; this is asyncio.timeout() with a message.
|
||||
task = asyncio.current_task()
|
||||
assert task is not None
|
||||
cancelling = task.cancelling()
|
||||
expired = False
|
||||
|
||||
def expire() -> None:
|
||||
nonlocal expired
|
||||
expired = True
|
||||
task.cancel(msg=f"{request_log.CANCEL_REASON_PREFIX}{reason}")
|
||||
|
||||
handle = asyncio.get_running_loop().call_later(wait, expire)
|
||||
try:
|
||||
return await iterator.__anext__()
|
||||
except BaseException as exc:
|
||||
if expired and task.uncancel() <= cancelling and isinstance(exc, asyncio.CancelledError):
|
||||
raise TimeoutError(f"model stream hit {reason} ({limit:.0f}s)") from None
|
||||
raise
|
||||
finally:
|
||||
handle.cancel()
|
||||
|
||||
|
||||
def _guard_event(
|
||||
event: TResponseStreamEvent, rewriter: TurnCallIdRewriter, limiter: TurnToolCallLimiter
|
||||
) -> TResponseStreamEvent | None:
|
||||
@@ -602,10 +550,7 @@ class StrixProvider(MultiProvider):
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
llm = load_settings().llm
|
||||
slug = codex.subscription_model(model_name)
|
||||
# The settings use 0 for no bound.
|
||||
idle_timeout = float(llm.stream_idle_timeout) or None
|
||||
first_event_timeout = float(llm.stream_first_event_timeout) or None
|
||||
total_timeout = float(llm.stream_total_timeout) or None
|
||||
idle_timeout = float(llm.stream_idle_timeout)
|
||||
if slug:
|
||||
# The ChatGPT subscription backend is always streamed; it has no
|
||||
# non-streaming mode to fall back to, so LLM_DISABLE_STREAMING
|
||||
@@ -646,13 +591,11 @@ class StrixProvider(MultiProvider):
|
||||
# The wrapper emits its single event only once the whole request
|
||||
# is done, so an idle gap is meaningless here; the request
|
||||
# timeout bounds it instead.
|
||||
idle_timeout = first_event_timeout = total_timeout = None
|
||||
idle_timeout = 0.0
|
||||
return _TurnGuardModel(
|
||||
model,
|
||||
max_tool_calls_per_turn=llm.max_tool_calls_per_turn,
|
||||
stream_idle_timeout=idle_timeout,
|
||||
first_event_timeout=first_event_timeout,
|
||||
total_timeout=total_timeout,
|
||||
)
|
||||
|
||||
|
||||
@@ -802,11 +745,6 @@ def _install_openrouter_stream_cost_capture() -> None:
|
||||
|
||||
class _StrixOpenRouterStreamingHandler(OpenRouterChatCompletionStreamingHandler):
|
||||
def chunk_parser(self, chunk: dict[str, Any]) -> Any:
|
||||
# Before parsing: LiteLLM raises on an error chunk and drops its
|
||||
# top-level ``provider``.
|
||||
request_log.record_upstream_provider(
|
||||
chunk.get("provider"), _openrouter_error_type(chunk.get("error"))
|
||||
)
|
||||
stream = super().chunk_parser(chunk)
|
||||
usage = chunk.get("usage")
|
||||
response_id = chunk.get("id") or getattr(stream, "id", None)
|
||||
@@ -825,22 +763,12 @@ def _install_openrouter_stream_cost_capture() -> None:
|
||||
json_mode=json_mode,
|
||||
)
|
||||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: Any) -> Any:
|
||||
# A non-2xx reply names the provider in ``error.metadata``.
|
||||
with contextlib.suppress(Exception):
|
||||
error = json.loads(error_message)["error"]
|
||||
request_log.record_upstream_provider(
|
||||
error["metadata"].get("provider_name"), _openrouter_error_type(error)
|
||||
)
|
||||
return super().get_error_class(error_message, status_code, headers)
|
||||
|
||||
def transform_response(self, *args: Any, **kwargs: Any) -> Any:
|
||||
# Non-streamed replies (LLM_DISABLE_STREAMING) skip the chunk parser.
|
||||
response = super().transform_response(*args, **kwargs)
|
||||
raw_response = kwargs.get("raw_response", args[1] if len(args) > 1 else None)
|
||||
with contextlib.suppress(Exception):
|
||||
body = raw_response.json() # type: ignore[union-attr]
|
||||
request_log.record_upstream_provider(body.get("provider"))
|
||||
if body.get("usage"):
|
||||
record_openrouter_provider(body.get("provider"), body["usage"])
|
||||
return response
|
||||
@@ -861,14 +789,6 @@ def _install_openrouter_stream_cost_capture() -> None:
|
||||
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
|
||||
|
||||
|
||||
def _openrouter_error_type(error: object) -> object:
|
||||
if isinstance(error, dict):
|
||||
metadata = error.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
return metadata.get("error_type")
|
||||
return None
|
||||
|
||||
|
||||
OPENROUTER_ATTRIBUTION_HEADERS = {
|
||||
"HTTP-Referer": "https://strix.ai",
|
||||
"X-Title": "Strix",
|
||||
|
||||
@@ -79,10 +79,6 @@ class LlmSettings(BaseSettings):
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
preflight_timeout: int = Field(default=30, ge=1, alias="LLM_PREFLIGHT_TIMEOUT")
|
||||
stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT")
|
||||
stream_first_event_timeout: int = Field(
|
||||
default=120, ge=0, alias="LLM_STREAM_FIRST_EVENT_TIMEOUT"
|
||||
)
|
||||
stream_total_timeout: int = Field(default=600, ge=0, alias="LLM_STREAM_TOTAL_TIMEOUT")
|
||||
max_tool_calls_per_turn: int = Field(
|
||||
default=32,
|
||||
ge=0,
|
||||
|
||||
@@ -71,7 +71,6 @@ class AgentCoordinator:
|
||||
self._lock = asyncio.Lock()
|
||||
self._snapshot_path: Path | None = None
|
||||
self.is_shutting_down = False
|
||||
self.root_finish_tool = "finish_scan"
|
||||
self._budget_stopped = False
|
||||
self._reserve_stopped = False
|
||||
self._budget_paused = False
|
||||
|
||||
+9
-10
@@ -66,8 +66,7 @@ _IMAGE_REJECTION = re.compile(
|
||||
r"|is not a multimodal model|at most 0 image\(s\)" # vLLM
|
||||
r"|does not support image input" # LiteLLM's Fireworks check
|
||||
r"|doesn't support the image field" # Bedrock Converse
|
||||
r"|unknown variant `image_url`" # DeepSeek, e.g. via Vercel AI Gateway
|
||||
r"|'[^']*image[^']*' functionality not supported", # Vercel AI Gateway (AI SDK), unverified
|
||||
r"|unknown variant `image_url`", # DeepSeek, e.g. via Vercel AI Gateway
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MAX_COMPACTIONS_PER_CYCLE = 2
|
||||
@@ -184,7 +183,9 @@ def _is_transient_model_error(exc: BaseException) -> bool:
|
||||
return True
|
||||
code = _model_error_status_code(exc)
|
||||
if code is not None:
|
||||
return code >= 400 and code not in (401, 402, 403, 404)
|
||||
import litellm
|
||||
|
||||
return bool(litellm._should_retry(code))
|
||||
return isinstance(exc, APIError)
|
||||
|
||||
|
||||
@@ -302,7 +303,7 @@ async def _run_agent_loop(
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
if reserve_stopped and start_parked and interactive and context.get("parent_id") is None:
|
||||
await coordinator.send(agent_id, _reserve_notice(coordinator.root_finish_tool))
|
||||
await coordinator.send(agent_id, _reserve_notice())
|
||||
|
||||
if not (start_parked and interactive):
|
||||
with contextlib.suppress(BudgetPausedError):
|
||||
@@ -642,7 +643,6 @@ async def _run_until_lifecycle(
|
||||
input_data = await _append_tool_required_message(
|
||||
session=session,
|
||||
context=context,
|
||||
root_finish_tool=coordinator.root_finish_tool,
|
||||
attempt=recoveries,
|
||||
limit=recovery_limit,
|
||||
interactive=interactive,
|
||||
@@ -989,9 +989,8 @@ async def _append_tool_required_message(
|
||||
limit: int,
|
||||
interactive: bool,
|
||||
silent_yield: bool = False,
|
||||
root_finish_tool: str = "finish_scan",
|
||||
) -> list[dict[str, str]]:
|
||||
finish_tool = root_finish_tool if context.get("parent_id") is None else "agent_finish"
|
||||
finish_tool = "finish_scan" if context.get("parent_id") is None else "agent_finish"
|
||||
if silent_yield:
|
||||
message = (
|
||||
"You called wait_for_user without having written anything to the user since "
|
||||
@@ -1109,7 +1108,7 @@ async def notify_parent_on_terminal(
|
||||
)
|
||||
|
||||
|
||||
def _reserve_notice(finish_tool: str) -> dict[str, Any]:
|
||||
def _reserve_notice() -> dict[str, Any]:
|
||||
return {
|
||||
"from": "system",
|
||||
"type": "budget_reserve_stop",
|
||||
@@ -1119,7 +1118,7 @@ def _reserve_notice(finish_tool: str) -> dict[str, Any]:
|
||||
"sub-agent is being force-stopped as soon as its in-flight turn completes, and "
|
||||
"none will send a completion report. Their confirmed vulnerabilities are "
|
||||
"already filed as they were found. Do not wait on any sub-agents and do not "
|
||||
f"spawn new ones — wrap up now and call {finish_tool}."
|
||||
"spawn new ones — wrap up now and call finish_scan."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1128,7 +1127,7 @@ async def _notify_root_on_budget_reserve(coordinator: AgentCoordinator) -> None:
|
||||
root = await coordinator.claim_reserve_notification()
|
||||
if root is None:
|
||||
return
|
||||
await coordinator.send(root, _reserve_notice(coordinator.root_finish_tool))
|
||||
await coordinator.send(root, _reserve_notice())
|
||||
|
||||
|
||||
async def _notify_parent_on_exit(
|
||||
|
||||
+5
-8
@@ -104,16 +104,16 @@ _ROOT_DIRECTIVES: tuple[str, ...] = (
|
||||
(
|
||||
"As the root agent, begin planning your wind-down of the whole scan: avoid "
|
||||
"starting large new lines of investigation, and keep your required objectives on "
|
||||
"track so you can call {finish_tool} comfortably before the limit."
|
||||
"track so you can call finish_scan comfortably before the limit."
|
||||
),
|
||||
(
|
||||
"As the root agent, prioritize wrapping up the whole scan now: stop opening new "
|
||||
"lines of investigation, close out only what is essential, and move toward calling "
|
||||
"{finish_tool} to compile and deliver the final report."
|
||||
"finish_scan to compile and deliver the final report."
|
||||
),
|
||||
(
|
||||
"As the root agent, STOP all other work on the whole scan and finish immediately: "
|
||||
"secure your findings and call {finish_tool} now — anything left unfinished when the "
|
||||
"secure your findings and call finish_scan now — anything left unfinished when the "
|
||||
"limit is hit is discarded."
|
||||
),
|
||||
)
|
||||
@@ -138,11 +138,8 @@ _SUBAGENT_DIRECTIVES: tuple[str, ...] = (
|
||||
|
||||
def _wrapup_directive(context: RunContextWrapper[dict[str, Any]], stage: int) -> str:
|
||||
is_root = context.context.get("parent_id") is None
|
||||
if not is_root:
|
||||
return _SUBAGENT_DIRECTIVES[stage]
|
||||
coordinator = coordinator_from_context(context.context)
|
||||
finish_tool = coordinator.root_finish_tool if coordinator is not None else "finish_scan"
|
||||
return _ROOT_DIRECTIVES[stage].format(finish_tool=finish_tool)
|
||||
directives = _ROOT_DIRECTIVES if is_root else _SUBAGENT_DIRECTIVES
|
||||
return directives[stage]
|
||||
|
||||
|
||||
def _urgency(stage: int) -> str:
|
||||
|
||||
+10
-29
@@ -49,7 +49,6 @@ from strix.report.state import get_global_report_state
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry import set_scan_phase
|
||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
||||
from strix.tools.finish.tool import finish_scan
|
||||
from strix.tools.output_store import (
|
||||
WORKSPACE_SPILL_DIR,
|
||||
configure_spill_writer,
|
||||
@@ -59,7 +58,6 @@ from strix.tools.output_store import (
|
||||
if TYPE_CHECKING:
|
||||
from agents.memory import SQLiteSession
|
||||
from agents.result import RunResultBase
|
||||
from agents.tool import Tool
|
||||
|
||||
from strix.runtime.status import StatusSink
|
||||
from strix.tools.mcp import (
|
||||
@@ -70,8 +68,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MCP_PROMPT_WARMUP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
StreamEventSink = Callable[[str, Any], None]
|
||||
|
||||
# Receives the run's MCP connection roster as a list of non-secret status dicts
|
||||
@@ -97,19 +93,6 @@ def _mcp_roster_payload(registry: McpRegistry) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _mcp_prompt_roster(registry: McpRegistry) -> list[dict[str, Any]]:
|
||||
"""The MCP roster rendered in agent prompts, with only verified counts."""
|
||||
return [
|
||||
{
|
||||
"name": summary.name,
|
||||
"purpose": summary.purpose,
|
||||
"tool_count": summary.tool_count if summary.state == "catalog_ready" else None,
|
||||
"state": summary.state,
|
||||
}
|
||||
for summary in registry.summaries()
|
||||
]
|
||||
|
||||
|
||||
def _record_mcp_connections(connection_names: list[str]) -> None:
|
||||
"""Record which MCP servers this run configured, for the interfaces.
|
||||
|
||||
@@ -217,7 +200,6 @@ async def run_strix_scan(
|
||||
status_sink: StatusSink | None = None,
|
||||
mcp_connection_requests: list[McpConnectionRequest] | None = None,
|
||||
mcp_status_sink: McpStatusSink | None = None,
|
||||
root_finish_tool: Tool = finish_scan,
|
||||
) -> RunResultBase | None:
|
||||
"""Run or resume one Strix scan against a sandbox.
|
||||
|
||||
@@ -240,7 +222,6 @@ async def run_strix_scan(
|
||||
command-line default) it reads ``~/.strix/mcp-servers.json`` itself. Either
|
||||
way the engine does the connecting, so the caller passes inert configs plus
|
||||
metadata and never live sessions.
|
||||
``root_finish_tool`` is the tool the root agent ends the run with.
|
||||
"""
|
||||
|
||||
def report(phase: str) -> None:
|
||||
@@ -297,7 +278,6 @@ async def run_strix_scan(
|
||||
coordinator = AgentCoordinator()
|
||||
coordinator.set_snapshot_path(agents_path)
|
||||
coordinator.set_budget_policy(budget_policy)
|
||||
coordinator.root_finish_tool = root_finish_tool.name
|
||||
|
||||
from strix.tools.coverage.tools import hydrate_coverage_from_disk
|
||||
from strix.tools.notes.tools import hydrate_notes_from_disk
|
||||
@@ -458,13 +438,19 @@ async def run_strix_scan(
|
||||
_record_mcp_connections(mcp_registry.names())
|
||||
report(
|
||||
f"MCP: configured {len(mcp_registry)} connection(s); "
|
||||
"connecting and listing tools"
|
||||
"warming them in the background"
|
||||
)
|
||||
scope_context["mcp_available"] = True
|
||||
scope_context["mcp_connections"] = _mcp_prompt_roster(mcp_registry)
|
||||
scope_context["mcp_connections"] = [
|
||||
{
|
||||
"name": summary.name,
|
||||
"purpose": summary.purpose,
|
||||
"tool_count": summary.tool_count,
|
||||
}
|
||||
for summary in mcp_registry.summaries()
|
||||
]
|
||||
|
||||
def _emit_mcp_status() -> None:
|
||||
scope_context["mcp_connections"] = _mcp_prompt_roster(mcp_registry)
|
||||
roster = _mcp_roster_payload(mcp_registry)
|
||||
_persist_mcp_status(roster)
|
||||
if mcp_status_sink is not None:
|
||||
@@ -475,11 +461,7 @@ async def run_strix_scan(
|
||||
|
||||
mcp_registry.set_status_sink(_emit_mcp_status)
|
||||
_emit_mcp_status()
|
||||
warmup_task = mcp_registry.start_warmup(max_concurrency=6)
|
||||
await asyncio.wait(
|
||||
{warmup_task},
|
||||
timeout=_MCP_PROMPT_WARMUP_TIMEOUT_SECONDS,
|
||||
)
|
||||
mcp_registry.start_warmup(max_concurrency=6)
|
||||
except Exception:
|
||||
logger.exception("Failed to configure user MCP servers; continuing without them")
|
||||
|
||||
@@ -508,7 +490,6 @@ async def run_strix_scan(
|
||||
system_prompt_context=root_context,
|
||||
instructions_override=root_instructions,
|
||||
supports_images=supports_images,
|
||||
finish_tool=root_finish_tool,
|
||||
)
|
||||
|
||||
if not is_resume:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -390,67 +389,3 @@ def test_openrouter_request_carries_agent_session_id() -> None:
|
||||
assert body()["session_id"] == session_id
|
||||
finally:
|
||||
request_log.reset_call_context(token)
|
||||
|
||||
|
||||
def _openrouter_config() -> Any:
|
||||
_install_openrouter_stream_cost_capture()
|
||||
config = ProviderConfigManager.get_provider_chat_config(
|
||||
model="z-ai/glm-5.3", provider=LlmProviders.OPENROUTER
|
||||
)
|
||||
assert config is not None
|
||||
return config
|
||||
|
||||
|
||||
def test_openrouter_records_upstream_of_stream_and_mid_stream_error() -> None:
|
||||
handler = _openrouter_config().get_model_response_iterator(
|
||||
streaming_response=iter([]), sync_stream=True
|
||||
)
|
||||
reply = request_log.HttpReply()
|
||||
token = request_log._http_reply.set(reply)
|
||||
try:
|
||||
handler.chunk_parser(
|
||||
{
|
||||
"id": "gen-a",
|
||||
"created": 1,
|
||||
"model": "z-ai/glm-5.3",
|
||||
"provider": "Relace",
|
||||
"choices": [{"index": 0, "delta": {"content": "x"}}],
|
||||
}
|
||||
)
|
||||
assert reply.upstream_provider == "Relace"
|
||||
assert reply.upstream_error_type is None
|
||||
with pytest.raises(Exception, match="incomplete tool call"):
|
||||
handler.chunk_parser(
|
||||
{
|
||||
"id": "gen-a",
|
||||
"provider": "Relace",
|
||||
"error": {
|
||||
"code": 400,
|
||||
"message": "Generation stopped with an incomplete tool call.",
|
||||
"metadata": {"error_type": "invalid_request"},
|
||||
},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
request_log._http_reply.reset(token)
|
||||
assert reply.upstream_provider == "Relace"
|
||||
assert reply.upstream_error_type == "invalid_request"
|
||||
|
||||
|
||||
def test_openrouter_records_upstream_of_pre_stream_error() -> None:
|
||||
body = {
|
||||
"error": {
|
||||
"message": "Provider returned error",
|
||||
"code": 400,
|
||||
"metadata": {"raw": "...", "provider_name": "InferenceNet"},
|
||||
}
|
||||
}
|
||||
reply = request_log.HttpReply()
|
||||
token = request_log._http_reply.set(reply)
|
||||
try:
|
||||
_openrouter_config().get_error_class(json.dumps(body), 400, {})
|
||||
_openrouter_config().get_error_class("<html>502 Bad Gateway</html>", 502, {})
|
||||
finally:
|
||||
request_log._http_reply.reset(token)
|
||||
assert reply.upstream_provider == "InferenceNet"
|
||||
assert reply.upstream_error_type is None
|
||||
@@ -132,27 +132,6 @@ async def test_reserve_stop_notifies_root_once(monkeypatch: pytest.MonkeyPatch)
|
||||
assert "finish_scan" in str(message["content"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserve_notice_names_the_root_finish_tool(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
coordinator = AgentCoordinator()
|
||||
coordinator.root_finish_tool = "finish_pr_review"
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
await coordinator.register("child-a", "recon", parent_id="root")
|
||||
|
||||
sent: list[dict[str, Any]] = []
|
||||
|
||||
async def _record(_target_agent_id: str, message: dict[str, Any]) -> bool:
|
||||
sent.append(message)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(coordinator, "send", _record)
|
||||
|
||||
await _notify_root_on_budget_reserve(coordinator)
|
||||
|
||||
assert "call finish_pr_review" in str(sent[0]["content"])
|
||||
assert "finish_scan" not in str(sent[0]["content"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_reserve_claims_yield_single_root() -> None:
|
||||
coordinator = AgentCoordinator()
|
||||
@@ -1316,21 +1295,6 @@ async def test_interactive_nudge_offers_waiting_without_repeating() -> None:
|
||||
assert "do not repeat it" in items[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nudge_names_the_root_finish_tool() -> None:
|
||||
items = await execution._append_tool_required_message(
|
||||
session=None,
|
||||
context={"parent_id": None},
|
||||
attempt=1,
|
||||
limit=3,
|
||||
interactive=False,
|
||||
root_finish_tool="finish_pr_review",
|
||||
)
|
||||
|
||||
assert "call finish_pr_review" in items[0]["content"]
|
||||
assert "finish_scan" not in items[0]["content"]
|
||||
|
||||
|
||||
def _cycle_with_items(
|
||||
coordinator: AgentCoordinator,
|
||||
agent_id: str,
|
||||
|
||||
@@ -79,13 +79,12 @@ def test_content_guardrail_is_not_retried() -> None:
|
||||
assert execution._is_transient_model_error(guardrail) is False
|
||||
|
||||
|
||||
def test_client_errors_are_transient() -> None:
|
||||
def test_client_errors_are_not_transient() -> None:
|
||||
bad_request = BadRequestError(
|
||||
"bad", response=httpx.Response(400, request=_request()), body=None
|
||||
)
|
||||
assert execution._is_transient_model_error(bad_request) is True
|
||||
for status in (401, 402, 403, 404):
|
||||
assert execution._is_transient_model_error(_status_error(status)) is False
|
||||
assert execution._is_transient_model_error(bad_request) is False
|
||||
assert execution._is_transient_model_error(_status_error(404)) is False
|
||||
assert execution._is_transient_model_error(ValueError("nope")) is False
|
||||
|
||||
|
||||
@@ -168,8 +167,11 @@ async def test_run_cycle_gives_up_after_max_retries(
|
||||
async def test_run_cycle_does_not_retry_permanent_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
streams = [_FakeStream(exc=ValueError("nope")), _FakeStream()]
|
||||
with pytest.raises(ValueError, match="nope"):
|
||||
bad_request = BadRequestError(
|
||||
"bad", response=httpx.Response(400, request=_request()), body=None
|
||||
)
|
||||
streams = [_FakeStream(exc=bad_request), _FakeStream()]
|
||||
with pytest.raises(BadRequestError):
|
||||
await _run_once(monkeypatch, streams)
|
||||
|
||||
|
||||
@@ -187,7 +189,6 @@ async def test_run_cycle_does_not_retry_permanent_error(
|
||||
(400, "my-model is not a multimodal model", True),
|
||||
(400, "This model doesn't support the image field for user messages.", True),
|
||||
(422, "messages[3]: unknown variant `image_url`, expected `text`", True),
|
||||
(400, "'Image URLs in user messages' functionality not supported.", True),
|
||||
(
|
||||
400,
|
||||
"OpenRouterException: Message: This model's maximum context length is 1048576 "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -394,30 +394,9 @@ def test_log_line_sink_formats_without_content(caplog: pytest.LogCaptureFixture)
|
||||
assert "request_id=req_line01" in line
|
||||
assert "status=400" in line
|
||||
assert "provider=anthropic" in line
|
||||
assert "upstream=- upstream_error=-" in line
|
||||
assert "SECRET PROMPT" not in line
|
||||
|
||||
|
||||
def test_log_line_names_upstream_recorded_during_attempt(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
exc = _anthropic_error(400, ANTHROPIC_BLOCK_BODY, {})
|
||||
token = request_log._http_reply.set(request_log.HttpReply())
|
||||
try:
|
||||
request_log.record_upstream_provider("Together", "provider_unavailable")
|
||||
event = request_log.event_from_litellm(
|
||||
_anthropic_kwargs(exc), None, None, None, outcome="error"
|
||||
)
|
||||
finally:
|
||||
request_log._http_reply.reset(token)
|
||||
assert event.details is not None
|
||||
assert event.details["upstream_provider"] == "Together"
|
||||
with caplog.at_level(logging.DEBUG, logger="strix.llm.request_log"):
|
||||
request_log._log_line_sink(event)
|
||||
line = caplog.records[-1].getMessage()
|
||||
assert "upstream=Together upstream_error=provider_unavailable" in line
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# sizes, timing, finish reason, free-form headers and details #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
+5
-226
@@ -43,7 +43,6 @@ from strix.tools.mcp import (
|
||||
resolve_mcp_call,
|
||||
search_mcp_tools,
|
||||
)
|
||||
from strix.tools.mcp import agent_tools as mcp_agent_tools
|
||||
from strix.tools.mcp import client as mcp_client
|
||||
from strix.tools.mcp import registry as mcp_registry_mod
|
||||
from strix.tools.mcp import session as mcp_session_mod
|
||||
@@ -422,18 +421,8 @@ def test_registry_summaries() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_mcps_returns_connections_with_ids_and_descriptions() -> None:
|
||||
registry = McpRegistry()
|
||||
registry.add(
|
||||
name="fs",
|
||||
server=FakeMCPServer("fs", [_mcp_tool("read_file"), _mcp_tool("write_file")]),
|
||||
purpose="local files",
|
||||
tool_count=2,
|
||||
)
|
||||
registry.add(
|
||||
name="db",
|
||||
server=FakeMCPServer("db", [_mcp_tool("query")]),
|
||||
purpose=None,
|
||||
tool_count=1,
|
||||
)
|
||||
registry.add(name="fs", server=FakeMCPServer("fs", []), purpose="local files", tool_count=2)
|
||||
registry.add(name="db", server=FakeMCPServer("db", []), purpose=None, tool_count=1)
|
||||
|
||||
out = await list_mcps.on_invoke_tool(_ctx(registry), "{}")
|
||||
|
||||
@@ -448,7 +437,7 @@ async def test_list_mcps_returns_connections_with_ids_and_descriptions() -> None
|
||||
"description": "local files",
|
||||
"tool_count": 2,
|
||||
"dead": False,
|
||||
"state": "catalog_ready",
|
||||
"state": "connected",
|
||||
},
|
||||
{
|
||||
"id": "db",
|
||||
@@ -456,143 +445,10 @@ async def test_list_mcps_returns_connections_with_ids_and_descriptions() -> None
|
||||
"description": None,
|
||||
"tool_count": 1,
|
||||
"dead": False,
|
||||
"state": "catalog_ready",
|
||||
"state": "connected",
|
||||
},
|
||||
]
|
||||
}
|
||||
await registry.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_mcps_loads_catalog_for_unwarmed_connection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
server = FakeMCPServer("fs", [_mcp_tool("read_file"), _mcp_tool("write_file")])
|
||||
monkeypatch.setattr(
|
||||
mcp_client,
|
||||
"_build_server",
|
||||
lambda _config: _built_server(server),
|
||||
)
|
||||
registry = McpRegistry()
|
||||
entry = registry.register(McpConnectionRequest(config=_config("fs", None)))
|
||||
|
||||
out = await list_mcps.on_invoke_tool(_ctx(registry), "{}")
|
||||
|
||||
assert entry.state == "catalog_ready"
|
||||
assert entry.tool_count == 2
|
||||
assert out["connections"][0]["tool_count"] == 2
|
||||
assert out["connections"][0]["state"] == "catalog_ready"
|
||||
await registry.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("slow_stage", ["connect", "catalog"])
|
||||
@pytest.mark.parametrize("warmup", [False, True])
|
||||
async def test_list_mcps_returns_healthy_connections_while_slow_catalog_keeps_loading(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
slow_stage: str,
|
||||
warmup: bool,
|
||||
) -> None:
|
||||
release = asyncio.Event()
|
||||
started = asyncio.Event()
|
||||
catalog_calls = 0
|
||||
|
||||
class _SlowServer(FakeMCPServer):
|
||||
async def connect(self) -> None:
|
||||
if slow_stage == "connect":
|
||||
started.set()
|
||||
await release.wait()
|
||||
|
||||
async def list_tools(self, run_context: Any = None, agent: Any = None) -> list[MCPTool]:
|
||||
nonlocal catalog_calls
|
||||
catalog_calls += 1
|
||||
if slow_stage == "catalog":
|
||||
started.set()
|
||||
await release.wait()
|
||||
return await super().list_tools(run_context, agent)
|
||||
|
||||
servers = {
|
||||
"healthy": FakeMCPServer("healthy", [_mcp_tool("read_file")]),
|
||||
"slow": _SlowServer("slow", [_mcp_tool("query")]),
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
mcp_client, "_build_server", lambda config: _built_server(servers[config.name])
|
||||
)
|
||||
monkeypatch.setattr(mcp_agent_tools, "_MCP_DISCOVERY_WAIT_SECONDS", 0.01)
|
||||
registry = McpRegistry()
|
||||
for name in servers:
|
||||
registry.register(McpConnectionRequest(config=_config(name, None)))
|
||||
if warmup:
|
||||
registry.start_warmup()
|
||||
entry = registry.get("slow")
|
||||
assert entry is not None
|
||||
try:
|
||||
out = await asyncio.wait_for(list_mcps.on_invoke_tool(_ctx(registry), "{}"), timeout=1)
|
||||
assert started.is_set()
|
||||
assert out["connections"][0]["tool_count"] == 1
|
||||
assert out["connections"][0]["state"] == "catalog_ready"
|
||||
assert out["connections"][1]["tool_count"] is None
|
||||
assert out["connections"][1]["state"] in {"connecting", "catalog_loading"}
|
||||
assert entry._catalog_task is not None
|
||||
assert not entry._catalog_task.done()
|
||||
|
||||
repeated = await asyncio.wait_for(list_mcps.on_invoke_tool(_ctx(registry), "{}"), timeout=1)
|
||||
assert repeated == out
|
||||
release.set()
|
||||
await asyncio.wait_for(entry.ensure_catalog(), timeout=1)
|
||||
finished = await list_mcps.on_invoke_tool(_ctx(registry), "{}")
|
||||
assert finished["connections"][1]["tool_count"] == 1
|
||||
assert finished["connections"][1]["state"] == "catalog_ready"
|
||||
assert catalog_calls == 1
|
||||
finally:
|
||||
release.set()
|
||||
await registry.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("fail_after_deadline", [False, True])
|
||||
async def test_list_mcps_retains_and_cleans_up_background_catalog_tasks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fail_after_deadline: bool,
|
||||
) -> None:
|
||||
release = asyncio.Event()
|
||||
finished = asyncio.Event()
|
||||
loop_errors: list[dict[str, Any]] = []
|
||||
|
||||
async def load_catalog() -> list[MCPTool]:
|
||||
await release.wait()
|
||||
raise RuntimeError("catalog unavailable")
|
||||
|
||||
registry = McpRegistry()
|
||||
entry = registry.add(
|
||||
name="blocked", server=FakeMCPServer("blocked", []), purpose=None, tool_count=0
|
||||
)
|
||||
assert entry.session is not None
|
||||
monkeypatch.setattr(entry.session, "list_tools", load_catalog)
|
||||
monkeypatch.setattr(mcp_agent_tools, "_MCP_DISCOVERY_WAIT_SECONDS", 0.01)
|
||||
loop = asyncio.get_running_loop()
|
||||
previous_handler = loop.get_exception_handler()
|
||||
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
|
||||
try:
|
||||
out = await asyncio.wait_for(list_mcps.on_invoke_tool(_ctx(registry), "{}"), timeout=1)
|
||||
assert out["connections"][0]["tool_count"] is None
|
||||
assert entry._catalog_task is not None
|
||||
assert not entry._catalog_task.done()
|
||||
if fail_after_deadline:
|
||||
entry._catalog_task.add_done_callback(lambda _task: finished.set())
|
||||
release.set()
|
||||
await asyncio.wait_for(finished.wait(), timeout=1)
|
||||
assert entry.state == "connected"
|
||||
# Drop the finished task without awaiting it; its error must already be consumed.
|
||||
entry._catalog_task = None
|
||||
else:
|
||||
task = entry._catalog_task
|
||||
await registry.close()
|
||||
assert task.cancelled()
|
||||
assert loop_errors == []
|
||||
finally:
|
||||
await registry.close()
|
||||
loop.set_exception_handler(previous_handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -877,37 +733,7 @@ async def test_registry_warmup_bounds_parallel_connections(
|
||||
release.set()
|
||||
await warmup
|
||||
|
||||
assert [summary.state for summary in registry.summaries()] == ["catalog_ready"] * 4
|
||||
assert [summary.tool_count for summary in registry.summaries()] == [1] * 4
|
||||
await registry.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_warmup_swallows_catalog_listing_errors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FailingListServer(FakeMCPServer):
|
||||
async def list_tools(
|
||||
self,
|
||||
run_context: Any = None,
|
||||
agent: Any = None,
|
||||
) -> list[MCPTool]:
|
||||
raise RuntimeError("catalog unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_client,
|
||||
"_build_server",
|
||||
lambda _config: _built_server(_FailingListServer("db", [])),
|
||||
)
|
||||
registry = McpRegistry()
|
||||
entry = registry.register(McpConnectionRequest(config=_config("db", None)))
|
||||
|
||||
warmup = registry.start_warmup()
|
||||
await warmup
|
||||
|
||||
assert warmup.done()
|
||||
assert entry.state == "connected"
|
||||
assert entry.tool_count == 0
|
||||
assert [summary.state for summary in registry.summaries()] == ["connected"] * 4
|
||||
await registry.close()
|
||||
|
||||
|
||||
@@ -1186,53 +1012,6 @@ def test_prompt_renders_named_connection_inventory() -> None:
|
||||
assert "read the app's schema" in prompt
|
||||
|
||||
|
||||
def test_prompt_omits_unloaded_tool_count() -> None:
|
||||
prompt = render_system_prompt(
|
||||
system_prompt_context={
|
||||
"mcp_available": True,
|
||||
"mcp_connections": [
|
||||
{"name": "pending_conn", "purpose": None, "tool_count": None, "state": "connected"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
connection_line = next(line for line in prompt.splitlines() if "pending_conn" in line)
|
||||
assert "(0 tools)" not in connection_line
|
||||
assert "tools)" not in connection_line
|
||||
|
||||
|
||||
def test_prompt_renders_loaded_tool_count() -> None:
|
||||
prompt = render_system_prompt(
|
||||
system_prompt_context={
|
||||
"mcp_available": True,
|
||||
"mcp_connections": [
|
||||
{"name": "ready_conn", "purpose": None, "tool_count": 8, "state": "catalog_ready"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert "ready_conn (8 tools)" in prompt
|
||||
|
||||
|
||||
def test_prompt_renders_unavailable_connection_without_tool_count() -> None:
|
||||
prompt = render_system_prompt(
|
||||
system_prompt_context={
|
||||
"mcp_available": True,
|
||||
"mcp_connections": [
|
||||
{
|
||||
"name": "unavailable_conn",
|
||||
"purpose": None,
|
||||
"tool_count": None,
|
||||
"state": "unavailable",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert "unavailable_conn (unavailable right now)" in prompt
|
||||
assert "unavailable_conn (0 tools)" not in prompt
|
||||
|
||||
|
||||
def test_prompt_inventory_is_gated_on_availability() -> None:
|
||||
"""The block is gated on ``mcp_available``; an ``mcp_connections`` payload
|
||||
without it renders nothing, so a stale or spoofed list cannot leak names."""
|
||||
|
||||
@@ -8,7 +8,6 @@ routine does the connecting.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
@@ -18,21 +17,10 @@ from agents import ModelSettings
|
||||
import strix.tools.mcp as mcp_pkg
|
||||
import strix.tools.notes.tools as notes_tools
|
||||
import strix.tools.todo.tools as todo_tools
|
||||
from strix.agents import factory
|
||||
from strix.core import runner
|
||||
from strix.core.agents import AgentCoordinator
|
||||
from strix.runtime import session_manager
|
||||
from strix.tools.mcp import McpConnectionConfig, McpConnectionRequest
|
||||
from strix.tools.mcp import client as mcp_client
|
||||
|
||||
|
||||
_test_mcp_client = importlib.import_module("tests.test_mcp_client")
|
||||
FakeMCPServer: Any = _test_mcp_client.FakeMCPServer
|
||||
_mcp_tool: Any = _test_mcp_client._mcp_tool
|
||||
|
||||
|
||||
def _built_server(server: Any) -> Any:
|
||||
return mcp_client.BuiltMcpServer(server, None)
|
||||
|
||||
|
||||
def _settings() -> Any:
|
||||
@@ -74,11 +62,6 @@ def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
|
||||
monkeypatch.setattr(runner, "build_strix_agent", lambda **_k: object())
|
||||
monkeypatch.setattr(runner, "make_child_factory", lambda **_k: lambda **_kk: object())
|
||||
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
|
||||
monkeypatch.setattr(
|
||||
mcp_client,
|
||||
"_build_server",
|
||||
lambda config: _built_server(FakeMCPServer(config.name, [])),
|
||||
)
|
||||
|
||||
async def _run_agent_loop(**_kwargs: Any) -> None:
|
||||
return None
|
||||
@@ -173,11 +156,6 @@ async def test_roster_is_persisted_even_without_a_status_sink(
|
||||
"load_user_mcp_configs",
|
||||
lambda: [McpConnectionConfig(name="local_fs", transport="stdio", command="npx")],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_client,
|
||||
"_build_server",
|
||||
lambda _config: _built_server(FakeMCPServer("local_fs", [])),
|
||||
)
|
||||
|
||||
persisted: list[list[dict[str, Any]]] = []
|
||||
|
||||
@@ -204,106 +182,3 @@ async def test_roster_is_persisted_even_without_a_status_sink(
|
||||
"state": "configured",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warmup_listing_failure_does_not_interrupt_scan(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
_wire_runner(monkeypatch, tmp_path)
|
||||
|
||||
server = FakeMCPServer("db", [])
|
||||
|
||||
async def _failing_list_tools(
|
||||
run_context: Any = None,
|
||||
agent: Any = None,
|
||||
) -> list[Any]:
|
||||
del run_context, agent
|
||||
raise RuntimeError("catalog unavailable")
|
||||
|
||||
server.list_tools = _failing_list_tools
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_client,
|
||||
"_build_server",
|
||||
lambda _config: _built_server(server),
|
||||
)
|
||||
root_builds: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(runner, "build_strix_agent", lambda **kwargs: root_builds.append(kwargs))
|
||||
|
||||
await runner.run_strix_scan(
|
||||
scan_config={"targets": [], "scan_mode": "deep"},
|
||||
scan_id="scan-mcp-list-failure",
|
||||
image="img",
|
||||
coordinator=AgentCoordinator(),
|
||||
mcp_connection_requests=[
|
||||
McpConnectionRequest(
|
||||
config=McpConnectionConfig(name="db", url="https://mcp.example.com")
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert len(root_builds) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warmed_tool_counts_reach_root_and_child_agent_builds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
_wire_runner(monkeypatch, tmp_path)
|
||||
server = FakeMCPServer("db", [_mcp_tool(f"tool_{index}") for index in range(3)])
|
||||
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: _built_server(server))
|
||||
|
||||
root_builds: list[dict[str, Any]] = []
|
||||
|
||||
def _build_root(**kwargs: Any) -> object:
|
||||
root_builds.append(kwargs)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"build_strix_agent",
|
||||
_build_root,
|
||||
)
|
||||
child_builds: list[dict[str, Any]] = []
|
||||
|
||||
def _build_child(**kwargs: Any) -> object:
|
||||
child_builds.append(kwargs)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
factory,
|
||||
"build_strix_agent",
|
||||
_build_child,
|
||||
)
|
||||
real_make_child_factory = factory.make_child_factory
|
||||
child_factory_capture: dict[str, Any] = {}
|
||||
|
||||
def _make_child_factory(**kwargs: Any) -> Any:
|
||||
child_factory_capture["context"] = kwargs["system_prompt_context"]
|
||||
child_factory_capture["factory"] = real_make_child_factory(**kwargs)
|
||||
return child_factory_capture["factory"]
|
||||
|
||||
monkeypatch.setattr(runner, "make_child_factory", _make_child_factory)
|
||||
request = McpConnectionRequest(
|
||||
config=McpConnectionConfig(name="db", url="https://mcp.example.com", notes="database")
|
||||
)
|
||||
|
||||
await runner.run_strix_scan(
|
||||
scan_config={"targets": [], "scan_mode": "deep"},
|
||||
scan_id="scan-mcp-prompt-counts",
|
||||
image="img",
|
||||
coordinator=AgentCoordinator(),
|
||||
mcp_connection_requests=[request],
|
||||
)
|
||||
|
||||
root_context = root_builds[0]["system_prompt_context"]
|
||||
assert root_context["mcp_connections"][0]["tool_count"] == 3
|
||||
assert child_factory_capture["context"] is root_context
|
||||
assert child_factory_capture["context"]["mcp_connections"][0]["tool_count"] == 3
|
||||
|
||||
child_factory_capture["factory"](name="child", skills=[])
|
||||
assert child_builds[0]["system_prompt_context"] is child_factory_capture["context"]
|
||||
assert child_builds[0]["system_prompt_context"]["mcp_connections"][0]["tool_count"] == 3
|
||||
@@ -6,7 +6,6 @@ flow through to the root agent's ``build_strix_agent`` call.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import types
|
||||
from typing import Any
|
||||
@@ -28,11 +27,6 @@ from strix.core.inputs import make_model_settings
|
||||
from strix.runtime import session_manager
|
||||
from strix.tools.load_skill.tool import load_skill
|
||||
from strix.tools.mcp import BearerAuth, McpConnectionConfig, McpConnectionRequest
|
||||
from strix.tools.mcp import client as mcp_client
|
||||
|
||||
|
||||
_test_mcp_client = importlib.import_module("tests.test_mcp_client")
|
||||
FakeMCPServer: Any = _test_mcp_client.FakeMCPServer
|
||||
|
||||
|
||||
def _make_rate_limit_error() -> RateLimitError:
|
||||
@@ -90,11 +84,6 @@ def _patch_engine_scaffold(
|
||||
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
|
||||
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: scope_context)
|
||||
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: ModelSettings())
|
||||
monkeypatch.setattr(
|
||||
mcp_client,
|
||||
"_build_server",
|
||||
lambda config: mcp_client.BuiltMcpServer(FakeMCPServer(config.name, []), None),
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
@@ -233,12 +222,7 @@ async def test_mcp_available_flag_set_when_a_connection_attaches(
|
||||
assert kwargs["system_prompt_context"]["mcp_available"] is True
|
||||
# The named inventory names each connected server for the prompt.
|
||||
assert kwargs["system_prompt_context"]["mcp_connections"] == [
|
||||
{
|
||||
"name": "fs",
|
||||
"purpose": "local files",
|
||||
"tool_count": 0,
|
||||
"state": "catalog_ready",
|
||||
}
|
||||
{"name": "fs", "purpose": "local files", "tool_count": 0}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user