mirror of
https://github.com/usestrix/strix.git
synced 2026-11-09 13:31:41 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f565cece23 | ||
|
|
94adabd67e | ||
|
|
3a69a45f86 | ||
|
|
e011253ebc | ||
|
|
5227960e99 | ||
|
|
f1386cad37 | ||
|
|
d6db4f1f89 | ||
|
|
03d2c6c168 |
No files matched your search
@@ -95,7 +95,7 @@ Key flags:
|
||||
| `--max-budget USD` | Hard LLM spend cap; scan wraps up cleanly at the limit. |
|
||||
| `--max-turns N` | Per-agent turn cap (default 500). |
|
||||
| `--fail-on SEVERITY` | Headless only: exit `2` only for findings at or above `critical`/`high`/`medium`/`low`/`info`. Default: any finding. |
|
||||
| `--resume RUN_NAME` | Resume a prior run from `strix_runs/`, with its agent history and targets. Cannot be combined with `-t`. |
|
||||
| `-r`, `--resume RUN_NAME` | Resume a prior run from `strix_runs/`, with its agent history and targets. Cannot be combined with `-t`. |
|
||||
| `--scope-mode` | For code targets: `auto` (diff-scope in CI/headless), `diff` (force changed files only), `full` (whole tree). |
|
||||
| `--diff-base REF` | Branch or commit that `diff` scope compares against. Defaults to the repo's default branch. |
|
||||
|
||||
|
||||
@@ -517,6 +517,8 @@ def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool:
|
||||
completion_key = "agent_completed"
|
||||
elif tool_name == "finish_scan":
|
||||
completion_key = "scan_completed"
|
||||
elif tool_name == "finish_pr_review": # registered by strix-pro
|
||||
completion_key = "review_completed"
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
+76
-2
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from functools import cache
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -34,7 +36,12 @@ from strix.core.sessions import (
|
||||
strip_all_images_from_session,
|
||||
)
|
||||
from strix.llm import request_log
|
||||
from strix.llm.compaction import is_context_overflow, maybe_compact
|
||||
from strix.llm.compaction import (
|
||||
CompactionNeededError,
|
||||
is_context_overflow,
|
||||
maybe_compact,
|
||||
needs_compaction,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -44,6 +51,7 @@ if TYPE_CHECKING:
|
||||
from agents.lifecycle import RunHooks
|
||||
from agents.memory import Session, SQLiteSession
|
||||
from agents.result import RunResultBase
|
||||
from agents.run_config import CallModelData, ModelInputData
|
||||
|
||||
from strix.core.agents import AgentCoordinator, Status
|
||||
|
||||
@@ -120,6 +128,51 @@ async def _compact_session(
|
||||
)
|
||||
|
||||
|
||||
async def _session_needs_compaction(agent: Any, session: Session, run_config: RunConfig) -> bool:
|
||||
model = _run_config_model(run_config)
|
||||
if model is None:
|
||||
return False
|
||||
return await asyncio.to_thread(
|
||||
needs_compaction,
|
||||
model,
|
||||
_agent_instructions(agent),
|
||||
_agent_tools_text(agent),
|
||||
list(await session.get_items()),
|
||||
)
|
||||
|
||||
|
||||
def _with_compaction_check(run_config: RunConfig) -> RunConfig:
|
||||
"""Stop the run before a model request that would overrun the context window so
|
||||
the cycle can compact and resume: the SDK builds each request from its in-memory
|
||||
history, so compacting the session mid-run would not shrink it."""
|
||||
model = _run_config_model(run_config)
|
||||
if model is None:
|
||||
return run_config
|
||||
inner = run_config.call_model_input_filter
|
||||
first_call = True
|
||||
|
||||
async def _check(data: CallModelData[Any]) -> ModelInputData:
|
||||
nonlocal first_call
|
||||
model_data = data.model_data
|
||||
if inner is not None:
|
||||
result = inner(data)
|
||||
model_data = await result if inspect.isawaitable(result) else result
|
||||
# Skip the first call: the cycle compacts right before the run, and the run's
|
||||
# new input is only persisted after this filter, so stopping here would lose it.
|
||||
if not first_call and await asyncio.to_thread(
|
||||
needs_compaction,
|
||||
model,
|
||||
model_data.instructions or "",
|
||||
_agent_tools_text(data.agent),
|
||||
model_data.input,
|
||||
):
|
||||
raise CompactionNeededError(model)
|
||||
first_call = False
|
||||
return model_data
|
||||
|
||||
return replace(run_config, call_model_input_filter=_check)
|
||||
|
||||
|
||||
_MAX_TRANSIENT_MODEL_RETRIES = 5
|
||||
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
|
||||
_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 90.0
|
||||
@@ -725,6 +778,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
image_strips = 0
|
||||
compactions = 0
|
||||
model_retries = 0
|
||||
check_context = session is not None
|
||||
request_log.set_retry_attempt(0)
|
||||
while True:
|
||||
stream: Any = None
|
||||
@@ -747,7 +801,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
stream = Runner.run_streamed(
|
||||
agent,
|
||||
input=input_data,
|
||||
run_config=run_config,
|
||||
run_config=_with_compaction_check(run_config) if check_context else run_config,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
session=session,
|
||||
@@ -824,6 +878,26 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
)
|
||||
input_data = []
|
||||
continue
|
||||
if session is not None and isinstance(exc, CompactionNeededError):
|
||||
# Rerun without the check if compaction can't bring the session under
|
||||
# the budget; a real overflow then falls to the capped recovery below.
|
||||
try:
|
||||
compacted = await _compact_session(agent, session, run_config, force=True)
|
||||
check_context = compacted and not await _session_needs_compaction(
|
||||
agent, session, run_config
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("context budget compaction failed for %s", agent_id)
|
||||
check_context = False
|
||||
logger.info(
|
||||
"%s hit the context budget; %s",
|
||||
agent_id,
|
||||
"compacted and resuming"
|
||||
if check_context
|
||||
else "rerunning without the budget check",
|
||||
)
|
||||
input_data = []
|
||||
continue
|
||||
if (
|
||||
compactions < _MAX_COMPACTIONS_PER_CYCLE
|
||||
and session is not None
|
||||
|
||||
+4
-10
@@ -592,16 +592,10 @@ async def run_strix_scan(
|
||||
)
|
||||
if not interactive and result is not None:
|
||||
final = getattr(result, "final_output", None)
|
||||
scan_completed = False
|
||||
if isinstance(final, str):
|
||||
try:
|
||||
parsed = json.loads(final)
|
||||
scan_completed = bool(isinstance(parsed, dict) and parsed.get("scan_completed"))
|
||||
except (ValueError, TypeError):
|
||||
scan_completed = False
|
||||
elif isinstance(final, dict):
|
||||
scan_completed = bool(final.get("scan_completed"))
|
||||
if not scan_completed:
|
||||
# Lifecycle tools mark the root completed.
|
||||
async with coordinator._lock:
|
||||
root_completed = coordinator.statuses.get(root_id) == "completed"
|
||||
if not root_completed:
|
||||
logger.error(
|
||||
"Scan %s ended without calling finish_scan. The agent "
|
||||
"emitted a text-only turn instead of a lifecycle tool call, "
|
||||
|
||||
@@ -299,6 +299,7 @@ Strix Cloud:
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--resume",
|
||||
type=str,
|
||||
nargs="?",
|
||||
|
||||
@@ -175,6 +175,7 @@ def filter_runs(runs: list[RunSummary], needle: str) -> list[RunSummary]:
|
||||
for run in runs
|
||||
if needle in run.run_name.lower()
|
||||
or needle in run.target.lower()
|
||||
or needle in run.title.lower()
|
||||
or needle in run.status.lower()
|
||||
]
|
||||
|
||||
@@ -215,16 +216,18 @@ class ResumePicker:
|
||||
def _visible(self) -> int:
|
||||
return max(3, min(len(self.rows), _MAX_VISIBLE, self.console.height - _CHROME_LINES))
|
||||
|
||||
def _columns(self) -> tuple[int, int, int]:
|
||||
def _columns(self) -> tuple[int, int, int, int]:
|
||||
width = max(40, self.console.width - 1)
|
||||
status_width = max(len("status"), *(len(_status_text(run)) for run in self.runs))
|
||||
run_width = min(_MAX_RUN_WIDTH, max(len("run"), *(len(run.run_name) for run in self.runs)))
|
||||
fixed = len(_CURSOR) + _STARTED_WIDTH + _FINDINGS_WIDTH + status_width + 4 * 2
|
||||
fixed = len(_CURSOR) + _STARTED_WIDTH + _FINDINGS_WIDTH + status_width + 5 * 2
|
||||
target_width = width - fixed - run_width
|
||||
if target_width < _MIN_TARGET_WIDTH:
|
||||
run_width = max(8, run_width + target_width - _MIN_TARGET_WIDTH)
|
||||
if target_width < 2 * _MIN_TARGET_WIDTH:
|
||||
run_width = max(8, run_width + target_width - 2 * _MIN_TARGET_WIDTH)
|
||||
target_width = width - fixed - run_width
|
||||
return max(_MIN_TARGET_WIDTH, target_width), status_width, run_width
|
||||
title_width = max(_MIN_TARGET_WIDTH, target_width // 2)
|
||||
target_width = max(_MIN_TARGET_WIDTH, target_width - title_width)
|
||||
return title_width, target_width, status_width, run_width
|
||||
|
||||
def _scroll(self) -> range:
|
||||
rows = self.rows
|
||||
@@ -250,7 +253,7 @@ class ResumePicker:
|
||||
title.append(self.filter)
|
||||
header = Text(
|
||||
" " * len(_CURSOR)
|
||||
+ self._cells("started", "target", "status", "findings", "run", widths),
|
||||
+ self._cells("started", "title", "target", "status", "findings", "run", widths),
|
||||
style="dim",
|
||||
)
|
||||
lines = [title, Text(), header]
|
||||
@@ -276,16 +279,18 @@ class ResumePicker:
|
||||
@staticmethod
|
||||
def _cells(
|
||||
started: str,
|
||||
title: str,
|
||||
target: str,
|
||||
status: str,
|
||||
findings: str,
|
||||
run: str,
|
||||
widths: tuple[int, int, int],
|
||||
widths: tuple[int, int, int, int],
|
||||
) -> str:
|
||||
target_width, status_width, run_width = widths
|
||||
title_width, target_width, status_width, run_width = widths
|
||||
return " ".join(
|
||||
[
|
||||
_fit(started, _STARTED_WIDTH),
|
||||
_fit(title, title_width),
|
||||
_fit(target, target_width),
|
||||
_fit(status, status_width),
|
||||
_fit(findings, _FINDINGS_WIDTH),
|
||||
@@ -293,8 +298,8 @@ class ResumePicker:
|
||||
]
|
||||
)
|
||||
|
||||
def _row(self, run: RunSummary, selected: bool, widths: tuple[int, int, int]) -> Text:
|
||||
target_width, status_width, run_width = widths
|
||||
def _row(self, run: RunSummary, selected: bool, widths: tuple[int, int, int, int]) -> Text:
|
||||
title_width, target_width, status_width, run_width = widths
|
||||
primary = "bold" if selected else ""
|
||||
muted = "" if selected else "dim"
|
||||
status_style = _STATUS_STYLES.get(run.status, "")
|
||||
@@ -304,7 +309,9 @@ class ResumePicker:
|
||||
line.append(_CURSOR if selected else " " * len(_CURSOR), style=_GREEN)
|
||||
line.append(_fit(relative_time(run.started_at, self.now), _STARTED_WIDTH), style=muted)
|
||||
line.append(" ")
|
||||
line.append(_fit(run.target, target_width), style=primary)
|
||||
line.append(_fit(run.title, title_width), style=primary)
|
||||
line.append(" ")
|
||||
line.append(_fit(run.target, target_width), style=muted)
|
||||
line.append(" ")
|
||||
line.append(_fit(_status_text(run), status_width), style=status_style)
|
||||
line.append(" ")
|
||||
|
||||
+51
-21
@@ -15,7 +15,11 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.interface import ModelTracing
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
from openai.types.responses import (
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
ResponseReasoningItem,
|
||||
)
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.config.models import StrixProvider
|
||||
@@ -160,22 +164,22 @@ def _content_text(content: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
return text if len(text) <= limit else f"{text[:limit]}\n[truncated]"
|
||||
def _truncate(text: str, limit: int | None) -> str:
|
||||
return text if limit is None or len(text) <= limit else f"{text[:limit]}\n[truncated]"
|
||||
|
||||
|
||||
def _serialize_item(item: Any) -> str:
|
||||
def _serialize_item(item: Any, max_chars: int | None = _TOOL_OUTPUT_MAX_CHARS) -> str:
|
||||
if not isinstance(item, dict):
|
||||
return str(item)
|
||||
item_type = item.get("type")
|
||||
role = item.get("role")
|
||||
if item_type == "function_call":
|
||||
args = _truncate(str(item.get("arguments", "")), _TOOL_OUTPUT_MAX_CHARS)
|
||||
args = _truncate(str(item.get("arguments", "")), max_chars)
|
||||
return f"[tool_call {item.get('name', '?')}] {args}"
|
||||
if item_type == "function_call_output":
|
||||
output = item.get("output")
|
||||
text = output if isinstance(output, str) else _content_text(output)
|
||||
return f"[tool_result] {_truncate(text, _TOOL_OUTPUT_MAX_CHARS)}"
|
||||
return f"[tool_result] {_truncate(text, max_chars)}"
|
||||
if item_type == "reasoning":
|
||||
return ""
|
||||
if role or item_type == "message":
|
||||
@@ -183,8 +187,30 @@ def _serialize_item(item: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _serialize_items(items: list[Any]) -> str:
|
||||
return "\n".join(s for s in (_serialize_item(item) for item in items) if s)
|
||||
def _serialize_items(items: list[Any], max_chars: int | None = _TOOL_OUTPUT_MAX_CHARS) -> str:
|
||||
return "\n".join(s for s in (_serialize_item(item, max_chars) for item in items) if s)
|
||||
|
||||
|
||||
class CompactionNeededError(Exception):
|
||||
"""The next model request would overrun the model's usable context window."""
|
||||
|
||||
|
||||
def _context_budget(model: str) -> int:
|
||||
context = load_settings().context
|
||||
reserve = max(context.compact_buffer_tokens, output_limit(model))
|
||||
return max(context.keep_tokens, context_window(model) - reserve)
|
||||
|
||||
|
||||
def _request_tokens(model: str, instructions: str, tools_text: str, items: list[Any]) -> int:
|
||||
text = "\n".join((instructions, tools_text, _serialize_items(items, max_chars=None)))
|
||||
return count_tokens(model, text)
|
||||
|
||||
|
||||
def needs_compaction(model: str, instructions: str, tools_text: str, items: list[Any]) -> bool:
|
||||
"""Whether a request carrying ``items`` would overrun ``model``'s context budget."""
|
||||
if not load_settings().context.auto_compact:
|
||||
return False
|
||||
return _request_tokens(model, instructions, tools_text, items) > _context_budget(model)
|
||||
|
||||
|
||||
def _is_tool_call(item: Any) -> bool:
|
||||
@@ -288,15 +314,22 @@ def _checkpoint_item(summary: str) -> dict[str, Any]:
|
||||
|
||||
def _extract_text(response: ModelResponse) -> str:
|
||||
parts: list[str] = []
|
||||
reasoning: list[str] = []
|
||||
for item in response.output:
|
||||
if not isinstance(item, ResponseOutputMessage):
|
||||
continue
|
||||
parts.extend(
|
||||
chunk.text
|
||||
for chunk in item.content
|
||||
if isinstance(chunk, ResponseOutputText) and chunk.text
|
||||
)
|
||||
return "".join(parts)
|
||||
if isinstance(item, ResponseOutputMessage):
|
||||
parts.extend(
|
||||
chunk.text
|
||||
for chunk in item.content
|
||||
if isinstance(chunk, ResponseOutputText) and chunk.text
|
||||
)
|
||||
elif isinstance(item, ResponseReasoningItem):
|
||||
reasoning.extend(summary.text for summary in item.summary if summary.text)
|
||||
if parts or not reasoning:
|
||||
return "".join(parts)
|
||||
# Reasoning models can spend the whole output budget thinking and return no
|
||||
# message; their reasoning is a degraded but usable summary.
|
||||
logger.warning("compaction summary had no output text; using reasoning text")
|
||||
return "\n".join(reasoning)
|
||||
|
||||
|
||||
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
|
||||
@@ -358,11 +391,8 @@ async def maybe_compact(
|
||||
if len(items) < _MIN_ITEMS_TO_COMPACT:
|
||||
return False
|
||||
|
||||
window = context_window(model)
|
||||
reserve = max(context.compact_buffer_tokens, output_limit(model))
|
||||
budget = max(context.keep_tokens, window - reserve)
|
||||
used = count_tokens(model, "\n".join((instructions, tools_text, _serialize_items(items))))
|
||||
if not force and used <= budget:
|
||||
used = _request_tokens(model, instructions, tools_text, items)
|
||||
if not force and used <= _context_budget(model):
|
||||
return False
|
||||
|
||||
split = _select_split(model, items, context.keep_tokens)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -22,6 +23,7 @@ class RunSummary:
|
||||
status: str
|
||||
findings: int
|
||||
resumable: bool
|
||||
title: str = ""
|
||||
|
||||
|
||||
def list_run_summaries(*, cwd: Path | None = None) -> list[RunSummary]:
|
||||
@@ -55,9 +57,23 @@ def _summarize(run_dir: Path, record: Any) -> RunSummary:
|
||||
status=str(record.get("status") or "unknown"),
|
||||
findings=len(findings) if isinstance(findings, list) else 0,
|
||||
resumable=(runtime_state_dir(run_dir) / "agents.json").is_file(),
|
||||
title=_title(record, findings),
|
||||
)
|
||||
|
||||
|
||||
_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
||||
|
||||
|
||||
def _title(record: dict[str, Any], findings: Any) -> str:
|
||||
"""The instruction's first line, else the most severe finding's title, shortened."""
|
||||
instruction = str(record.get("user_instruction") or record.get("instruction") or "").strip()
|
||||
if instruction:
|
||||
return instruction.splitlines()[0]
|
||||
found = [f for f in findings if isinstance(f, dict)] if isinstance(findings, list) else []
|
||||
worst = min(found, key=lambda f: _SEVERITY_RANK.get(str(f.get("severity")), 4), default={})
|
||||
return re.split(r" via | on | \(", str(worst.get("title") or ""))[0]
|
||||
|
||||
|
||||
def _describe_target(record: dict[str, Any]) -> str:
|
||||
targets = record.get("targets_info")
|
||||
originals = [
|
||||
|
||||
@@ -128,3 +128,16 @@ def test_disabling_strict_leaves_shared_tools_untouched() -> None:
|
||||
agent = factory.build_strix_agent(is_root=True)
|
||||
|
||||
assert any(t.strict_json_schema for t in agent.tools if isinstance(t, FunctionTool))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_name", "output", "completed"),
|
||||
[
|
||||
("finish_pr_review", '{"success": true, "review_completed": true}', True),
|
||||
("finish_pr_review", '{"success": false, "error": "agents_still_active"}', False),
|
||||
("finish_scan", '{"success": true, "scan_completed": true}', True),
|
||||
("think", '{"success": true, "review_completed": true}', False),
|
||||
],
|
||||
)
|
||||
def test_lifecycle_tool_completed(tool_name: str, output: str, completed: bool) -> None:
|
||||
assert factory._lifecycle_tool_completed(tool_name, output) is completed
|
||||
@@ -41,10 +41,13 @@ def _write_run(base: Path, name: str, *, state: bool = True) -> None:
|
||||
(run_dir / ".state" / "agents.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
|
||||
def test_bare_resume_defers_to_the_picker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@pytest.mark.parametrize("flag", ["--resume", "-r"])
|
||||
def test_bare_resume_defers_to_the_picker(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, flag: str
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_run(tmp_path / "strix_runs", "example-com_1111")
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume"])
|
||||
monkeypatch.setattr(sys, "argv", ["strix", flag])
|
||||
|
||||
args = cli_main.parse_arguments()
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from agents.extensions.models.litellm_model import InternalChatCompletionMessage
|
||||
from agents.models.chatcmpl_converter import Converter
|
||||
from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
|
||||
@@ -136,7 +138,9 @@ def test_select_split_handles_parallel_calls(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert not _has_orphan_tool_output(items[split:])
|
||||
|
||||
|
||||
def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window: int) -> None:
|
||||
def _patch_budget(
|
||||
monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window: int
|
||||
) -> ContextSettings:
|
||||
monkeypatch.setattr(compaction, "count_tokens", lambda _m, t: len(t))
|
||||
monkeypatch.setattr(compaction, "context_window", lambda _m: window)
|
||||
monkeypatch.setattr(compaction, "output_limit", lambda _m: 0)
|
||||
@@ -150,6 +154,7 @@ def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window:
|
||||
llm=SimpleNamespace(api_key=None, api_base=None, timeout=1, extra_headers=None),
|
||||
)
|
||||
monkeypatch.setattr(compaction, "load_settings", lambda: settings)
|
||||
return context
|
||||
|
||||
|
||||
def _model_response(text: str) -> Any:
|
||||
@@ -178,6 +183,20 @@ def _patch_summary(
|
||||
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
|
||||
|
||||
|
||||
def test_needs_compaction_counts_full_tool_outputs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_budget(monkeypatch, keep_tokens=50, window=10_000)
|
||||
items = [_call("c1"), _output("c1", "x" * 20_000)]
|
||||
|
||||
assert compaction.needs_compaction("m", "", "", items) is True
|
||||
assert compaction.needs_compaction("m", "", "", _turns(2)) is False
|
||||
|
||||
|
||||
def test_needs_compaction_respects_auto_compact_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_budget(monkeypatch, keep_tokens=50, window=10_000).auto_compact = False
|
||||
|
||||
assert compaction.needs_compaction("m", "", "", [_output("c1", "x" * 20_000)]) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maybe_compact_noop_when_within_budget(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_budget(monkeypatch, keep_tokens=50, window=1_000_000)
|
||||
@@ -242,6 +261,29 @@ async def test_summarize_routes_through_provider_with_settings(
|
||||
assert settings.max_tokens == 64
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarize_falls_back_to_reasoning_when_no_output_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
||||
message = InternalChatCompletionMessage(
|
||||
role="assistant", content=None, reasoning_content="REASONED SUMMARY"
|
||||
)
|
||||
response = SimpleNamespace(output=Converter.message_to_output_items(message))
|
||||
|
||||
class FakeModel:
|
||||
async def get_response(self, **_kwargs: Any) -> Any:
|
||||
return response
|
||||
|
||||
class FakeProvider:
|
||||
def get_model(self, _model_name: str | None) -> Any:
|
||||
return FakeModel()
|
||||
|
||||
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
|
||||
|
||||
assert await compaction._summarize("m", "p", 64) == "REASONED SUMMARY"
|
||||
|
||||
|
||||
def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(compaction, "count_tokens", lambda _m, t: len(t))
|
||||
text = "x" * 10_000
|
||||
|
||||
@@ -9,9 +9,11 @@ from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agents import RunConfig
|
||||
from agents.exceptions import MaxTurnsExceeded
|
||||
from agents.items import MessageOutputItem
|
||||
from agents.memory import SQLiteSession
|
||||
from agents.run_config import CallModelData, ModelInputData
|
||||
from agents.tool_context import ToolContext
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal, ResponseOutputText
|
||||
|
||||
@@ -22,6 +24,7 @@ from strix.core.execution import (
|
||||
notify_parent_on_terminal,
|
||||
)
|
||||
from strix.core.sessions import seed_initial_input
|
||||
from strix.llm.compaction import CompactionNeededError
|
||||
from strix.tools.agents_graph.tools import agent_finish, stop_agent
|
||||
from strix.tools.finish.tool import finish_scan
|
||||
|
||||
@@ -1531,3 +1534,146 @@ async def test_autonomous_nudge_does_not_offer_the_user() -> None:
|
||||
)
|
||||
|
||||
assert "wait_for_user" not in items[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compaction_check_stops_run_after_first_call_when_over_budget(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(execution, "needs_compaction", lambda *_args: True)
|
||||
guard: Any = execution._with_compaction_check(RunConfig(model="m")).call_model_input_filter
|
||||
data = CallModelData(
|
||||
model_data=ModelInputData(input=[{"role": "user", "content": "hi"}], instructions="sys"),
|
||||
agent=MagicMock(tools=[]),
|
||||
context=None,
|
||||
)
|
||||
|
||||
assert await guard(data) is data.model_data
|
||||
with pytest.raises(CompactionNeededError):
|
||||
await guard(data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compaction_check_chains_an_existing_input_filter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen: list[Any] = []
|
||||
monkeypatch.setattr(execution, "needs_compaction", lambda *args: seen.append(args[3]))
|
||||
filtered = ModelInputData(input=[{"role": "user", "content": "scrubbed"}], instructions="sys")
|
||||
run_config = RunConfig(model="m", call_model_input_filter=lambda _data: filtered)
|
||||
check: Any = execution._with_compaction_check(run_config).call_model_input_filter
|
||||
data = CallModelData(
|
||||
model_data=ModelInputData(input=[], instructions="sys"),
|
||||
agent=MagicMock(tools=[]),
|
||||
context=None,
|
||||
)
|
||||
|
||||
assert await check(data) is filtered
|
||||
assert await check(data) is filtered
|
||||
assert seen == [filtered.input]
|
||||
|
||||
|
||||
class _BudgetStream:
|
||||
def __init__(self, exc: BaseException | None) -> None:
|
||||
self.run_loop_exception = exc
|
||||
self.new_items: list[Any] = []
|
||||
|
||||
async def stream_events(self) -> Any:
|
||||
for event in _NO_STREAM_EVENTS:
|
||||
yield event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_compacts_and_resumes_on_every_budget_stop(
|
||||
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
stops = execution._MAX_COMPACTIONS_PER_CYCLE + 1
|
||||
streams = [_BudgetStream(CompactionNeededError("m")) for _ in range(stops)]
|
||||
streams.append(_BudgetStream(None))
|
||||
runs: list[dict[str, Any]] = []
|
||||
|
||||
def _run_streamed(*_args: Any, **kwargs: Any) -> _BudgetStream:
|
||||
runs.append(kwargs)
|
||||
return streams[len(runs) - 1]
|
||||
|
||||
forced: list[bool] = []
|
||||
|
||||
async def _compact(*_args: Any, force: bool) -> bool:
|
||||
forced.append(force)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _run_streamed)
|
||||
monkeypatch.setattr(execution, "_compact_session", _compact)
|
||||
monkeypatch.setattr(execution, "needs_compaction", lambda *_args: False)
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
session = SQLiteSession("root", tmp_path / "agents.db")
|
||||
|
||||
result = await execution._run_cycle(
|
||||
MagicMock(),
|
||||
coordinator,
|
||||
"root",
|
||||
input_data="task",
|
||||
run_config=RunConfig(model="m"),
|
||||
context={},
|
||||
max_turns=5,
|
||||
session=session,
|
||||
interactive=True,
|
||||
event_sink=None,
|
||||
hooks=None,
|
||||
)
|
||||
|
||||
assert cast("Any", result) is streams[-1]
|
||||
assert len(runs) == stops + 1
|
||||
assert forced.count(True) == stops
|
||||
assert all(run["input"] == [] for run in runs[1:])
|
||||
assert all(run["run_config"].call_model_input_filter is not None for run in runs)
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("compacted", "still_over"),
|
||||
[(False, False), (True, True)],
|
||||
ids=["compaction_failed", "still_over_budget"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_reruns_unchecked_when_compaction_cannot_fit(
|
||||
tmp_path: Any, monkeypatch: pytest.MonkeyPatch, compacted: bool, still_over: bool
|
||||
) -> None:
|
||||
streams = [_BudgetStream(CompactionNeededError("m")), _BudgetStream(None)]
|
||||
runs: list[dict[str, Any]] = []
|
||||
|
||||
def _run_streamed(*_args: Any, **kwargs: Any) -> _BudgetStream:
|
||||
runs.append(kwargs)
|
||||
return streams[len(runs) - 1]
|
||||
|
||||
async def _compact(*_args: Any, **_kwargs: Any) -> bool:
|
||||
return compacted
|
||||
|
||||
monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _run_streamed)
|
||||
monkeypatch.setattr(execution, "_compact_session", _compact)
|
||||
monkeypatch.setattr(execution, "needs_compaction", lambda *_args: still_over)
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
session = SQLiteSession("root", tmp_path / "agents.db")
|
||||
|
||||
result = await execution._run_cycle(
|
||||
MagicMock(),
|
||||
coordinator,
|
||||
"root",
|
||||
input_data="task",
|
||||
run_config=RunConfig(model="m"),
|
||||
context={},
|
||||
max_turns=5,
|
||||
session=session,
|
||||
interactive=True,
|
||||
event_sink=None,
|
||||
hooks=None,
|
||||
)
|
||||
|
||||
assert cast("Any", result) is streams[-1]
|
||||
assert len(runs) == 2
|
||||
assert runs[0]["run_config"].call_model_input_filter is not None
|
||||
assert runs[1]["run_config"].call_model_input_filter is None
|
||||
assert runs[1]["input"] == []
|
||||
session.close()
|
||||
@@ -38,6 +38,7 @@ def _run(
|
||||
status: str = "completed",
|
||||
findings: int = 0,
|
||||
resumable: bool = True,
|
||||
title: str = "",
|
||||
) -> RunSummary:
|
||||
started = (NOW - timedelta(minutes=minutes_ago)).isoformat()
|
||||
return RunSummary(
|
||||
@@ -48,6 +49,7 @@ def _run(
|
||||
status=status,
|
||||
findings=findings,
|
||||
resumable=resumable,
|
||||
title=title,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +64,7 @@ RUNS = [
|
||||
minutes_ago=60 * 26,
|
||||
status="stopped",
|
||||
findings=7,
|
||||
title="SQL injection",
|
||||
),
|
||||
_run(
|
||||
"strix_41c0",
|
||||
@@ -127,7 +130,8 @@ def test_render_lists_every_run_with_its_metadata() -> None:
|
||||
for run in RUNS:
|
||||
assert run.run_name in text
|
||||
assert "12 min ago" in text
|
||||
assert "https://juice-shop.herokuapp.com" in text
|
||||
assert "https://juice-shop" in text
|
||||
assert "SQL injection" in text
|
||||
assert "interrupted" in text
|
||||
assert "failed · no state" in text
|
||||
assert text.splitlines()[3].startswith(" \u276f ")
|
||||
|
||||
@@ -139,3 +139,25 @@ def test_blank_instruction_does_not_break_the_listing(
|
||||
[summary] = list_run_summaries()
|
||||
|
||||
assert summary.target == ""
|
||||
|
||||
|
||||
def test_title_is_the_instruction_else_the_most_severe_finding(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_write_run(
|
||||
base, "instructed_1111", {"user_instruction": "focus on auth\nand billing"}, modified=2
|
||||
)
|
||||
_write_run(base, "found_2222", {}, modified=1)
|
||||
(base / "found_2222" / "vulnerabilities.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"title": "Verbose errors", "severity": "low"},
|
||||
{"title": "SQL injection via id on /login", "severity": "critical"},
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert [run.title for run in list_run_summaries(cwd=tmp_path)] == [
|
||||
"focus on auth",
|
||||
"SQL injection",
|
||||
]
|
||||
@@ -91,3 +91,31 @@ async def test_a_live_child_is_settled_before_sessions_close(
|
||||
task = child_task["t"]
|
||||
assert task.done(), "the child task was left running past scan teardown"
|
||||
assert task.cancelled(), "the child was not cancelled cleanly on a finish"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("root_status", "logged"), [("completed", False), ("stopped", True)])
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_finish_is_logged_only_when_root_did_not_complete(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Any,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
root_status: str,
|
||||
logged: bool,
|
||||
) -> None:
|
||||
_wire_runner(monkeypatch, tmp_path)
|
||||
coordinator = AgentCoordinator()
|
||||
|
||||
async def _root_ends_with_text(**kwargs: Any) -> Any:
|
||||
await coordinator.set_status(kwargs["agent_id"], root_status)
|
||||
return types.SimpleNamespace(final_output="The review is complete.")
|
||||
|
||||
monkeypatch.setattr(runner, "run_agent_loop", _root_ends_with_text)
|
||||
|
||||
await runner.run_strix_scan(
|
||||
scan_config={"targets": [], "scan_mode": "deep"},
|
||||
scan_id="scan-test",
|
||||
image="img",
|
||||
coordinator=coordinator,
|
||||
)
|
||||
|
||||
assert ("ended without calling finish_scan" in caplog.text) is logged
|
||||
Reference in new issue
Block a user