mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99baead5f2 | ||
|
|
55d8c63dc0 | ||
|
|
6735a6f89e | ||
|
|
8bd6c8e87a | ||
|
|
68ea6fca65 | ||
|
|
657aa5cbe6 |
@@ -236,6 +236,9 @@ ignore = [
|
||||
"strix/interface/auth_cli.py" = ["N802"]
|
||||
"tests/test_codex_streaming.py" = ["N802"]
|
||||
"tests/test_disable_streaming.py" = ["N802"]
|
||||
"tests/test_tool_call_ids.py" = ["N802"]
|
||||
"tests/test_tool_call_limits.py" = ["N802", "SLF001"]
|
||||
"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"]
|
||||
"tests/test_unknown_tool_recovery.py" = ["N802"]
|
||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
|
||||
@@ -28,6 +28,7 @@ INTER-AGENT MESSAGES:
|
||||
- Messages from other agents arrive prefixed with a header like `[Message from agent <name> | type=... | priority=...]`. Treat them as internal context — never repeat them verbatim in your own output.
|
||||
- Treat agent identity / inherited-context preambles as internal metadata; do not echo them in outputs or tool calls.
|
||||
- Minimize inter-agent messaging: only message when essential for coordination or assistance; avoid routine status updates; batch non-urgent information; prefer parent/child completion flows and shared artifacts over messaging
|
||||
- wait_for_agents blocks and resumes you automatically, so it is never a poll you repeat: issue exactly ONE wait, then stop and react to what it returns. Never write out a wait/check loop (wait → view_agent_graph → wait → ...) ahead of time — those extra calls only strand you and are collapsed anyway
|
||||
|
||||
{% if interactive %}
|
||||
INTERACTIVE BEHAVIOR:
|
||||
|
||||
+194
-7
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from agents import (
|
||||
set_default_openai_api,
|
||||
@@ -24,12 +27,19 @@ from agents.retry import (
|
||||
RetryPolicyContext,
|
||||
retry_policies,
|
||||
)
|
||||
from openai.types.responses import Response, ResponseCompletedEvent
|
||||
from openai.types.responses import (
|
||||
Response,
|
||||
ResponseCompletedEvent,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
)
|
||||
from openai.types.responses.response_usage import ResponseUsage
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input
|
||||
from strix.config.tool_call_limits import TurnToolCallLimiter
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -48,6 +58,9 @@ if TYPE_CHECKING:
|
||||
from strix.config.settings import LlmSettings, ReasoningEffort, Settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
||||
"""Per-request model timeout; a plain float so ``ModelSettings.to_json_dict()`` stays serializable.""" # noqa: E501
|
||||
if not timeout_s or timeout_s <= 0:
|
||||
@@ -229,6 +242,170 @@ class _NonStreamingModel(Model):
|
||||
yield _completed_stream_event(response, getattr(self._inner, "model", None))
|
||||
|
||||
|
||||
class _TurnGuardModel(Model):
|
||||
"""Keep one turn from corrupting the conversation or running away.
|
||||
|
||||
Tool-call ids: providers that number calls per turn (``exec_command:0``,
|
||||
...) restart the counter each turn, so the same id eventually appears twice
|
||||
in one conversation and strict providers reject every subsequent request.
|
||||
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 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.
|
||||
|
||||
Stalled streams: a turn that emits a few tokens and then goes silent is
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner: Model,
|
||||
*,
|
||||
max_tool_calls_per_turn: int = 0,
|
||||
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
|
||||
|
||||
def _limiter(self) -> TurnToolCallLimiter:
|
||||
return TurnToolCallLimiter(self._max_tool_calls_per_turn)
|
||||
|
||||
def _log_dropped(self, limiter: TurnToolCallLimiter) -> None:
|
||||
if limiter.dropped:
|
||||
logger.warning(
|
||||
"dropped %d tool call(s) past the per-response limit of %d",
|
||||
limiter.dropped,
|
||||
self._max_tool_calls_per_turn,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._inner.close()
|
||||
|
||||
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
|
||||
return self._inner.get_retry_advice(request)
|
||||
|
||||
async def get_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem], # noqa: A002
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> ModelResponse:
|
||||
sanitized = dedupe_input(input)
|
||||
rewriter = TurnCallIdRewriter(sanitized)
|
||||
response = await self._inner.get_response(
|
||||
system_instructions,
|
||||
cast("str | list[TResponseInputItem]", sanitized),
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
limiter = self._limiter()
|
||||
response.output = limiter.filter_items(rewriter.rewrite_items(list(response.output)))
|
||||
self._log_dropped(limiter)
|
||||
return response
|
||||
|
||||
async def stream_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem], # noqa: A002
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
sanitized = dedupe_input(input)
|
||||
rewriter = TurnCallIdRewriter(sanitized)
|
||||
limiter = self._limiter()
|
||||
stream = self._inner.stream_response(
|
||||
system_instructions,
|
||||
cast("str | list[TResponseInputItem]", sanitized),
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
async def _aclose(stream: AsyncIterator[TResponseStreamEvent]) -> None:
|
||||
if isinstance(stream, AsyncGenerator):
|
||||
with contextlib.suppress(Exception):
|
||||
await stream.aclose()
|
||||
|
||||
|
||||
async def _with_idle_timeout(
|
||||
stream: AsyncIterator[TResponseStreamEvent], timeout: float
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
if timeout <= 0:
|
||||
async for event in stream:
|
||||
yield event
|
||||
return
|
||||
|
||||
iterator = stream.__aiter__()
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(iterator.__anext__(), timeout)
|
||||
except StopAsyncIteration:
|
||||
return
|
||||
except TimeoutError:
|
||||
await _aclose(stream)
|
||||
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
|
||||
|
||||
|
||||
def _guard_event(
|
||||
event: TResponseStreamEvent, rewriter: TurnCallIdRewriter, limiter: TurnToolCallLimiter
|
||||
) -> TResponseStreamEvent | None:
|
||||
if isinstance(event, ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent):
|
||||
rewritten = rewriter.rewrite_item(event.item)
|
||||
if not limiter.allow(rewritten):
|
||||
return None
|
||||
if rewritten is not event.item:
|
||||
return event.model_copy(update={"item": rewritten})
|
||||
return event
|
||||
if isinstance(event, ResponseCompletedEvent):
|
||||
original = list(event.response.output)
|
||||
output = limiter.filter_items(rewriter.rewrite_items(original))
|
||||
if output != original:
|
||||
return event.model_copy(
|
||||
update={"response": event.response.model_copy(update={"output": output})}
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
def _completed_stream_event(
|
||||
model_response: ModelResponse, model_name: object | None
|
||||
) -> TResponseStreamEvent:
|
||||
@@ -294,19 +471,29 @@ class StrixProvider(MultiProvider):
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
llm = load_settings().llm
|
||||
slug = codex.subscription_model(model_name)
|
||||
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
|
||||
# does not apply here.
|
||||
return _CodexResponsesModel(
|
||||
model: Model = _CodexResponsesModel(
|
||||
slug,
|
||||
codex.get_subscription_client(),
|
||||
reasoning_effort=llm.reasoning_effort,
|
||||
)
|
||||
model = super().get_model(model_name)
|
||||
if llm.disable_streaming:
|
||||
return _NonStreamingModel(model)
|
||||
return model
|
||||
else:
|
||||
model = super().get_model(model_name)
|
||||
if llm.disable_streaming:
|
||||
model = _NonStreamingModel(model)
|
||||
# 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 = 0.0
|
||||
return _TurnGuardModel(
|
||||
model,
|
||||
max_tool_calls_per_turn=llm.max_tool_calls_per_turn,
|
||||
stream_idle_timeout=idle_timeout,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
|
||||
@@ -57,6 +57,12 @@ class LlmSettings(BaseSettings):
|
||||
alias="LLM_DISABLE_STREAMING",
|
||||
)
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT")
|
||||
max_tool_calls_per_turn: int = Field(
|
||||
default=32,
|
||||
ge=0,
|
||||
alias="LLM_MAX_TOOL_CALLS_PER_TURN",
|
||||
)
|
||||
|
||||
|
||||
class DedupeSettings(BaseSettings):
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Keep tool-call ids unique within a conversation.
|
||||
|
||||
Some providers return per-turn tool-call ids (``exec_command:0``,
|
||||
``exec_command:1``, ...) whose counter restarts on every turn. Once the same
|
||||
id appears twice in one conversation, the request payload has two assistant
|
||||
tool calls sharing an id and strict providers reject the whole turn, which
|
||||
permanently kills the agent because the malformed history is replayed on
|
||||
every retry. Rewriting duplicates to fresh unique ids keeps the history
|
||||
valid for any provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
|
||||
def new_call_id() -> str:
|
||||
return f"call_{uuid4().hex}"
|
||||
|
||||
|
||||
def collect_call_ids(items: list[Any]) -> set[str]:
|
||||
used: set[str] = set()
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id")
|
||||
if isinstance(call_id, str):
|
||||
used.add(call_id)
|
||||
elif isinstance(item, ResponseFunctionToolCall):
|
||||
used.add(item.call_id)
|
||||
return used
|
||||
|
||||
|
||||
def dedupe_history_call_ids(items: list[Any]) -> tuple[list[Any], bool]:
|
||||
"""Rewrite duplicate call ids in a conversation history.
|
||||
|
||||
Outputs are paired with their call by order, so parallel calls that share
|
||||
an id keep answering the right call after the rewrite.
|
||||
"""
|
||||
used: set[str] = set()
|
||||
pending: dict[str, deque[str]] = defaultdict(deque)
|
||||
rebuilt: list[Any] = []
|
||||
changed = False
|
||||
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
rebuilt.append(item)
|
||||
continue
|
||||
call_id = item.get("call_id")
|
||||
if not isinstance(call_id, str):
|
||||
rebuilt.append(item)
|
||||
continue
|
||||
|
||||
kind = item.get("type")
|
||||
if kind == "function_call":
|
||||
effective = call_id
|
||||
if call_id in used:
|
||||
effective = new_call_id()
|
||||
item = {**item, "call_id": effective} # noqa: PLW2901
|
||||
changed = True
|
||||
used.add(effective)
|
||||
pending[call_id].append(effective)
|
||||
elif kind == "function_call_output":
|
||||
queue = pending.get(call_id)
|
||||
if queue:
|
||||
effective = queue.popleft()
|
||||
if effective != call_id:
|
||||
item = {**item, "call_id": effective} # noqa: PLW2901
|
||||
changed = True
|
||||
rebuilt.append(item)
|
||||
|
||||
return rebuilt, changed
|
||||
|
||||
|
||||
def dedupe_input(model_input: str | list[Any]) -> str | list[Any]:
|
||||
if isinstance(model_input, str):
|
||||
return model_input
|
||||
rebuilt, changed = dedupe_history_call_ids(model_input)
|
||||
return rebuilt if changed else model_input
|
||||
|
||||
|
||||
class TurnCallIdRewriter:
|
||||
"""Rewrite a single turn's tool-call ids that collide with the history.
|
||||
|
||||
A turn's items surface several times (streamed item events, then the
|
||||
completed response), so the same original id must always map to the same
|
||||
replacement within the turn.
|
||||
"""
|
||||
|
||||
def __init__(self, model_input: str | list[Any]) -> None:
|
||||
self._used = set() if isinstance(model_input, str) else collect_call_ids(model_input)
|
||||
self._remap: dict[str, str] = {}
|
||||
self._settled: set[str] = set()
|
||||
|
||||
def rewrite_item(self, item: Any) -> Any:
|
||||
if not isinstance(item, ResponseFunctionToolCall):
|
||||
return item
|
||||
original = item.call_id
|
||||
if original in self._settled:
|
||||
return item
|
||||
replacement = self._remap.get(original)
|
||||
if replacement is None:
|
||||
if original not in self._used:
|
||||
self._used.add(original)
|
||||
self._settled.add(original)
|
||||
return item
|
||||
replacement = new_call_id()
|
||||
self._remap[original] = replacement
|
||||
self._used.add(replacement)
|
||||
self._settled.add(replacement)
|
||||
return item.model_copy(update={"call_id": replacement})
|
||||
|
||||
def rewrite_items(self, items: list[Any]) -> list[Any]:
|
||||
return [self.rewrite_item(item) for item in items]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Bound how many tool calls one assistant response may queue.
|
||||
|
||||
A degenerate generation can emit hundreds or thousands of tool calls in a
|
||||
single response — typically a poll/wait loop the model writes out ahead of
|
||||
time instead of issuing one call and yielding. The run loop honours all of
|
||||
them, so the agent stops reacting to anything for hours. Keeping only the
|
||||
first ``limit`` calls of a response bounds that blast radius; the model sees
|
||||
their results on the next turn and can reconsider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
|
||||
class TurnToolCallLimiter:
|
||||
"""Decide, once per call, whether a turn's tool call is within the limit."""
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
self._limit = limit
|
||||
self._decisions: dict[str, bool] = {}
|
||||
self._kept = 0
|
||||
self.dropped = 0
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._limit > 0
|
||||
|
||||
def allow(self, item: Any) -> bool:
|
||||
if not self.enabled or not isinstance(item, ResponseFunctionToolCall):
|
||||
return True
|
||||
decided = self._decisions.get(item.call_id)
|
||||
if decided is not None:
|
||||
return decided
|
||||
allowed = self._kept < self._limit
|
||||
if allowed:
|
||||
self._kept += 1
|
||||
else:
|
||||
self.dropped += 1
|
||||
self._decisions[item.call_id] = allowed
|
||||
return allowed
|
||||
|
||||
def filter_items(self, items: list[Any]) -> list[Any]:
|
||||
return [item for item in items if self.allow(item)]
|
||||
@@ -20,6 +20,8 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
LLM_TURN_KEY = "llm_turn"
|
||||
|
||||
_STAGE_LABELS: tuple[str, ...] = ("NOTICE", "URGENT", "CRITICAL")
|
||||
_TURN_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||
_ROOT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||
@@ -144,6 +146,7 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
system_prompt: str | None, # noqa: ARG002
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
context.context[LLM_TURN_KEY] = int(context.context.get(LLM_TURN_KEY, 0)) + 1
|
||||
try:
|
||||
self._maybe_warn_turns(context, input_items)
|
||||
self._maybe_warn_budget(context, input_items)
|
||||
|
||||
@@ -74,6 +74,8 @@ func vulnerabilityMarkdownReport(v map[string]any) string {
|
||||
field("Ecosystem", render.StringValue(dep["package_ecosystem"]))
|
||||
field("Installed Version", render.StringValue(dep["installed_version"]))
|
||||
field("Fixed Version", render.StringValue(dep["fixed_version"]))
|
||||
field("Introduced By", render.StringValue(dep["introduced_by"]))
|
||||
field("Dependency Chain", render.StringValue(dep["dependency_path"]))
|
||||
}
|
||||
field("Endpoint", render.StringValue(v["endpoint"]))
|
||||
field("Method", render.StringValue(v["method"]))
|
||||
|
||||
@@ -298,6 +298,8 @@ func vulnerabilityBody(v map[string]any) string {
|
||||
field("Ecosystem", render.StringValue(dep["package_ecosystem"]))
|
||||
field("Installed Version", render.StringValue(dep["installed_version"]))
|
||||
field("Fixed Version", render.StringValue(dep["fixed_version"]))
|
||||
field("Introduced By", render.StringValue(dep["introduced_by"]))
|
||||
field("Dependency Chain", render.StringValue(dep["dependency_path"]))
|
||||
}
|
||||
field("Endpoint", render.StringValue(v["endpoint"]))
|
||||
field("Method", render.StringValue(v["method"]))
|
||||
|
||||
@@ -531,6 +531,10 @@ def _result_properties(
|
||||
if value not in (None, ""):
|
||||
strix[key] = value
|
||||
|
||||
dependency_metadata = report.get("dependency_metadata")
|
||||
if isinstance(dependency_metadata, dict) and dependency_metadata:
|
||||
strix["dependency_metadata"] = dependency_metadata
|
||||
|
||||
# SARIF is written for external upload (code-scanning / ASPM), so it must
|
||||
# NOT carry the weaponized exploit payload — that stays a local run
|
||||
# artifact (vulnerabilities.json / the finding MD). We surface the PoC
|
||||
|
||||
@@ -205,6 +205,8 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
("Ecosystem", dep_meta.get("package_ecosystem")),
|
||||
("Installed Version", dep_meta.get("installed_version")),
|
||||
("Fixed Version", dep_meta.get("fixed_version")),
|
||||
("Introduced By", dep_meta.get("introduced_by")),
|
||||
("Dependency Chain", dep_meta.get("dependency_path")),
|
||||
("Endpoint", report.get("endpoint")),
|
||||
("Method", report.get("method")),
|
||||
("CVE", report.get("cve")),
|
||||
|
||||
@@ -39,9 +39,11 @@ trivy version --format json 2>/dev/null | tee "$ART/trivy-version.json"
|
||||
# sandbox with egress gets the freshest CVEs; if the update fails, fall back to the
|
||||
# cached DB instead of failing the scan. --offline-scan keeps per-package advisory
|
||||
# lookups offline.
|
||||
trivy fs --scanners vuln --timeout 30m --offline-scan \
|
||||
# --list-all-pkgs includes the package graph (Relationship + DependsOn) needed
|
||||
# to attribute transitive CVEs to the direct dependency that introduces them.
|
||||
trivy fs --scanners vuln --timeout 30m --offline-scan --list-all-pkgs \
|
||||
--format json --output "$ART/trivy-sca.json" . \
|
||||
|| trivy fs --scanners vuln --timeout 30m --offline-scan --skip-db-update \
|
||||
|| trivy fs --scanners vuln --timeout 30m --offline-scan --skip-db-update --list-all-pkgs \
|
||||
--format json --output "$ART/trivy-sca.json" . \
|
||||
|| true
|
||||
```
|
||||
@@ -78,6 +80,36 @@ For each entry under `.Results[].Vulnerabilities[]` in `trivy-sca.json`, collect
|
||||
Deduplicate by `(CVE, PkgName, InstalledVersion)`. File one
|
||||
`create_dependency_report` per CVE — do not batch multiple CVEs into one report.
|
||||
|
||||
### Attribute transitive CVEs to the direct dependency
|
||||
|
||||
With `--list-all-pkgs`, each `.Results[].Packages[]` entry carries `ID`
|
||||
(`name@version`), `Relationship` (`direct` / `indirect`) and `DependsOn` (the
|
||||
`ID`s it resolves to). For every vulnerable package that is **indirect**, walk
|
||||
the `DependsOn` graph backwards to find the `direct` package(s) whose closure
|
||||
contains it, then pass to `create_dependency_report`:
|
||||
|
||||
- `introduced_by` — the direct dependency as `name@version` (e.g.
|
||||
`express@4.18.1`). If several direct dependencies pull it in, pick the
|
||||
primary one and name the rest in `technical_analysis`.
|
||||
- `dependency_path` — the shortest resolution chain from that direct
|
||||
dependency to the vulnerable package, joined with ` > ` (e.g.
|
||||
`express@4.18.1 > body-parser@1.20.0 > qs@6.10.2`).
|
||||
- Omit both when the vulnerable package is itself a direct dependency.
|
||||
|
||||
If the ecosystem's lockfile gives trivy no graph (`DependsOn` absent), derive
|
||||
the chain from the package manager instead (`npm ls <pkg>`, `pnpm why <pkg>`,
|
||||
`yarn why <pkg>`, `pipdeptree --reverse -p <pkg>`, `go mod graph`,
|
||||
`mvn dependency:tree`, ...) — and if that also fails, leave the fields out
|
||||
rather than guessing.
|
||||
|
||||
For transitive findings, `remediation_steps` must be actionable at the
|
||||
**direct-dependency level**: upgrading the vulnerable package directly is
|
||||
usually impossible from the app's own manifest. Say which direct dependency to
|
||||
bump (a version whose closure resolves the fixed version), or how to force the
|
||||
resolution (npm `overrides` / yarn `resolutions` / pnpm `pnpm.overrides` /
|
||||
Maven `dependencyManagement` / Gradle resolution strategy / `go mod edit`),
|
||||
not just "upgrade <vulnerable pkg> to <fixed>".
|
||||
|
||||
### Reachability is a confidence modifier, not a gate
|
||||
|
||||
Do NOT suppress or downgrade a known CVE just because you could not prove the
|
||||
|
||||
@@ -14,6 +14,7 @@ from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.core.agents import Status, coordinator_from_context
|
||||
from strix.core.execution import notify_parent_on_terminal
|
||||
from strix.core.hooks import LLM_TURN_KEY
|
||||
from strix.skills import validate_requested_skills
|
||||
|
||||
|
||||
@@ -224,6 +225,7 @@ _WAIT_DEFAULT_TIMEOUT_S = 300
|
||||
# ``timeout_seconds`` the model asks for. One second of headroom lets the
|
||||
# tool's own timeout fire first and return a clean result.
|
||||
_WAIT_HARD_CEILING_S = _WAIT_DEFAULT_TIMEOUT_S + 1
|
||||
_WAITED_TURN_KEY = "waited_llm_turn"
|
||||
|
||||
|
||||
@function_tool(timeout=_WAIT_HARD_CEILING_S)
|
||||
@@ -239,6 +241,11 @@ async def wait_for_agents( # noqa: PLR0911
|
||||
completion reports. You resume the instant any message arrives, so
|
||||
size ``timeout_seconds`` to the work you're awaiting.
|
||||
|
||||
**Issue exactly one wait, then stop and react to what it returns.**
|
||||
This call blocks and resumes on its own; it is not a poll you repeat.
|
||||
Do not write out a wait/check loop ahead of time — a second wait in
|
||||
the same turn returns immediately without waiting.
|
||||
|
||||
**This tool is only for waiting on other agents.** Two things it is
|
||||
NOT for:
|
||||
|
||||
@@ -290,6 +297,24 @@ async def wait_for_agents( # noqa: PLR0911
|
||||
default=str,
|
||||
)
|
||||
|
||||
turn = inner.get(LLM_TURN_KEY)
|
||||
if turn is not None and inner.get(_WAITED_TURN_KEY) == turn:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"wait_outcome": "already_waited",
|
||||
"reason": reason,
|
||||
"note": (
|
||||
"You already waited in this turn. A single wait_for_agents blocks and "
|
||||
"resumes on its own, so queueing more waits only strands you — issue one "
|
||||
"wait, then react to what it returns."
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
inner[_WAITED_TURN_KEY] = turn
|
||||
|
||||
async with coordinator._lock:
|
||||
stopped = coordinator.statuses.get(me) == "stopped"
|
||||
if stopped:
|
||||
|
||||
@@ -725,6 +725,8 @@ def _build_dependency_metadata(
|
||||
installed_version: str,
|
||||
package_ecosystem: str | None,
|
||||
fixed_version: str | None,
|
||||
introduced_by: str | None,
|
||||
dependency_path: str | None,
|
||||
) -> dict[str, str]:
|
||||
metadata = {
|
||||
"package_name": package_name.strip(),
|
||||
@@ -734,6 +736,10 @@ def _build_dependency_metadata(
|
||||
metadata["package_ecosystem"] = package_ecosystem.strip()
|
||||
if fixed_version and fixed_version.strip():
|
||||
metadata["fixed_version"] = fixed_version.strip()
|
||||
if introduced_by and introduced_by.strip():
|
||||
metadata["introduced_by"] = introduced_by.strip()
|
||||
if dependency_path and dependency_path.strip():
|
||||
metadata["dependency_path"] = dependency_path.strip()
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -743,6 +749,8 @@ def _build_dependency_evidence(
|
||||
package_name: str,
|
||||
installed_version: str,
|
||||
fixed_version: str | None,
|
||||
introduced_by: str | None,
|
||||
dependency_path: str | None,
|
||||
) -> str:
|
||||
evidence = (
|
||||
f"**Advisory evidence:** `{cve}` applies to `{package_name}` "
|
||||
@@ -750,6 +758,13 @@ def _build_dependency_evidence(
|
||||
)
|
||||
if fixed_version and fixed_version.strip():
|
||||
evidence += f" The advisory is fixed in `{fixed_version.strip()}`."
|
||||
if introduced_by and introduced_by.strip():
|
||||
evidence += (
|
||||
f"\n\n**Transitive dependency:** introduced by the direct "
|
||||
f"dependency `{introduced_by.strip()}`."
|
||||
)
|
||||
if dependency_path and dependency_path.strip():
|
||||
evidence += f"\n\n**Dependency chain:** `{dependency_path.strip()}`"
|
||||
return evidence
|
||||
|
||||
|
||||
@@ -770,6 +785,8 @@ async def _do_create_dependency( # noqa: PLR0912
|
||||
advisory_cvss: float | None,
|
||||
technical_analysis: str | None,
|
||||
fix_effort: str,
|
||||
introduced_by: str | None = None,
|
||||
dependency_path: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
@@ -824,12 +841,16 @@ async def _do_create_dependency( # noqa: PLR0912
|
||||
installed_version=installed_version,
|
||||
package_ecosystem=package_ecosystem,
|
||||
fixed_version=fixed_version,
|
||||
introduced_by=introduced_by,
|
||||
dependency_path=dependency_path,
|
||||
)
|
||||
evidence = _build_dependency_evidence(
|
||||
cve=parsed_cve,
|
||||
package_name=package_name.strip(),
|
||||
installed_version=installed_version.strip(),
|
||||
fixed_version=fixed_version,
|
||||
introduced_by=introduced_by,
|
||||
dependency_path=dependency_path,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -926,6 +947,8 @@ async def create_dependency_report(
|
||||
cwe: str | None = None,
|
||||
technical_analysis: str | None = None,
|
||||
fix_effort: str = "low",
|
||||
introduced_by: str | None = None,
|
||||
dependency_path: str | None = None,
|
||||
) -> str:
|
||||
"""File a known-CVE dependency (SCA) finding — one report per CVE x package.
|
||||
|
||||
@@ -978,6 +1001,15 @@ async def create_dependency_report(
|
||||
technical_analysis: Optional deeper mechanism/root-cause detail.
|
||||
fix_effort: One of ``trivial`` / ``low`` / ``medium`` / ``high``
|
||||
(dependency upgrades are usually ``trivial``/``low``).
|
||||
introduced_by: For a **transitive** dependency, the direct
|
||||
dependency (from the project's own manifest) that pulls the
|
||||
vulnerable package in, as ``name@version`` (e.g.
|
||||
``express@4.18.1``). Omit when the vulnerable package is
|
||||
itself a direct dependency.
|
||||
dependency_path: The resolution chain from the direct dependency
|
||||
to the vulnerable package, joined with `` > `` (e.g.
|
||||
``express@4.18.1 > body-parser@1.20.0 > qs@6.10.2``). Omit
|
||||
for direct dependencies.
|
||||
"""
|
||||
agent_id, agent_name = _caller_identity(ctx)
|
||||
|
||||
@@ -997,6 +1029,8 @@ async def create_dependency_report(
|
||||
advisory_cvss=advisory_cvss,
|
||||
technical_analysis=technical_analysis,
|
||||
fix_effort=fix_effort,
|
||||
introduced_by=introduced_by,
|
||||
dependency_path=dependency_path,
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ from openai.types.responses import (
|
||||
|
||||
from strix.config import codex, loader
|
||||
from strix.config.loader import load_settings
|
||||
from strix.config.models import StrixProvider, _NonStreamingModel
|
||||
from strix.config.models import StrixProvider, _NonStreamingModel, _TurnGuardModel
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -299,10 +299,11 @@ def test_get_model_wraps_when_disabled(
|
||||
load_settings()
|
||||
|
||||
model = StrixProvider().get_model("openai/gpt-4o-mini")
|
||||
assert isinstance(model, _NonStreamingModel)
|
||||
assert isinstance(model, _TurnGuardModel)
|
||||
assert isinstance(model._inner, _NonStreamingModel)
|
||||
|
||||
|
||||
def test_get_model_unwrapped_by_default(
|
||||
def test_get_model_keeps_streaming_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
|
||||
) -> None:
|
||||
inner = _DummyModel()
|
||||
@@ -310,17 +311,20 @@ def test_get_model_unwrapped_by_default(
|
||||
load_settings()
|
||||
|
||||
model = StrixProvider().get_model("openai/gpt-4o-mini")
|
||||
assert model is inner
|
||||
assert isinstance(model, _TurnGuardModel)
|
||||
assert model._inner is inner
|
||||
|
||||
|
||||
def test_get_model_does_not_wrap_subscription_model(
|
||||
def test_get_model_guards_subscription_model_but_keeps_it_streaming(
|
||||
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
|
||||
) -> None:
|
||||
# Subscription (ChatGPT) models are always streamed and must not be wrapped.
|
||||
# Subscription (ChatGPT) models are always streamed, so LLM_DISABLE_STREAMING
|
||||
# must not apply — but a runaway response needs capping there too.
|
||||
monkeypatch.setattr(codex, "subscription_model", lambda *_: "gpt-5.5")
|
||||
monkeypatch.setattr(codex, "get_subscription_client", lambda: AsyncOpenAI(api_key="x"))
|
||||
monkeypatch.setenv("LLM_DISABLE_STREAMING", "true")
|
||||
load_settings()
|
||||
|
||||
model = StrixProvider().get_model("gpt-5.5")
|
||||
assert not isinstance(model, _NonStreamingModel)
|
||||
assert isinstance(model, _TurnGuardModel)
|
||||
assert not isinstance(model._inner, _NonStreamingModel)
|
||||
|
||||
@@ -164,6 +164,69 @@ async def test_dependency_report_sets_class_and_metadata(report_state: ReportSta
|
||||
}
|
||||
|
||||
|
||||
async def test_dependency_report_records_transitive_chain(report_state: ReportState) -> None:
|
||||
result = await _do_create_dependency(
|
||||
title="CVE-2022-24999 in qs 6.10.2",
|
||||
description="Prototype pollution in qs parsing.",
|
||||
target="repo/package.json",
|
||||
cve="CVE-2022-24999",
|
||||
package_name="qs",
|
||||
installed_version="6.10.2",
|
||||
impact="Denial of service via crafted query strings.",
|
||||
remediation_steps="Upgrade express to 4.18.2, which resolves qs 6.11.0.",
|
||||
assumptions="qs parses all incoming query strings by default.",
|
||||
package_ecosystem="npm",
|
||||
fixed_version="6.10.3",
|
||||
cwe="CWE-1321",
|
||||
advisory_cvss=7.5,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
introduced_by="express@4.18.1",
|
||||
dependency_path="express@4.18.1 > body-parser@1.20.0 > qs@6.10.2",
|
||||
)
|
||||
assert result["success"] is True
|
||||
report = report_state.vulnerability_reports[0]
|
||||
assert report["dependency_metadata"]["introduced_by"] == "express@4.18.1"
|
||||
assert (
|
||||
report["dependency_metadata"]["dependency_path"]
|
||||
== "express@4.18.1 > body-parser@1.20.0 > qs@6.10.2"
|
||||
)
|
||||
assert (
|
||||
"**Transitive dependency:** introduced by the direct dependency `express@4.18.1`."
|
||||
in report["evidence"]
|
||||
)
|
||||
assert (
|
||||
"**Dependency chain:** `express@4.18.1 > body-parser@1.20.0 > qs@6.10.2`"
|
||||
in report["evidence"]
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
description="Published advisory affects the pinned version.",
|
||||
target="repo/package.json",
|
||||
cve="CVE-2024-0001",
|
||||
package_name="sample",
|
||||
installed_version="1.0.0",
|
||||
impact="Impact.",
|
||||
remediation_steps="Upgrade.",
|
||||
assumptions="Assumptions.",
|
||||
package_ecosystem="npm",
|
||||
fixed_version=None,
|
||||
cwe=None,
|
||||
advisory_cvss=5.0,
|
||||
technical_analysis=None,
|
||||
fix_effort="trivial",
|
||||
introduced_by=" ",
|
||||
dependency_path=None,
|
||||
)
|
||||
assert result["success"] is True
|
||||
report = report_state.vulnerability_reports[0]
|
||||
assert "introduced_by" not in report["dependency_metadata"]
|
||||
assert "dependency_path" not in report["dependency_metadata"]
|
||||
|
||||
|
||||
async def test_dependency_report_with_zero_cvss_remains_low_severity(
|
||||
report_state: ReportState,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for the model-stream idle watchdog.
|
||||
|
||||
A turn that streams a few tokens and then goes silent is not covered by the
|
||||
request timeout: the read timeout resets on every byte, keepalives included.
|
||||
The watchdog bounds the gap between events so the turn fails and can be
|
||||
retried instead of parking the agent forever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.interface import Model, ModelTracing
|
||||
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from strix.config import loader
|
||||
from strix.config.loader import load_settings
|
||||
from strix.config.models import StrixProvider, _TurnGuardModel, _with_idle_timeout
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
|
||||
_STALL_SECONDS = 30.0
|
||||
|
||||
|
||||
def _chunk(text: str) -> bytes:
|
||||
payload = {
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": "gw-model",
|
||||
"choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}],
|
||||
}
|
||||
return b"data: " + json.dumps(payload).encode() + b"\n\n"
|
||||
|
||||
|
||||
class _StallingHandler(BaseHTTPRequestHandler):
|
||||
"""Streams a couple of tokens, then stops producing anything."""
|
||||
|
||||
stop = threading.Event()
|
||||
|
||||
def log_message(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
self.rfile.read(length)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.end_headers()
|
||||
self.wfile.write(_chunk("Now"))
|
||||
self.wfile.write(_chunk(" spawning"))
|
||||
self.wfile.flush()
|
||||
self.stop.wait(_STALL_SECONDS)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stalling_gateway() -> Iterator[str]:
|
||||
_StallingHandler.stop.clear()
|
||||
server = HTTPServer(("127.0.0.1", 0), _StallingHandler)
|
||||
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:
|
||||
_StallingHandler.stop.set()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
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 = OpenAIChatCompletionsModel(model="gw-model", openai_client=client)
|
||||
guarded = _TurnGuardModel(inner, stream_idle_timeout=idle_timeout)
|
||||
return guarded.stream_response(
|
||||
None,
|
||||
"go",
|
||||
ModelSettings(),
|
||||
[],
|
||||
None,
|
||||
[],
|
||||
ModelTracing.DISABLED,
|
||||
previous_response_id=None,
|
||||
conversation_id=None,
|
||||
prompt=None,
|
||||
)
|
||||
|
||||
|
||||
async def _drain(base_url: str, *, idle_timeout: float) -> list[Any]:
|
||||
return [event async for event in _stream(base_url, idle_timeout=idle_timeout)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stalled_stream_hangs_without_the_watchdog(stalling_gateway: str) -> None:
|
||||
# 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=0), timeout=2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stalled_stream_is_abandoned_by_the_watchdog(stalling_gateway: str) -> None:
|
||||
started = time.monotonic()
|
||||
with pytest.raises(TimeoutError, match="produced no event"):
|
||||
await _drain(stalling_gateway, idle_timeout=1)
|
||||
|
||||
assert time.monotonic() - started < _STALL_SECONDS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_keep_flowing_while_the_stream_is_alive() -> None:
|
||||
async def _live() -> AsyncIterator[Any]:
|
||||
for i in range(5):
|
||||
await asyncio.sleep(0.05)
|
||||
yield f"event-{i}"
|
||||
|
||||
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.fixture
|
||||
def _reset_settings(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
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)
|
||||
yield
|
||||
|
||||
|
||||
class _DummyModel(Model):
|
||||
async def get_response(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_response(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_idle_timeout_is_configurable(
|
||||
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
|
||||
) -> None:
|
||||
monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: _DummyModel())
|
||||
monkeypatch.setenv("LLM_STREAM_IDLE_TIMEOUT", "45")
|
||||
load_settings()
|
||||
|
||||
model = StrixProvider().get_model("openai/gpt-4o-mini")
|
||||
assert isinstance(model, _TurnGuardModel)
|
||||
assert model._stream_idle_timeout == 45
|
||||
|
||||
|
||||
def test_idle_timeout_is_off_without_streaming(
|
||||
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
|
||||
) -> None:
|
||||
# LLM_DISABLE_STREAMING turns the whole request into one event, so an idle
|
||||
# gap would just be the request duration — the request timeout bounds that.
|
||||
monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: _DummyModel())
|
||||
monkeypatch.setenv("LLM_STREAM_IDLE_TIMEOUT", "45")
|
||||
monkeypatch.setenv("LLM_DISABLE_STREAMING", "true")
|
||||
load_settings()
|
||||
|
||||
model = StrixProvider().get_model("openai/gpt-4o-mini")
|
||||
assert isinstance(model, _TurnGuardModel)
|
||||
assert model._stream_idle_timeout == 0
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for tool-call id uniqueness.
|
||||
|
||||
Providers that number tool calls per turn (``exec_command:0``, ``:1``, ...)
|
||||
restart the counter on every turn, so the same id eventually appears twice in
|
||||
one conversation. Strict providers then reject the whole request, and because
|
||||
the history is replayed on every retry the agent can never recover. A gateway
|
||||
that validates id uniqueness the way those providers 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
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
from strix.config.models import _NonStreamingModel, _TurnGuardModel
|
||||
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_history_call_ids
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
def _tool_call_completion(call_id: str, n: int = 1) -> 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_id,
|
||||
"type": "function",
|
||||
"function": {"name": "do_thing", "arguments": json.dumps({"n": n})},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
"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_call_ids(messages: list[dict[str, Any]]) -> list[str]:
|
||||
return [str(call.get("id")) 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 a history reusing a tool-call id, like strict providers 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)
|
||||
call_ids = _assistant_call_ids(messages)
|
||||
|
||||
if len(call_ids) != len(set(call_ids)):
|
||||
self._respond(
|
||||
400,
|
||||
{
|
||||
"error": {
|
||||
"message": (
|
||||
"tool messages need a resolvable tool name: carry `tool`/`name`, "
|
||||
"or match a preceding assistant tool_call by order"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
turn = len(_REQUESTS)
|
||||
if turn <= 2:
|
||||
# The provider restarts its per-turn counter, so both turns say ":0".
|
||||
self._respond(200, _tool_call_completion("exec_command:0", n=turn))
|
||||
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:
|
||||
# The gateway answers plain JSON, so the run loop's streamed turns are
|
||||
# served non-streamed; the ids on the wire are the same either way.
|
||||
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_recycled_call_id_erases_a_turn_without_the_wrapper(strict_gateway: str) -> None:
|
||||
# Repro: two turns run a tool and both are labelled ``exec_command:0``, so
|
||||
# the colliding call and its result are dropped as duplicates. The agent
|
||||
# ends the run having silently lost a turn of its own work — and a provider
|
||||
# that does not drop them instead rejects the malformed history outright.
|
||||
result = await _run_agent(strict_gateway, wrap=False)
|
||||
|
||||
assert result.final_output == "all done"
|
||||
assert _assistant_call_ids(_REQUESTS[-1]) == ["exec_command:0"]
|
||||
assert _tool_results(_REQUESTS[-1]) == ["did 2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recycled_call_id_is_rewritten_so_no_turn_is_lost(strict_gateway: str) -> None:
|
||||
result = await _run_agent(strict_gateway, wrap=True)
|
||||
|
||||
assert result.final_output == "all done"
|
||||
call_ids = _assistant_call_ids(_REQUESTS[-1])
|
||||
assert len(call_ids) == len(set(call_ids)) == 2
|
||||
assert call_ids[0] == "exec_command:0"
|
||||
assert call_ids[1].startswith("call_")
|
||||
assert _tool_results(_REQUESTS[-1]) == ["did 1", "did 2"]
|
||||
|
||||
|
||||
def test_history_dedupe_keeps_outputs_paired_with_their_call() -> None:
|
||||
items = [
|
||||
{"type": "function_call", "call_id": "exec_command:0", "name": "a", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "exec_command:0", "output": "first"},
|
||||
{"type": "function_call", "call_id": "exec_command:0", "name": "b", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "exec_command:0", "output": "second"},
|
||||
]
|
||||
|
||||
rebuilt, changed = dedupe_history_call_ids(items)
|
||||
|
||||
assert changed
|
||||
ids = [item["call_id"] for item in rebuilt]
|
||||
assert ids[0] == ids[1] == "exec_command:0"
|
||||
assert ids[2] == ids[3] != "exec_command:0"
|
||||
assert rebuilt[3]["output"] == "second"
|
||||
|
||||
|
||||
def test_history_dedupe_pairs_parallel_calls_by_order() -> None:
|
||||
items = [
|
||||
{"type": "function_call", "call_id": "dup", "name": "a", "arguments": "{}"},
|
||||
{"type": "function_call", "call_id": "dup", "name": "b", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "dup", "output": "for-a"},
|
||||
{"type": "function_call_output", "call_id": "dup", "output": "for-b"},
|
||||
]
|
||||
|
||||
rebuilt, changed = dedupe_history_call_ids(items)
|
||||
|
||||
assert changed
|
||||
assert rebuilt[0]["call_id"] == rebuilt[2]["call_id"] == "dup"
|
||||
assert rebuilt[1]["call_id"] == rebuilt[3]["call_id"]
|
||||
assert rebuilt[1]["call_id"] != "dup"
|
||||
|
||||
|
||||
def test_history_dedupe_leaves_unique_ids_alone() -> None:
|
||||
items = [
|
||||
{"type": "function_call", "call_id": "call_a", "name": "a", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_a", "output": "x"},
|
||||
{"type": "function_call", "call_id": "call_b", "name": "b", "arguments": "{}"},
|
||||
]
|
||||
|
||||
rebuilt, changed = dedupe_history_call_ids(items)
|
||||
|
||||
assert not changed
|
||||
assert rebuilt == items
|
||||
|
||||
|
||||
def test_turn_rewriter_is_stable_across_repeated_sightings() -> None:
|
||||
history = [{"type": "function_call", "call_id": "exec_command:0", "name": "a"}]
|
||||
rewriter = TurnCallIdRewriter(history)
|
||||
call = ResponseFunctionToolCall(
|
||||
call_id="exec_command:0", name="a", arguments="{}", type="function_call"
|
||||
)
|
||||
|
||||
first = rewriter.rewrite_item(call)
|
||||
second = rewriter.rewrite_item(first)
|
||||
|
||||
assert first.call_id != "exec_command:0"
|
||||
assert second.call_id == first.call_id
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Tests for the per-response tool-call cap.
|
||||
|
||||
A degenerate generation can emit hundreds of tool calls in one assistant
|
||||
response — a wait/poll loop the model writes out ahead of time. The run loop
|
||||
honours every one of them, so the agent stops reacting for hours. The cap
|
||||
keeps the first N calls of a response and drops the tail.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
from strix.config import loader
|
||||
from strix.config.loader import load_settings
|
||||
from strix.config.models import StrixProvider, _NonStreamingModel, _TurnGuardModel
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
_RUNAWAY_CALLS = 200
|
||||
_CAP = 32
|
||||
|
||||
|
||||
def _runaway_completion() -> 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": f"call_{i}",
|
||||
"type": "function",
|
||||
"function": {"name": "wait_for_message", "arguments": "{}"},
|
||||
}
|
||||
for i in range(_RUNAWAY_CALLS)
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
|
||||
}
|
||||
|
||||
|
||||
def _text_completion() -> 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": "done"},
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
|
||||
}
|
||||
|
||||
|
||||
_TURNS: list[int] = []
|
||||
|
||||
|
||||
class _RunawayHandler(BaseHTTPRequestHandler):
|
||||
"""First turn queues a huge poll loop; the next turn ends the run."""
|
||||
|
||||
def log_message(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
self.rfile.read(length)
|
||||
_TURNS.append(1)
|
||||
payload = _runaway_completion() if len(_TURNS) == 1 else _text_completion()
|
||||
encoded = json.dumps(payload).encode()
|
||||
self.send_response(200)
|
||||
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 runaway_gateway() -> Iterator[str]:
|
||||
_TURNS.clear()
|
||||
server = HTTPServer(("127.0.0.1", 0), _RunawayHandler)
|
||||
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, *, cap: int) -> list[int]:
|
||||
executed: list[int] = []
|
||||
|
||||
@function_tool
|
||||
def wait_for_message() -> str:
|
||||
executed.append(1)
|
||||
return "nothing new"
|
||||
|
||||
class _Provider(ModelProvider):
|
||||
def get_model(self, model_name: str | None) -> Model: # noqa: ARG002
|
||||
return _TurnGuardModel(_model(base_url), max_tool_calls_per_turn=cap)
|
||||
|
||||
agent = Agent(name="t", instructions="orchestrate", tools=[wait_for_message], model="gw-model")
|
||||
result = Runner.run_streamed(
|
||||
agent, input="go", run_config=RunConfig(model_provider=_Provider())
|
||||
)
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
assert result.final_output == "done"
|
||||
return executed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runaway_response_runs_every_queued_call_when_uncapped(runaway_gateway: str) -> None:
|
||||
# Repro: one response queues 200 calls and the run loop honours all of them.
|
||||
executed = await _run_agent(runaway_gateway, cap=0)
|
||||
|
||||
assert len(executed) == _RUNAWAY_CALLS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runaway_response_is_capped(runaway_gateway: str) -> None:
|
||||
executed = await _run_agent(runaway_gateway, cap=_CAP)
|
||||
|
||||
assert len(executed) == _CAP
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_below_the_cap_is_untouched(runaway_gateway: str) -> None:
|
||||
executed = await _run_agent(runaway_gateway, cap=_RUNAWAY_CALLS + 1)
|
||||
|
||||
assert len(executed) == _RUNAWAY_CALLS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _reset_settings(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
for key in ("STRIX_LLM", "LLM_DISABLE_STREAMING", "LLM_MAX_TOOL_CALLS_PER_TURN"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setattr(loader, "_cached", None)
|
||||
monkeypatch.setattr(loader, "_override", None)
|
||||
yield
|
||||
|
||||
|
||||
class _DummyModel(Model):
|
||||
async def get_response(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_response(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_cap_is_configurable(monkeypatch: pytest.MonkeyPatch, _reset_settings: None) -> None:
|
||||
monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: _DummyModel())
|
||||
monkeypatch.setenv("LLM_MAX_TOOL_CALLS_PER_TURN", "7")
|
||||
load_settings()
|
||||
|
||||
model = StrixProvider().get_model("openai/gpt-4o-mini")
|
||||
assert isinstance(model, _TurnGuardModel)
|
||||
assert model._max_tool_calls_per_turn == 7
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for collapsing repeated waits queued inside one model turn.
|
||||
|
||||
An orchestrator that writes out its whole poll loop ahead of time queues
|
||||
many ``wait_for_agents`` calls in a single response. Each one parks for its
|
||||
full timeout, so the agent stops reacting for hours while its children run
|
||||
unsupervised. Only the first wait of a turn parks; the rest return at once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
from agents import RunContextWrapper
|
||||
from agents.tool_context import ToolContext
|
||||
|
||||
from strix.core.agents import AgentCoordinator
|
||||
from strix.core.hooks import LLM_TURN_KEY, ReportUsageHooks
|
||||
from strix.tools.agents_graph.tools import wait_for_agents
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
_WAIT_SECONDS = 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _fast_wait(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
# The real ceiling is 300s per wait; the shape of the bug is the same.
|
||||
monkeypatch.setattr(
|
||||
"strix.tools.agents_graph.tools._WAIT_DEFAULT_TIMEOUT_S", _WAIT_SECONDS, raising=True
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
async def _context() -> dict[str, Any]:
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
return {"agent_id": "root", "coordinator": coordinator}
|
||||
|
||||
|
||||
async def _wait(inner: dict[str, Any]) -> dict[str, Any]:
|
||||
ctx = ToolContext(
|
||||
context=inner,
|
||||
tool_name="wait_for_agents",
|
||||
tool_call_id="call-1",
|
||||
tool_arguments="{}",
|
||||
)
|
||||
raw: str = await wait_for_agents.on_invoke_tool(
|
||||
ctx, json.dumps({"reason": "waiting for wave 1", "timeout_seconds": _WAIT_SECONDS})
|
||||
)
|
||||
return cast("dict[str, Any]", json.loads(raw))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waits_queued_in_one_turn_each_park_without_the_guard(_fast_wait: None) -> None:
|
||||
# Repro: no turn marker in context (as before the fix) — every queued wait
|
||||
# parks for its full timeout, so N waits cost N x timeout.
|
||||
inner = await _context()
|
||||
|
||||
started = time.monotonic()
|
||||
outcomes = [(await _wait(inner))["wait_outcome"] for _ in range(3)]
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
assert outcomes == ["timeout", "timeout", "timeout"]
|
||||
assert elapsed >= 3 * _WAIT_SECONDS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_waits_in_one_turn_are_collapsed(_fast_wait: None) -> None:
|
||||
inner = await _context()
|
||||
inner[LLM_TURN_KEY] = 1
|
||||
|
||||
started = time.monotonic()
|
||||
outcomes = [(await _wait(inner))["wait_outcome"] for _ in range(3)]
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
assert outcomes == ["timeout", "already_waited", "already_waited"]
|
||||
assert elapsed < 2 * _WAIT_SECONDS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_wait_in_the_next_turn_still_parks(_fast_wait: None) -> None:
|
||||
inner = await _context()
|
||||
inner[LLM_TURN_KEY] = 1
|
||||
assert (await _wait(inner))["wait_outcome"] == "timeout"
|
||||
assert (await _wait(inner))["wait_outcome"] == "already_waited"
|
||||
|
||||
inner[LLM_TURN_KEY] = 2
|
||||
|
||||
assert (await _wait(inner))["wait_outcome"] == "timeout"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_each_model_turn_bumps_the_turn_marker() -> None:
|
||||
hooks = ReportUsageHooks(model="gw-model")
|
||||
context: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={})
|
||||
agent = cast("Any", None)
|
||||
|
||||
await hooks.on_llm_start(context, agent, None, [])
|
||||
await hooks.on_llm_start(context, agent, None, [])
|
||||
|
||||
assert context.context[LLM_TURN_KEY] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_collapsed_wait_still_reports_arriving_messages(_fast_wait: None) -> None:
|
||||
inner = await _context()
|
||||
inner[LLM_TURN_KEY] = 1
|
||||
coordinator = cast("AgentCoordinator", inner["coordinator"])
|
||||
|
||||
async def _send() -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
await coordinator.send("root", {"type": "information", "content": "child done"})
|
||||
|
||||
task = asyncio.create_task(_send())
|
||||
first = await _wait(inner)
|
||||
await task
|
||||
|
||||
assert first["wait_outcome"] == "message_arrived"
|
||||
assert (await _wait(inner))["wait_outcome"] == "already_waited"
|
||||
Reference in New Issue
Block a user