mirror of
https://github.com/usestrix/strix.git
synced 2026-11-11 23:45:27 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34def3e6a | ||
|
|
e6b48187bd | ||
|
|
0142eafe41 | ||
|
|
10c9f61576 | ||
|
|
fde37a149f | ||
|
|
3a3855fc4f | ||
|
|
aa2036f229 | ||
|
|
f4a7413963 |
No files matched your search
@@ -141,6 +141,10 @@ affecting the agents that do the actual testing.
|
||||
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_RUN_RECORD_TIMINGS" default="false" type="boolean">
|
||||
Record every LLM request's and tool call's timing in `run.json`, under `llm_requests` and `tool_calls`. Adds about 0.5 MB to a typical scan's `run.json`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="TRACELOOP_BASE_URL" type="string">
|
||||
OTLP/Traceloop base URL for remote OpenTelemetry export. If unset, Strix keeps traces local only.
|
||||
</ParamField>
|
||||
|
||||
+19
-2
@@ -619,6 +619,8 @@ def _finish_tool_use_behavior(
|
||||
return ToolsToFinalOutputResult(is_final_output=False, final_output=None)
|
||||
|
||||
|
||||
_COVERAGE_TOOLS: tuple[Tool, ...] = (record_coverage, update_coverage, list_coverage)
|
||||
|
||||
_BASE_TOOLS: tuple[Tool, ...] = (
|
||||
think,
|
||||
load_skill,
|
||||
@@ -724,6 +726,8 @@ def build_strix_agent(
|
||||
instructions_override: str | None = None,
|
||||
supports_images: bool = True,
|
||||
finish_tool: Tool = finish_scan,
|
||||
report_tool: Tool = create_vulnerability_report,
|
||||
coverage_tools: bool = True,
|
||||
) -> SandboxAgent[Any]:
|
||||
"""Build a SandboxAgent for either root or child use.
|
||||
|
||||
@@ -737,6 +741,10 @@ def build_strix_agent(
|
||||
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.
|
||||
report_tool: The tool every agent files findings with, in place of
|
||||
``create_vulnerability_report``.
|
||||
coverage_tools: Give the agent the coverage ledger tools and the
|
||||
prompt rules for them.
|
||||
"""
|
||||
if instructions_override is not None:
|
||||
instructions = instructions_override
|
||||
@@ -750,16 +758,21 @@ def build_strix_agent(
|
||||
interactive=interactive,
|
||||
system_prompt_context=system_prompt_context,
|
||||
supports_images=supports_images,
|
||||
report_tool=report_tool.name,
|
||||
coverage_tools=coverage_tools,
|
||||
)
|
||||
|
||||
agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])]
|
||||
if interactive:
|
||||
# Yielding to the user is only meaningful when one is attached.
|
||||
agent_tools.append(wait_for_user)
|
||||
base_tools = [report_tool if t is create_vulnerability_report else t for t in _BASE_TOOLS]
|
||||
if not coverage_tools:
|
||||
base_tools = [t for t in base_tools if t not in _COVERAGE_TOOLS]
|
||||
if is_root:
|
||||
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_tool]
|
||||
tools: list[Tool] = [*base_tools, *agent_tools, finish_tool]
|
||||
else:
|
||||
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
||||
tools = [*base_tools, *agent_tools, agent_finish]
|
||||
_ensure_unique_tool_names(tools)
|
||||
tools = [
|
||||
_with_bounded_result(_with_strictness(_with_coerced_arguments(tool), strict_tool_schemas))
|
||||
@@ -812,6 +825,8 @@ def make_child_factory(
|
||||
strict_tool_schemas: bool = True,
|
||||
system_prompt_context: dict[str, Any] | None = None,
|
||||
supports_images: bool = True,
|
||||
report_tool: Tool = create_vulnerability_report,
|
||||
coverage_tools: bool = True,
|
||||
) -> Any:
|
||||
"""Return the runner-owned builder used by ``spawn_child_agent``.
|
||||
|
||||
@@ -833,6 +848,8 @@ def make_child_factory(
|
||||
strict_tool_schemas=strict_tool_schemas,
|
||||
system_prompt_context=system_prompt_context,
|
||||
supports_images=supports_images,
|
||||
report_tool=report_tool,
|
||||
coverage_tools=coverage_tools,
|
||||
)
|
||||
|
||||
return _factory
|
||||
@@ -86,6 +86,8 @@ def render_system_prompt(
|
||||
system_prompt_context: dict[str, Any] | None = None,
|
||||
include_scope: bool = True,
|
||||
supports_images: bool = True,
|
||||
report_tool: str = "create_vulnerability_report",
|
||||
coverage_tools: bool = True,
|
||||
) -> str:
|
||||
"""Render the system prompt. Returns empty string on template failure.
|
||||
|
||||
@@ -133,6 +135,8 @@ def render_system_prompt(
|
||||
is_root=is_root,
|
||||
system_prompt_context=system_prompt_context or {},
|
||||
include_scope=include_scope,
|
||||
report_tool=report_tool,
|
||||
coverage_tools=coverage_tools,
|
||||
**skill_content,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -119,7 +119,7 @@ WHITE-BOX TESTING (code provided):
|
||||
- Local execution, unit/integration testing, patch verification, and HTTP requests against locally started in-scope services are normal authorized white-box validation
|
||||
- If dynamically running the code proves impossible after exhaustive attempts, pivot to comprehensive static analysis.
|
||||
- Try to infer how to run the code based on its structure and content.
|
||||
- Derive the code fix as PART OF reporting, not as a separate later pass: create_vulnerability_report already requires the concrete patch inline (`code_locations` with verbatim `fix_before`/`fix_after` and `fix_pr_body`), so the reporting agent that analyzes the root cause is the one that produces the fix. Do NOT spawn a downstream agent afterwards to re-derive/re-apply the same patch.
|
||||
- Derive the code fix as PART OF reporting, not as a separate later pass: {{ report_tool }} already requires the concrete patch inline (`code_locations` with verbatim `fix_before`/`fix_after` and `fix_pr_body`), so the reporting agent that analyzes the root cause is the one that produces the fix. Do NOT spawn a downstream agent afterwards to re-derive/re-apply the same patch.
|
||||
- If you also apply and verify the patch in the repo (edit the file, re-test that the vulnerability is gone), do it in the same agent/turn while the analysis is fresh — right before or as part of filing the report — never as a second re-analysis pass.
|
||||
|
||||
COMBINED MODE (code + deployed target present):
|
||||
@@ -202,12 +202,14 @@ VALIDATION REQUIREMENTS:
|
||||
- Keep going until you find something that matters
|
||||
- CLOSURE DISCIPLINE: every candidate you open ends in exactly one explicit state — `confirmed` (working PoC, or a complete source→control→sink→impact trace that is reachable), `ruled_out` (you can name the SPECIFIC control, at a location, that runs on every attacker-reachable path before the sink), or `open_proof_gap` (plausible, unconfirmed, and you could NOT name such a control). "I moved on" is not a closure state. Silently dropping an uncertain candidate is mislabelling an `open_proof_gap` as `ruled_out` and is how real bugs get missed.
|
||||
- Missing information is NOT proof of safety: no caller found, can't tell if deployed/exposed, couldn't stand up the service, build failed — each is an `open_proof_gap`, never a reason to mark a candidate clean. Difficulty is a reason to defer, not to suppress.
|
||||
{% if coverage_tools -%}
|
||||
- COVERAGE: record every surface you assess with `record_coverage` (surface + risk area + outcome + evidence), including the ones that came back clean — a report that only lists findings cannot say what was reviewed and cleared. Use the `needs_follow_up` outcome for anything left in an `open_proof_gap` state, and carry the same items up in `agent_finish(open_items=[...])`. The ledger is shared and mutable: when you resolve a surface another agent left open — or find that a closed one is not — move that entry with `update_coverage` instead of recording a second one for the same surface. The root agent reconciles all of it via `list_coverage` before `finish_scan`.
|
||||
{% endif -%}
|
||||
- THREAT MODEL: before you start testing, call `get_threat_model` on the target you were pointed at — it is the scan's shared answer to who the attacker is, where the trust boundaries sit, and what counts as critical here. It is scoped to this scan and nothing carries over from an earlier run, so `found: false` means no agent on this run has derived one yet. Read it instead of re-deriving trust boundaries yourself; where your testing disproves it — a boundary it calls trusted turns out to be attacker-reachable, a role it did not know about, a host or endpoint it never listed — record that with `amend_threat_model` so the agents after you inherit the correction. Amending is not optional politeness: a model nobody corrects turns the first agent's guesses into everyone's assumptions.
|
||||
- Before filing any report, run the counterevidence pass: argue the strongest case AGAINST the finding, record what you found in the `counterevidence` field, set `confidence` honestly (a static-only trace you couldn't execute is at best `medium`), and state what evidence would change the severity. See the counterevidence and severity-calibration knowledge above.
|
||||
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
|
||||
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent. If your evidence proves more than the finding it matched (a working exploit where that one had only a static trace, a chain that raises the impact), revise that finding with update_vulnerability_report using the duplicate_of id — never re-file it.
|
||||
- A vulnerability is ONLY considered reported when a reporting agent uses {{ report_tool }} (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via {{ report_tool }} (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
|
||||
- DEDUPLICATION: The {{ report_tool }} tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent. If your evidence proves more than the finding it matched (a working exploit where that one had only a static trace, a chain that raises the impact), revise that finding with update_vulnerability_report using the duplicate_of id — never re-file it.
|
||||
- HTTP EVIDENCE: a finding you validated through the proxy is not fully filed until `http_exchange_ids` carries the proxy request ids of the exchanges that prove it — the request that triggers the vulnerability plus the baseline/control request it differs from (an unauthenticated success next to the authenticated one, the payload response next to the benign one). Copy the ids exactly as `list_requests`/`view_request` show them, never invent or guess one, and never omit the field to bypass validation. Leave it out only when there is no captured HTTP exchange at all (static-only code findings, dependency CVEs). If you filed before the proving exchanges existed, attach them afterwards with update_vulnerability_report. Without the ids, the finding ships as prose nobody can replay.
|
||||
- REVISING A FINDING: use update_vulnerability_report (report id + the fields you want to replace + update_reason) when you learn something a finding already on file does not carry — you built the PoC after filing it, a chain raised its impact, further testing weakened it, or its counterevidence/remediation/code locations were wrong. Editing a finding needs no duplicate verdict, and it is always better than filing a second report for the same issue. Read the finding first with get_report, and pass only the fields that change.
|
||||
- WITHDRAWING A FINDING: when re-testing proves a finding you filed is not a vulnerability at all — the exploit only worked because of your own test setup (a mixed-up session, a self-inflicted state change, a misread response), the behaviour is intended, or the control is in fact enforced — delete it with delete_vulnerability_report (report id + delete_reason). Never leave a disproved finding on file as a zero-CVSS, "retracted", or "false positive" report, and never rewrite its text into a note saying it was wrong: a report that exists is read as a finding. Delete is for a finding that does not exist; a finding that is real but weaker than filed is revised, not deleted.
|
||||
@@ -220,14 +222,16 @@ Every one of these tools writes to state the rest of the scan reads. Reaching fo
|
||||
- TODOS — `create_todo` / `list_todos` / `update_todo` / `mark_todo_done` / `mark_todo_pending` / `delete_todo`: your own working checklist for a multi-step task. Create todos when your task has several distinct steps so nothing is dropped across a long run; mark them done as you finish. This is private working memory — use `notes` for anything another agent needs.
|
||||
- NOTES — `create_note` / `list_notes` / `get_note` / `update_note` / `delete_note`: the scan's shared scratchpad, visible to every agent. Write a note for a durable cross-agent fact that is not a finding and not coverage — a working credential set, a discovered endpoint inventory, an enumerated tenant list, a rate-limit quirk the next agent needs. `update_note` to keep a living inventory current; `delete_note` only for something now wrong or superseded. Check `list_notes`/`get_note` before recon work so you build on what is already mapped instead of redoing it.
|
||||
- THREAT MODEL — `get_threat_model` / `amend_threat_model` / `save_threat_model`: covered above. `save_threat_model` REPLACES the whole document and clears amendments, so it is for establishing the baseline or folding amendments in (normally root) — to correct part of an existing model, `amend_threat_model` instead.
|
||||
{% if coverage_tools -%}
|
||||
- COVERAGE — `record_coverage` / `update_coverage` / `list_coverage`: covered above. One row per surface+risk; correct an existing row with `update_coverage`, never a second `record_coverage`.
|
||||
{% endif -%}
|
||||
- RESEARCH — `web_search`: pull fresh, target-specific external knowledge — latest bypasses, WAF evasions, DB-/framework-specific syntax, CVE and advisory detail — before falling back to memorized payloads, and refresh payload corpora mid-spray.
|
||||
- SPAWN WORK — `create_agent`: delegate a focused subtask to a specialist child (see the multi-agent rules below for when to spawn and how to scope it). Give it the target to model against and what is already known.
|
||||
- TRACK CHILDREN — `view_agent_graph`: your live map of every agent and its status. Call it before spawning (to confirm no existing agent already covers the scope) and before finishing (to confirm no child is still running).
|
||||
- STEER CHILDREN — `send_message_to_agent`: send a running child new information, a course correction, or a request to wrap up, without killing it. Use it to answer a child's question or narrow its scope mid-run.
|
||||
- BLOCK ON CHILDREN — `wait_for_agents`: block until named children report back when your next move genuinely depends on their results. If you can keep making progress in parallel, keep working instead of waiting.
|
||||
- CANCEL CHILDREN — `stop_agent`: gracefully cancel a child whose work is redundant, misdirected, or no longer needed. Prefer `send_message_to_agent` to redirect a child that is merely off-track; reserve `stop_agent` for work that should not continue at all.
|
||||
- FINISH — subagents call `agent_finish` (with `open_items=[...]` for anything left unresolved); the root agent calls `finish_scan` exactly once, only after every child is wrapped up and coverage is reconciled. `agent_finish`/`finish_scan` are handoffs, not reporting channels — a vulnerability is reported only via `create_vulnerability_report`/`create_dependency_report`.
|
||||
- FINISH — subagents call `agent_finish` (with `open_items=[...]` for anything left unresolved); the root agent calls `finish_scan` exactly once, only after every child is wrapped up and coverage is reconciled. `agent_finish`/`finish_scan` are handoffs, not reporting channels — a vulnerability is reported only via `{{ report_tool }}`/`create_dependency_report`.
|
||||
</execution_guidelines>
|
||||
|
||||
<vulnerability_focus>
|
||||
@@ -380,7 +384,7 @@ CRITICAL RULES:
|
||||
- **REALISTIC OUTCOMES** - Some tests find nothing, some validations fail
|
||||
- **ONE AGENT = ONE TASK** - Don't let agents do multiple unrelated jobs
|
||||
- **SPAWN REACTIVELY** - Create new agents based on what you discover
|
||||
- **ONLY REPORTING AGENTS** can use create_vulnerability_report tool
|
||||
- **ONLY REPORTING AGENTS** can use {{ report_tool }} tool
|
||||
- **AGENT SPECIALIZATION MANDATORY** - Each agent must be highly specialized; prefer 1–3 skills, up to 5 for complex contexts
|
||||
- **NO GENERIC AGENTS** - Avoid creating broad, multi-purpose agents that dilute focus
|
||||
|
||||
|
||||
@@ -705,7 +705,11 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
"""Apply Strix config to SDK-native defaults."""
|
||||
llm = settings.llm
|
||||
set_tracing_disabled(True)
|
||||
from strix.report.state import record_llm_request
|
||||
|
||||
request_log.install()
|
||||
if settings.telemetry.run_record_timings:
|
||||
request_log.register_sink(record_llm_request)
|
||||
if codex.subscription_model(llm.model):
|
||||
return
|
||||
_configure_litellm_compatibility()
|
||||
|
||||
@@ -163,6 +163,7 @@ class TelemetrySettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
enabled: bool = Field(default=True, alias="STRIX_TELEMETRY")
|
||||
run_record_timings: bool = Field(default=False, alias="STRIX_RUN_RECORD_TIMINGS")
|
||||
|
||||
|
||||
WebSearchProvider = Literal["auto", "perplexity", "exa"]
|
||||
|
||||
@@ -24,6 +24,8 @@ from openai import (
|
||||
|
||||
from strix.config import codex
|
||||
from strix.core.hooks import (
|
||||
SUBAGENT_TURN_LIMITS_KEY,
|
||||
TURN_LIMIT_KEY,
|
||||
BudgetExceededError,
|
||||
BudgetPausedError,
|
||||
SubagentBudgetReservedError,
|
||||
@@ -39,6 +41,7 @@ from strix.core.sessions import (
|
||||
)
|
||||
from strix.llm import request_log
|
||||
from strix.llm.compaction import is_context_overflow, maybe_compact
|
||||
from strix.skills import skill_turn_limit
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -427,6 +430,7 @@ async def spawn_child_agent(
|
||||
name=name,
|
||||
parent_id=parent_id,
|
||||
task=task,
|
||||
skills=skills,
|
||||
initial_input=child_initial_input(
|
||||
name=name,
|
||||
child_id=child_id,
|
||||
@@ -510,6 +514,7 @@ async def respawn_subagents(
|
||||
name=name,
|
||||
parent_id=parent_id,
|
||||
task=str(md.get("task", "")),
|
||||
skills=child_skills,
|
||||
initial_input=[],
|
||||
start_parked=start_parked,
|
||||
event_sink=event_sink,
|
||||
@@ -1161,6 +1166,7 @@ async def _start_child_runner(
|
||||
name: str,
|
||||
parent_id: str | None,
|
||||
task: str,
|
||||
skills: list[str],
|
||||
initial_input: Any,
|
||||
start_parked: bool = False,
|
||||
event_sink: StreamEventSink | None = None,
|
||||
@@ -1174,6 +1180,9 @@ async def _start_child_runner(
|
||||
child_ctx["agent_id"] = child_id
|
||||
child_ctx["parent_id"] = parent_id
|
||||
child_ctx["task"] = task
|
||||
child_ctx[TURN_LIMIT_KEY] = skill_turn_limit(
|
||||
skills, parent_ctx.get(SUBAGENT_TURN_LIMITS_KEY) or {}
|
||||
)
|
||||
|
||||
async def _child_loop() -> None:
|
||||
# A budget stop is a clean scan-wide shutdown, not a child failure: the
|
||||
|
||||
+80
-1
@@ -4,24 +4,32 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents.lifecycle import RunHooks
|
||||
|
||||
from strix.core.agents import BudgetPolicy, coordinator_from_context
|
||||
from strix.report.state import get_global_report_state
|
||||
from strix.report.state import get_global_report_state, record_tool_call
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents import RunContextWrapper
|
||||
from agents.agent import Agent
|
||||
from agents.items import ModelResponse, TResponseInputItem
|
||||
from agents.tool import Tool
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
LLM_TURN_KEY = "llm_turn"
|
||||
# ``run_strix_scan(subagent_turn_limits=...)``, and the limit it gives one sub-agent.
|
||||
SUBAGENT_TURN_LIMITS_KEY = "subagent_turn_limits"
|
||||
TURN_LIMIT_KEY = "turn_limit"
|
||||
_TURN_LIMIT_WARN_BEFORE = 10
|
||||
_TURN_LIMIT_REPEAT = 5
|
||||
|
||||
# ``BudgetPolicy`` decides what happens when the accumulated LLM cost reaches
|
||||
# ``max_budget_usd``.
|
||||
@@ -35,6 +43,8 @@ LLM_TURN_KEY = "llm_turn"
|
||||
# same call when the operator raises the limit or resumes.
|
||||
__all__ = [
|
||||
"LLM_TURN_KEY",
|
||||
"SUBAGENT_TURN_LIMITS_KEY",
|
||||
"TURN_LIMIT_KEY",
|
||||
"BudgetExceededError",
|
||||
"BudgetPausedError",
|
||||
"BudgetPolicy",
|
||||
@@ -163,6 +173,7 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
max_turns: int | None = None,
|
||||
interactive: bool = False,
|
||||
budget_policy: BudgetPolicy = "stop",
|
||||
record_tool_timings: bool = False,
|
||||
) -> None:
|
||||
_validate_budget(max_budget_usd)
|
||||
if max_turns is not None and max_turns <= 0:
|
||||
@@ -175,6 +186,8 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
self._max_turns = max_turns
|
||||
self._interactive = interactive
|
||||
self._budget_policy: BudgetPolicy = budget_policy
|
||||
self._record_tool_timings = record_tool_timings
|
||||
self._tool_starts: dict[str, tuple[datetime, float]] = {}
|
||||
|
||||
@property
|
||||
def max_budget_usd(self) -> float | None:
|
||||
@@ -207,6 +220,7 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
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_turn_limit(context, input_items)
|
||||
self._maybe_warn_budget(context, input_items)
|
||||
except Exception:
|
||||
logger.exception("budget/turn warning injection failed")
|
||||
@@ -270,6 +284,40 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
)
|
||||
input_items.append({"role": "user", "content": content})
|
||||
|
||||
def _maybe_warn_turn_limit(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
"""A soft limit, never a stop: warn 10 turns before it, then every few turns past it."""
|
||||
limit = context.context.get(TURN_LIMIT_KEY)
|
||||
requests = getattr(getattr(context, "usage", None), "requests", None)
|
||||
if not isinstance(limit, int) or not isinstance(requests, int):
|
||||
return
|
||||
turns_used = requests + 1
|
||||
if turns_used == limit - _TURN_LIMIT_WARN_BEFORE:
|
||||
content = (
|
||||
f"[NOTICE] You have used {turns_used} of your {limit}-turn limit. Start "
|
||||
"wrapping up: take only the steps you still need to answer your task, then "
|
||||
"call agent_finish. You may go past the limit only while you are still "
|
||||
"making real progress."
|
||||
)
|
||||
elif turns_used >= limit and (turns_used - limit) % _TURN_LIMIT_REPEAT == 0:
|
||||
content = (
|
||||
f"[NOTICE] You have used {turns_used} turns, past your {limit}-turn limit. "
|
||||
"Call agent_finish now with what you have, listing anything unresolved in "
|
||||
"open_items, unless your next steps are clearly making progress on your task."
|
||||
)
|
||||
else:
|
||||
return
|
||||
logger.info(
|
||||
"Turn-limit notice for agent %s: turn %d, limit %d",
|
||||
context.context.get("agent_id"),
|
||||
turns_used,
|
||||
limit,
|
||||
)
|
||||
input_items.append({"role": "user", "content": content})
|
||||
|
||||
def _maybe_warn_budget(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
@@ -315,6 +363,37 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
)
|
||||
input_items.append({"role": "user", "content": content})
|
||||
|
||||
async def on_tool_start(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
agent: Agent[dict[str, Any]], # noqa: ARG002
|
||||
tool: Tool, # noqa: ARG002
|
||||
) -> None:
|
||||
call_id = getattr(context, "tool_call_id", None)
|
||||
if self._record_tool_timings and isinstance(call_id, str):
|
||||
self._tool_starts[call_id] = (datetime.now(UTC), time.monotonic())
|
||||
|
||||
async def on_tool_end(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
agent: Agent[dict[str, Any]], # noqa: ARG002
|
||||
tool: Tool,
|
||||
result: object, # noqa: ARG002
|
||||
) -> None:
|
||||
call_id = getattr(context, "tool_call_id", None)
|
||||
start = self._tool_starts.pop(call_id, None) if isinstance(call_id, str) else None
|
||||
if start is None:
|
||||
return
|
||||
started_at, started = start
|
||||
ctx = context.context if isinstance(context.context, dict) else {}
|
||||
agent_id = ctx.get("agent_id")
|
||||
record_tool_call(
|
||||
started_at=started_at,
|
||||
agent_id=agent_id if isinstance(agent_id, str) else None,
|
||||
tool=tool.name,
|
||||
duration_ms=round((time.monotonic() - started) * 1000),
|
||||
)
|
||||
|
||||
async def on_llm_end(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
|
||||
+30
-2
@@ -8,7 +8,7 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -36,7 +36,12 @@ from strix.core.execution import (
|
||||
from strix.core.execution import (
|
||||
spawn_child_agent as start_child_agent,
|
||||
)
|
||||
from strix.core.hooks import BudgetExceededError, ReportUsageHooks, recomputed_budget_flags
|
||||
from strix.core.hooks import (
|
||||
SUBAGENT_TURN_LIMITS_KEY,
|
||||
BudgetExceededError,
|
||||
ReportUsageHooks,
|
||||
recomputed_budget_flags,
|
||||
)
|
||||
from strix.core.inputs import (
|
||||
build_root_task,
|
||||
build_scan_targets,
|
||||
@@ -54,6 +59,7 @@ from strix.tools.output_store import (
|
||||
WORKSPACE_SPILL_DIR,
|
||||
configure_spill_writer,
|
||||
)
|
||||
from strix.tools.reporting.tool import create_vulnerability_report
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -172,6 +178,8 @@ def _compose_root_instructions_override(
|
||||
interactive: bool,
|
||||
system_prompt_context: dict[str, Any],
|
||||
supports_images: bool,
|
||||
report_tool: str,
|
||||
coverage_tools: bool,
|
||||
) -> str | None:
|
||||
if root_instructions_override is None:
|
||||
return None
|
||||
@@ -186,6 +194,8 @@ def _compose_root_instructions_override(
|
||||
system_prompt_context=system_prompt_context,
|
||||
include_scope=False,
|
||||
supports_images=supports_images,
|
||||
report_tool=report_tool,
|
||||
coverage_tools=coverage_tools,
|
||||
)
|
||||
return (
|
||||
f"{base_instructions}\n\n"
|
||||
@@ -218,6 +228,9 @@ async def run_strix_scan(
|
||||
mcp_connection_requests: list[McpConnectionRequest] | None = None,
|
||||
mcp_status_sink: McpStatusSink | None = None,
|
||||
root_finish_tool: Tool = finish_scan,
|
||||
report_tool: Tool = create_vulnerability_report,
|
||||
subagent_turn_limits: Mapping[str, int] | None = None,
|
||||
coverage_tools: bool = True,
|
||||
) -> RunResultBase | None:
|
||||
"""Run or resume one Strix scan against a sandbox.
|
||||
|
||||
@@ -241,6 +254,13 @@ async def run_strix_scan(
|
||||
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.
|
||||
``report_tool`` is the tool every agent files findings with, in place of
|
||||
``create_vulnerability_report``.
|
||||
``subagent_turn_limits`` maps a skill name to a soft turn limit for sub-agents
|
||||
spawned with that skill (the largest applies). Agents are warned 10 turns
|
||||
before it and reminded past it, but never stopped.
|
||||
``coverage_tools=False`` drops the coverage ledger tools and their prompt
|
||||
rules from every agent.
|
||||
"""
|
||||
|
||||
def report(phase: str) -> None:
|
||||
@@ -419,6 +439,7 @@ async def run_strix_scan(
|
||||
max_turns=max_turns,
|
||||
interactive=interactive,
|
||||
budget_policy=budget_policy,
|
||||
record_tool_timings=settings.telemetry.run_record_timings,
|
||||
)
|
||||
coordinator.set_budget_limit_setter(hooks.set_max_budget_usd)
|
||||
if interactive and budget_policy != "pause":
|
||||
@@ -494,6 +515,8 @@ async def run_strix_scan(
|
||||
interactive=interactive,
|
||||
system_prompt_context=root_context,
|
||||
supports_images=supports_images,
|
||||
report_tool=report_tool.name,
|
||||
coverage_tools=coverage_tools,
|
||||
)
|
||||
|
||||
root_agent = build_strix_agent(
|
||||
@@ -510,6 +533,8 @@ async def run_strix_scan(
|
||||
instructions_override=root_instructions,
|
||||
supports_images=supports_images,
|
||||
finish_tool=root_finish_tool,
|
||||
report_tool=report_tool,
|
||||
coverage_tools=coverage_tools,
|
||||
)
|
||||
|
||||
if not is_resume:
|
||||
@@ -530,6 +555,8 @@ async def run_strix_scan(
|
||||
strict_tool_schemas=strict_tool_schemas,
|
||||
system_prompt_context=scope_context,
|
||||
supports_images=supports_images,
|
||||
report_tool=report_tool,
|
||||
coverage_tools=coverage_tools,
|
||||
)
|
||||
|
||||
async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]:
|
||||
@@ -558,6 +585,7 @@ async def run_strix_scan(
|
||||
"scan_targets": build_scan_targets(scan_config),
|
||||
"max_context_images": settings.runtime.max_context_images,
|
||||
"supports_images": supports_images,
|
||||
SUBAGENT_TURN_LIMITS_KEY: dict(subagent_turn_limits or {}),
|
||||
}
|
||||
|
||||
root_session = open_agent_session(root_id, agents_db)
|
||||
|
||||
@@ -28,6 +28,8 @@ from strix.telemetry import posthog, scarf
|
||||
if TYPE_CHECKING:
|
||||
from agents.usage import Usage
|
||||
|
||||
from strix.llm.request_log import LlmRequestEvent
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1019,6 +1021,47 @@ class StreamedOpenRouterCosts:
|
||||
streamed_openrouter_costs = StreamedOpenRouterCosts()
|
||||
|
||||
|
||||
def record_llm_request(event: "LlmRequestEvent") -> None:
|
||||
"""Request-log sink: each provider attempt's timing, kept in run.json's ``llm_requests``."""
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return
|
||||
details = event.details or {}
|
||||
report_state.run_record.setdefault("llm_requests", []).append(
|
||||
{
|
||||
"started_at": event.started_at.isoformat(),
|
||||
"agent_id": event.agent_id,
|
||||
"model": event.model,
|
||||
"provider": details.get("upstream_provider") or event.provider,
|
||||
"outcome": event.outcome,
|
||||
"status": event.status_code,
|
||||
"duration_ms": event.duration_ms,
|
||||
"ttft_ms": event.time_to_first_token_ms,
|
||||
"input_tokens": event.input_tokens,
|
||||
"cached_tokens": event.cached_input_tokens,
|
||||
"output_tokens": event.output_tokens,
|
||||
"retry_attempt": event.retry_attempt,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def record_tool_call(
|
||||
*, started_at: datetime, agent_id: str | None, tool: str, duration_ms: int
|
||||
) -> None:
|
||||
"""Each tool call's timing, kept in run.json's ``tool_calls``."""
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return
|
||||
report_state.run_record.setdefault("tool_calls", []).append(
|
||||
{
|
||||
"started_at": started_at.isoformat(),
|
||||
"agent_id": agent_id,
|
||||
"tool": tool,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def record_openrouter_provider(provider: Any, usage: Any) -> None:
|
||||
"""Tally which upstream provider served a stream, from its final usage chunk.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Iterator, Mapping
|
||||
from pathlib import Path
|
||||
from typing import TypeGuard
|
||||
|
||||
@@ -270,6 +270,19 @@ def _candidate_skill_files(skill_name: str) -> list[Path]:
|
||||
return _bare_skill_files(skill_name)
|
||||
|
||||
|
||||
def skill_turn_limit(skill_names: list[str], turn_limits: Mapping[str, int]) -> int | None:
|
||||
"""Largest of ``turn_limits`` (keyed by skill name) among these skills, if any."""
|
||||
files = {path for name in skill_names for path in _candidate_skill_files(name)}
|
||||
return max(
|
||||
(
|
||||
limit
|
||||
for name, limit in turn_limits.items()
|
||||
if files.intersection(_candidate_skill_files(name))
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def load_skills(skill_names: list[str], *, supports_images: bool = True) -> dict[str, str]:
|
||||
"""Load skill markdown bodies (frontmatter stripped) by name.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ state, and "I moved on" is not one of them.
|
||||
|
||||
**1. `confirmed`** — you have a working PoC or, in white-box, a complete
|
||||
source → control → sink → impact trace plus evidence the path is
|
||||
reachable. File it with `create_vulnerability_report`.
|
||||
reachable. File it as a vulnerability report.
|
||||
|
||||
**2. `ruled_out`** — you can name the **specific control** that makes the
|
||||
code safe, at a specific location, and you have checked that the control
|
||||
@@ -33,8 +33,8 @@ that sentence, you are not in `ruled_out`.
|
||||
**3. `open_proof_gap`** — the candidate is plausible, you could not
|
||||
confirm it, and you also could not name a control that rules it out. This
|
||||
is a legitimate, expected outcome. Record it with
|
||||
`record_coverage(outcome="needs_follow_up")`, carry it up in
|
||||
`agent_finish(open_items=[...])`, and reflect it in `counterevidence` /
|
||||
`record_coverage(outcome="needs_follow_up")` if you have that tool, carry
|
||||
it up in `agent_finish(open_items=[...])`, and reflect it in `counterevidence` /
|
||||
`confidence_rationale` if you file a related report. Do **not** convert
|
||||
it to `ruled_out` to tidy up your worklist.
|
||||
|
||||
@@ -99,8 +99,8 @@ do not delete it.
|
||||
|
||||
## Recording Closure
|
||||
|
||||
Closure is only useful if it is written down. Every surface you assess
|
||||
gets a `record_coverage` entry:
|
||||
Closure is only useful if it is written down. If you have the coverage
|
||||
tools, every surface you assess gets a `record_coverage` entry:
|
||||
|
||||
- `confirmed` → outcome `reported`, once the report is filed.
|
||||
- `ruled_out` → outcome `ruled_out`, with the named control in
|
||||
@@ -144,8 +144,7 @@ endpoint is broken/unreachable for unrelated reasons".
|
||||
|
||||
## Before You File a Report
|
||||
|
||||
Run this pass on every finding before calling
|
||||
`create_vulnerability_report`:
|
||||
Run this pass on every finding before filing it:
|
||||
|
||||
1. **Argue the other side.** Spend real effort building the strongest
|
||||
case that this is *not* exploitable, or not as severe as you think.
|
||||
|
||||
@@ -207,5 +207,5 @@ maintainability complaints, and cosmetic variants of a candidate you
|
||||
already opened.
|
||||
|
||||
Keep reading until no distinct plausible candidate remains — then record
|
||||
what you swept with `record_coverage`, including the families that came
|
||||
back clean.
|
||||
what you swept with `record_coverage` (if you have it), including the
|
||||
families that came back clean.
|
||||
@@ -637,13 +637,13 @@ async def agent_finish(
|
||||
doing: what did you test, what did you find/confirm/rule out,
|
||||
what's still open.
|
||||
|
||||
**Close out honestly.** Before calling this, every surface you
|
||||
assessed should have a ``record_coverage`` entry, and anything you
|
||||
could neither confirm nor rule out belongs in ``open_items`` — an
|
||||
unresolved candidate handed up to the parent is useful, a silently
|
||||
dropped one is a missed vulnerability. Reporting nothing and
|
||||
listing no open items asserts the area is clean; only say that if
|
||||
you mean it.
|
||||
**Close out honestly.** Before calling this, every surface you assessed
|
||||
should have a ``record_coverage`` entry if you have that tool.
|
||||
Anything you could neither confirm nor rule out belongs in
|
||||
``open_items`` — an unresolved candidate handed up to the parent is
|
||||
useful, a silently dropped one is a missed vulnerability. Reporting
|
||||
nothing and listing no open items asserts the area is clean; only
|
||||
say that if you mean it.
|
||||
|
||||
Args:
|
||||
result_summary: What you accomplished and discovered. Concrete
|
||||
|
||||
@@ -69,6 +69,15 @@ async def _call[T](client: Client, fn: Callable[[Client], Awaitable[T]]) -> T:
|
||||
return await fn(client)
|
||||
|
||||
|
||||
_REQUEST_EXISTS_QUERY = "query RequestExists($id: ID!) { request(id: $id) { id } }"
|
||||
_REQUEST_EXISTS_ATTEMPTS = 2
|
||||
|
||||
|
||||
async def _request_exists(client: Client, request_id: str) -> bool:
|
||||
data = await client.graphql.query(_REQUEST_EXISTS_QUERY, {"id": request_id})
|
||||
return bool(data) and data.get("request") is not None
|
||||
|
||||
|
||||
async def existing_request_ids(
|
||||
ctx: RunContextWrapper,
|
||||
request_ids: list[str],
|
||||
@@ -81,16 +90,24 @@ async def existing_request_ids(
|
||||
if client is None:
|
||||
raise RuntimeError("Caido client is not available")
|
||||
|
||||
# Request IDs are not an HTTPQL field. Resolve each ID through the same
|
||||
# project-bound lookup as view_request rather than constructing a filter.
|
||||
# Request IDs are not an HTTPQL field, so each one is resolved by id. Only
|
||||
# the id is selected: pulling the raw request and response bodies just to
|
||||
# learn whether a row exists is what made this time out on busy projects.
|
||||
existing: set[str] = set()
|
||||
for request_id in request_ids:
|
||||
result = await _call(
|
||||
client,
|
||||
functools.partial(caido_api.get_request_with_client, request_id=request_id),
|
||||
)
|
||||
if result is not None:
|
||||
existing.add(str(result.request.id))
|
||||
for attempt in range(1, _REQUEST_EXISTS_ATTEMPTS + 1):
|
||||
try:
|
||||
exists = await _call(
|
||||
client,
|
||||
functools.partial(_request_exists, request_id=request_id),
|
||||
)
|
||||
except Exception:
|
||||
if attempt == _REQUEST_EXISTS_ATTEMPTS:
|
||||
raise
|
||||
continue
|
||||
if exists:
|
||||
existing.add(request_id)
|
||||
break
|
||||
return existing
|
||||
|
||||
|
||||
|
||||
@@ -222,9 +222,9 @@ def _normalize_http_exchange_ids(raw: Any) -> tuple[list[str] | None, list[str]]
|
||||
return normalized, errors
|
||||
|
||||
|
||||
_HTTP_EXCHANGE_DROPPED_WARNING = (
|
||||
"http_exchange_ids were not stored: the proxy project could not be reached to verify "
|
||||
"them. Attach them with update_vulnerability_report when the proxy responds again."
|
||||
_HTTP_EXCHANGE_UNVERIFIED_WARNING = (
|
||||
"http_exchange_ids were stored without verification because the proxy project did "
|
||||
"not respond in time. Ids that do not exist in the project will not render as evidence."
|
||||
)
|
||||
|
||||
|
||||
@@ -235,9 +235,9 @@ async def _verify_http_exchange_ids(
|
||||
"""Verify proxy exchange IDs against the current Caido project.
|
||||
|
||||
IDs the project does not know are rejected. When the proxy itself cannot be
|
||||
queried the IDs are dropped and a warning is returned instead, so a proxy
|
||||
outage never blocks a finding and unverified IDs are never recorded as
|
||||
evidence.
|
||||
queried the IDs are kept and a warning is returned instead: a slow or
|
||||
unreachable proxy must never strip evidence from a finding, and consumers
|
||||
resolve each id against the recorded traffic before rendering it.
|
||||
"""
|
||||
request_ids, errors = _normalize_http_exchange_ids(raw)
|
||||
if request_ids is None or errors or not request_ids:
|
||||
@@ -250,7 +250,7 @@ async def _verify_http_exchange_ids(
|
||||
"Could not verify HTTP exchange IDs against the current Caido project",
|
||||
exc_info=True,
|
||||
)
|
||||
return None, [], _HTTP_EXCHANGE_DROPPED_WARNING
|
||||
return request_ids, [], _HTTP_EXCHANGE_UNVERIFIED_WARNING
|
||||
|
||||
missing_ids = [request_id for request_id in request_ids if request_id not in existing_ids]
|
||||
if missing_ids:
|
||||
|
||||
@@ -93,6 +93,30 @@ def test_per_call_extra_tools_reject_duplicate_registered_names() -> None:
|
||||
factory.build_strix_agent(is_root=True, extra_tools=[_tool("same_name")])
|
||||
|
||||
|
||||
def test_report_tool_replaces_create_vulnerability_report() -> None:
|
||||
for is_root in (True, False):
|
||||
agent = factory.build_strix_agent(is_root=is_root, report_tool=_tool("file_pr_finding"))
|
||||
names = [t.name for t in agent.tools]
|
||||
assert "file_pr_finding" in names
|
||||
assert "create_vulnerability_report" not in names
|
||||
assert isinstance(agent.instructions, str)
|
||||
assert "file_pr_finding" in agent.instructions
|
||||
assert "create_vulnerability_report" not in agent.instructions
|
||||
|
||||
|
||||
def test_coverage_tools_can_be_turned_off() -> None:
|
||||
coverage = {"record_coverage", "update_coverage", "list_coverage"}
|
||||
for is_root in (True, False):
|
||||
on = factory.build_strix_agent(is_root=is_root)
|
||||
off = factory.build_strix_agent(is_root=is_root, coverage_tools=False)
|
||||
assert coverage <= {t.name for t in on.tools}
|
||||
assert not coverage & {t.name for t in off.tools}
|
||||
assert isinstance(on.instructions, str)
|
||||
assert isinstance(off.instructions, str)
|
||||
assert "- COVERAGE" in on.instructions
|
||||
assert "- COVERAGE" not in off.instructions
|
||||
|
||||
|
||||
def test_instructions_override_is_used_verbatim() -> None:
|
||||
custom = "You are a scan agent. Follow the provided scope."
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
@@ -18,11 +19,13 @@ from strix.config.models import (
|
||||
_configure_litellm_compatibility,
|
||||
_install_openrouter_stream_cost_capture,
|
||||
)
|
||||
from strix.core.hooks import ReportUsageHooks
|
||||
from strix.llm import request_log
|
||||
from strix.report.state import (
|
||||
ReportState,
|
||||
litellm_cost_callback,
|
||||
openrouter_stream_cost,
|
||||
record_llm_request,
|
||||
set_global_report_state,
|
||||
streamed_openrouter_costs,
|
||||
)
|
||||
@@ -455,3 +458,73 @@ def test_openrouter_records_upstream_of_pre_stream_error() -> None:
|
||||
request_log._http_reply.reset(token)
|
||||
assert reply.upstream_provider == "InferenceNet"
|
||||
assert reply.upstream_error_type is None
|
||||
|
||||
|
||||
def test_request_timings_land_in_run_record() -> None:
|
||||
started = datetime(2026, 10, 19, 12, 0, tzinfo=UTC)
|
||||
event = request_log.LlmRequestEvent(
|
||||
call_id="call-1",
|
||||
route="litellm",
|
||||
provider="openrouter",
|
||||
model="openrouter/deepseek/deepseek-v4.1-flash",
|
||||
api_host="openrouter.ai",
|
||||
streaming=True,
|
||||
outcome="success",
|
||||
status_code=200,
|
||||
provider_request_id=None,
|
||||
response_id="gen-1",
|
||||
error_type=None,
|
||||
error_message=None,
|
||||
started_at=started,
|
||||
finished_at=started + timedelta(milliseconds=4200),
|
||||
duration_ms=4200,
|
||||
input_tokens=90_000,
|
||||
output_tokens=600,
|
||||
cached_input_tokens=85_000,
|
||||
agent_id="agent-1",
|
||||
time_to_first_token_ms=900,
|
||||
details={"upstream_provider": "Together"},
|
||||
)
|
||||
state = ReportState.__new__(ReportState)
|
||||
state.run_record = {}
|
||||
try:
|
||||
set_global_report_state(state)
|
||||
record_llm_request(event)
|
||||
finally:
|
||||
set_global_report_state(None)
|
||||
|
||||
assert state.run_record["llm_requests"] == [
|
||||
{
|
||||
"started_at": "2026-10-19T12:00:00+00:00",
|
||||
"agent_id": "agent-1",
|
||||
"model": "openrouter/deepseek/deepseek-v4.1-flash",
|
||||
"provider": "Together",
|
||||
"outcome": "success",
|
||||
"status": 200,
|
||||
"duration_ms": 4200,
|
||||
"ttft_ms": 900,
|
||||
"input_tokens": 90_000,
|
||||
"cached_tokens": 85_000,
|
||||
"output_tokens": 600,
|
||||
"retry_attempt": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_tool_call_timings_land_in_run_record() -> None:
|
||||
hooks = ReportUsageHooks(model="m", record_tool_timings=True)
|
||||
context = SimpleNamespace(tool_call_id="call-1", context={"agent_id": "agent-1"})
|
||||
tool = SimpleNamespace(name="terminal_execute")
|
||||
state = ReportState.__new__(ReportState)
|
||||
state.run_record = {}
|
||||
try:
|
||||
set_global_report_state(state)
|
||||
await hooks.on_tool_start(context, MagicMock(), tool) # type: ignore[arg-type]
|
||||
await hooks.on_tool_end(context, MagicMock(), tool, "ok") # type: ignore[arg-type]
|
||||
finally:
|
||||
set_global_report_state(None)
|
||||
|
||||
[entry] = state.run_record["tool_calls"]
|
||||
assert entry["agent_id"] == "agent-1"
|
||||
assert entry["tool"] == "terminal_execute"
|
||||
assert entry["duration_ms"] >= 0
|
||||
@@ -507,3 +507,30 @@ def test_recomputed_budget_flags(
|
||||
expected: tuple[bool, bool],
|
||||
) -> None:
|
||||
assert recomputed_budget_flags(cost, max_budget, interactive=interactive) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_limit_warns_ten_before_and_repeats_past_it() -> None:
|
||||
hooks = ReportUsageHooks(model="test-model")
|
||||
noticed = []
|
||||
for requests in range(40):
|
||||
ctx = _make_warn_context(requests=requests, parent_id="root-1")
|
||||
ctx.context["turn_limit"] = 25
|
||||
items: list[Any] = []
|
||||
await hooks.on_llm_start(ctx, MagicMock(), None, items)
|
||||
if items:
|
||||
noticed.append((requests + 1, items[0]["content"]))
|
||||
|
||||
assert [turn for turn, _ in noticed] == [15, 25, 30, 35, 40]
|
||||
assert "15 of your 25-turn limit" in noticed[0][1]
|
||||
assert "past your 25-turn limit" in noticed[1][1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_turn_limit_no_notice() -> None:
|
||||
hooks = ReportUsageHooks(model="test-model")
|
||||
items: list[Any] = []
|
||||
await hooks.on_llm_start(
|
||||
_make_warn_context(requests=14, parent_id="root-1"), MagicMock(), None, items
|
||||
)
|
||||
assert items == []
|
||||
+45
-15
@@ -9,7 +9,6 @@ call at a time against the shared client.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
@@ -27,6 +26,7 @@ class _FakeClient:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.closed = False
|
||||
self.graphql: Any = None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
@@ -231,20 +231,23 @@ async def test_ctx_client_degrades_when_bootstrap_failed() -> None:
|
||||
assert await tools._ctx_client(cast("Any", _Ctx({"caido_client": handle}))) is None
|
||||
|
||||
|
||||
async def test_existing_request_ids_queries_current_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FakeGraphQL:
|
||||
def __init__(self, answers: dict[str, list[Any]]) -> None:
|
||||
self.answers = answers
|
||||
self.queries: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
async def query(self, document: str, variables: dict[str, Any]) -> dict[str, Any]:
|
||||
self.queries.append((document, dict(variables)))
|
||||
answer = self.answers[variables["id"]].pop(0)
|
||||
if isinstance(answer, Exception):
|
||||
raise answer
|
||||
return {"request": answer}
|
||||
|
||||
|
||||
async def test_existing_request_ids_select_only_the_id() -> None:
|
||||
client = _FakeClient("host")
|
||||
looked_up: list[str] = []
|
||||
|
||||
async def get_request_with_client(passed_client: Any, request_id: str) -> Any:
|
||||
assert passed_client is client
|
||||
looked_up.append(request_id)
|
||||
if request_id == "1042":
|
||||
return SimpleNamespace(request=SimpleNamespace(id="1042"))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(caido_api, "get_request_with_client", get_request_with_client)
|
||||
graphql = _FakeGraphQL({"1042": [{"id": "1042"}], "1088": [None]})
|
||||
client.graphql = graphql
|
||||
|
||||
existing = await tools.existing_request_ids(
|
||||
cast("Any", _Ctx({"caido_client": client})),
|
||||
@@ -252,7 +255,34 @@ async def test_existing_request_ids_queries_current_project(
|
||||
)
|
||||
|
||||
assert existing == {"1042"}
|
||||
assert looked_up == ["1042", "1088"]
|
||||
assert [variables["id"] for _, variables in graphql.queries] == ["1042", "1088"]
|
||||
for document, _ in graphql.queries:
|
||||
assert "raw" not in document
|
||||
assert "{ id }" in document
|
||||
|
||||
|
||||
async def test_existing_request_ids_retry_once_before_giving_up() -> None:
|
||||
client = _FakeClient("host")
|
||||
graphql = _FakeGraphQL(
|
||||
{
|
||||
"1042": [TimeoutError("deadline exceeded"), {"id": "1042"}],
|
||||
"1088": [TimeoutError("deadline exceeded"), TimeoutError("deadline exceeded")],
|
||||
}
|
||||
)
|
||||
client.graphql = graphql
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await tools.existing_request_ids(
|
||||
cast("Any", _Ctx({"caido_client": client})),
|
||||
["1042", "1088"],
|
||||
)
|
||||
|
||||
assert [variables["id"] for _, variables in graphql.queries] == [
|
||||
"1042",
|
||||
"1042",
|
||||
"1088",
|
||||
"1088",
|
||||
]
|
||||
|
||||
|
||||
def test_err_logs_caido_errors_as_warnings(caplog: pytest.LogCaptureFixture) -> None:
|
||||
|
||||
@@ -1640,7 +1640,7 @@ async def test_http_exchange_ids_must_exist_in_current_proxy_project(
|
||||
assert warning is None
|
||||
|
||||
|
||||
async def test_http_exchange_ids_are_dropped_when_proxy_cannot_be_queried(
|
||||
async def test_http_exchange_ids_are_kept_when_proxy_cannot_be_queried(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def existing_request_ids(
|
||||
@@ -1656,11 +1656,10 @@ async def test_http_exchange_ids_are_dropped_when_proxy_cannot_be_queried(
|
||||
["1042", "1042", "1088"],
|
||||
)
|
||||
|
||||
assert request_ids is None
|
||||
assert request_ids == ["1042", "1088"]
|
||||
assert errors == []
|
||||
assert warning is not None
|
||||
assert "not stored" in warning
|
||||
assert "update_vulnerability_report" in warning
|
||||
assert "without verification" in warning
|
||||
|
||||
|
||||
async def test_create_reports_persistence_failure_as_tool_error(
|
||||
@@ -1679,7 +1678,7 @@ async def test_create_reports_persistence_failure_as_tool_error(
|
||||
assert report_state.vulnerability_reports == []
|
||||
|
||||
|
||||
async def test_evidence_only_update_reports_proxy_outage_as_retryable(
|
||||
async def test_evidence_only_update_keeps_ids_when_proxy_cannot_be_queried(
|
||||
report_state: ReportState,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -1712,11 +1711,11 @@ async def test_evidence_only_update_reports_proxy_outage_as_retryable(
|
||||
)
|
||||
result = json.loads(raw)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "No fields to update" not in result["error"]
|
||||
assert "update_vulnerability_report" in result["error"]
|
||||
assert result["success"] is True
|
||||
assert "without verification" in result["warning"]
|
||||
assert result["report_id"] == "vuln-0009"
|
||||
assert report_state.vulnerability_reports[0] == original
|
||||
assert report_state.vulnerability_reports[0]["http_exchange_ids"] == ["204"]
|
||||
assert report_state.vulnerability_reports[0] != original
|
||||
|
||||
|
||||
def test_update_reports_persistence_failure_as_tool_error(report_state: ReportState) -> None:
|
||||
|
||||
@@ -12,6 +12,7 @@ from strix.skills import (
|
||||
register_skill_dir,
|
||||
registered_skill_dirs,
|
||||
skill_search_dirs,
|
||||
skill_turn_limit,
|
||||
validate_requested_skills,
|
||||
)
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
@@ -282,3 +283,16 @@ def test_new_skill_files_load() -> None:
|
||||
for name in names:
|
||||
key = name.split("/")[-1]
|
||||
assert loaded.get(key), f"{name} failed to load"
|
||||
|
||||
|
||||
def test_skill_turn_limit_matches_bare_and_qualified_names(tmp_path: Path) -> None:
|
||||
_write_skill(tmp_path, "pr_review", "investigator", "---\nname: investigator\n---\nx")
|
||||
_write_skill(tmp_path, "pr_review", "validator", "---\nname: validator\n---\nx")
|
||||
register_skill_dir(tmp_path)
|
||||
limits = {"pr_review/investigator": 25, "pr_review/validator": 40}
|
||||
|
||||
assert skill_turn_limit(["investigator"], limits) == 25
|
||||
assert skill_turn_limit(["pr_review/validator", "idor"], limits) == 40
|
||||
assert skill_turn_limit(["investigator", "validator"], limits) == 40
|
||||
assert skill_turn_limit(["idor"], limits) is None
|
||||
assert skill_turn_limit(["investigator"], {}) is None
|
||||
Reference in new issue
Block a user