Strip narrative comments and module/helper docstrings
Five rounds of sweep across the tree. Net ~544 lines removed. Removed: - Section-divider banners and one-line section labels (# Display utilities, # ----- list_requests -----, # CVSS breakdown, etc.). - Module-level prose docstrings on internal modules. Kept one-line summaries; trimmed multi-paragraph narration about SDK/Strix responsibility splits, cache strategies, three-source precedence. - Internal-helper docstrings that just restate the function name — caido_api helpers (caido_url, get_client, view_request, etc.), settings-class one-liners (LLMSettings, RuntimeSettings, ...), UI helper docstrings. - Args/Returns blocks on non-LLM-facing internal helpers (build_strix_agent, render_system_prompt, create_or_reuse, bootstrap_caido) — kept only the genuinely non-obvious params. - Internal-history phrasing — "Mirrors main-branch shape", "pre-SDK harness", "previous lookup matched no attribute". - Narrative comments inside function bodies that explained what the next line does, design rationale obvious from the surrounding code, or "we used to..." asides. - Trailing periods on every error-string literal across the tool tree. - Duplicated roundtripTime quirk comment (kept the LLM-facing copy in tools/proxy/tools.py). Kept (every one names an upstream bug, vendored-code provenance, or non-obvious data quirk): - core/runner.py: SDK replay-with-empty-initial-input + on_agent_end lifecycle gap. - runtime/docker_client.py: VERBATIM COPY block of the upstream _create_container body, pinned to SDK v0.14.6. - runtime/session_manager.py: NO_PROXY for agent-browser CDP loopback. - tools/proxy/caido_api.py: generated-pydantic Request.raw quirk, replay double-history pitfall. - tools/proxy/tools.py: Caido roundtripTime=0 quirk for proxy captures. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,4 @@
|
||||
"""Multi-agent graph tools backed by the SDK-native :class:`AgentCoordinator`.
|
||||
|
||||
- ``view_agent_graph``: render the parent/child tree.
|
||||
- ``send_message_to_agent``: append a message to another agent's SDK session.
|
||||
- ``wait_for_message``: pause this agent until a message arrives or
|
||||
``timeout_seconds`` elapses.
|
||||
- ``create_agent``: asks the scan runner to spawn an addressable child.
|
||||
- ``stop_agent``: cancel a running agent (optionally cascading to its
|
||||
descendants).
|
||||
- ``agent_finish``: subagents only — posts a structured completion
|
||||
report to the parent's SDK session and returns a final-output marker.
|
||||
"""
|
||||
"""Multi-agent graph tools backed by AgentCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,9 +16,6 @@ from strix.core.agents import Status, coordinator_from_context
|
||||
from strix.skills import validate_requested_skills
|
||||
|
||||
|
||||
# An agent is "active" when stop_agent can meaningfully act on it. Anything
|
||||
# else (completed / stopped / crashed / failed) is terminal — request_stop
|
||||
# would forcibly overwrite that status, erasing the original outcome.
|
||||
_ACTIVE_STATUSES: frozenset[str] = frozenset({"running", "waiting"})
|
||||
|
||||
|
||||
@@ -117,9 +103,6 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str:
|
||||
for root in roots:
|
||||
render(root, 0)
|
||||
|
||||
# Derive per-status counts from the canonical ``Status`` literal so a
|
||||
# new status added in core.agents auto-flows into this summary without
|
||||
# the buckets silently going out of sync with the source of truth.
|
||||
counts = Counter(statuses.values())
|
||||
summary: dict[str, int] = {"total": len(parent_of)}
|
||||
for status_name in get_args(Status):
|
||||
@@ -445,8 +428,6 @@ async def create_agent(
|
||||
default=str,
|
||||
)
|
||||
|
||||
# ``ctx.turn_input`` carries the parent's full conversation up to and
|
||||
# including the call that's currently invoking ``create_agent``.
|
||||
parent_history = list(ctx.turn_input) if inherit_context and ctx.turn_input else []
|
||||
try:
|
||||
result = await spawner(
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
"""Per-run notes (shared across agents).
|
||||
|
||||
Module-level dict shared across every agent in the same scan process.
|
||||
Mirrored to ``{state_dir}/notes.json`` after every CRUD via :func:`_persist`
|
||||
so a process restart can :func:`hydrate_notes_from_disk` and the resumed
|
||||
scan picks up exactly where it left off. Concurrent writers are
|
||||
serialised by ``_notes_lock`` since each tool entry-point dispatches
|
||||
the impl onto a worker thread via ``asyncio.to_thread``.
|
||||
"""
|
||||
"""Per-run notes storage — mirrored to {state_dir}/notes.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -31,20 +23,10 @@ _VALID_NOTE_CATEGORIES = ["general", "findings", "methodology", "questions", "pl
|
||||
_notes_lock = threading.RLock()
|
||||
_DEFAULT_CONTENT_PREVIEW_CHARS = 280
|
||||
|
||||
# On-disk mirror path. Set by :func:`hydrate_notes_from_disk` once per
|
||||
# scan; unset means "no persistence" (e.g. unit tests). All writes go
|
||||
# through :func:`_persist`, which is a no-op until the path is set.
|
||||
_notes_path: Path | None = None
|
||||
|
||||
|
||||
def hydrate_notes_from_disk(state_dir: Path) -> None:
|
||||
"""Wire the on-disk mirror at ``{state_dir}/notes.json`` and reload it.
|
||||
|
||||
Called by :func:`run_strix_scan` once at scan setup. Subsequent CRUD
|
||||
calls auto-persist after every mutation. Idempotent on missing file.
|
||||
Tolerant of corruption — logs and starts empty rather than failing
|
||||
the scan over a broken sidecar artifact.
|
||||
"""
|
||||
global _notes_path # noqa: PLW0603
|
||||
_notes_path = state_dir / "notes.json"
|
||||
with _notes_lock:
|
||||
@@ -76,11 +58,6 @@ def hydrate_notes_from_disk(state_dir: Path) -> None:
|
||||
|
||||
|
||||
def _persist() -> None:
|
||||
"""Atomic-rename mirror of ``_notes_storage`` → ``{state_dir}/notes.json``.
|
||||
|
||||
No-op when ``_notes_path`` isn't wired (tests). Errors are logged
|
||||
and swallowed — a disk hiccup must never tear down the agent's call.
|
||||
"""
|
||||
path = _notes_path
|
||||
if path is None:
|
||||
return
|
||||
@@ -300,9 +277,6 @@ def _delete_note_impl(note_id: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# --- public tools ---------------------------------------------------------
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def create_note(
|
||||
ctx: RunContextWrapper,
|
||||
|
||||
@@ -55,7 +55,6 @@ _REQ_FIELD_MAP: dict[SortBy, tuple[str, str]] = {
|
||||
|
||||
|
||||
def caido_url() -> str:
|
||||
"""Return the in-sandbox Caido endpoint used by ``caido_api``."""
|
||||
return os.environ.get("STRIX_CAIDO_URL", _DEFAULT_CAIDO_URL).rstrip("/")
|
||||
|
||||
|
||||
@@ -83,7 +82,6 @@ def _login_as_guest() -> str:
|
||||
|
||||
|
||||
async def get_client() -> Client:
|
||||
"""Return a connected Caido SDK client for the local sandbox sidecar."""
|
||||
if client := _CLIENT_CACHE.get("default"):
|
||||
return client
|
||||
|
||||
@@ -95,7 +93,6 @@ async def get_client() -> Client:
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
"""Close the cached sandbox Caido client, if one was opened."""
|
||||
client = _CLIENT_CACHE.pop("default", None)
|
||||
if client is None:
|
||||
return
|
||||
@@ -169,10 +166,6 @@ def build_raw_request(
|
||||
return ConnectionInfoInput(host=host, port=port, is_tls=is_tls), raw
|
||||
|
||||
|
||||
# Cap inline response bodies returned through tool results so a single
|
||||
# large response (HTML pages, JSON dumps) can't blow out the model's
|
||||
# context. The model can re-fetch the full body via ``view_request``
|
||||
# using the captured request id from ``list_requests`` if it needs more.
|
||||
_RESPONSE_BODY_MAX_CHARS = 8192
|
||||
|
||||
|
||||
@@ -286,12 +279,6 @@ def apply_modifications(
|
||||
}
|
||||
|
||||
|
||||
# Hard wall-clock bound on a single replay dispatch. Caido's Replay
|
||||
# API has no built-in send-side timeout, so a stalled connection
|
||||
# (unroutable target, slow loopback, etc.) hangs the caller until the
|
||||
# function_tool wrapper's 120s budget expires — by which point we've
|
||||
# lost any useful error context. 30s is generous for legitimate HTTP
|
||||
# and short enough that the model can decide to retry rather than wait.
|
||||
_REPLAY_SEND_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
@@ -332,10 +319,6 @@ async def replay_send_raw(
|
||||
"response_raw": None,
|
||||
}
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
# ``result.entry.response`` is the parsed Response (with ``.raw`` bytes
|
||||
# when ``includeResponseRaw`` was True, which is the entries SDK's
|
||||
# default). The previous ``result.entry.response_raw`` lookup matched
|
||||
# no attribute on ReplayEntry and silently returned ``None``.
|
||||
response = getattr(result.entry, "response", None)
|
||||
response_raw = getattr(response, "raw", None) if response is not None else None
|
||||
return {
|
||||
@@ -402,7 +385,6 @@ async def list_requests(
|
||||
sort_order: SortOrder = "desc",
|
||||
scope_id: str | None = None,
|
||||
) -> Any:
|
||||
"""List captured HTTP requests from sandbox Python."""
|
||||
return await list_requests_with_client(
|
||||
await get_client(),
|
||||
httpql_filter=httpql_filter,
|
||||
@@ -415,7 +397,6 @@ async def list_requests(
|
||||
|
||||
|
||||
async def view_request(request_id: str, *, part: RequestPart = "request") -> Any:
|
||||
"""Return one captured request/response from sandbox Python."""
|
||||
return await get_request_with_client(await get_client(), request_id, part=part)
|
||||
|
||||
|
||||
@@ -424,7 +405,6 @@ async def repeat_request(
|
||||
*,
|
||||
modifications: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Replay a captured request after applying request modifications."""
|
||||
mods = modifications or {}
|
||||
result = await get_request_with_client(await get_client(), request_id, part="request")
|
||||
if result is None or result.request.raw is None:
|
||||
@@ -452,7 +432,6 @@ async def scope_rules(
|
||||
scope_id: str | None = None,
|
||||
scope_name: str | None = None,
|
||||
) -> Any:
|
||||
"""Manage Caido scope rules from sandbox Python."""
|
||||
client = await get_client()
|
||||
if action == "list":
|
||||
result = await scope_list(client)
|
||||
@@ -569,9 +548,6 @@ def _clean_sitemap_response(resp: dict[str, Any]) -> dict[str, Any]:
|
||||
out["status_code"] = resp["statusCode"]
|
||||
if resp.get("length"):
|
||||
out["length"] = resp["length"]
|
||||
# Suppress 0 the same way list_requests does — Caido leaves it unset
|
||||
# on a lot of proxy-captured traffic and a misleading "0ms" is worse
|
||||
# than the field simply being absent.
|
||||
if resp.get("roundtripTime"):
|
||||
out["roundtrip_ms"] = resp["roundtripTime"]
|
||||
return out
|
||||
@@ -586,13 +562,11 @@ async def list_sitemap_with_client(
|
||||
page: int = 1,
|
||||
page_size: int = _SITEMAP_PAGE_SIZE,
|
||||
) -> dict[str, Any]:
|
||||
"""Browse Caido's discovered sitemap. Mirrors main-branch shape.
|
||||
"""Browse Caido's discovered sitemap.
|
||||
|
||||
The Caido GraphQL ``sitemap*Entries`` operations don't support native
|
||||
pagination, so we fetch all edges for the requested level and slice
|
||||
client-side. That's fine for typical surface sizes; for very large
|
||||
sitemaps the caller can drill into ``parent_id`` instead of paging
|
||||
the root list.
|
||||
client-side.
|
||||
"""
|
||||
if parent_id:
|
||||
raw = await client.graphql.query(
|
||||
@@ -636,7 +610,6 @@ async def view_sitemap_entry_with_client(
|
||||
client: CaidoClient,
|
||||
entry_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch one sitemap entry plus its recent related requests."""
|
||||
raw = await client.graphql.query(_SITEMAP_ENTRY_QUERY, variables={"id": entry_id})
|
||||
entry = raw.get("sitemapEntry")
|
||||
if not entry:
|
||||
@@ -678,7 +651,6 @@ async def list_sitemap(
|
||||
page: int = 1,
|
||||
page_size: int = _SITEMAP_PAGE_SIZE,
|
||||
) -> dict[str, Any]:
|
||||
"""Sandbox-Python entry point for sitemap browsing."""
|
||||
return await list_sitemap_with_client(
|
||||
await get_client(),
|
||||
scope_id=scope_id,
|
||||
@@ -690,7 +662,6 @@ async def list_sitemap(
|
||||
|
||||
|
||||
async def view_sitemap_entry(entry_id: str) -> dict[str, Any]:
|
||||
"""Sandbox-Python entry point for sitemap entry detail."""
|
||||
return await view_sitemap_entry_with_client(await get_client(), entry_id)
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
"""Caido proxy tools — host-side ``@function_tool`` wrappers.
|
||||
|
||||
The four tools delegate to :mod:`strix.tools.proxy.caido_api` for the actual
|
||||
caido-sdk-client work and add LLM-friendly JSON serialization + error
|
||||
wrapping on top. The delegated ``caido_api.py`` module is also copied into
|
||||
the sandbox image as the importable ``caido_api`` Python module.
|
||||
|
||||
Tools: ``list_requests``, ``view_request``, ``repeat_request``,
|
||||
``scope_rules``. Arbitrary one-off requests should be made through
|
||||
``exec_command`` (e.g. ``curl``) — they're captured automatically via
|
||||
the sandbox's ``HTTP_PROXY`` env, so wrapping them in a Strix tool only
|
||||
adds an extra layer of indirection.
|
||||
"""
|
||||
"""Caido proxy host-side @function_tool wrappers around caido_api.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -40,10 +28,6 @@ if TYPE_CHECKING:
|
||||
SortOrder,
|
||||
)
|
||||
else:
|
||||
# Runtime import: ``function_tool`` resolves the annotations via
|
||||
# ``typing.get_type_hints`` so the Literal aliases must be reachable
|
||||
# in module globals at decoration time even though they're "only"
|
||||
# used in annotations.
|
||||
from strix.tools.proxy.caido_api import ( # noqa: TC001
|
||||
RequestPart,
|
||||
SitemapDepth,
|
||||
@@ -60,8 +44,6 @@ def _ctx_client(ctx: RunContextWrapper) -> Client | None:
|
||||
return inner.get("caido_client")
|
||||
|
||||
|
||||
# Tool-output formatting. Caido SDK returns typed Python objects; function
|
||||
# tools need compact JSON-safe values for the model and TUI.
|
||||
def _to_tool_json(value: Any) -> Any:
|
||||
"""Recursively convert SDK dataclasses/Pydantic objects to tool JSON values."""
|
||||
if value is None or isinstance(value, str | int | float | bool):
|
||||
@@ -101,9 +83,6 @@ def _err(name: str, exc: Exception) -> str:
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# list_requests
|
||||
# ----------------------------------------------------------------------
|
||||
@function_tool(timeout=120)
|
||||
async def list_requests(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -231,9 +210,6 @@ async def list_requests(
|
||||
return _err("list_requests", exc)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# view_request
|
||||
# ----------------------------------------------------------------------
|
||||
@function_tool(timeout=60)
|
||||
async def view_request(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -352,9 +328,6 @@ def _format_text_page(content: str, *, page: int, page_size: int) -> dict[str, A
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# repeat_request
|
||||
# ----------------------------------------------------------------------
|
||||
@function_tool(timeout=120, strict_mode=False)
|
||||
async def repeat_request(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -431,9 +404,6 @@ def _format_replay_tool_result(replay: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# list_sitemap
|
||||
# ----------------------------------------------------------------------
|
||||
@function_tool(timeout=60)
|
||||
async def list_sitemap(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -483,9 +453,6 @@ async def list_sitemap(
|
||||
return _err("list_sitemap", exc)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# view_sitemap_entry
|
||||
# ----------------------------------------------------------------------
|
||||
@function_tool(timeout=60)
|
||||
async def view_sitemap_entry(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -511,9 +478,6 @@ async def view_sitemap_entry(
|
||||
return _err("view_sitemap_entry", exc)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# scope_rules
|
||||
# ----------------------------------------------------------------------
|
||||
@function_tool(timeout=60)
|
||||
async def scope_rules(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -612,7 +576,6 @@ async def scope_rules(
|
||||
return json.dumps(
|
||||
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
|
||||
)
|
||||
# action == "delete" — exhaustive Literal
|
||||
if not scope_id:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Scope_id is required for action='delete'"},
|
||||
|
||||
@@ -298,10 +298,6 @@ async def _do_create( # noqa: PLR0912
|
||||
}
|
||||
|
||||
|
||||
# Generous timeout: the dedup check makes a separate LLM call, and
|
||||
# large scans can have many existing reports to compare against.
|
||||
# strict_mode=False because cvss_breakdown is a dict[str, str] and
|
||||
# code_locations is list[dict] — both free-form for the strict schema.
|
||||
@function_tool(timeout=180, strict_mode=False)
|
||||
async def create_vulnerability_report(
|
||||
ctx: RunContextWrapper,
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
"""Per-agent todo tools.
|
||||
|
||||
Per-agent in-memory dict, scoped via ``ctx.context['agent_id']``. The
|
||||
table is mirrored to ``{state_dir}/todos.json`` after every mutation so a
|
||||
process restart can ``hydrate_todos_from_disk`` and each respawned
|
||||
agent finds its prior list intact. The persistence is best-effort —
|
||||
errors are logged and swallowed so a disk failure can't kill the agent
|
||||
mid-call. Bulk forms are preserved so the prompt-template documentation
|
||||
still works (``todos`` / ``updates`` / ``todo_ids`` accept JSON strings
|
||||
or comma-separated strings).
|
||||
"""
|
||||
"""Per-agent todo tools — mirrored to {state_dir}/todos.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -42,26 +32,13 @@ def _todo_sort_key(todo: dict[str, Any]) -> tuple[int, int, str]:
|
||||
)
|
||||
|
||||
|
||||
# Per-agent silo: ``_todos_storage[agent_id][todo_id] = todo_dict``.
|
||||
# Keyed by ``ctx.context['agent_id']`` so two agents in the same scan
|
||||
# don't see each other's lists.
|
||||
_todos_storage: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
|
||||
# On-disk mirror path. Set by ``hydrate_todos_from_disk`` once per scan;
|
||||
# unset means "no persistence" (e.g. unit tests). All writes go through
|
||||
# ``_persist`` which is a no-op until the path is set.
|
||||
_todos_path: Path | None = None
|
||||
_todos_io_lock = threading.RLock()
|
||||
|
||||
|
||||
def hydrate_todos_from_disk(state_dir: Path) -> None:
|
||||
"""Wire the on-disk mirror at ``{state_dir}/todos.json`` and reload it.
|
||||
|
||||
Called by :func:`run_strix_scan` once at scan setup. Subsequent CRUD
|
||||
calls auto-persist after every mutation. Idempotent on missing file.
|
||||
Tolerant of corruption — logs and starts empty rather than failing
|
||||
the scan over a broken sidecar artifact.
|
||||
"""
|
||||
global _todos_path # noqa: PLW0603
|
||||
_todos_path = state_dir / "todos.json"
|
||||
with _todos_io_lock:
|
||||
@@ -99,11 +76,6 @@ def hydrate_todos_from_disk(state_dir: Path) -> None:
|
||||
|
||||
|
||||
def _persist() -> None:
|
||||
"""Atomic-rename mirror of ``_todos_storage`` → ``{state_dir}/todos.json``.
|
||||
|
||||
No-op when ``_todos_path`` isn't wired (tests). Errors are logged
|
||||
and swallowed.
|
||||
"""
|
||||
path = _todos_path
|
||||
if path is None:
|
||||
return
|
||||
@@ -284,9 +256,6 @@ def _apply_single_update(
|
||||
return None
|
||||
|
||||
|
||||
# --- public tools ---------------------------------------------------------
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
|
||||
"""Create one or many todos for the current agent.
|
||||
|
||||
@@ -67,9 +67,6 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas
|
||||
],
|
||||
}
|
||||
|
||||
# Internal details (upstream URL, HTTP status, library exception text) stay
|
||||
# in the logs; the model only ever sees a short actionable category so it
|
||||
# can decide whether to retry, refine, or work around the gap.
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=300)
|
||||
response.raise_for_status()
|
||||
@@ -121,8 +118,6 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas
|
||||
}
|
||||
|
||||
|
||||
# Perplexity request timeout is 300s; give the SDK a slightly larger
|
||||
# budget so the round-trip + JSON decode doesn't push us over.
|
||||
@function_tool(timeout=330)
|
||||
async def web_search(ctx: RunContextWrapper, query: str) -> str:
|
||||
"""Real-time web search via Perplexity — your primary research tool.
|
||||
|
||||
Reference in New Issue
Block a user