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,12 +0,0 @@
|
||||
"""Strix agent assembly.
|
||||
|
||||
- :func:`strix.agents.factory.build_strix_agent` — assemble a root or
|
||||
child ``SandboxAgent``.
|
||||
- :func:`strix.agents.factory.make_child_factory` — closure factory
|
||||
passed via context to the multi-agent ``create_agent`` graph tool.
|
||||
- :func:`strix.agents.prompt.render_system_prompt` — render the Jinja
|
||||
system prompt.
|
||||
|
||||
Import deeply so ``import strix.agents`` doesn't pull every submodule's
|
||||
deps in eagerly.
|
||||
"""
|
||||
|
||||
+2
-64
@@ -1,20 +1,4 @@
|
||||
"""``build_strix_agent`` — assemble an ``agents.Agent`` for root or child.
|
||||
|
||||
Wires the SDK function tools, multi-agent graph tools, and the rendered
|
||||
Jinja prompt into one ``agents.Agent`` ready for ``Runner.run``.
|
||||
|
||||
Two flavors:
|
||||
|
||||
- **Root** (``is_root=True``): top-level scan agent. Carries
|
||||
``finish_scan`` and stops after that tool reports ``scan_completed``.
|
||||
- **Child** (``is_root=False``): subagents spawned by the
|
||||
``create_agent`` graph tool. Carries ``agent_finish`` and stops
|
||||
after that tool reports ``agent_completed``.
|
||||
|
||||
Skills are baked into the system prompt at scan bring-up. The
|
||||
``load_skill`` tool is also available for on-demand inline reference
|
||||
to any skill the agent didn't preload.
|
||||
"""
|
||||
"""Build SandboxAgents for root + child Strix runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -138,8 +122,6 @@ def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
||||
|
||||
|
||||
def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
||||
"""Expose an SDK raw-input custom tool through Chat-Completions function calling."""
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
custom_input = _extract_custom_input(tool, raw_input)
|
||||
if not custom_input:
|
||||
@@ -337,42 +319,28 @@ def _finish_tool_use_behavior(
|
||||
return ToolsToFinalOutputResult(is_final_output=False, final_output=None)
|
||||
|
||||
|
||||
# Host-side Strix tools. Sandbox shell + filesystem are added per-run
|
||||
# by the SDK via the ``Shell`` and ``Filesystem`` capabilities below
|
||||
# (they bind to the live sandbox session and emit ``exec_command`` /
|
||||
# ``write_stdin`` / ``apply_patch`` / ``view_image`` function tools).
|
||||
_BASE_TOOLS: tuple[Tool, ...] = (
|
||||
# Thinking + planning
|
||||
think,
|
||||
# On-demand skill reference (returns the skill markdown inline)
|
||||
load_skill,
|
||||
# Per-agent todos
|
||||
create_todo,
|
||||
list_todos,
|
||||
update_todo,
|
||||
mark_todo_done,
|
||||
mark_todo_pending,
|
||||
delete_todo,
|
||||
# Shared notes (per-run JSONL store)
|
||||
create_note,
|
||||
list_notes,
|
||||
get_note,
|
||||
update_note,
|
||||
delete_note,
|
||||
# Web search (only registered if PERPLEXITY_API_KEY is set; the
|
||||
# tool itself returns a structured error when not configured, so
|
||||
# always exposing it is safe)
|
||||
web_search,
|
||||
# Reporting
|
||||
create_vulnerability_report,
|
||||
# Caido HTTP/HTTPS proxy
|
||||
list_requests,
|
||||
view_request,
|
||||
repeat_request,
|
||||
list_sitemap,
|
||||
view_sitemap_entry,
|
||||
scope_rules,
|
||||
# Multi-agent graph tools (the coordinator is in ctx.context)
|
||||
view_agent_graph,
|
||||
send_message_to_agent,
|
||||
wait_for_message,
|
||||
@@ -392,35 +360,11 @@ def build_strix_agent(
|
||||
chat_completions_tools: bool = False,
|
||||
system_prompt_context: dict[str, Any] | None = None,
|
||||
) -> SandboxAgent[Any]:
|
||||
"""Build a ``SandboxAgent`` configured for either root or child use.
|
||||
|
||||
The ``Shell`` and ``Filesystem`` capabilities are added unbound; the
|
||||
SDK's runtime binds them per-run against the live sandbox session
|
||||
set on ``RunConfig.sandbox`` and merges their tools (``exec_command``,
|
||||
``write_stdin``, ``apply_patch``, ``view_image``) into the agent's
|
||||
final tool list. We deliberately exclude ``Compaction`` (OpenAI
|
||||
Responses API only).
|
||||
"""Build a SandboxAgent for either root or child use.
|
||||
|
||||
Args:
|
||||
name: Agent name. Surfaces in traces and the coordinator's ``names`` map.
|
||||
Defaults to ``"strix"`` for the root; create_agent passes
|
||||
distinct names per child.
|
||||
skills: Skills to preload into the system prompt.
|
||||
is_root: Selects the tool list and ``tool_use_behavior``.
|
||||
Root carries ``finish_scan`` and child carries ``agent_finish``;
|
||||
the run only stops when the lifecycle tool result succeeds.
|
||||
scan_mode: ``"deep"`` etc.; routes the scan-mode skill section
|
||||
of the prompt template.
|
||||
is_whitebox: Whitebox source-aware mode toggle. Adds two extra
|
||||
skills to the prompt and gates whitebox-only behavior in
|
||||
the create_agent / wiki integration.
|
||||
interactive: Renders the interactive-mode communication block
|
||||
in the system prompt.
|
||||
chat_completions_tools: Wrap SDK custom tools as function tools
|
||||
when the selected backend cannot accept Responses custom tools.
|
||||
system_prompt_context: Free-form dict the prompt template
|
||||
renders into the ``system_prompt_context`` variable —
|
||||
today carries the scan scope / authorization block.
|
||||
"""
|
||||
instructions = render_system_prompt(
|
||||
skills=skills,
|
||||
@@ -451,13 +395,7 @@ def build_strix_agent(
|
||||
instructions=instructions,
|
||||
tools=tools,
|
||||
tool_use_behavior=_finish_tool_use_behavior,
|
||||
# Non-interactive runs must keep forcing tool calls until the
|
||||
# lifecycle tool completes. Interactive runs need the SDK default
|
||||
# reset so a tool-assisted answer can end as plain text instead of
|
||||
# looping through think/list_todos forever.
|
||||
reset_tool_choice=interactive,
|
||||
# model=None so ``RunConfig.model`` drives provider selection
|
||||
# through the SDK's default MultiProvider.
|
||||
model=None,
|
||||
capabilities=[
|
||||
Filesystem(
|
||||
|
||||
+2
-27
@@ -1,9 +1,4 @@
|
||||
"""Jinja-based system-prompt renderer.
|
||||
|
||||
Loads ``strix/agents/prompts/system_prompt.jinja`` and renders it with
|
||||
the caller's per-run context (skills, scan mode, whitebox flag,
|
||||
interactive flag, scope authorization block).
|
||||
"""
|
||||
"""Jinja-based system-prompt renderer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -71,27 +66,7 @@ def render_system_prompt(
|
||||
interactive: bool = False,
|
||||
system_prompt_context: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Render the system prompt.
|
||||
|
||||
Args:
|
||||
skills: Skills the caller wants preloaded into the prompt context.
|
||||
scan_mode: ``"deep" | "fast" | ...``. Maps to ``scan_modes/<mode>``
|
||||
skill.
|
||||
is_whitebox: When True, the source-aware whitebox skill stack
|
||||
is loaded too.
|
||||
is_root: When True, ``coordination/root_agent`` orchestration
|
||||
guidance is auto-loaded.
|
||||
interactive: When True, the prompt renders the interactive-mode
|
||||
communication rules block.
|
||||
system_prompt_context: Free-form dict that the template's
|
||||
``system_prompt_context`` variable receives — carries the
|
||||
scan-scope authorization block.
|
||||
|
||||
Returns the rendered prompt string. If anything goes wrong (template
|
||||
missing, render failure), returns an empty string and logs — a
|
||||
missing prompt is survivable, a hard failure during agent
|
||||
construction is not.
|
||||
"""
|
||||
"""Render the system prompt. Returns empty string on template failure."""
|
||||
try:
|
||||
prompt_dir = get_strix_resource_path("agents", _PROMPT_DIRNAME)
|
||||
skills_dir = get_strix_resource_path("skills")
|
||||
|
||||
+1
-10
@@ -1,9 +1,4 @@
|
||||
"""Settings loader, override switch, and disk persistence.
|
||||
|
||||
Process-wide module cache so repeated ``load_settings()`` calls in the
|
||||
same scan are free. ``apply_config_override(path)`` invalidates the
|
||||
cache so the next ``load_settings()`` re-resolves with the new file.
|
||||
"""
|
||||
"""Settings loader, override switch, and disk persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -81,9 +76,6 @@ def persist_current() -> None:
|
||||
target.chmod(0o600)
|
||||
|
||||
|
||||
# --- internals ---------------------------------------------------------
|
||||
|
||||
|
||||
def _aliases_for(finfo: FieldInfo) -> list[str]:
|
||||
"""Collect every env-var name that should populate ``finfo``."""
|
||||
aliases: list[str] = []
|
||||
@@ -113,7 +105,6 @@ def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(env_block, dict):
|
||||
return {}
|
||||
|
||||
# Normalize to upper-case keys for matching.
|
||||
env_block_upper = {str(k).upper(): v for k, v in env_block.items()}
|
||||
|
||||
nested: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""SDK model configuration helpers.
|
||||
|
||||
This module is intentionally not a provider abstraction. Strix accepts
|
||||
friendly model names at the config boundary, normalizes them to the
|
||||
OpenAI Agents SDK's native model ids, then lets the SDK's default
|
||||
``MultiProvider`` do the actual routing.
|
||||
"""
|
||||
"""SDK model configuration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -63,7 +57,7 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
|
||||
|
||||
def _configure_litellm_compatibility() -> None:
|
||||
"""Match the permissive LiteLLM behavior used by the pre-SDK harness."""
|
||||
"""Enable LiteLLM's permissive param-handling mode."""
|
||||
import litellm
|
||||
|
||||
litellm.drop_params = True
|
||||
|
||||
@@ -1,22 +1,4 @@
|
||||
"""Strix application settings — pydantic-settings powered.
|
||||
|
||||
Three sources, env-precedence-first:
|
||||
|
||||
1. Environment variables (``STRIX_LLM``, ``LLM_API_KEY``, etc.) — highest.
|
||||
2. ``~/.strix/cli-config.json`` (or ``--config <path>``) — middle.
|
||||
3. Field defaults — lowest.
|
||||
|
||||
Bool fields auto-parse ``"0"``/``"false"``/``"no"``/``"off"`` as falsy
|
||||
and any other non-empty string as truthy. Int fields auto-coerce from
|
||||
string env. The ``api_base`` field walks an alias chain so users can
|
||||
point at any OpenAI-compatible endpoint via whichever env name they
|
||||
prefer (``LLM_API_BASE`` / ``OPENAI_API_BASE`` / ``OPENAI_BASE_URL`` /
|
||||
``LITELLM_BASE_URL`` / ``OLLAMA_API_BASE``).
|
||||
|
||||
Each sub-model is a :class:`BaseSettings` so it reads env independently
|
||||
— the alternative (one mega-BaseSettings with flat fields) would lose
|
||||
the logical grouping ``s.llm.model`` / ``s.runtime.image`` / etc.
|
||||
"""
|
||||
"""Strix application settings — pydantic-settings powered."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -36,8 +18,6 @@ _BASE_CONFIG = SettingsConfigDict(
|
||||
|
||||
|
||||
class LlmSettings(BaseSettings):
|
||||
"""LLM provider + model + per-call defaults."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_LLM")
|
||||
@@ -60,8 +40,6 @@ class LlmSettings(BaseSettings):
|
||||
|
||||
|
||||
class RuntimeSettings(BaseSettings):
|
||||
"""Sandbox image + backend selector."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
image: str = Field(
|
||||
@@ -72,24 +50,18 @@ class RuntimeSettings(BaseSettings):
|
||||
|
||||
|
||||
class TelemetrySettings(BaseSettings):
|
||||
"""Telemetry toggle."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
enabled: bool = Field(default=True, alias="STRIX_TELEMETRY")
|
||||
|
||||
|
||||
class IntegrationSettings(BaseSettings):
|
||||
"""Third-party integration credentials."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Composite Strix settings. Instantiate via :func:`strix.config.load_settings`."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
llm: LlmSettings = Field(default_factory=LlmSettings)
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""SDK-native state for Strix's addressable agent graph.
|
||||
|
||||
The Agents SDK owns model/tool execution and per-agent conversation
|
||||
history. Strix owns only product semantics the SDK does not provide:
|
||||
agent ids, the parent/child graph, wake/stop signals, TUI-visible
|
||||
status, and process-resume metadata.
|
||||
"""
|
||||
"""SDK-native state for Strix's addressable agent graph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -189,10 +189,7 @@ async def respawn_subagents(
|
||||
event_sink: StreamEventSink | None = None,
|
||||
hooks: RunHooks[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Re-spawn subagent runners from a restored coordinator snapshot."""
|
||||
async with coordinator._lock:
|
||||
# Snapshot the iteration view first so we can mutate via coordinator
|
||||
# below without "dict changed during iteration" trouble.
|
||||
agents_snapshot = [
|
||||
(aid, status, dict(coordinator.metadata.get(aid, {})))
|
||||
for aid, status in coordinator.statuses.items()
|
||||
|
||||
@@ -15,12 +15,10 @@ if TYPE_CHECKING:
|
||||
from strix.config.settings import ReasoningEffort
|
||||
|
||||
|
||||
# Default max_turns budget passed to the SDK runner.
|
||||
DEFAULT_MAX_TURNS = 500
|
||||
|
||||
|
||||
def build_root_task(scan_config: dict[str, Any]) -> str:
|
||||
"""Format the user-facing task for the root agent."""
|
||||
targets = scan_config.get("targets", []) or []
|
||||
diff_scope = scan_config.get("diff_scope") or {}
|
||||
user_instructions = scan_config.get("user_instructions", "") or ""
|
||||
@@ -81,7 +79,6 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the system_prompt_context block consumed by the prompt template."""
|
||||
authorized: list[dict[str, str]] = []
|
||||
value_keys = {
|
||||
"repository": "target_repo",
|
||||
|
||||
+1
-23
@@ -1,9 +1,4 @@
|
||||
"""Top-level Strix scan runner.
|
||||
|
||||
The SDK owns model/tool execution and per-agent sessions. This module owns
|
||||
Strix-specific scan setup, child-agent startup, resume, and the small wake loop
|
||||
needed to keep every agent addressable after its SDK run parks.
|
||||
"""
|
||||
"""Top-level Strix scan runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -72,8 +67,6 @@ async def run_strix_scan(
|
||||
if scan_id is None:
|
||||
scan_id = f"scan-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Resolve run_dir before any heavy bring-up so the log file captures
|
||||
# everything from sandbox start onwards.
|
||||
run_dir = run_dir_for(scan_id)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
state_dir = runtime_state_dir(run_dir)
|
||||
@@ -105,15 +98,10 @@ async def run_strix_scan(
|
||||
logger.info("LLM model resolved: %s", resolved_model)
|
||||
chat_completions_tools = uses_chat_completions_tool_schema(resolved_model, settings)
|
||||
|
||||
# Caller may pre-create the coordinator so it can route stop/chat
|
||||
# commands while the scan loop runs in another thread.
|
||||
if coordinator is None:
|
||||
coordinator = AgentCoordinator()
|
||||
coordinator.set_snapshot_path(agents_path)
|
||||
|
||||
# Wire the per-agent todo store to ``{run_dir}/.state/todos.json`` (mirrored
|
||||
# on every CRUD) and reload any prior todos so respawned subagents
|
||||
# find their lists intact. Same for the shared notes store.
|
||||
from strix.tools.notes.tools import hydrate_notes_from_disk
|
||||
from strix.tools.todo.tools import hydrate_todos_from_disk
|
||||
|
||||
@@ -228,8 +216,6 @@ async def run_strix_scan(
|
||||
"spawn_child_agent": spawn_child_agent,
|
||||
}
|
||||
|
||||
# All agents share one SQLite database; SDK session_id separates
|
||||
# each agent's conversation inside that database.
|
||||
root_session = open_agent_session(root_id, agents_db)
|
||||
sessions_to_close.append(root_session)
|
||||
await coordinator.attach_runtime(root_id, session=root_session)
|
||||
@@ -291,16 +277,8 @@ async def run_strix_scan(
|
||||
)
|
||||
except BaseException:
|
||||
logger.exception("Strix scan %s failed", scan_id)
|
||||
# Cancel any descendant tasks the root spawned before unwinding.
|
||||
# cancel_descendants is idempotent and handles the empty-tree case.
|
||||
if root_id is not None:
|
||||
await coordinator.cancel_descendants(root_id)
|
||||
# The SDK's on_agent_end hook only fires after a successful
|
||||
# ``Runner.run_streamed`` reaches the agent's first turn. A
|
||||
# failure earlier (e.g., model-provider routing, sandbox
|
||||
# bring-up) leaves the root stuck at status="running" — the
|
||||
# TUI keeps animating "Initializing" forever. Finalize it
|
||||
# here so the coordinator reflects reality.
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "failed")
|
||||
raise
|
||||
|
||||
@@ -93,9 +93,6 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
"local_sources": getattr(args, "local_sources", None) or [],
|
||||
"scope_mode": getattr(args, "scope_mode", "auto"),
|
||||
"diff_base": getattr(args, "diff_base", None),
|
||||
# Forward the new --instruction (if any) to the resume path so it
|
||||
# can deliver it as a fresh user message after SDK session replay.
|
||||
# Empty string when the user didn't pass one on resume — no-op.
|
||||
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
|
||||
}
|
||||
|
||||
@@ -190,10 +187,6 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
finally:
|
||||
stop_updates.set()
|
||||
update_thread.join(timeout=1)
|
||||
# Best-effort: tear down the sandbox session even if the
|
||||
# run raised. ``run_strix_scan`` already does this in its
|
||||
# own ``finally``, but call here too in case the failure
|
||||
# was during early setup.
|
||||
with contextlib.suppress(Exception):
|
||||
await session_manager.cleanup(args.run_name)
|
||||
|
||||
|
||||
@@ -54,13 +54,6 @@ HOST_GATEWAY_HOSTNAME = "host.docker.internal"
|
||||
import logging # noqa: E402
|
||||
|
||||
|
||||
# Per-scan logging is set up by ``setup_scan_logging`` from inside
|
||||
# ``core.runner.run_strix_scan`` once the scan ``run_dir`` is
|
||||
# known — that's where ``strix.*`` levels and handlers are owned. Pre-scan
|
||||
# work (``main()``, env validation, image pull) emits via the module
|
||||
# logger; once setup_scan_logging runs, those records start landing in
|
||||
# the file too.
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -423,9 +416,6 @@ Examples:
|
||||
except Exception as e:
|
||||
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
|
||||
|
||||
# Capture before ``_load_resume_state`` overrides — used by the resume
|
||||
# path in ``run_strix_scan`` to decide whether to inject the new
|
||||
# instruction into the root's SDK session after replay.
|
||||
args.user_explicit_instruction = args.instruction if args.resume else None
|
||||
|
||||
if args.resume:
|
||||
@@ -472,7 +462,6 @@ Examples:
|
||||
|
||||
|
||||
def _persist_run_record(args: argparse.Namespace) -> None:
|
||||
"""Write the single public run descriptor used by resume and reporting."""
|
||||
run_dir = run_dir_for(args.run_name)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
run_record = {
|
||||
@@ -511,10 +500,6 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
if not args.targets_info:
|
||||
parser.error(f"--resume {args.resume}: run.json has no targets_info")
|
||||
|
||||
# Validate any persisted ``cloned_repo_path`` still exists on disk.
|
||||
# The resume path skips re-cloning, so a missing dir would mean the
|
||||
# container mounts an empty source tree and agents silently scan
|
||||
# nothing.
|
||||
for target in args.targets_info:
|
||||
if not isinstance(target, dict):
|
||||
continue
|
||||
@@ -539,11 +524,6 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
args.diff_scope = state.get("diff_scope")
|
||||
persisted_scan_mode = state.get("scan_mode")
|
||||
if persisted_scan_mode and args.scan_mode == "deep":
|
||||
# Default scan_mode is "deep"; only override from disk if the user
|
||||
# didn't explicitly pass a different one. (Best-effort: argparse
|
||||
# can't tell "user passed 'deep'" from "default 'deep'"; if the
|
||||
# persisted run was "quick" and user re-runs with an explicit
|
||||
# ``-m deep``, we'll honor the persisted mode. Acceptable.)
|
||||
args.scan_mode = persisted_scan_mode
|
||||
|
||||
|
||||
@@ -730,9 +710,6 @@ def main() -> None:
|
||||
else:
|
||||
args.instruction = diff_scope.instruction_block
|
||||
|
||||
# Persist the fully-resolved run descriptor so a future
|
||||
# ``--resume <run_name>`` invocation can pick up without
|
||||
# re-supplying targets / instructions / scope.
|
||||
_persist_run_record(args)
|
||||
|
||||
_telemetry_start_kwargs = {
|
||||
|
||||
@@ -262,8 +262,6 @@ class StopAgentScreen(ModalScreen): # type: ignore[misc]
|
||||
|
||||
|
||||
class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
"""Modal screen to display vulnerability details."""
|
||||
|
||||
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
|
||||
"critical": "#dc2626", # Red
|
||||
"high": "#ea580c", # Orange
|
||||
@@ -390,7 +388,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
text.append("CVE: ", style=self.FIELD_STYLE)
|
||||
text.append(cve)
|
||||
|
||||
# CVSS breakdown
|
||||
cvss_breakdown = vuln.get("cvss_breakdown", {})
|
||||
if cvss_breakdown:
|
||||
cvss_parts = []
|
||||
@@ -464,12 +461,10 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
vuln = self.vulnerability
|
||||
lines: list[str] = []
|
||||
|
||||
# Title
|
||||
title = vuln.get("title", "Untitled Vulnerability")
|
||||
lines.append(f"# {title}")
|
||||
lines.append("")
|
||||
|
||||
# Metadata
|
||||
if vuln.get("id"):
|
||||
lines.append(f"**ID:** {vuln['id']}")
|
||||
if vuln.get("severity"):
|
||||
@@ -489,7 +484,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
if vuln.get("cvss") is not None:
|
||||
lines.append(f"**CVSS:** {vuln['cvss']}")
|
||||
|
||||
# CVSS Vector
|
||||
cvss_breakdown = vuln.get("cvss_breakdown", {})
|
||||
if cvss_breakdown:
|
||||
abbrevs = {
|
||||
@@ -508,21 +502,17 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
if parts:
|
||||
lines.append(f"**CVSS Vector:** {'/'.join(parts)}")
|
||||
|
||||
# Description
|
||||
lines.append("")
|
||||
lines.append("## Description")
|
||||
lines.append("")
|
||||
lines.append(vuln.get("description") or "No description provided.")
|
||||
|
||||
# Impact
|
||||
if vuln.get("impact"):
|
||||
lines.extend(["", "## Impact", "", vuln["impact"]])
|
||||
|
||||
# Technical Analysis
|
||||
if vuln.get("technical_analysis"):
|
||||
lines.extend(["", "## Technical Analysis", "", vuln["technical_analysis"]])
|
||||
|
||||
# Proof of Concept
|
||||
if vuln.get("poc_description") or vuln.get("poc_script_code"):
|
||||
lines.extend(["", "## Proof of Concept", ""])
|
||||
if vuln.get("poc_description"):
|
||||
@@ -533,7 +523,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
lines.append(vuln["poc_script_code"])
|
||||
lines.append("```")
|
||||
|
||||
# Code Analysis
|
||||
if vuln.get("code_locations"):
|
||||
lines.extend(["", "## Code Analysis", ""])
|
||||
for i, loc in enumerate(vuln["code_locations"]):
|
||||
@@ -559,7 +548,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
# Remediation
|
||||
if vuln.get("remediation_steps"):
|
||||
lines.extend(["", "## Remediation", "", vuln["remediation_steps"]])
|
||||
|
||||
@@ -584,8 +572,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
|
||||
|
||||
class VulnerabilityItem(Static): # type: ignore[misc]
|
||||
"""A clickable vulnerability item."""
|
||||
|
||||
def __init__(self, label: Text, vuln_data: dict[str, Any], **kwargs: Any) -> None:
|
||||
super().__init__(label, **kwargs)
|
||||
self.vuln_data = vuln_data
|
||||
@@ -596,8 +582,6 @@ class VulnerabilityItem(Static): # type: ignore[misc]
|
||||
|
||||
|
||||
class VulnerabilitiesPanel(VerticalScroll): # type: ignore[misc]
|
||||
"""A scrollable panel showing found vulnerabilities with severity-colored dots."""
|
||||
|
||||
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
|
||||
"critical": "#dc2626", # Red
|
||||
"high": "#ea580c", # Orange
|
||||
@@ -716,8 +700,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self.live_view.hydrate_from_run_dir(self.report_state.get_run_dir())
|
||||
self._agent_graph_sync_future: Any | None = None
|
||||
|
||||
# Pre-create the coordinator here so the TUI can route stop/chat
|
||||
# commands while the scan loop runs in a worker thread.
|
||||
from strix.core.agents import AgentCoordinator
|
||||
|
||||
self.coordinator = AgentCoordinator()
|
||||
@@ -731,13 +713,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self._scan_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._scan_stop_event = threading.Event()
|
||||
self._scan_completed = threading.Event()
|
||||
# Captured by ``scan_target`` when the scan thread crashes; read
|
||||
# by ``run_tui`` after ``run_async()`` returns so the user sees
|
||||
# the traceback on stderr instead of just a silent UI hang.
|
||||
self._scan_error: BaseException | None = None
|
||||
|
||||
self._spinner_frame_index: int = 0 # Current animation frame index
|
||||
self._sweep_num_squares: int = 6 # Number of squares in sweep animation
|
||||
self._spinner_frame_index: int = 0
|
||||
self._sweep_num_squares: int = 6
|
||||
self._sweep_colors: list[str] = [
|
||||
"#000000", # Dimmest (shows dot)
|
||||
"#031a09",
|
||||
@@ -764,8 +743,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
"local_sources": getattr(args, "local_sources", None) or [],
|
||||
"scope_mode": getattr(args, "scope_mode", "auto"),
|
||||
"diff_base": getattr(args, "diff_base", None),
|
||||
# Forward the new --instruction (if any) so the resume path
|
||||
# can deliver it as a fresh user message after session replay.
|
||||
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
|
||||
}
|
||||
|
||||
@@ -1378,9 +1355,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
# Stash the loop so synchronous TUI handlers (stop /
|
||||
# chat) can submit coordinator coroutines onto it from the
|
||||
# main thread.
|
||||
self._scan_loop = loop
|
||||
|
||||
try:
|
||||
@@ -1410,8 +1384,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
logging.exception("Unexpected error during scan")
|
||||
self._scan_error = e
|
||||
finally:
|
||||
# Best-effort sandbox teardown if early setup failed
|
||||
# before run_strix_scan's own ``finally`` ran.
|
||||
with contextlib.suppress(Exception):
|
||||
loop.run_until_complete(
|
||||
session_manager.cleanup(self.scan_config["run_name"]),
|
||||
@@ -1722,11 +1694,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
return agent_name, False
|
||||
|
||||
def action_confirm_stop_agent(self, agent_id: str) -> None:
|
||||
# Graceful stop: each agent's current turn finishes (and is saved to
|
||||
# session) before the run loop honors the cancel. The interactive
|
||||
# outer loop parks with status="stopped".
|
||||
# The hard ``cancel_descendants`` path remains for KeyboardInterrupt
|
||||
# in entry.py where graceful isn't possible.
|
||||
if self._scan_loop is None or self._scan_loop.is_closed():
|
||||
logger.warning("No active scan loop; cannot stop agent %s", agent_id)
|
||||
return
|
||||
@@ -1869,12 +1836,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
|
||||
async def run_tui(args: argparse.Namespace) -> None:
|
||||
"""Run strix in interactive TUI mode with textual."""
|
||||
app = StrixTUIApp(args)
|
||||
await app.run_async()
|
||||
# Propagate scan-thread failures: ``app.run_async`` returns normally
|
||||
# when the user quits (ctrl-q) regardless of whether the scan
|
||||
# crashed. Without this re-raise, ``main.py`` would treat a failed
|
||||
# scan as success and print the completion banner.
|
||||
if app._scan_error is not None:
|
||||
raise app._scan_error
|
||||
|
||||
@@ -15,8 +15,6 @@ from strix.interface.tui.history import load_session_history
|
||||
|
||||
|
||||
class TuiLiveView:
|
||||
"""UI projection of agent state plus SDK stream/session events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.agents: dict[str, dict[str, Any]] = {}
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
@@ -18,7 +18,6 @@ def send_user_message_to_agent(
|
||||
target_agent_id: str,
|
||||
message: str,
|
||||
) -> bool:
|
||||
"""Record a local user message and enqueue it into the target SDK session."""
|
||||
if loop is None or loop.is_closed():
|
||||
return False
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ def _truncate(text: str, max_len: int = 80) -> str:
|
||||
|
||||
|
||||
def _sanitize(text: str, max_len: int = 150) -> str:
|
||||
"""Remove newlines and truncate text."""
|
||||
clean = text.replace("\n", " ").replace("\r", "").replace("\t", " ")
|
||||
return _truncate(clean, max_len)
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from rich.text import Text
|
||||
from strix.config import load_settings
|
||||
|
||||
|
||||
# Display utilities
|
||||
def get_severity_color(severity: str) -> str:
|
||||
severity_colors = {
|
||||
"critical": "#dc2626",
|
||||
@@ -57,7 +56,6 @@ def format_token_count(count: float | None) -> str:
|
||||
|
||||
|
||||
def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR0915
|
||||
"""Format a vulnerability report for CLI display with all rich fields."""
|
||||
field_style = "bold #4ade80"
|
||||
|
||||
text = Text()
|
||||
@@ -206,7 +204,6 @@ def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR091
|
||||
|
||||
|
||||
def _build_vulnerability_stats(stats_text: Text, report_state: Any) -> None:
|
||||
"""Build vulnerability section of stats text."""
|
||||
vuln_count = len(report_state.vulnerability_reports)
|
||||
|
||||
if vuln_count > 0:
|
||||
@@ -318,7 +315,6 @@ def _build_llm_usage_stats(
|
||||
|
||||
|
||||
def build_final_stats_text(report_state: Any) -> Text:
|
||||
"""Build final stats from Strix-owned scan artifacts."""
|
||||
stats_text = Text()
|
||||
if not report_state:
|
||||
return stats_text
|
||||
@@ -401,9 +397,6 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
||||
return stats_text
|
||||
|
||||
|
||||
# Name generation utilities
|
||||
|
||||
|
||||
def _slugify_for_run_name(text: str, max_length: int = 32) -> str:
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||
@@ -461,8 +454,6 @@ def generate_run_name(targets_info: list[dict[str, Any]] | None = None) -> str:
|
||||
return f"{slug}_{random_suffix}"
|
||||
|
||||
|
||||
# Target processing utilities
|
||||
|
||||
_SUPPORTED_SCOPE_MODES = {"auto", "diff", "full"}
|
||||
_MAX_FILES_PER_SECTION = 120
|
||||
|
||||
@@ -712,9 +703,6 @@ def _parse_name_status_z(raw_output: bytes) -> list[DiffEntry]:
|
||||
if len(status_raw) > 1 and status_raw[1:].isdigit():
|
||||
similarity = int(status_raw[1:])
|
||||
|
||||
# Git's -z output for --name-status is:
|
||||
# - non-rename/copy: <status>\0<path>\0
|
||||
# - rename/copy: <statusN>\0<old_path>\0<new_path>\0
|
||||
if status_code in {"R", "C"} and index + 2 < len(tokens):
|
||||
old_path = tokens[index + 1]
|
||||
new_path = tokens[index + 2]
|
||||
@@ -1264,7 +1252,6 @@ def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway:
|
||||
details["target_ip"] = host_gateway
|
||||
|
||||
|
||||
# Repository utilities
|
||||
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:
|
||||
console = Console()
|
||||
|
||||
@@ -1341,7 +1328,6 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Docker utilities
|
||||
def check_docker_connection() -> Any:
|
||||
try:
|
||||
return docker.from_env()
|
||||
|
||||
@@ -1,15 +1 @@
|
||||
"""Strix runtime — pluggable sandbox lifecycle on top of the Agents SDK.
|
||||
|
||||
- :mod:`.backends` — registry mapping ``STRIX_RUNTIME_BACKEND`` values
|
||||
to async factories that bring up a ``(client, session)`` pair. Ships
|
||||
with ``"docker"`` out of the box; ``register_backend`` lets downstream
|
||||
users add Daytona / K8s / Modal / etc. without forking.
|
||||
- :mod:`.session_manager` — ``create_or_reuse`` / ``cleanup`` keyed
|
||||
by scan id; bundles the SDK session with a ready Caido client.
|
||||
- :mod:`.caido_bootstrap` — runtime-agnostic Caido auth dance via
|
||||
``session.exec``.
|
||||
- :class:`strix.runtime.docker_client.StrixDockerSandboxClient` —
|
||||
``DockerSandboxClient`` subclass that injects ``NET_ADMIN`` /
|
||||
``NET_RAW`` capabilities and ``host.docker.internal`` extra-hosts
|
||||
(used only by the Docker backend).
|
||||
"""
|
||||
"""Pluggable sandbox lifecycle on top of the Agents SDK."""
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
"""Sandbox backend registry — runtime-agnostic session bring-up.
|
||||
|
||||
A *backend* is an async callable that takes an image tag + an SDK
|
||||
:class:`Manifest` + the ports to expose, and returns the matching
|
||||
``(client, session)`` pair. The caller owns lifecycle from there
|
||||
(``await client.delete(session)``).
|
||||
|
||||
This keeps :mod:`strix.runtime.session_manager` free of any
|
||||
backend-specific imports — switching to Daytona / K8s / Modal /
|
||||
whatever is one new factory function plus one registry entry.
|
||||
|
||||
Selection is driven by ``STRIX_RUNTIME_BACKEND`` (default: ``"docker"``).
|
||||
Unknown values raise :class:`ValueError` rather than silently falling
|
||||
back, so typos fail loudly.
|
||||
"""
|
||||
"""Sandbox backend registry — selected via STRIX_RUNTIME_BACKEND (default: docker)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -28,11 +14,6 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# A backend brings up a fresh session and returns the (client, session)
|
||||
# pair. The client is whatever object exposes ``await client.delete(session)``
|
||||
# for cleanup — typically an ``agents.sandbox.client.BaseSandboxClient``
|
||||
# subclass, but the protocol is duck-typed so non-SDK backends could
|
||||
# also plug in if they implement the same interface.
|
||||
SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]]
|
||||
|
||||
|
||||
@@ -103,5 +84,4 @@ def register_backend(name: str, backend: SandboxBackend) -> None:
|
||||
|
||||
|
||||
def supported_backends() -> list[str]:
|
||||
"""Snapshot of registered backend names. Useful for ``--help`` text."""
|
||||
return sorted(_BACKENDS)
|
||||
|
||||
@@ -5,10 +5,6 @@ The Caido CLI runs as an in-container sidecar listening on
|
||||
``session.exec()``-ing curl from inside the container, then construct
|
||||
a host-side :class:`caido_sdk_client.Client` against the runtime's
|
||||
exposed-port URL for all subsequent SDK calls.
|
||||
|
||||
Running the auth dance through ``session.exec`` keeps this module
|
||||
runtime-agnostic — Docker / Daytona / K8s sessions all implement
|
||||
``exec`` even when their port-exposure semantics differ.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -89,22 +85,7 @@ async def bootstrap_caido(
|
||||
host_url: str,
|
||||
container_url: str,
|
||||
) -> Client:
|
||||
"""Connect to the in-container Caido sidecar and select a fresh project.
|
||||
|
||||
Args:
|
||||
session: Bound sandbox session — used for ``exec`` to call into
|
||||
the in-container Caido API for the guest-login dance.
|
||||
host_url: Host-reachable URL for Caido's GraphQL endpoint
|
||||
(e.g. ``http://127.0.0.1:{exposed_port}``). Used by the
|
||||
host-side :class:`Client` for all post-bootstrap calls.
|
||||
container_url: In-container URL for Caido's GraphQL endpoint
|
||||
(e.g. ``http://127.0.0.1:48080``). Used by the in-sandbox
|
||||
curl for the guest-login dance.
|
||||
|
||||
Returns:
|
||||
A connected :class:`caido_sdk_client.Client` with a temporary
|
||||
``"sandbox"`` project selected.
|
||||
"""
|
||||
"""Connect to the in-container Caido sidecar and select a fresh project."""
|
||||
logger.info("Bootstrapping Caido client (host=%s, container=%s)", host_url, container_url)
|
||||
|
||||
access_token = await _login_as_guest(session, container_url=container_url)
|
||||
|
||||
@@ -42,12 +42,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StrixDockerSandboxClient(DockerSandboxClient):
|
||||
"""``DockerSandboxClient`` subclass that injects Strix-required capabilities.
|
||||
|
||||
Only ``_create_container`` is overridden. All other behavior — image
|
||||
management, session lifecycle, port resolution, cleanup — is inherited.
|
||||
"""
|
||||
|
||||
async def _create_container(
|
||||
self,
|
||||
image: str,
|
||||
@@ -104,7 +98,7 @@ class StrixDockerSandboxClient(DockerSandboxClient):
|
||||
|
||||
# Strix injections — append, don't overwrite, so FUSE/SYS_ADMIN survives.
|
||||
cap_add = create_kwargs.setdefault("cap_add", [])
|
||||
if not isinstance(cap_add, list): # defensive — parent always sets list
|
||||
if not isinstance(cap_add, list):
|
||||
cap_add = list(cap_add)
|
||||
create_kwargs["cap_add"] = cap_add
|
||||
for cap in ("NET_ADMIN", "NET_RAW"):
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
"""Per-scan sandbox session lifecycle.
|
||||
|
||||
One session per scan, reused across every agent in that scan's tree.
|
||||
|
||||
The bundle returned by :func:`create_or_reuse` carries the SDK
|
||||
``client`` + ``session`` plus a ready-to-use Caido client (already
|
||||
authenticated and pointing at a temporary sandbox project).
|
||||
|
||||
Cache strategy: a module-level dict keyed by ``scan_id``. The same scan
|
||||
issuing multiple ``create_or_reuse`` calls (e.g., resume after a crash
|
||||
on the host side) gets the same bundle back. ``cleanup`` is best-effort
|
||||
— a leaked container is preferable to a stuck cleanup that prevents the
|
||||
next scan from starting.
|
||||
"""
|
||||
"""Per-scan sandbox session lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,10 +21,6 @@ logger = logging.getLogger(__name__)
|
||||
_CONTAINER_CAIDO_PORT = 48080
|
||||
|
||||
|
||||
# Per-scan session cache. Module-level so a scan that bounces through
|
||||
# multiple host-side processes (e.g., re-imports the module) doesn't
|
||||
# spin up a second container — though in practice we expect one
|
||||
# Strix process per scan.
|
||||
_SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
@@ -47,29 +30,16 @@ async def create_or_reuse(
|
||||
image: str,
|
||||
local_sources: list[dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
"""Return the existing bundle for ``scan_id`` or create a new one.
|
||||
"""Return the existing session bundle for ``scan_id`` or create a new one.
|
||||
|
||||
Args:
|
||||
scan_id: Caller-provided scan identifier (used as cache key).
|
||||
image: Docker image tag (e.g. ``"strix-sandbox:0.2.0"``).
|
||||
local_sources: Each entry's ``source_path`` (host) is mounted at
|
||||
``/workspace/<workspace_subdir>`` inside the container — the
|
||||
same path the root-task prompt advertises. Empty list means
|
||||
no host code is mounted (web/IP-only scans).
|
||||
|
||||
Returns the bundle dict containing ``client``, ``session``, and
|
||||
``caido_client``.
|
||||
Each ``local_sources`` entry mounts its host ``source_path`` at
|
||||
``/workspace/<workspace_subdir>`` inside the container.
|
||||
"""
|
||||
cached = _SESSION_CACHE.get(scan_id)
|
||||
if cached is not None:
|
||||
logger.info("Reusing existing sandbox session for scan %s", scan_id)
|
||||
return cached
|
||||
|
||||
# Build Manifest entries keyed by ``workspace_subdir`` — the SDK
|
||||
# mounts each at ``/workspace/<key>``, which is exactly the path
|
||||
# ``build_root_task`` puts in the agent's task prompt. Mounting
|
||||
# only the listed source dirs (not their parent) avoids leaking
|
||||
# unrelated host content into the sandbox.
|
||||
entries: dict[str | Path, BaseEntry] = {}
|
||||
for src in local_sources:
|
||||
ws_subdir = src.get("workspace_subdir") or ""
|
||||
|
||||
@@ -9,9 +9,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
|
||||
|
||||
# Categories loaded by the prompt template via explicit slash-form paths
|
||||
# (``scan_modes/<mode>``, ``coordination/<role>``). They're not
|
||||
# user-selectable through ``create_agent``'s ``skills`` argument.
|
||||
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"})
|
||||
|
||||
|
||||
@@ -35,7 +32,6 @@ def get_all_skill_names() -> set[str]:
|
||||
|
||||
|
||||
def get_available_skills() -> dict[str, list[str]]:
|
||||
"""Return user-selectable skills grouped by category, alphabetised."""
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for category, name in _iter_user_skill_files():
|
||||
grouped.setdefault(category, []).append(name)
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
"""Per-scan logging setup.
|
||||
|
||||
Every scan calls :func:`setup_scan_logging` to attach a ``FileHandler``
|
||||
to ``{run_dir}/strix.log`` (DEBUG, all ``strix.*`` events) plus a
|
||||
stderr handler (ERROR-only by default; DEBUG when ``STRIX_DEBUG=1``).
|
||||
``scan_id`` and ``agent_id`` are pulled from ``ContextVar``s by a
|
||||
``Filter`` so every log line is auto-tagged without callers passing
|
||||
them explicitly.
|
||||
|
||||
Third-party loggers (``httpx``, ``litellm``, ``openai``, etc.) are
|
||||
capped at ``WARNING`` so the file isn't drowned in their internals.
|
||||
"""
|
||||
"""Per-scan logging setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -46,8 +35,6 @@ def set_agent_id(agent_id: str | None) -> None:
|
||||
|
||||
|
||||
class _StrixContextFilter(logging.Filter):
|
||||
"""Inject ``scan_id`` and ``agent_id`` from ``ContextVar``s onto each record."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.scan_id = _SCAN_ID.get() or "-"
|
||||
record.agent_id = _AGENT_ID.get() or "-"
|
||||
@@ -73,12 +60,7 @@ _NOISY_LIBS: tuple[str, ...] = (
|
||||
_HANDLER_TAG = "_strix_scan_handler"
|
||||
|
||||
|
||||
# Logger roots that also receive our scan handlers. ``strix`` covers
|
||||
# everything we own. ``openai.agents`` is the openai-agents SDK's
|
||||
# canonical logger (verified: ``agents/logger.py``, ``agents/__init__.py``,
|
||||
# ``agents/tracing/logger.py`` all use this namespace) — without
|
||||
# attaching here, SDK-internal Runner / tool-dispatch / model-retry
|
||||
# events would be invisible.
|
||||
# ``openai.agents`` is the openai-agents SDK's canonical logger root.
|
||||
_TRACKED_ROOTS: tuple[str, ...] = ("strix", "openai.agents")
|
||||
|
||||
|
||||
@@ -144,8 +126,6 @@ def setup_scan_logging(run_dir: Path, *, debug: bool | None = None) -> Callable[
|
||||
tracked.setLevel(logging.DEBUG)
|
||||
tracked.addHandler(file_handler)
|
||||
tracked.addHandler(stream_handler)
|
||||
# Stop these records from also bubbling to the python root
|
||||
# logger's lastResort handler (would double-print to stderr).
|
||||
tracked.propagate = False
|
||||
|
||||
for name in _NOISY_LIBS:
|
||||
|
||||
@@ -45,7 +45,6 @@ def _send(event: str, properties: dict[str, Any]) -> None:
|
||||
with urllib.request.urlopen(req, timeout=10): # noqa: S310 # nosec B310
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
# Telemetry must never disrupt a scan; log + swallow.
|
||||
logger.debug("posthog send failed for event %s", event, exc_info=True)
|
||||
else:
|
||||
logger.debug("posthog event sent: %s", event)
|
||||
|
||||
@@ -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