mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
271 lines
10 KiB
Python
271 lines
10 KiB
Python
"""SDK run hooks used by Strix orchestration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from agents.lifecycle import RunHooks
|
|
|
|
from strix.report.state import get_global_report_state
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from agents import RunContextWrapper
|
|
from agents.agent import Agent
|
|
from agents.items import ModelResponse, TResponseInputItem
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_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)
|
|
_SUBAGENT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.75, 0.80, 0.85)
|
|
_SUBAGENT_BUDGET_RESERVE = 0.90
|
|
|
|
|
|
class BudgetExceededError(RuntimeError):
|
|
"""Raised when the accumulated LLM cost reaches the configured budget."""
|
|
|
|
|
|
class SubagentBudgetReservedError(RuntimeError):
|
|
"""Raised to stop a single sub-agent once the reserve threshold is crossed."""
|
|
|
|
|
|
class BudgetPausedError(RuntimeError):
|
|
"""Raised to park one agent when an interactive scan reaches its budget."""
|
|
|
|
|
|
def recomputed_budget_flags(
|
|
cost: float,
|
|
max_budget_usd: float | None,
|
|
*,
|
|
interactive: bool,
|
|
) -> tuple[bool, bool]:
|
|
"""Return the (budget_stopped, reserve_stopped) flags a resumed scan should carry."""
|
|
if max_budget_usd is None:
|
|
return False, False
|
|
if interactive:
|
|
return False, False
|
|
budget_stopped = cost >= max_budget_usd
|
|
reserve_stopped = cost >= max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
|
return budget_stopped, reserve_stopped
|
|
|
|
|
|
def _crossed_stage(fraction: float, bands: tuple[float, ...]) -> int | None:
|
|
crossed: int | None = None
|
|
for index, band in enumerate(bands):
|
|
if fraction >= band:
|
|
crossed = index
|
|
return crossed
|
|
|
|
|
|
_ROOT_DIRECTIVES: tuple[str, ...] = (
|
|
(
|
|
"As the root agent, begin planning your wind-down of the whole scan: avoid "
|
|
"starting large new lines of investigation, and keep your required objectives on "
|
|
"track so you can call finish_scan comfortably before the limit."
|
|
),
|
|
(
|
|
"As the root agent, prioritize wrapping up the whole scan now: stop opening new "
|
|
"lines of investigation, close out only what is essential, and move toward calling "
|
|
"finish_scan to compile and deliver the final report."
|
|
),
|
|
(
|
|
"As the root agent, STOP all other work on the whole scan and finish immediately: "
|
|
"secure your findings and call finish_scan now — anything left unfinished when the "
|
|
"limit is hit is discarded."
|
|
),
|
|
)
|
|
_SUBAGENT_DIRECTIVES: tuple[str, ...] = (
|
|
(
|
|
"As a sub-agent, begin planning your wind-down: avoid starting large new subtasks, "
|
|
"and if you are close to a confirmed, validated vulnerability, drive it to a result "
|
|
"you can report."
|
|
),
|
|
(
|
|
"As a sub-agent, prioritize wrapping up your task now: report any confirmed, "
|
|
"validated vulnerability, finish work that is nearly done rather than starting "
|
|
"anything new, and prepare to call agent_finish."
|
|
),
|
|
(
|
|
"As a sub-agent, STOP all other work and finish immediately: report any confirmed "
|
|
"vulnerability right now and call agent_finish to hand your results back to your "
|
|
"parent before you are cut off."
|
|
),
|
|
)
|
|
|
|
|
|
def _wrapup_directive(context: RunContextWrapper[dict[str, Any]], stage: int) -> str:
|
|
is_root = context.context.get("parent_id") is None
|
|
directives = _ROOT_DIRECTIVES if is_root else _SUBAGENT_DIRECTIVES
|
|
return directives[stage]
|
|
|
|
|
|
def _urgency(stage: int) -> str:
|
|
return _STAGE_LABELS[stage]
|
|
|
|
|
|
class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
|
"""Persist SDK-native usage and warn/stop as turn and cost budgets are consumed."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
model: str,
|
|
max_budget_usd: float | None = None,
|
|
max_turns: int | None = None,
|
|
interactive: bool = False,
|
|
) -> None:
|
|
if max_budget_usd is not None and (
|
|
not math.isfinite(max_budget_usd) or max_budget_usd <= 0
|
|
):
|
|
raise ValueError("max_budget_usd must be a finite number greater than 0")
|
|
if max_turns is not None and max_turns <= 0:
|
|
raise ValueError("max_turns must be a positive integer")
|
|
self._model = model
|
|
self._max_budget_usd = max_budget_usd
|
|
self._budget_increment = max_budget_usd
|
|
self._max_turns = max_turns
|
|
self._interactive = interactive
|
|
|
|
def extend_budget(self) -> None:
|
|
if self._max_budget_usd is None or self._budget_increment is None:
|
|
return
|
|
self._max_budget_usd += self._budget_increment
|
|
|
|
async def on_llm_start(
|
|
self,
|
|
context: RunContextWrapper[dict[str, Any]],
|
|
agent: Agent[dict[str, Any]], # noqa: ARG002
|
|
system_prompt: str | None, # noqa: ARG002
|
|
input_items: list[TResponseInputItem],
|
|
) -> None:
|
|
try:
|
|
self._maybe_warn_turns(context, input_items)
|
|
self._maybe_warn_budget(context, input_items)
|
|
except Exception:
|
|
logger.exception("budget/turn warning injection failed")
|
|
|
|
def _maybe_warn_turns(
|
|
self,
|
|
context: RunContextWrapper[dict[str, Any]],
|
|
input_items: list[TResponseInputItem],
|
|
) -> None:
|
|
if not self._max_turns:
|
|
return
|
|
usage = getattr(context, "usage", None)
|
|
requests = getattr(usage, "requests", None)
|
|
if not isinstance(requests, int):
|
|
return
|
|
turns_used = requests + 1
|
|
stage = _crossed_stage(turns_used / self._max_turns, _TURN_WARN_BANDS)
|
|
if stage is None:
|
|
return
|
|
remaining = max(self._max_turns - turns_used, 0)
|
|
pct = round(100 * turns_used / self._max_turns)
|
|
content = (
|
|
f"[{_urgency(stage)}] Turn budget: {turns_used}/{self._max_turns} used ({pct}%). "
|
|
f"About {remaining} turn(s) remain before this agent is force-stopped and any "
|
|
f"in-progress work is discarded. {_wrapup_directive(context, stage)}"
|
|
)
|
|
input_items.append({"role": "user", "content": content})
|
|
|
|
def _maybe_warn_budget(
|
|
self,
|
|
context: RunContextWrapper[dict[str, Any]],
|
|
input_items: list[TResponseInputItem],
|
|
) -> None:
|
|
if self._max_budget_usd is None:
|
|
return
|
|
report_state = get_global_report_state()
|
|
if report_state is None:
|
|
return
|
|
cost = report_state.get_total_llm_cost()
|
|
is_root = context.context.get("parent_id") is None
|
|
if self._interactive:
|
|
bands = _ROOT_BUDGET_WARN_BANDS
|
|
else:
|
|
bands = _ROOT_BUDGET_WARN_BANDS if is_root else _SUBAGENT_BUDGET_WARN_BANDS
|
|
stage = _crossed_stage(cost / self._max_budget_usd, bands)
|
|
if stage is None:
|
|
return
|
|
pct = round(100 * cost / self._max_budget_usd)
|
|
reserve_pct = round(_SUBAGENT_BUDGET_RESERVE * 100)
|
|
if self._interactive:
|
|
content = (
|
|
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
|
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
|
"is reached all agents are paused until the user chooses to continue. "
|
|
f"{_wrapup_directive(context, stage)}"
|
|
)
|
|
elif is_root:
|
|
content = (
|
|
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
|
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
|
"is reached the whole scan is stopped immediately, and sub-agents are stopped at "
|
|
f"{reserve_pct}% to reserve the remainder for your final report. "
|
|
f"{_wrapup_directive(context, stage)}"
|
|
)
|
|
else:
|
|
content = (
|
|
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
|
f"spent ({pct}%). This budget is shared across every agent in the scan; "
|
|
f"sub-agents are stopped at {reserve_pct}% to leave the remainder for the root "
|
|
f"agent's final report. {_wrapup_directive(context, stage)}"
|
|
)
|
|
input_items.append({"role": "user", "content": content})
|
|
|
|
async def on_llm_end(
|
|
self,
|
|
context: RunContextWrapper[dict[str, Any]],
|
|
agent: Agent[dict[str, Any]],
|
|
response: ModelResponse,
|
|
) -> None:
|
|
report_state = get_global_report_state()
|
|
if report_state is None:
|
|
return
|
|
|
|
ctx = context.context if isinstance(context.context, dict) else {}
|
|
agent_name = getattr(agent, "name", None)
|
|
if not isinstance(agent_name, str):
|
|
agent_name = None
|
|
agent_id = ctx.get("agent_id")
|
|
if not isinstance(agent_id, str) or not agent_id:
|
|
agent_id = agent_name or "unknown"
|
|
|
|
try:
|
|
report_state.record_sdk_usage(
|
|
agent_id=agent_id,
|
|
agent_name=agent_name,
|
|
model=self._model,
|
|
usage=response.usage,
|
|
)
|
|
except Exception:
|
|
logger.exception("failed to record SDK usage for agent %s", agent_id)
|
|
|
|
if self._max_budget_usd is not None:
|
|
cost = report_state.get_total_llm_cost()
|
|
if cost >= self._max_budget_usd:
|
|
if self._interactive:
|
|
raise BudgetPausedError(
|
|
f"Scan budget of ${self._max_budget_usd:.2f} reached "
|
|
f"(spent ${cost:.4f}); pausing until the user continues"
|
|
)
|
|
raise BudgetExceededError(
|
|
f"Token budget of ${self._max_budget_usd:.2f} exceeded (spent ${cost:.4f})"
|
|
)
|
|
is_root = ctx.get("parent_id") is None
|
|
if not self._interactive and not is_root:
|
|
reserve_limit = self._max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
|
if cost >= reserve_limit:
|
|
raise SubagentBudgetReservedError(
|
|
f"Sub-agent budget reserve reached: spent ${cost:.4f} of "
|
|
f"${self._max_budget_usd:.2f} "
|
|
f"(>= {round(_SUBAGENT_BUDGET_RESERVE * 100)}% reserve); stopping this "
|
|
"sub-agent so the root agent can finish the scan."
|
|
)
|