diff --git a/AGENT_ORCHESTRATION_REFACTOR_PLAN.md b/AGENT_ORCHESTRATION_REFACTOR_PLAN.md deleted file mode 100644 index 32c7af6..0000000 --- a/AGENT_ORCHESTRATION_REFACTOR_PLAN.md +++ /dev/null @@ -1,69 +0,0 @@ -# Agent Orchestration Refactor - -## Goal - -Keep every Strix agent first-class and addressable. Root and subagents are all SDK agents with -their own `SQLiteSession`; any registered agent can be messaged, stopped, resumed, and observed. -Strix should not collapse to a manager-only pattern and should not rebuild the SDK model/tool loop. - -## SDK Boundary - -- The OpenAI Agents SDK owns model/tool execution through `Runner.run_streamed`. -- The SDK session owns per-agent conversation history and message replay. -- Strix owns only product semantics the SDK does not provide: agent ids, parent/child graph, - wake/stop signals, TUI-visible status, snapshots, and process-resume metadata. -- SDK stream events are enough for tool/usage telemetry; lifecycle should not depend on custom - `RunHooks`. - -## Current File Shape - -- `strix/orchestration/coordinator.py`: graph state, runtime handles, SDK session messaging, - wake/stop signals, snapshots, resume, stream telemetry, and the interactive continuation loop. -- `strix/orchestration/scan.py`: top-level scan assembly only: sandbox setup, root construction, - resume restore, session opening, and subagent respawn. -- `strix/tools/agents_graph/tools.py`: thin tool facade over `AgentCoordinator`. -- `strix/tools/finish/tool.py`: report finalization plus active-agent guard. - -Removed custom orchestration modules: - -- `bus.py` -- `filter.py` -- `hooks.py` -- `run_loop.py` - -`agents.json` is the only graph/status snapshot. `agents.db` is the only SDK session database; -each agent uses its own SDK `session_id` inside that shared database. - -## Lifecycle Semantics - -- `running`: an SDK run cycle is active. -- `waiting`: parked and addressable. -- `completed`: task completed, parked, and still addressable in interactive mode. -- `llm_failed`: parked after SDK/model failure; only user input resumes it. -- `stopped`: gracefully stopped, parked, and addressable. -- `crashed` / `failed`: parked after failure and addressable for user recovery. - -Unknown agent id is the only invalid message target. There is no routing-closed status. - -## Tool Semantics - -- `create_agent` registers a child, opens its SDK session, and starts its SDK runner task. -- `send_message_to_agent` appends a user-role item to the target SDK session and wakes the target. -- `wait_for_message` parks immediately in interactive mode; in non-interactive mode it blocks on - the coordinator wake event and returns newly appended session items to the current run. -- `agent_finish` sends the parent completion report and returns a final-output marker; the - coordinator settles status from that marker. -- `finish_scan` refuses to finalize while active agents remain and lets the coordinator settle root - status from the successful final-output marker. -- `stop_agent` uses SDK streaming cancellation when a run is active and wakes parked agents so the - continuation loop observes the stop. - -## Remaining Regression Checks To Add - -- Completed child remains messageable and resumes from its SDK session. -- Stopped/crashed/failed child remains messageable and resumes from user input. -- Interactive `wait_for_message` parks and returns control to the continuation loop. -- Non-interactive `wait_for_message` returns the newly appended session message content. -- `finish_scan` blocks while child agents are active. -- Resume rebuilds parked child runners from `agents.json` plus the shared `agents.db`. -- Graceful stop works for both active-stream and parked agents. diff --git a/AUDIT.md b/AUDIT.md deleted file mode 100644 index 3b5ee33..0000000 --- a/AUDIT.md +++ /dev/null @@ -1,350 +0,0 @@ -# Migration Audit — Pre-Execution Verification - -> Source-verified against `openai-agents` v0.14.6 at `/tmp/openai-agents` and Strix at `9fb1012`. Five parallel deep-dives covering: agent loop, tool execution, sandbox, LLM/sessions/tracing, and Strix internals. -> -> **Verdict: GO.** No architectural blockers. Five concrete corrections to the plan must be applied before Phase 1; all are <300 LOC total. Migration today is feasible if we apply the corrections below in the listed order. - ---- - -## 1. Verified bridges (no change needed) - -These claims from `MIGRATION_EVALUATION.md` were **confirmed against source** — proceed as planned: - -| Plan claim | Verification | Source | -|---|---|---| -| `call_model_input_filter` runs before every model call | ✓ Confirmed at `turn_preparation.py:55-80`. **Bonus: filter runs ONCE per turn — its output is captured in a lambda closure for retries (`model_retry.py:34-35`). Inbox messages will NOT be drained twice on retry.** Open question #1 in plan §11 is resolved. | `run_internal/turn_preparation.py:48-82`, `run_internal/run_loop.py:1363-1369, 1803-1809` | -| `asyncio.create_task(Runner.run(...))` is isolation-safe | ✓ Each task gets own `RunContextWrapper`; contextvars properly isolated. No global state mutation inside `Runner.run`. | `run.py:486-615`, `run_context.py:42-51` | -| Shared `SandboxRunConfig(session=...)` across parallel runs | ✓ SDK does NOT tear down sandbox sessions at run end; caller owns lifecycle. Safe to reuse one session across N children. | `run_config.py:115-138`, `docker.py:1372-1401` | -| `RunContextWrapper.context` mutable across turns | ✓ Dict is by-reference; mutations persist. Bus + agent_id stash will work as designed. | `run_context.py:42-51`, `run.py:615` | -| `RunHooks.on_agent_end` fires once per Runner.run | ✓ Single fire when `final_output` established. | `run_internal/turn_resolution.py:204-255` | -| Custom Docker image accepted | ✓ `DockerSandboxClientOptions(image=str)` is verbatim pass-through to `containers.create(image=...)`. No assumed binaries. | `sandbox/sandboxes/docker.py:106-122, 1340, 1444-1456` | -| `Manifest.environment` reaches container | ✓ Resolved via `await manifest.environment.resolve()` and passed to `containers.create(environment=...)`. | `sandbox/sandboxes/docker.py:1448-1450` | -| `Manifest` entries are a strict superset (LocalDir, GitRepo, mounts) | ✓ Direct LocalDir maps to our tar-pipe with concurrency limits. | `sandbox/entries/__init__.py`, `artifacts.py:127-179` | -| Capability lifecycle (clone, bind, tools, instructions, process_manifest) | ✓ Per-run cloned; bound after container start; can hold mutable state. CaidoCapability viable. | `sandbox/capabilities/capability.py:15-100`, `sandbox/runtime.py:180-256` | -| MultiProvider with custom prefix routing | ✓ `MultiProviderMap.add_provider("strix", StrixModelProvider())` works exactly. | `models/multi_provider.py:16-49, 138-232` | -| Custom `ModelProvider` interface | ✓ Just `get_model(model_name) -> Model`. Post-prefix-strip name received. | `models/interface.py:127-151` | -| LitellmModel reasoning effort priority | ✓ Exact: `reasoning.effort` > `extra_body["reasoning_effort"]` > `extra_args["reasoning_effort"]`. | `extensions/models/litellm_model.py:162-199` | -| LitellmModel streaming + tool-call assembly across providers | ✓ `ChatCmplStreamHandler.handle_stream()` unifies provider-native streaming (Anthropic, OpenAI, etc.) into common stream format. | `extensions/models/litellm_model.py:315-351` | -| `add_trace_processor()` / `set_trace_processors()` | ✓ Both exist; can disable defaults entirely. | `tracing/__init__.py:94-130` | -| `RunHooks` 7-hook surface area | ✓ All 7 hooks fire as documented. RunHooks + AgentHooks both fire (gathered). | `lifecycle.py:13-99, 102-199` | -| Per-tool timeout default is `None` | ✓ Confirmed. Our `strix_tool()` factory will re-impose 120s. | `tool.py:337-338` | -| `RunState.to_json()/from_json()` resumable across processes | ✓ Schema v1.9; full serialization. | `run_state.py:1-200` | -| `tracing_disabled` per-RunConfig | ✓ Disables ALL tracing for that run. | `run_config.py:186-188` | -| `OPENAI_AGENTS_DONT_LOG_MODEL_DATA` env | ✓ Logging only; independent from tracing. | `_debug.py:12-21` | -| Sync function tools auto-offload via `asyncio.to_thread` | ✓ **Plan was wrong** — SDK DOES auto-thread sync `@function_tool` bodies. We can drop the manual `asyncio.to_thread` wrapping in our libtmux/IPython tools and just write sync functions. ~30 LOC saved. | `tool.py:1820-1829` | -| `ToolGuardrailFunctionOutput.reject_content("nope")` continues run | ✓ Model sees "nope" as tool output and proceeds. Run NOT halted. | `tool_guardrails.py:79-105` | -| Multi-agent stat aggregation via hooks | ✓ `on_llm_end` + `on_agent_end` fire on each child Runner.run; bus aggregation works. | `lifecycle.py`, `run_internal/turn_resolution.py:204-255` | - ---- - -## 2. Critical corrections (must apply before / during Phase 1) - -Five corrections to the plan. All are concrete and small. - -### 2.1 [BLOCKER] Strix tool server slot serialization vs SDK parallel tool calls - -**The collision.** SDK fires N tool calls in one turn as N concurrent `asyncio.create_task` (`run_internal/tool_execution.py:1414`, `:1424-1430`). Strix tool server cancels the previous in-flight task for the same agent on every new request (`tool_server.py:94-97`). When SDK issues `terminal_execute` + `web_search` simultaneously for the same agent, the second cancels the first. - -**Fix (Phase 1 — safe default).** Add to the default RunConfig for every Strix run: - -```python -RunConfig( - model_settings=ModelSettings( - parallel_tool_calls=False, # model-side hint: emit one tool call per turn - ... - ), - isolate_parallel_failures=False, # if model emits multiple anyway, don't cascade-cancel - ... -) -``` - -**Caveat.** `parallel_tool_calls` is a **provider hint** (`model_settings.py:89-96`), not enforced SDK-side. The model may still emit multiple. With `isolate_parallel_failures=False`, sibling tools survive a single failure; but the tool server still cancels prev-task on same-agent collision. - -**Fix (Phase 2 — proper).** Relax `tool_server.py:94-97` to allow concurrent same-agent tool calls. The cancellation logic was Strix's old serialization; we don't need it under the SDK's orchestration. ~10 LOC removal. Re-test multi-agent end-to-end. - -**Effort:** 0.5 day (safe default in Phase 1) + 0.5 day (proper fix + tests in Phase 2). - ---- - -### 2.2 [BLOCKER] Anthropic prompt cache placement is wrong in plan - -**The defect.** Plan §3.2 said: set `ModelSettings(extra_body={"cache_control": {"type": "ephemeral"}})`. Verified at `extensions/models/litellm_model.py:509-516` — this lands `cache_control` in the **request-level** `extra_body`, not on the system message. **Anthropic requires `cache_control` on the system message itself** (per Anthropic API spec). Plan would silently cache nothing. - -**Fix.** Build a thin `LitellmModel` subclass that injects `cache_control` into the message list before delegating to parent: - -```python -# strix/llm/anthropic_cache_wrapper.py (~40 LOC) -from agents.extensions.models.litellm_model import LitellmModel - -class AnthropicCachingLitellmModel(LitellmModel): - def _patch_system_message_for_cache(self, input_items: list) -> list: - if not _is_anthropic(self.model): - return input_items - patched = [] - for item in input_items: - if isinstance(item, dict) and item.get("role") == "system": - content = item["content"] - if isinstance(content, str): - content = [{"type": "text", "text": content, - "cache_control": {"type": "ephemeral"}}] - patched.append({**item, "content": content}) - else: - patched.append(item) - return patched - - async def get_response(self, *, input, **kwargs): - return await super().get_response(input=self._patch_system_message_for_cache(input), **kwargs) - - async def stream_response(self, *, input, **kwargs): - async for ev in super().stream_response(input=self._patch_system_message_for_cache(input), **kwargs): - yield ev -``` - -Wire into our `MultiProviderMap` so any `litellm/anthropic/...` route uses this wrapper. - -**Effort:** 0.5 day (~40 LOC + tests). - ---- - -### 2.3 [BLOCKER] DockerSandboxClient subclass requires full method duplication - -**The reality.** Audit #2 verified that `_create_container()` (`sandbox/sandboxes/docker.py:1434-1477`) builds `create_kwargs` locally and **does not** expose a hook for kwarg injection. Subclass must reimplement the method body. ~100-120 LOC duplication. Plan said "~80 LOC" — bump to ~120 LOC. - -**Fix.** Subclass and copy the parent body verbatim, adding our injections before the final `containers.create(**create_kwargs)` line: - -```python -# strix/runtime/strix_docker_client.py (~120 LOC) -from agents.sandbox.sandboxes.docker import ( - DockerSandboxClient, _build_docker_volume_mounts, - _manifest_requires_fuse, _manifest_requires_sys_admin, - _docker_port_key, parse_repository_tag, -) - -class StrixDockerSandboxClient(DockerSandboxClient): - async def _create_container(self, image, *, manifest=None, exposed_ports=(), session_id=None): - # --- copy of parent _create_container body (lines 1442-1476) --- - if not self.image_exists(image): - repo, tag = parse_repository_tag(image) - self.docker_client.images.pull(repo, tag=tag or None, all_tags=False) - - environment = None - if manifest: - environment = await manifest.environment.resolve() - - create_kwargs = { - "entrypoint": ["tail"], - "image": image, - "detach": True, - "command": ["-f", "/dev/null"], - "environment": environment, - } - - if manifest is not None: - mounts = _build_docker_volume_mounts(manifest, session_id=session_id) - if mounts: - create_kwargs["mounts"] = mounts - if _manifest_requires_fuse(manifest): - create_kwargs.setdefault("devices", []).append("/dev/fuse") - create_kwargs.setdefault("cap_add", []).append("SYS_ADMIN") - create_kwargs.setdefault("security_opt", []).append("apparmor:unconfined") - if _manifest_requires_sys_admin(manifest): - create_kwargs.setdefault("cap_add", []).append("SYS_ADMIN") - - if exposed_ports: - create_kwargs["ports"] = { - _docker_port_key(p): ("127.0.0.1", None) for p in exposed_ports - } - - # --- STRIX INJECTIONS --- - create_kwargs.setdefault("cap_add", []).extend(["NET_ADMIN", "NET_RAW"]) - create_kwargs.setdefault("extra_hosts", {})["host.docker.internal"] = "host-gateway" - - return self.docker_client.containers.create(**create_kwargs) -``` - -**Risk.** SDK upstream changes to `_create_container` won't propagate. Pin SDK version; track upstream in CI; consider upstream PR for `additional_create_kwargs` hook. - -**Effort:** 0.5 day (~120 LOC + integration test). - ---- - -### 2.4 [BLOCKER] Subagent must exit cleanly via `agent_finish` — needs `tool_use_behavior` configuration - -**The risk.** When subagent calls `agent_finish` and we return a result string from the tool, SDK's loop checks `Agent.tool_use_behavior` (`turn_resolution.py:512-544`). If not configured to permit early exit, the loop continues until `max_turns`. Children would burn budget instead of finishing. - -**Fix.** On every Strix subagent's `Agent`, configure: - -```python -child_agent = Agent( - name=name, - instructions=..., - tools=[..., agent_finish, ...], - tool_use_behavior={ - "stop_at_tool_names": ["agent_finish"], - }, - ... -) -``` - -This tells the SDK: as soon as `agent_finish` returns, treat that as final output. Same pattern for root agent + `finish_scan`: - -```python -root_agent = Agent( - name="strix-root", - tool_use_behavior={"stop_at_tool_names": ["finish_scan"]}, - ... -) -``` - -**Effort:** Trivial — one config line per agent factory. - ---- - -### 2.5 [HIGH] Streaming TUI integration needs a planned shape - -**The reality.** Plan §10 phase 5 said "TUI re-pointed at `Runner.run_streamed().stream_events()`" — true, but Strix's current TUI polls `tracer.streaming_content` at 2 Hz with **per-chunk granularity**. SDK `stream_events()` exposes `RawResponsesStreamEvent` (raw chunks) and `RunItemStreamEvent` (semantic items) — sufficient, but we lose Strix's `update_streaming_content(agent_id, accumulated_text)` API that aggregates incremental text. - -**Fix.** Build a `StrixStreamAccumulator` that consumes `Runner.run_streamed().stream_events()` and synthesizes the same shape Strix's tracer used to expose: - -```python -async for event in result.stream_events(): - if event.type == "raw_response_event": - delta = _extract_text_delta(event.data) - if delta: - tracer.append_streaming_content(agent_id, delta) - elif event.type == "run_item_stream_event": - if event.name == "tool_called": - tracer.log_tool_start(agent_id, event.item.tool_name) - elif event.name == "tool_output": - tracer.log_tool_end(agent_id, event.item.tool_name, event.item.output) -``` - -Plus, `RunHooks.on_llm_start/on_llm_end/on_tool_start/on_tool_end` fire regardless of streaming mode, so child agents launched via `Runner.run` (not streamed) still feed the tracer through hooks. The TUI subscribes to the same tracer. - -**Effort:** 1.5 days for both stream accumulator + hook bridge + TUI repoint. - ---- - -## 3. Medium-severity adjustments (Phase 1-2) - -| # | Issue | Source | Fix | Effort | -|---|---|---|---|---| -| M1 | Cost tracking — SDK has `Usage(input/output/cached_tokens)` but no cost field. `litellm.completion_cost()` requires raw litellm response, not SDK's `ModelResponse`. | `usage.py`, `extensions/models/litellm_model.py:254-293` | Inside our `AnthropicCachingLitellmModel` and a similar light wrapper for OpenAI, capture the litellm response and store cost in `ModelResponse.usage` via `litellm.cost_per_token(...)` (which takes tokens, not response). Then `RunHooks.on_llm_end` reads it. | 0.5 day | -| M2 | Vision-less model image stripping — SDK has none, will pass-through and provider rejects. | None | If we end up routing to a non-vision model, build a wrapper Model that strips images. Defer; current models (Claude Sonnet 4.6, GPT-5, Gemini) are all vision-capable. | Defer (0 day) | -| M3 | SQLiteSession uses `threading.RLock`, not `asyncio.Lock`. Concurrent async writes from parallel children may interleave. | `memory/sqlite_session.py:17-175` | Use a separate `Session` per child (history is per-agent anyway); only share `SandboxRunConfig.session`. Plan §4.7 already says this — emphasize it in code review. | 0 day (already in plan) | -| M4 | Trace processor memory pressure on 300-turn runs. | `tracing/processor_interface.py` | Custom processor batches every 100 spans + `force_flush()` periodically. | 0.5 day | -| M5 | Streaming events don't expose token deltas — only raw chunks. | `stream_events.py` | Parse `RawResponsesStreamEvent.data` chunks for token text manually in our accumulator. | (rolled into 2.5) | -| M6 | `trace_include_sensitive_data` is binary, no field-level. | `run_config.py:193-199` | Custom trace processor scrubs PII via existing `TelemetrySanitizer`. Plan already says this. | 0 day (already in plan) | -| M7 | Caido + tool server readiness check needs a place to await — Capability.bind() is sync. | `sandbox/capabilities/capability.py:29-31` | Spawn a background task in bind() (`_healthcheck_task`); await it inside `RunHooks.on_agent_start`. ~30 LOC. | 0.5 day | -| M8 | `vulnerability_found_callback` (TUI popup trigger) — no SDK-native equivalent. | Strix `telemetry/tracer.py:89` | Wrap `create_vulnerability_report` tool with an output guardrail that fires the callback on success. | 0.5 day | -| M9 | `` XML wrapper today contains structured identity that the system prompt has rules to ignore. | Strix `system_prompt.jinja:19-22`, `agents_graph_actions.py:238-266` | Replicate exact XML envelope when `inject_messages_filter` adds the parent's task message OR when `create_agent` builds the child's initial input. Keeps system prompt rules intact unchanged. | 0.5 day | -| M10 | Whitebox wiki note auto-update on subagent finish (side effect on `agent_finish` tool). | Strix `agents_graph_actions.py:161-202` | Implement directly inside our `agent_finish` function tool body, just like today. | 0 day (free port) | -| M11 | `_force_stop` mid-turn soft-interrupt has no SDK equivalent. | Strix `base_agent.py:84` | Use `result.cancel(mode="after_turn")` for cooperative cancel; for mid-turn hard cancel, `.cancel(mode="immediate")`. | 0 day (use `result.cancel`) | -| M12 | 85% / N-3 turn warnings as user messages. | Strix `base_agent.py:186-211` | `RunHooks.on_llm_start` checks `ctx.usage` turn count; if at threshold, mutate `input_items` (passed by reference per `lifecycle.py:18-26`). Verify mutation visibility in source: hook signature shows `input_items` is the list; mutations propagate. | 0.5 day | - ---- - -## 4. Pre-Phase-1 spike (1 day) - -Before writing production code, validate the assumptions in a tiny throwaway script: - -1. **Two-children messaging smoke test.** Build minimal `MessageBus` + `inject_messages_filter` + 2 child agents that exchange one message each. Run with `LitellmModel("anthropic/claude-sonnet-4-5-20250929")` (or whatever Anthropic alias is current). Verify: messages arrive, hooks fire, no deadlock, no message duplication on retry. -2. **Anthropic cache wrapper smoke test.** Send 3 requests with identical system prompt; check Anthropic response usage `cache_creation_input_tokens` on call 1 and `cache_read_input_tokens` on calls 2-3. -3. **`StrixDockerSandboxClient` smoke test.** Pull our Kali image, create a session, run `nmap -sS scanme.nmap.org` via `session.exec()` to verify NET_RAW works. -4. **`tool_use_behavior={"stop_at_tool_names": [...]}` smoke test.** Toy agent with `agent_finish`-equivalent; verify SDK terminates exactly when expected. -5. **Tool server parallel-call smoke test.** Issue two POSTs to local tool server with same `agent_id` simultaneously; observe whether second cancels first under current code. - -If any spike fails, fix before Phase 1. If all pass, proceed. - -**Effort:** 1 day. - ---- - -## 5. Updated migration plan (rev 3 sequencing) - -Replaces `MIGRATION_EVALUATION.md` §10. Same scope, with corrections folded in. - -### Phase 0 — Spike & corrections (1.5 days) -- Run the 5 spikes above. -- Build `AnthropicCachingLitellmModel` (~40 LOC). Smoke-tested. -- Build `StrixDockerSandboxClient` (~120 LOC). Smoke-tested. -- Decide tool server fix: relax serialization (recommended) OR set `parallel_tool_calls=False` + `isolate_parallel_failures=False` (safe default). - -### Phase 1 — Foundation (4 days) -- `MultiProvider` + `MultiProviderMap` with `StrixModelProvider` for our aliases. -- Wire `AnthropicCachingLitellmModel` into the provider map. -- `strix_tool` decorator (~30 LOC; just `function_tool` with default `timeout=120, timeout_behavior="error_as_result"`). -- Custom `Session` subclass with our memory compressor strategy. -- Custom `TracingProcessor` with JSONL + scrubadub PII scrub. `set_trace_processors([StrixProcessor()])` to disable defaults. -- `RunConfig` factory that bakes in: `tracing_disabled=False`, `isolate_parallel_failures=False`, `model_settings.parallel_tool_calls=False` (until Phase 6 relaxes), our processors. -- Cost tracking inside the model wrapper (M1). - -### Phase 2 — Tool ports (8 days) -- Sandbox dispatcher: one helper that POSTs to FastAPI tool server with httpx (`Timeout(connect=10, total=150)`) + Bearer auth. -- All 30+ tools as `@strix_tool`. Sync ones use `def`, SDK auto-threads them. -- Browser as `ComputerTool` + `AsyncComputer` subclass (or as a single `@strix_tool` if `ComputerTool` semantics don't match). -- Stateful tools key off `RunContextWrapper.context["agent_id"]` (helper: `get_agent_id(ctx)`). -- `create_vulnerability_report` wraps `ToolOutputGuardrail` to fire the TUI popup callback (M8). -- Verify reentrancy on browser singleton, tmux sessions, IPython kernels. - -### Phase 3 — Multi-agent orchestration (4 days) -- `AgentMessageBus` + tests. -- `inject_messages_filter` + tests including retry simulation (verified safe by audit; no de-dup needed). -- `StrixOrchestrationHooks` for stat aggregation + tracer wiring. -- Six graph tools (`create_agent`, `send_message_to_agent`, `wait_for_message`, `agent_status`, `view_agent_graph`, `agent_finish`). -- Every child Agent: `tool_use_behavior={"stop_at_tool_names": ["agent_finish"]}`. -- Root Agent: `tool_use_behavior={"stop_at_tool_names": ["finish_scan"]}`. -- Identity injection via `` XML in the **first user message** of the child Runner (M9). -- Wiki auto-update on whitebox `agent_finish` (M10). - -### Phase 4 — Sandbox + Caido capability (2 days) -- `StrixDockerSandboxClient` (already in Phase 0). -- `CaidoCapability` with `process_manifest` (env vars), `tools()` (7 Caido tools), `instructions()` (proxy-aware system block), `bind()` (spawn healthcheck task). -- `RunHooks.on_agent_start` awaits Caido + tool server readiness via the capability's healthcheck task (M7). -- Container reuse keyed by scan_id in our session map. - -### Phase 5 — Interface + persistence (3 days) -- Streaming accumulator wires `Runner.run_streamed().stream_events()` → tracer (replaces today's per-chunk `update_streaming_content`). -- TUI keeps its 2 Hz polling against the tracer; tracer is now event-driven from the accumulator. -- 85% / N-3 turn warnings via `RunHooks.on_llm_start` mutating `input_items` (M12). -- Run-directory layout via custom `TracingProcessor` writing `events.jsonl`, `vulnerabilities/`, etc. -- CLI / config / argparse layer unchanged. - -### Phase 6 — Validation + tool server relaxation (4 days) -- Smoke: every tool runs in sandbox. -- Multi-agent: 2+ parallel children + messaging + cancel. -- Bedrock + Anthropic + OpenAI parity. -- Memory compression at 90K. -- PII redaction. -- Real pentest end-to-end vs Strix baseline diff. -- **Now relax tool_server.py:94-97** (remove per-agent task cancellation), set `parallel_tool_calls=True` and `isolate_parallel_failures=True`. Re-run multi-agent test. If clean → ship parallelism. - -### Buffer (2 days) -For unforeseen issues from spike feedback or test failures. - -**Total: ~28.5 days** (vs plan's 25–35). Within budget. - ---- - -## 6. Final go/no-go - -✅ **GO.** - -**Why:** -- All architectural assumptions validated. No showstoppers found. -- The 5 corrections in §2 are concrete, small, and isolated to specific phases. -- The 12 medium adjustments in §3 are sensible and most are already implicit in the plan. -- The plan's rev-2 effort estimate (25–35 days) holds with corrections (~28.5 days). - -**Day-1 first commits (in order):** -1. `strix/llm/anthropic_cache_wrapper.py` — `AnthropicCachingLitellmModel`. -2. `strix/runtime/strix_docker_client.py` — `StrixDockerSandboxClient`. -3. `strix/orchestration/bus.py` — `AgentMessageBus`. -4. `strix/orchestration/filter.py` — `inject_messages_filter`. -5. `strix/orchestration/hooks.py` — `StrixOrchestrationHooks`. -6. `strix/tools/_decorator.py` — `strix_tool` factory. -7. `strix/llm/multi_provider_setup.py` — `MultiProviderMap` wiring + `StrixModelProvider`. - -These seven files (~600 LOC total) form the migration's load-bearing foundation. Everything else is incremental ports onto this foundation. - -Branch is already on `harness-migration`. Ready when you are. diff --git a/AUDIT_R2.md b/AUDIT_R2.md deleted file mode 100644 index 7bb6190..0000000 --- a/AUDIT_R2.md +++ /dev/null @@ -1,243 +0,0 @@ -# Migration Audit — Round 2 Findings - -> Five-agent deep verification of areas not covered in `AUDIT.md`: SDK surface area exhaustive catalog, Strix port-readiness contracts, concurrency forensics, data-flow mapping, error-handling forensics. Source-verified against `openai-agents` v0.14.6 and Strix at `9fb1012`. Adds seven new concrete corrections to the migration plan. - ---- - -## 1. New corrections discovered - -These are additive to the five blockers in `AUDIT.md` §2. Each was missed in earlier rounds. - -### 1.1 [CRITICAL] notes/notes.jsonl writes are not lock-protected - -**Defect.** `strix/tools/notes/notes_actions.py:40-54` (`_append_note_event`) opens the JSONL file and writes without holding `_notes_lock`. Today this is invisible because Strix daemon-thread subagents serialize on Python's GIL during the `f.write(...)` call — but **the file `open + seek + write + close` is not atomic across multiple threads**. Two simultaneous notes operations from sibling agents can interleave bytes mid-line, corrupting the JSONL file. - -**Post-migration risk.** Same bug. SDK runs tool calls in parallel within a turn (`run_internal/tool_execution.py:1414, 1424`), so two `create_note` invocations on different agents in the same event loop tick will hit the file simultaneously. - -**Fix.** - -```python -# notes_actions.py:40-54 (today) -def _append_note_event(op, note_id, note=None): - notes_path = _get_notes_jsonl_path() - if not notes_path: - return - event = {"timestamp": datetime.now(UTC).isoformat(), "op": op, "note_id": note_id} - if note is not None: - event["note"] = note - with _notes_lock: # <- ADD - with notes_path.open("a", encoding="utf-8") as f: - f.write(f"{json.dumps(event, ensure_ascii=True)}\n") -``` - -Same fix for `_persist_wiki_note()` (write to `wiki/.md`). - -**Apply during Phase 2** when porting notes tool. - -### 1.2 [CRITICAL] events.jsonl writes are not lock-protected either - -**Defect.** `strix/telemetry/tracer.py:162-268` (`_emit_event` → `_append_event_record`) calls `append_jsonl_record(self.events_file_path, record)` **without** acquiring the lock that `_get_events_write_lock()` (line 106-108) is designed to provide. The lock exists in the codebase but is unused at the call site. - -**Post-migration risk.** Even more acute. Our custom `TracingProcessor` will write SDK spans → `events.jsonl` from multiple concurrent agent tasks. JSONL corruption guaranteed under load. - -**Fix.** - -```python -# tracer.py:_append_event_record (today) -def _append_event_record(self, record): - try: - with self._get_events_write_lock(): # <- ADD - append_jsonl_record(self.events_file_path, record) - except OSError: - logger.exception("Failed to append JSONL event record") -``` - -In our custom processor (the migration-phase replacement), apply the same lock. - -**Apply in Phase 1** when wiring the custom `TracingProcessor`. - -### 1.3 [HIGH] Subagent crash silent — parent never learns - -**Defect.** `strix/tools/agents_graph/agents_graph_actions.py:281-287` catches the daemon-thread exception, sets the graph node status to `"error"`, and **re-raises** inside the thread. The thread dies. The parent agent calling `wait_for_message(timeout=600)` polls for 600s and resumes with "Timed out" — never knows the child was dead. - -**Post-migration risk.** Same problem in different shape. If a child `Runner.run` task raises, our `MessageBus.tasks[child_id]` is in `done` state with exception, but parent's `wait_for_message` only checks `inboxes`. - -**Fix.** In `StrixOrchestrationHooks.on_agent_end` (Phase 3), if exit was due to exception, push a synthetic completion report to parent's inbox so `call_model_input_filter` surfaces it on parent's next turn: - -```python -class StrixOrchestrationHooks(RunHooks): - async def on_agent_end(self, ctx, agent, output): - bus = ctx.context["bus"] - me = ctx.context["agent_id"] - parent = bus.parent_of.get(me) - # Detect crash: did agent_finish run? if not, output is None or the run errored. - crashed = (output is None) or (ctx.context.get("agent_finish_called") is not True) - if crashed and parent is not None: - await bus.send(parent, { - "from": me, - "content": f"" - f"Agent terminated without calling agent_finish. " - f"Parent should not wait further on this child." - f"", - "type": "crash", - }) - await bus.finalize(me, "completed" if not crashed else "crashed") -``` - -The `agent_finish_called` flag is set by the `agent_finish` tool body. Also add a watchdog in the bus: any task in `tasks` whose `done()` is True but `bus.statuses` is still `running` is reaped. - -### 1.4 [HIGH] Cancellation cascade incomplete - -**Defect.** Strix's `stop_agent(agent_id)` (`agents_graph_actions.py:688-748`) requires explicit invocation. Today if the user Ctrl+C's the root, only the root agent loop is cancelled — children running in daemon threads keep executing. - -**Post-migration risk.** Same. SDK's `result.cancel()` cancels the root task; child `Runner.run` tasks (spawned by `asyncio.create_task` in `create_agent` tool) are NOT cancelled by SDK and continue. - -**Fix.** Top-level run wrapper walks `bus.parent_of` to enumerate descendants and explicitly cancels each: - -```python -# strix/orchestration/cancellation.py -async def cancel_run_with_descendants(bus: AgentMessageBus, root_agent_id: str): - descendants = [] - queue = [root_agent_id] - while queue: - aid = queue.pop() - descendants.append(aid) - queue.extend(child for child, parent in bus.parent_of.items() if parent == aid) - for aid in reversed(descendants): # leaves first - task = bus.tasks.get(aid) - if task is not None and not task.done(): - task.cancel() - # Wait briefly for cancellations to settle - await asyncio.gather(*(t for t in bus.tasks.values() if not t.done()), - return_exceptions=True) -``` - -Wire from CLI signal handler and TUI stop button. - -### 1.5 [MEDIUM] Memory compressor has no graceful fallback - -**Defect.** `strix/llm/memory_compressor.py:152-219` makes a separate LLM call to summarize old messages. If that call times out or fails, the exception bubbles to the agent loop and **fails the iteration** — the only purpose of the compressor (avoiding context-window overflow) is undermined by an even harsher failure. - -**Post-migration risk.** Same. Custom `Session` subclass calling our compressor inherits the brittleness. - -**Fix.** Wrap compressor invocations: - -```python -# In our custom Session subclass -async def _compress_if_needed(self, items): - try: - return await self._compressor.compress_history(items) - except (asyncio.TimeoutError, Exception) as e: - logger.warning("Compression failed (%s); returning uncompressed history", e) - return items # let context-window error happen later if it must -``` - -The downstream context-window error (if it happens) is itself retryable via SDK retry policies, so we degrade rather than fail. - -### 1.6 [MEDIUM] 401 retry policy mismatch between Strix and SDK - -**Detail.** Strix's `_should_retry` (`llm/llm.py:326-330`) treats `status_code is None` as retryable AND defers HTTP codes to `litellm._should_retry(code)` — which does NOT retry 401. So Strix fails fast on auth errors. - -The SDK's retry default (configurable via `ModelRetrySettings.retry_policies`) may include 401 retries depending on policy composition. We don't want to retry 401 (it wastes time and clutters traces). - -**Fix.** Explicit retry policy in our `RunConfig` factory: - -```python -from agents.retry import retry_policies, ModelRetrySettings, ModelRetryBackoffSettings - -DEFAULT_RETRY = ModelRetrySettings( - max_retries=5, - backoff=ModelRetryBackoffSettings( - initial_delay=2.0, multiplier=2.0, max_delay=90.0, jitter=0.0, - ), - policy=retry_policies.any( - retry_policies.network_error(), - retry_policies.http_status([429, 500, 502, 503, 504]), - # explicitly NOT including 401, 403, 400 - ), -) -``` - -Bake into our `make_run_config()` factory so every Strix run gets it automatically. - -### 1.7 [MEDIUM] `_completed_agent_llm_totals` read without lock from tracer - -**Defect.** `agents_graph_actions.py:35` declares the dict; finalize writes hold `_agent_llm_stats_lock`. Tracer's `get_total_llm_stats()` (`telemetry/tracer.py:801-834`) reads it without acquiring the lock. Possible partial-update read. - -**Post-migration risk.** Reduced (single asyncio loop), but our `MessageBus.total_stats()` should still snapshot under the bus's own `asyncio.Lock`. - -**Fix.** Already in `MessageBus` design — `total_stats` acquires lock. Just confirm the implementation does this. - ---- - -## 2. Round 1 verification snapshot - -What the five Round 1 audits actually verified: - -| Audit | Output | Key new finding | -|---|---|---| -| 1.1 SDK surface | Exhaustive catalog (~55 sections) — every `Agent` field, every `RunConfig` knob, every `ModelSettings` field, every span type, every error class, every hook, every Session impl, every Model interface method | No surprises — confirms Strix-side decisions in plan | -| 1.2 Strix port-readiness | Per-tool exact contract reference (params, return shapes, side effects, threading) | Confirms tool-level mapping; surfaces no new blockers | -| 1.3 Concurrency forensics | Lock-by-lock inventory both repos + post-migration topology | **Discovered the two JSONL race conditions (§1.1, §1.2 above) and cancellation cascade gap (§1.4)** | -| 1.4 Data flow & persistence | Every artifact + every in-memory structure mapped pre/post | Confirms invariants survive migration; no data loss paths | -| 1.5 Error handling forensics | 60+ failure modes catalogued with detection/error class/retry/fallback/visibility | **Discovered subagent-crash silence (§1.3), compressor fail-open (§1.5), 401 retry mismatch (§1.6)** | - ---- - -## 3. Updated correction set (consolidated from `AUDIT.md` + Round 1) - -| # | Severity | Defect | Phase to apply | -|---|---|---|---| -| **C1** | Blocker | Strix tool-server slot serialization vs SDK parallel calls (`AUDIT.md` §2.1) | Phase 0 (set safe `parallel_tool_calls=False`/`isolate_parallel_failures=False`) → Phase 6 (relax) | -| **C2** | Blocker | Anthropic `cache_control` placement on system message (`AUDIT.md` §2.2) | Phase 0 (`AnthropicCachingLitellmModel`) | -| **C3** | Blocker | `DockerSandboxClient` subclass needs full method-body copy (`AUDIT.md` §2.3) | Phase 0 (`StrixDockerSandboxClient`) | -| **C4** | Blocker | Subagent `tool_use_behavior={"stop_at_tool_names": [...]}` required (`AUDIT.md` §2.4) | Phase 3 (multi-agent) | -| **C5** | High | Streaming TUI integration via `StrixStreamAccumulator` (`AUDIT.md` §2.5) | Phase 5 | -| **C6** | Critical | notes JSONL write race (Round 1 §1.1) | Phase 2 (notes tool port) | -| **C7** | Critical | events.jsonl write race (Round 1 §1.2) | Phase 1 (custom processor) | -| **C8** | High | Subagent crash silent — synthetic completion-report on `on_agent_end` (Round 1 §1.3) | Phase 3 | -| **C9** | High | Cancellation cascade walks `bus.parent_of` tree (Round 1 §1.4) | Phase 3 | -| **C10** | Medium | Memory compressor try/except → degrade to uncompressed (Round 1 §1.5) | Phase 1 (custom Session) | -| **C11** | Medium | Retry policy excludes 401/403/400 (Round 1 §1.6) | Phase 1 (RunConfig factory) | -| **C12** | Medium | Bus stats snapshot under lock (Round 1 §1.7) | Phase 3 (already in design) | - -Plus the original twelve medium adjustments from `AUDIT.md` §3 (M1–M12). - ---- - -## 4. Verified-safe areas (no further investigation needed) - -| Area | Verification | -|---|---| -| `call_model_input_filter` retry safety | Filter runs once per turn; output captured in lambda closure, not re-invoked on retry. Inbox drain is safe. (Round 1 §1.1 confirmed via `turn_preparation.py:55-80` + `model_retry.py:34-35`.) | -| `asyncio.create_task(Runner.run)` isolation | Each task gets fresh `RunContextWrapper`; contextvars isolated per task; no global state mutation in `Runner.run`. | -| Shared `SandboxRunConfig.session` across parallel runs | SDK does NOT auto-tear-down sandbox sessions; safe to reuse one session across N children. | -| `RunHooks.on_agent_end` firing | Once per `Runner.run` invocation (verified `turn_resolution.py:204-255`). | -| `RunContextWrapper.context` mutability | Dict by-reference; mutations persist within and across turns. | -| Sync function tools | SDK auto-threads sync `@function_tool` bodies via `asyncio.to_thread` (`tool.py:1820-1829`) — drop manual offload. | -| Custom Docker image | `DockerSandboxClientOptions(image=str)` pass-through; no assumed binaries. | -| `Manifest.entries` superset of Strix needs | `LocalDir`, `LocalFile`, `GitRepo`, `Mount` types cover all Strix patterns. | -| MultiProvider routing | `MultiProviderMap.add_provider("strix", StrixModelProvider())` works as designed. | -| Tracing API | `set_trace_processors([...])` disables defaults; custom processors can write to JSONL/OTel. | -| `RunState.to_json/from_json` | Serializable (`CURRENT_SCHEMA_VERSION=1.9`); cross-process resumable. | -| Sandbox capability hooks | `process_manifest`, `tools()`, `instructions()`, `bind()` cover `CaidoCapability` needs. | - ---- - -## 5. Areas flagged for monitoring during implementation - -These aren't blockers but warrant attention during Phase work: - -- **Browser singleton event-loop init race** — low risk, double-check pattern recommended in `_ensure_event_loop` (`browser_instance.py:34-48`). -- **`agent_tasks` dict in tool server** — currently unprotected; if we ever switch uvicorn to threaded workers, needs `asyncio.Lock`. -- **SQLiteSession async-task ordering** — `threading.RLock` doesn't serialize asyncio tasks deterministically. Mitigated by per-child Sessions (already in plan). -- **Trace processor memory pressure on long runs** — `BatchTraceProcessor` accumulates spans; periodic `force_flush()` recommended. -- **Bus.inboxes resize race** — asyncio.Lock around all dict mutations covers this; verify lock scope in implementation. - ---- - -## 6. Round 1 outcome - -**No new architectural blockers.** Plan structure remains sound. Twelve corrections (five from `AUDIT.md`, seven from Round 1) all bounded, all implementable in their assigned phase. - -Next: **Round 2** dispatches deep-dives on file-by-file implementation specs, per-tool migration contracts, test plans, and cross-cutting concerns. Round 2 output is the actual day-1 engineering reference, not more audit findings. diff --git a/AUDIT_R3.md b/AUDIT_R3.md deleted file mode 100644 index 51aff62..0000000 --- a/AUDIT_R3.md +++ /dev/null @@ -1,476 +0,0 @@ -# Migration Audit — Round 3 Findings - -> Five new deep-dives covering scenario walkthroughs, type/signature compatibility, CLI/interface migration, pathological edge cases, and build/deps/deployment. Adds 13 more concrete corrections (C13–C25) and 3 critical type-signature fixes that must be applied to `PLAYBOOK.md` before Phase 0 starts. - ---- - -## 1. Critical type/signature fixes (PLAYBOOK skeletons are wrong) - -These three fixes are **applied inline to `PLAYBOOK.md`** in this round. The skeletons as written would not compile against SDK v0.14.6. - -### F1 — `AnthropicCachingLitellmModel` method signature wrong - -**PLAYBOOK §2.1 was:** `async def get_response(self, *, system_instructions, input, model_settings, ...)` - -**SDK reality** (`models/interface.py:57-89`): The first 7 params (`system_instructions, input, model_settings, tools, output_schema, handoffs, tracing`) are **positional-first**, then `*,` then keyword-only (`previous_response_id`, `conversation_id`, `prompt`). - -Same fix applies to `stream_response`. Both must drop the leading `*,` and pass the first 7 params positionally to `super()`. - -**Status:** Inline-corrected in `PLAYBOOK.md` §2.1 below. - -### F2 — `RunHooks` lifecycle hook signatures wrong - -**PLAYBOOK §2.5 was:** `on_agent_start(self, ctx, agent)` and `on_agent_end(self, ctx, agent, output)` typed `ctx` as `RunContextWrapper`. - -**SDK reality** (`lifecycle.py:37-59`): These two specific hooks receive `context: AgentHookContext[TContext]`, not `RunContextWrapper`. `AgentHookContext` is a different generic wrapper with the same `.context` attribute pattern but a distinct type. - -Also: `on_tool_end(self, ctx, agent, tool, result)` — the `result` parameter is typed `str`, not `Any`. - -**Status:** Inline-corrected in `PLAYBOOK.md` §2.5 below. - -### F3 — `TracingProcessor` hook methods are SYNC - -**PLAYBOOK §2.9 was:** All hook methods (`on_trace_start`, `on_trace_end`, `on_span_start`, `on_span_end`, `force_flush`, `shutdown`) shown without explicit `async` keyword — implementation accidentally implied async. - -**SDK reality** (`tracing/processor_interface.py:53-129`): All hooks are **synchronous** (`def`, not `async def`). Our processor must use sync methods. JSONL writes are sync I/O which is fine; if we ever want async export, we'd need to schedule via `asyncio.run_coroutine_threadsafe()` from the sync hook. - -**Status:** Inline-corrected in `PLAYBOOK.md` §2.9 below. - ---- - -## 2. New corrections (C13–C25) - -Additive to the 12 corrections in `AUDIT.md` + `AUDIT_R2.md`. - -### C13 [HIGH] Bus must clear inbox/parent_of/names on finalize (Round 3.4) - -**Defect.** When an agent finishes, `bus.finalize` only updates statuses and stats — but children whose parent already finished may still call `bus.send(parent_id, msg)`, accumulating messages in `bus.inboxes[parent_id]` forever. Memory leak bounded by agent count × messages per cycle. - -**Fix.** Update `AgentMessageBus.finalize()`: - -```python -async def finalize(self, agent_id: str, status: str) -> None: - async with self._lock: - self.statuses[agent_id] = status - self.stats_completed[agent_id] = self.stats_live.pop(agent_id, {}) - self.inboxes.pop(agent_id, None) # NEW - self.parent_of.pop(agent_id, None) # NEW - self.names.pop(agent_id, None) # NEW -``` - -**Apply in Phase 3** (in `strix/orchestration/bus.py`). - -### C14 [HIGH] `inject_messages_filter` must be defensive - -**Defect.** If a bug in the filter raises, SDK treats it as a model invocation failure and retries. Filter raises on every retry → run fails after `max_retries` exhausted. - -**Fix.** Wrap filter body in try/except; return unmodified `data.model_data` on exception: - -```python -async def inject_messages_filter(data: CallModelData) -> ModelInputData: - try: - if not isinstance(data.context, dict): - return data.model_data - bus = data.context.get("bus") - agent_id = data.context.get("agent_id") - if bus is None or agent_id is None: - return data.model_data - pending = await bus.drain(agent_id) - if not pending: - return data.model_data - new_input = list(data.model_data.input) - for msg in pending: - # ... XML wrapping - return ModelInputData(input=new_input, instructions=data.model_data.instructions) - except Exception: - logger.exception("inject_messages_filter failed; proceeding without injection") - return data.model_data -``` - -**Apply in Phase 3** (in `strix/orchestration/filter.py`). - -### C15 [HIGH] `RunHooks` must be defensive - -**Defect.** If our hook bodies raise (e.g., bus operation fails, tracer disk error), exception propagates and tears down the run. - -**Fix.** Each hook wraps its body in try/except; logs and continues: - -```python -async def on_llm_start(self, context, agent, system_prompt, input_items): - try: - # ... mutate input_items, increment turn count, etc. - except Exception: - logger.exception("on_llm_start failed") - -# Same for on_llm_end, on_agent_start, on_agent_end, on_tool_start, on_tool_end, on_handoff -``` - -**Apply in Phase 3** (in `strix/orchestration/hooks.py`). - -### C16 [HIGH] Custom `TracingProcessor` must catch disk errors - -**Defect.** PLAYBOOK §2.9 `_emit()` opens file with `"a"` mode; OSError (disk full, permission denied) propagates from sync hook. SDK's hook caller may not gracefully handle. Run dies. - -**Fix.** Wrap `_emit` body in try/except; log and continue: - -```python -def _emit(self, event: dict[str, Any]) -> None: - try: - clean = self.sanitizer.sanitize(event) - with _lock_for(self.events_path): - with self.events_path.open("a", encoding="utf-8") as f: - f.write(json.dumps(clean, ensure_ascii=True) + "\n") - except OSError: - logger.exception("Failed to write event to JSONL") -``` - -**Apply in Phase 1** (in `strix/telemetry/strix_processor.py`). - -### C17 [MEDIUM] `StrixModelProvider` must validate model alias - -**Defect.** If `STRIX_LLM=strix/typo-model-name` (alias not in `STRIX_MODEL_MAP`), our provider falls through to `(model_name, model_name)` and the LLM call later fails with provider's "model not found" — opaque diagnostic. - -**Fix.** Validate at `get_model()` entry; raise `UserError` with the list of valid aliases: - -```python -def get_model(self, model_name: str | None) -> Model: - if model_name is None: - raise UserError("Model name required for StrixModelProvider") - if model_name not in STRIX_MODEL_MAP: - raise UserError( - f"Unknown Strix alias '{model_name}'. " - f"Valid: {list(STRIX_MODEL_MAP.keys())}" - ) - api_model, _ = STRIX_MODEL_MAP[model_name] - if "anthropic/" in api_model or "claude" in api_model.lower(): - return AnthropicCachingLitellmModel(model=api_model, base_url=STRIX_API_BASE) - return LitellmModel(model=api_model, base_url=STRIX_API_BASE) -``` - -**Apply in Phase 1** (in `strix/llm/multi_provider_setup.py`). - -### C18 [MEDIUM] Model output size cap on sandbox tools - -**Defect.** A tool returning 50MB binary output (e.g., browser screenshot of a huge map) gets base64-encoded into JSON; httpx loads the response into RAM; OOM on small hosts. - -**Fix.** Configure `httpx.Limits(max_content_size=...)` in `post_to_sandbox`: - -```python -_TIMEOUT = httpx.Timeout(connect=10.0, read=150.0, write=150.0, pool=150.0) -_LIMITS = httpx.Limits(max_connections=10, max_keepalive_connections=5) - -async def post_to_sandbox(ctx, tool_name, kwargs) -> dict: - # ... - async with httpx.AsyncClient(timeout=_TIMEOUT, limits=_LIMITS) as client: - r = await client.post(url, json=body, headers=headers) - if int(r.headers.get("content-length", 0)) > 50_000_000: - return {"error": "Sandbox response too large (>50MB)"} - # ... -``` - -Plus: cap on the sandbox side too (tool server limits its own response payload). - -**Apply in Phase 2** (in `strix/tools/_sandbox_dispatch.py`). - -### C19 [MEDIUM] `tool_choice="required"` requires at least one enabled tool - -**Defect.** If `is_enabled` callbacks gate out all tools and `ModelSettings(tool_choice="required")`, model has no legal response. SDK raises `ModelBehaviorError`. Run fails opaquely. - -**Fix.** Assert at agent build time: - -```python -def build_strix_agent(name, tools, ...) -> Agent: - enabled_count = len([t for t in tools if not _statically_disabled(t)]) - if enabled_count == 0: - raise UserError(f"Agent {name} has no enabled tools but tool_choice='required'") - return Agent(name=name, tools=tools, ...) -``` - -**Apply in Phase 1** (in agent factory). - -### C20 [MEDIUM] Per-tool `timeout_behavior` discrimination - -**Defect.** If `timeout_behavior="error_as_result"` on a critical sandbox tool (e.g., `terminal_execute`), model sees the timeout error string and may retry the same tool with same args → infinite loop. - -**Fix.** For critical sandbox tools, use `timeout_behavior="raise_exception"` so the model is told via SDK's error machinery that the tool genuinely failed (not just timed out gracefully). For idempotent local tools (notes, todos), `error_as_result` is fine. - -**Apply in Phase 2** — when porting each tool, pick the appropriate behavior. - -### C21 [MEDIUM] `make_run_config` and `make_agent_context` need overrides - -**Defect.** Plan §H1: today there's no path for per-run override of `model_settings` (e.g., user wants `tool_choice="auto"` for a specific run). And `is_whitebox` flag isn't propagated to context — wiki auto-update on subagent finish (M10) reads `ctx.context.get("is_whitebox")` but it's never set. - -**Fix.** - -```python -def make_run_config(*, sandbox_session, bus, model="strix/claude-sonnet-4.6", - max_turns=300, model_settings_override: dict | None = None) -> RunConfig: - base_settings = ModelSettings(parallel_tool_calls=False, tool_choice="required", retry=...) - if model_settings_override: - base_settings = base_settings.model_copy(update=model_settings_override) - return RunConfig(model_settings=base_settings, ...) - - -def make_agent_context(*, bus, sandbox_session, sandbox_token, - tool_server_host_port, caido_host_port, agent_id, agent_name, - parent_id, tracer, model_settings, max_turns=300, - is_whitebox: bool = False, # NEW - diff_scope: dict | None = None, # NEW (J1) - run_id: str | None = None) -> dict: # NEW (run-id propagation) - return { - "bus": bus, "sandbox_session": sandbox_session, - "sandbox_token": sandbox_token, - "tool_server_host_port": tool_server_host_port, - "caido_host_port": caido_host_port, - "agent_id": agent_id, "agent_name": agent_name, - "parent_id": parent_id, "tracer": tracer, - "model_settings": model_settings, "max_turns": max_turns, - "turn_count": 0, "agent_finish_called": False, - "is_whitebox": is_whitebox, - "diff_scope": diff_scope, - "run_id": run_id, - } -``` - -**Apply in Phase 1** (in `strix/run_config_factory.py`). - -### C22 [MEDIUM] `finish_scan` must check children status before exit - -**Defect.** Strix today's `finish_scan` validates that all child agents are not running/stopping (`tools/finish/finish_actions.py:98`). PLAYBOOK §4.2 didn't carry this forward. Without the check, root could finish while children are still in-flight. - -**Fix.** Inside `finish_scan` tool body: - -```python -@strix_tool(timeout=30) -async def finish_scan(ctx, executive_summary: str, methodology: str, - technical_analysis: str, recommendations: str) -> str: - if ctx.context.get("parent_id") is not None: - return "Error: finish_scan is for the root agent only. Subagents must call agent_finish." - bus = ctx.context["bus"] - me = ctx.context["agent_id"] - async with bus._lock: - in_flight = [ - child_id for child_id, parent in bus.parent_of.items() - if parent == me and bus.statuses.get(child_id) in ("running", "waiting") - ] - if in_flight: - names = [bus.names.get(c, c) for c in in_flight] - return ( - f"Cannot finish: subagents still running: {names}. " - f"Wait for completion (or call stop_agent) before finishing the scan." - ) - ctx.context["agent_finish_called"] = True - # ... write narrative fields, persist final report - return "Scan completed. Report written." -``` - -**Apply in Phase 2** (when porting `finish_scan`). - -### C23 [MEDIUM] Diff-scope context injection point - -**Defect.** Plan §J1: PLAYBOOK doesn't say where the diff scope context (from `resolve_diff_scope_context()`) is injected post-migration. - -**Fix.** Two-part: -1. CLI parses `--scope-mode=diff` + `--diff-base=...` and computes `DiffScopeResult` (same as today). -2. The `instruction_block` from the result is **prepended to the user's instruction** in the first message of `Runner.run`. (Same as Strix today; the agent sees it as part of its task.) - -```python -# strix/interface/cli.py (or main.py) -diff_scope = resolve_diff_scope_context(args) -user_instruction = args.instruction or "" -if diff_scope.instruction_block: - user_instruction = f"{diff_scope.instruction_block}\n\n{user_instruction}".strip() - -context = make_agent_context(..., diff_scope=diff_scope.metadata, ...) -result = await Runner.run( - agent, - input=[{"role": "user", "content": user_instruction or "Conduct a thorough penetration test."}], - ... -) -``` - -**Apply in Phase 5** (CLI/interface migration). - -### C24 [MEDIUM] Run-name uniqueness + Docker availability checks - -**Defect.** Plan §28 + §32: nothing prevents two parallel `strix` invocations colliding on `run_name` and competing for the same container name. And nothing surfaces a clear error when Docker daemon isn't running. - -**Fix.** Pre-flight checks at CLI startup: - -```python -def main(): - args = parse_arguments() - apply_config_override(args.config) - if args.use_sdk_harness: - if not _docker_daemon_available(): - sys.exit("Docker daemon unavailable. Start Docker Desktop / dockerd and try again.") - run_dir = Path("strix_runs") / args.run_name - if run_dir.exists() and (run_dir / "events.jsonl").exists(): - sys.exit( - f"Run '{args.run_name}' already exists at {run_dir}. " - f"Use a different --name or rm the directory." - ) - # ... continue with scan -``` - -**Apply in Phase 5** (in `strix/interface/main.py`). - -### C25 [MEDIUM] Hook cancel mode mapping + cleanup - -**Defect.** Plan §C8: PLAYBOOK §C9 mentions `result.cancel(mode=...)` but doesn't specify which mode for which trigger. - -**Fix.** -- **Ctrl+C from user** → `result.cancel(mode="immediate")` + `await bus.cancel_descendants(root_id)`. -- **TUI "stop agent" button (graceful)** → `result.cancel(mode="after_turn")` + `await bus.cancel_descendants(root_id)`. -- **`stop_agent(child_id)` tool called by parent** → directly `bus.tasks[child_id].cancel()`. -- **Run finished naturally** → no cancellation needed; `on_agent_end` hooks finalize. - -**Apply in Phase 5** (signal handler + TUI binding). - ---- - -## 3. New scenario gaps from walkthrough audit (Round 3.1) - -| # | Scenario | Gap | Fix | -|---|---|---|---| -| **W1** | Cold-start single-agent | Most gaps already in C1–C12 | Use new C13–C25 | -| **W2** | Multi-agent parallel | `finish_scan` had no children-running check | C22 | -| **W3** | Mid-run Ctrl+C | Cancel-mode mapping ambiguous | C25 | -| **W4** | Subagent silent crash | Background post-invoke task exceptions don't trigger crash detection | Document — exceptions in `post_invoke_task` are logged async, not via `on_agent_end`. Bus watchdog optional. | -| **W5** | Compressor cascade fail | After compression fails once, next iteration retries forever | Set `_compression_disabled=True` in context after first failure; subsequent calls skip. Apply in Phase 1 custom Session. | -| **W6** | Container dies mid-run | No periodic liveness check | Optional: background asyncio task pings `/health` every N turns. Phase 5 / Phase 6 enhancement. | -| **W7** | Whitebox wiki on finish | `is_whitebox` not propagated to context | C21 | -| **W8** | RunConfig override | No injection point | C21 | -| **W9** | Resume from RunState | Out of scope for MVP | Defer; document as Phase 7+ | -| **W10** | Diff-scope mode | Injection point unspecified | C23 | - ---- - -## 4. CLI/interface migration spec (Round 3.3 highlights) - -Full spec in the audit; key takeaways folded into PLAYBOOK and corrections above. - -**Survives unchanged:** All argparse flags, `Config` class, target inference, run-name generation, scope-diff resolution, helper utilities (`format_*`, `infer_target_type`, `clone_repository`, etc.), exit code logic. - -**Refactor needed:** - -- **`run_cli()` headless** — wrap `Runner.run_streamed()` with `StrixStreamAccumulator`; same Rich panels, same exit codes. -- **`run_tui()` interactive** — Textual app subscribes to tracer reactive fields; tracer fed by accumulator + hooks. -- **Vulnerability popup** — direct call from `create_vulnerability_report` tool body to `tracer.vulnerability_found_callback` (simpler than `ToolOutputGuardrail`; both viable). -- **Live stats** — `build_live_stats_text(tracer, agent_config)` reads tracer; tracer fed via hooks; no change to read path. -- **Interactive resume** — after `Runner.run()` returns, re-run with appended history + new user message; SDK `Session` makes this clean. - -**`infer_target_type()` → Manifest entries:** - -| Inferred type | Manifest action | -|---|---| -| `local_code` | `LocalDir(src=Path(...))` mounted under `/workspace/` | -| `repository` (git URL) | Pre-clone via existing `clone_repository()`; mount cloned dir as `LocalDir`. (Keep pre-clone logic; don't use SDK's `GitRepo` until after MVP.) | -| `web_application` / `domain` / `ip_address` | No mount; agent reaches via Caido proxy | - ---- - -## 5. Build/deps/deployment (Round 3.5 highlights) - -### pyproject.toml diff - -**Drop:** -```diff -- "litellm[proxy]>=1.81.1,<1.82.0", -``` - -**Add:** -```diff -+ "openai-agents[litellm]==0.14.6", -``` - -**Transitive new (no action):** `griffelib`, `mcp`, `websockets`, `types-requests`, `openai>=2.26.0`. Pulled in by `openai-agents`. - -**Container image (`containers/Dockerfile`):** **NO changes.** `[sandbox]` extras stay in image. SDK's host-side code does not run inside the container. - -**`strix.spec` (PyInstaller):** Add SDK hidden imports: - -```python -hiddenimports += [ - "agents", "agents.agent", "agents.run", "agents.run_config", - "agents.memory.session", "agents.memory.sqlite_session", - "agents.sandbox.sandboxes.docker", "agents.sandbox.manifest", - "agents.sandbox.capabilities.capability", "agents.sandbox.entries", - "agents.extensions.models.litellm_model", - "agents.tool", "agents.tool_context", "agents.tool_guardrails", - "agents.lifecycle", "agents.guardrail", - "agents.tracing.processor_interface", "agents.tracing.spans", "agents.tracing.traces", - "agents.models.interface", "agents.models.multi_provider", - "agents.retry", "mcp", "websockets", -] -``` - -### Config bridge - -`strix/config/config.py` adds an env-var bridge before SDK init: - -```python -def bridge_to_sdk_env() -> None: - """Map legacy Strix env vars to SDK-native names where applicable.""" - if Config.get("llm_api_key") and not os.environ.get("OPENAI_API_KEY"): - os.environ["OPENAI_API_KEY"] = Config.get("llm_api_key") - if Config.get("llm_api_base") and not os.environ.get("OPENAI_BASE_URL"): - os.environ["OPENAI_BASE_URL"] = Config.get("llm_api_base") -``` - -Call from `main.py` before any SDK import. - -### CI - -`.github/workflows/build-release.yml` — unchanged. Add new test workflow (`tests.yml`) for pytest + mypy + ruff on PRs (recommended but not blocking for cutover). - -### Feature flag - -`STRIX_USE_SDK_HARNESS` env var, default `0`. CLI entry checks; routes to legacy or SDK harness implementation. - ---- - -## 6. Consolidated correction register (full) - -After Rounds 1, 2, 3 — twenty-five corrections to apply. - -| # | Severity | Phase | Source | Topic | -|---|---|---|---|---| -| C1 | Blocker | 1/6 | AUDIT.md §2.1 | Tool-server slot serialization vs SDK parallel calls | -| C2 | Blocker | 0 | AUDIT.md §2.2 | Anthropic cache-control on system message | -| C3 | Blocker | 0 | AUDIT.md §2.3 | DockerSandboxClient subclass | -| C4 | Blocker | 3 | AUDIT.md §2.4 | Subagent `tool_use_behavior` | -| C5 | High | 5 | AUDIT.md §2.5 | StrixStreamAccumulator | -| C6 | Critical | 2 | AUDIT_R2.md §1.1 | Notes JSONL write lock | -| C7 | Critical | 1 | AUDIT_R2.md §1.2 | events.jsonl write lock | -| C8 | High | 3 | AUDIT_R2.md §1.3 | Subagent crash detection | -| C9 | High | 3 | AUDIT_R2.md §1.4 | Cancellation cascade | -| C10 | Medium | 1 | AUDIT_R2.md §1.5 | Compressor try/except | -| C11 | Medium | 1 | AUDIT_R2.md §1.6 | Retry policy excludes 401/403/400 | -| C12 | Medium | 3 | AUDIT_R2.md §1.7 | Stats snapshot under lock | -| **F1** | **Critical** | **0** | **AUDIT_R3 §1** | **AnthropicCachingLitellmModel signature** | -| **F2** | **Critical** | **3** | **AUDIT_R3 §1** | **RunHooks signature (`AgentHookContext`, `result: str`)** | -| **F3** | **Critical** | **1** | **AUDIT_R3 §1** | **TracingProcessor methods are sync** | -| C13 | High | 3 | AUDIT_R3 §2 | Bus.finalize cleans up stale state | -| C14 | High | 3 | AUDIT_R3 §2 | Filter try/except | -| C15 | High | 3 | AUDIT_R3 §2 | Hooks try/except | -| C16 | High | 1 | AUDIT_R3 §2 | TracingProcessor catches OSError | -| C17 | Medium | 1 | AUDIT_R3 §2 | Model alias validation | -| C18 | Medium | 2 | AUDIT_R3 §2 | Sandbox response size cap | -| C19 | Medium | 1 | AUDIT_R3 §2 | Assert ≥1 enabled tool when `tool_choice='required'` | -| C20 | Medium | 2 | AUDIT_R3 §2 | Per-tool `timeout_behavior` discrimination | -| C21 | Medium | 1 | AUDIT_R3 §2 | RunConfig override + context fields (`is_whitebox`, `diff_scope`, `run_id`) | -| C22 | Medium | 2 | AUDIT_R3 §2 | `finish_scan` checks children running | -| C23 | Medium | 5 | AUDIT_R3 §2 | Diff-scope injection in user message | -| C24 | Medium | 5 | AUDIT_R3 §2 | Run-name + Docker preflight | -| C25 | Medium | 5 | AUDIT_R3 §2 | Cancel mode mapping (immediate/after_turn) | - ---- - -## 7. Outcome - -**No new architectural blockers.** All corrections are bounded. - -The three F-fixes (type/signature corrections) are inline-applied to `PLAYBOOK.md` in this round. C13–C25 are added to the playbook's correction register and the relevant phases. After this round, the playbook's load-bearing skeletons compile against SDK v0.14.6, and defensive error handling is wired through filter, hooks, processor, and bus. - -Ready for Phase 0. diff --git a/HARNESS_WIKI.md b/HARNESS_WIKI.md deleted file mode 100644 index f2c79cb..0000000 --- a/HARNESS_WIKI.md +++ /dev/null @@ -1,798 +0,0 @@ -# Strix Harness — Internal Architecture Wiki - -> Internal deep-dive for the team. Maps every subsystem, every tool, every architectural decision, with `path:line` references throughout. -> -> Generated against `main` at `9fb1012` (`fix: --config flag now fully overrides ~/.strix/cli-config.json`). When code drifts, prefer `git log` and the source over this document. - ---- - -## Table of Contents - -1. [What Strix Is](#1-what-strix-is) -2. [Architecture at a Glance](#2-architecture-at-a-glance) -3. [Repository Layout](#3-repository-layout) -4. [Lifecycle of One Run](#4-lifecycle-of-one-run) -5. [Agent System](#5-agent-system) -6. [LLM Layer](#6-llm-layer) -7. [Tool System](#7-tool-system) -8. [Runtime & Sandbox](#8-runtime--sandbox) -9. [Interface (CLI / TUI / Headless)](#9-interface-cli--tui--headless) -10. [Prompts](#10-prompts) -11. [Skills](#11-skills) -12. [Config](#12-config) -13. [Telemetry & Persistence](#13-telemetry--persistence) -14. [Cross-Cutting Design Decisions](#14-cross-cutting-design-decisions) -15. [Recent Evolution (Notable Commits)](#15-recent-evolution-notable-commits) -16. [Quick File Index](#16-quick-file-index) - ---- - -## 1. What Strix Is - -`strix-agent` (PyPI: `strix-agent`, version `0.8.3` per `pyproject.toml:3`) is an **autonomous AI hacker harness**. The agent dynamically pentests apps — runs targets in a sandboxed Kali container, finds vulns, validates them with PoCs, and writes a report. - -**Mission shape:** loop an LLM against a Kali-loaded sandbox until it produces a vulnerability report. Provider-agnostic via litellm. Multi-agent orchestration. Whitebox + blackbox + greybox modes. - -**Core deps** (`pyproject.toml:25-39`): `litellm[proxy]`, `pydantic`, `rich`, `docker`, `textual`, `tenacity`, `cvss`, `traceloop-sdk`, `opentelemetry-exporter-otlp-proto-http`, `scrubadub`. Optional `sandbox` extra adds `fastapi`, `uvicorn`, `ipython`, `playwright`, `pyte`, `libtmux`, `gql`, `openhands-aci`. - -**Entry point:** `strix.interface.main:main` (`pyproject.toml:43`). - ---- - -## 2. Architecture at a Glance - -``` - ┌──────────────────────────────────────────────────────┐ - │ HOST PROCESS │ - │ │ - user CLI ──▶ │ interface/main.py │ - │ │ │ - │ ▼ │ - │ StrixAgent (root) ──spawns──▶ StrixAgent (subagent) │ - │ │ │ thread │ - │ │ agent_loop() │ │ - │ ▼ ▼ │ - │ ┌──────────┐ ┌─────────────────────────┐ │ - │ │ LLM │ │ Tool Executor │ │ - │ │ litellm │ │ ──local────▶ in-process │ │ - │ │ stream │ │ ──sandbox──▶ HTTP POST │ │ - │ └──────────┘ └─────────────┬───────────┘ │ - │ │ │ - │ Tracer ──▶ events.jsonl │ │ - │ OTel/Traceloop │ │ - │ PostHog │ │ - └──────────────────────────────┼───────────────────────┘ - │ Bearer-auth HTTP - ▼ - ┌──────────────────────────────────────────────────────┐ - │ SANDBOX CONTAINER (Kali, one per scan) │ - │ │ - │ FastAPI tool server :48081 │ - │ POST /execute → registry → tool fn │ - │ │ - │ Caido HTTP proxy :48080 (CA-injected) │ - │ Playwright Chromium (headless, no-sandbox) │ - │ tmux + IPython (terminal, python) │ - │ /workspace (shared FS) │ - │ nmap, sqlmap, nuclei, semgrep, trivy, ... │ - └──────────────────────────────────────────────────────┘ -``` - -**Two key boundaries:** - -1. **Host ↔ sandbox**: HTTP/JSON over Bearer-token auth. Host owns LLM + telemetry; sandbox owns dangerous tools. -2. **Provider-agnostic LLM**: everything goes through `litellm.acompletion`; tool calls are **custom XML in text**, not native function-calling — provides multi-provider compatibility at the cost of native streaming schemas. - ---- - -## 3. Repository Layout - -``` -strix/ -├── agents/ # Agent loop, state, multi-agent graph orchestration -│ ├── base_agent.py # Core loop, ~620 lines -│ ├── state.py # AgentState pydantic model -│ └── StrixAgent/ -│ ├── strix_agent.py # execute_scan() entry, task assembly -│ └── system_prompt.jinja # 32 KB Jinja system prompt -├── llm/ # Completion wrapper, provider routing, memory mgmt -│ ├── llm.py # acompletion wrapper, streaming, retries -│ ├── config.py # LLMConfig dataclass -│ ├── memory_compressor.py # Token-budget pruning + LLM summarization -│ ├── utils.py # Tool-format normalization, XML parsing -│ └── dedupe.py # Vulnerability dedup via LLM similarity -├── tools/ # Every tool the agent can call -│ ├── registry.py # @register_tool decorator, schema loader -│ ├── executor.py # Dual local/sandbox dispatch, result fmt -│ ├── context.py # contextvar agent_id propagation -│ ├── argument_parser.py # Type coercion of XML string args -│ ├── browser/ # Playwright Chromium (24 actions) -│ ├── terminal/ # tmux + libtmux interactive shell -│ ├── python/ # IPython kernel, persistent -│ ├── proxy/ # Caido GraphQL client -│ ├── notes/ # Wiki + categorized notes (JSONL persisted) -│ ├── todo/ # In-memory todo list -│ ├── reporting/ # create_vulnerability_report w/ CVSS -│ ├── web_search/ # Perplexity sonar-reasoning-pro -│ ├── file_edit/ # openhands-aci str_replace_editor + rg -│ ├── finish/ # finish_scan (root only) -│ ├── thinking/ # think tool (planning escape hatch) -│ ├── load_skill/ # Runtime skill injection -│ └── agents_graph/ # create_agent, send_message_to_agent, ... -├── runtime/ # Docker-side container management -│ ├── docker_runtime.py # Host-side: launch/healthcheck/cleanup -│ └── tool_server.py # Sandbox-side FastAPI tool server -├── interface/ # CLI + Textual TUI + headless -│ ├── main.py # argparse, validation, mode dispatch -│ ├── cli.py # Headless mode (Rich) -│ ├── tui.py # Textual app, modal screens -│ └── utils.py # Helpers (target inference, run-dir, ...) -├── prompts/ # Vulnerability-specific Jinja prompts (e.g. NoSQLi) -├── skills/ # Markdown playbooks (vulns, frameworks, scan modes) -├── config/ # Config class, env layering, file persistence -├── telemetry/ # Tracer, OTel, Scrubadub PII redaction, PostHog -└── utils/ # resource_paths.py for frozen-vs-dev path resolution - -containers/ -├── Dockerfile # Kali rolling, all the pentest tools -└── docker-entrypoint.sh # Caido boot, CA install, tool server start -``` - ---- - -## 4. Lifecycle of One Run - -End-to-end trace of `strix --target ./app`: - -1. **CLI parse** (`interface/main.py:267-426`): parse args, validate, infer target types via `infer_target_type()` (`interface/utils.py`). Resolve diff scope if `--scope-mode=diff`. -2. **Config layering** (`config/config.py`): apply `~/.strix/cli-config.json` (or `--config ` override) into `os.environ`; resolve `STRIX_LLM`, `LLM_API_KEY` via `resolve_llm_config()` at `config/config.py:199-224`. -3. **LLM warm**: validate model reachable. -4. **Docker pull** if needed; image pin `ghcr.io/usestrix/strix-sandbox:0.2.0` (`strix/config/settings.py:64`). -5. **Run name + run dir**: `strix_runs//`. Tracer init (`telemetry/tracer.py:50+`). -6. **Mode dispatch**: TUI (`tui.py`) or CLI (`cli.py`). Both end up calling `StrixAgent.execute_scan(scan_config)`. -7. **Sandbox launch** (`runtime/docker_runtime.py:111-173`): container created with name `strix-scan-{scan_id}`, two random host ports mapped to container ports `48080` (Caido) and `48081` (tool server), 32-byte bearer token generated, `local_sources` tar-copied into `/workspace`. -8. **Container boot** (`containers/docker-entrypoint.sh`): start Caido → fetch GraphQL token via `loginAsGuest` → create temp project → install CA cert into NSS + system trust → set system-wide proxy env vars → spawn tool server as `pentester` user → wait for `/health` ready. -9. **Agent loop** (`agents/base_agent.py:152-260`): see §5. -10. **Termination**: root agent calls `finish_scan` (`tools/finish/finish_actions.py`) when work is done; tracer writes final report `penetration_test_report.md` and per-finding JSONs under `vulnerabilities/`. -11. **Cleanup**: `docker rm -f` async-spawned (`runtime/docker_runtime.py:334-352`). -12. **Exit code**: `0` clean, `2` if vulns found in headless mode (per `cli.py`). - ---- - -## 5. Agent System - -### 5.1 Single-Agent Loop - -`agents/base_agent.py:152-260` (`agent_loop`). Each iteration: - -1. `_initialize_sandbox_and_state()` once at start (`base_agent.py:158`). -2. Check messages: `_check_agent_messages()` (`base_agent.py:448-531`) drains the inter-agent message queue, wrapping each in `` XML and appending to history. -3. Iteration counter bump and warning watchdog: at 85% of `max_iterations` (default 300) emit warning; at `max-3` emit critical "next message MUST be finish" warning (`base_agent.py:186-211`). -4. `_process_iteration()` (`base_agent.py:214-216`): - - Compress history (memory compressor, see §6.4). - - Build messages, call LLM via async generator (`llm.py:156-218`). - - Parse tool invocations from streamed response (custom XML parser, see §6.5). - - `_execute_actions()` → `process_tool_invocations()` (`tools/executor.py:313-342`) — sequential per-action dispatch. - - Append observation XML to history. -5. Loop until `state.should_stop()` (`state.py`): `completed | stop_requested | iteration >= max_iterations`. - -In **interactive mode**, after `completed=True` the loop pauses in `_enter_waiting_state()` (`base_agent.py:287-329`) instead of exiting — user can send more input. `_wait_for_input()` resumes on message arrival or `waiting_timeout` (default 600 s for subagents, 0 = forever for root, `base_agent.py:265-266`). - -### 5.2 State Model - -`agents/state.py:12-173` (Pydantic `AgentState`): - -| Field | Purpose | -|---|---| -| `agent_id`, `agent_name`, `parent_id` | Identity. `parent_id is None` ⇔ root agent. | -| `task` | Initial task string. | -| `messages` | Conversation history list (role/content tuples; multimodal-capable). | -| `iteration`, `max_iterations` | Hard budget (default 300). | -| `waiting_for_input`, `waiting_start_time`, `waiting_timeout` | Interactive-mode pause state. | -| `completed`, `stop_requested`, `final_result` | Termination signals. | -| `sandbox_id`, `sandbox_token`, `sandbox_info` | Sandbox handles (port, ID). | -| `actions_taken`, `observations`, `errors` | Local audit trail. | -| `start_time`, `last_updated` | ISO timestamps. | - -Snapshots (`state.model_dump()`) are stored verbatim in the agent-graph node when the agent is registered (`base_agent.py:122-134`). - -### 5.3 Multi-Agent Graph - -`tools/agents_graph/agents_graph_actions.py` (839 lines) is the orchestration plane. Globals at `:9-37`: - -- `_agent_graph = {"nodes": {agent_id: node}, "edges": [...]}` — node = full agent metadata; edges = `delegation` or `message`. -- `_agent_messages: dict[agent_id, list[msg]]` — per-agent inbox. -- `_agent_instances: dict[agent_id, agent_obj]` — live in-process instances (for stat snapshots). -- `_agent_states: dict[agent_id, AgentState]`. -- `_running_agents: dict[agent_id, threading.Thread]` — daemon threads. -- `_completed_agent_llm_totals` + `_agent_llm_stats_lock` — accumulated stats from finished subagents. - -**Spawning** (`create_agent`, `:383-492`): validate skills → build child `LLMConfig` inheriting parent flags → `StrixAgent(config)` → optionally copy parent history (`inherit_context=True`) → spawn daemon thread running `_run_agent_in_thread()` (`:205-298`). The thread creates a fresh asyncio event loop and runs `agent.agent_loop(state.task)`. On finish, status flips to `completed | stopped | failed`, `_finalize_agent_llm_stats()` is called. - -**Identity injection**: parent task is wrapped in `...` so the child knows its name/ID and is told to never echo it (`:238-266`). - -**Finish from subagent** (`agent_finish`, `:567-685`): only callable when `parent_id != None`. Builds `` XML with summary/findings/recommendations and pushes it onto the parent's inbox. - -**Finish from root** (`finish_scan`, `tools/finish/finish_actions.py:86-149`): only callable from root (`parent_id is None`); blocks if any sibling/child agent is still `running`/`stopping`. Triggers `tracer.update_scan_final_fields()` which writes `penetration_test_report.md`. - -**Inter-agent messaging** (`send_message_to_agent`, `:495-563`): synchronous append to `_agent_messages[target]` with edge metadata. No broker, no durability, single-process only. - -**Waiting** (`wait_for_message`, `:796-839`): subagent calls this to pause; `_check_agent_messages` resumes it on arrival. - -**Graph view** (`view_agent_graph`, `:302-380`): traversal printout, root-first. - -### 5.4 Stats Aggregation Across the Tree - -Recent fix (`15c9571`). `_finalize_agent_llm_stats()` (`:54-68`) snapshots a finished subagent's `llm._total_stats` and adds it under lock to `_completed_agent_llm_totals`. The root's reported totals (via `tracer.get_total_llm_stats()` at `telemetry/tracer.py:801-834`) are: `sum(_completed_agent_llm_totals) + sum(live agent _total_stats)`. Before the fix, finalized children were dropped on cleanup — root undercounted. - -### 5.5 Termination, Interrupts, and Cancellation - -- **Hard limit**: `iteration >= max_iterations` → loop exits (`base_agent.py:174`). 85% / N-3 warnings give the model time to wrap up. -- **User Ctrl+C / parent kill**: `stop_agent(agent_id)` (`agents_graph_actions.py:688-748`) sets `state.request_stop()` + calls `agent_instance.cancel_current_execution()` (`base_agent.py:615-623`) which cancels the running asyncio task. `asyncio.CancelledError` is caught (`base_agent.py:232-243`) — interactive mode enters waiting state; non-interactive re-raises. -- **Finish tools**: see §5.3. - -### 5.6 Streaming to TUI - -The agent loop consumes the LLM async generator and after each chunk calls `tracer.update_streaming_content(agent_id, accumulated_text)` (`base_agent.py:373-375`). The TUI polls the tracer at 2 Hz and re-renders. On finalize, `tracer.clear_streaming_content()` and `tracer.log_chat_message()` snapshot the full message into the events log. - ---- - -## 6. LLM Layer - -### 6.1 Completion Wrapper - -`strix/llm/llm.py:156-218` — `LLM.generate()` is an async generator yielding `LLMResponse` objects. Pipeline: - -1. **Retry loop** (`:162-171`): max `STRIX_LLM_MAX_RETRIES` (default 5) attempts. Backoff `min(90, 2 * 2**attempt)` seconds. `_should_retry()` (`:326-330`) is True for network errors (no status_code) or `litellm._should_retry(code)` for HTTP statuses. -2. **Build args** (`_build_completion_args`, `:265-274`): model resolution, reasoning effort, drop unsupported params (`litellm.drop_params = True`, `litellm.modify_params = True`, set at `:25-26`). -3. **Stream** (`_stream`, `:173-218`): - - `acompletion(...)` wrapped in `asyncio.wait_for(timeout=self.config.timeout)` — commit `60abc09`. - - Each `await it.__anext__()` *also* wrapped in `asyncio.wait_for(timeout)` — needed because litellm's own timeout doesn't propagate to httpx for Bedrock streaming, which can accept TCP and then send no data forever. - - Accumulate text; when a closing `` tag is seen, set `done_streaming=1` and continue 5 more chunks for trailers, then stop. -4. **Stats** (`_update_usage_stats`, `:287-312`): extract `response.usage` (input, completion, cached tokens). Cost via `_extract_cost()` (`:314-324`) — prefers `response.usage.cost`, else `litellm.completion_cost(...)` with provider stripped. -5. **Tool parsing** (`:212-217`): `normalize_tool_format` → `fix_incomplete_tool_call` → `parse_tool_invocations` (all in `llm/utils.py`). - -### 6.2 Provider Routing - -`strix/llm/utils.py:34-61` defines `STRIX_MODEL_MAP` — `strix/` aliases (e.g. `strix/claude-sonnet-4.6`) resolve to a tuple `(api_model, canonical_model)`: -- `api_model` is what gets passed to `acompletion` (typically OpenAI-compatible against the Strix proxy `https://models.strix.ai/api/v1` set at `config/config.py:8`). -- `canonical_model` is what's used for litellm capability checks like `supports_prompt_caching()` and `supports_reasoning()`. - -For non-`strix/` prefixes, the model string is passed straight through. `LLMConfig.__init__` (`llm/config.py:8-41`) reads `LLM_API_BASE` → `OPENAI_API_BASE` → `LITELLM_BASE_URL` → `OLLAMA_API_BASE` from env in priority order. - -Per-provider quirks: -- **Anthropic**: `_is_anthropic()` (`llm.py:338-341`) detects `anthropic/` or `claude` substrings and adds an ephemeral cache control block to the system message via `_add_cache_control()` (`:371-387`). -- **OpenAI reasoning**: if `supports_reasoning()` is true, set `reasoning_effort` (`:265-266`) — env `STRIX_REASONING_EFFORT` > config > scan-mode default (`medium` for quick, `high` otherwise). -- **Vision-less models**: `_strip_images()` (`:343-369`) replaces image content with `"[Image removed - model doesn't support vision]"`. -- **Vertex AI**: optional extra in `pyproject.toml:48`. Documented in `docs/llm-providers/vertex.mdx`. - -### 6.3 Retries & Timeouts (the `60abc09` story) - -Symptom: Bedrock converse-stream calls would TCP-connect, send no chunks, and hang the agent loop indefinitely. faulthandler showed the loop blocked in `selectors.select()`. - -Fix: wrap **both** the initial `acompletion` call **and** every per-chunk read in `asyncio.wait_for`. Timeouts surface as `TimeoutError` (no `status_code` attr) which `_should_retry()` treats as retryable, kicking the backoff loop. - -Default `LLM_TIMEOUT = 300` (`config/config.py:24`). - -### 6.4 Memory Compression - -`strix/llm/memory_compressor.py:152-219`. Hard ceiling `MAX_TOTAL_TOKENS = 100_000`; compression triggers above ~90 K. - -- **Image budget**: keep last 3 images, replace older ones with `[Previously attached image removed]` (`:134-149`). -- **System messages**: never compressed. -- **Recent floor**: keep last 15 messages intact (`MIN_RECENT_MESSAGES = 15`). -- **Older messages**: chunk in groups of 10, summarize via LLM call (separate `acompletion`, 120 s timeout). The summary prompt (`:15-43`) emphasizes preserving exact technical details — URLs, payloads, credentials, failed attempts (so the agent doesn't repeat them) — wrapped as `...`. -- Token counting via `litellm.token_counter()` with a fallback of `len(text) / 4`. - -This runs every iteration. Anthropic prompt cache helps with system-prompt cost but not history (which mutates). - -### 6.5 Tool-Call Format (custom XML, NOT native function-calling) - -Tools are injected into the system prompt as XML descriptions via `get_tools_prompt()` (`tools/registry.py:280-300`). The model is instructed to emit: - -```xml - - value - value - -``` - -Parser (`llm/utils.py`): -- `normalize_tool_format` (`:12-31`): converts Anthropic-style `` and other variants to the canonical form. -- `fix_incomplete_tool_call` (`:110-121`): auto-closes unclosed tags when streaming is truncated. -- `parse_tool_invocations` (`:80-133`): regex extraction; HTML-entity-decodes values. -- `clean_content` (`:135-160`): strips tool XML and inter-agent control tags before logging to telemetry. - -**Why XML over native tool use?** Multi-provider compatibility (works on any text LLM), graceful streaming truncation (early-exit at ``), and full client-side control of formatting. Cost: tool descriptions are re-injected as text every call (no native streaming of schemas), and content has to be parsed by hand. - -### 6.6 Prompt Assembly - -`_prepare_messages` (`llm.py:220-248`): -1. Render system prompt from Jinja template (`agents/StrixAgent/system_prompt.jinja`) with: `interactive` flag, `system_prompt_context` (authorized targets), `loaded_skill_names`, tools prompt. -2. Insert agent-identity user message (hidden control block) — see §5.3. -3. Run `MemoryCompressor.compress_history()` over conversation. -4. If last message is assistant, append a `Continue the task.` continuation prompt (autonomous mode). -5. Apply Anthropic cache control on the system message if applicable. - -### 6.7 Stats / Cost - -Per-call: extracted into `RequestStats` dataclass and accumulated in `LLM._total_stats`. Across the tree: see §5.4. Tracer renders these into the live TUI status panel and the final summary text (`utils/format_token_count`, etc.). - -### 6.8 Vulnerability Deduplication - -`strix/llm/dedupe.py` (~213 lines) — separate LLM call to compare a new finding against existing ones. Wired into `tools/reporting/reporting_actions.py` so duplicate vuln reports get rejected on submission. - ---- - -## 7. Tool System - -### 7.1 Registry & Schema Loading - -`strix/tools/registry.py`: - -- Decorator: `@register_tool(sandbox_execution: bool, requires_browser_mode: bool = False, requires_web_search_mode: bool = False)` (`:190-250`). -- Conditional registration via `_should_register_tool()` (`:175-187`): in sandbox mode, only `sandbox_execution=True` tools register. If `STRIX_DISABLE_BROWSER=true`, browser tools are skipped. If `PERPLEXITY_API_KEY` is missing, `web_search` is skipped. -- Schema: each tool group has `_actions_schema.xml` next to the implementation. Parsed via `_parse_param_schema()` (`:131-149`) → `_tool_param_schemas[name] = {params, required, has_params}`. -- `get_tools_prompt()` (`:280-300`) emits the XML descriptions injected into the system prompt. Includes a `{{DYNAMIC_SKILLS_DESCRIPTION}}` placeholder filled by the load-skill subsystem. - -### 7.2 Executor (Local vs Sandbox Dispatch) - -`strix/tools/executor.py`: - -- `execute_tool()` is the single entrypoint. `should_execute_in_sandbox(tool_name)` (`:29-37`) decides routing. -- **Sandbox path** (`_execute_tool_in_sandbox`, `:39-99`): POST to `http://{host}:{tool_server_port}/execute` with `{agent_id, tool_name, kwargs}` and `Authorization: Bearer {token}`. Connect timeout `STRIX_SANDBOX_CONNECT_TIMEOUT=10`, request timeout `STRIX_SANDBOX_EXECUTION_TIMEOUT + 30 = 150` s. -- **Local path** (`_execute_tool_locally`, `:101-115`): look up function, coerce arg types via `argument_parser.convert_arguments()`, inject `agent_state` if the function requests it (introspection over signature), `await` if async. -- **Argument validation** (`_validate_tool_arguments`, `:130-186`): unknown params and missing required params → formatted error string returned to the LLM (no exception). -- **Result formatting** (`_format_tool_result`, `:227-256`): truncate >10 KB to 4 KB head + `... [middle content truncated] ...` + 4 KB tail. Wrap in `......`. Extract any `screenshot` key into a separate base64 image attachment. -- **Process orchestrator** (`process_tool_invocations`, `:313-342`): iterate actions sequentially, aggregate result text, attach images as multimodal content blocks, return `should_agent_finish` boolean. - -**Sequential, not parallel.** No `asyncio.gather` over tools — could be a future optimization. - -### 7.3 Tool Catalog - -#### Browser (`strix/tools/browser/`) — `browser_action` -Playwright Chromium singleton per container, shared event loop in a daemon thread (`browser_instance.py:34-48`). 24 actions: -- Navigation: `launch`, `goto`, `back`, `forward` -- Interaction: `click`, `double_click`, `hover`, `type`, `press_key`, `scroll_up`, `scroll_down` -- Tabs: `new_tab`, `switch_tab`, `close_tab`, `list_tabs` -- Misc: `wait`, `execute_js`, `save_pdf`, `get_console_logs`, `view_source`, `close` - -Returns `{screenshot: base64-png, url, title, tab_id, all_tabs, js_result, console_logs, page_source}`. Viewport 1280×720, screenshot is viewport-only (not full-page) by default. Page source truncated to 20 KB; JS result to 5 KB; console logs capped at 200 entries / 30 KB total / 1 KB each. Browser launched with `--no-sandbox --disable-web-security` — intentional for XSS/CORS pentesting. - -State keyed by `get_current_agent_id()` (contextvar). Tabs persist with sequential IDs (`tab_1`, `tab_2`, ...). `atexit` registered via `_register_cleanup_handlers()`. - -#### Terminal (`strix/tools/terminal/`) — `terminal_execute` -libtmux-backed (`>=0.46.2`). One tmux session per `(agent_id, terminal_id)`, default `terminal_id="default"`. PS1 customized to `[STRIX_$?]$ ` so the **exit code can be regex-extracted** from the prompt (`terminal_session.py:49-54`). Pane history limited to 10 K lines. - -Params: `command`, `is_input` (false=new command, true=feed input to running process), `timeout` (default 30 s), `no_enter`, `terminal_id`. Returns `{content, status, exit_code, working_dir}` where status is `completed | running | error` (with sub-states `CONTINUE`, `NO_CHANGE_TIMEOUT`, `HARD_TIMEOUT`). - -Special-key sequences supported: `C-c`, `^X`, `S-X`, `M-X`, `F1-F12`, arrow keys, `Enter`, `Tab`, `BSpace`. Output deduplication strips previous output prefix to show only new bytes. - -#### Python (`strix/tools/python/`) — `python_action` -Persistent IPython kernel (`>=9.3.0`) per `(agent_id, session_id)`. Actions: `new_session`, `execute`, `close`, `list_sessions`. cwd `/workspace`. Stdout truncated to 10 K, stderr to 5 K, repr to 10 K. Execution timeout enforced via thread join + cancellation flag (default 30 s). - -Pre-injects proxy helper functions into the user namespace so the agent can `send_request(...)` directly (`python_instance.py:30-47`). - -`KeyboardInterrupt` and `SystemExit` are caught and returned as errors rather than propagating. - -#### HTTP Proxy (`strix/tools/proxy/`) — 7 tools -GraphQL client against Caido at `http://127.0.0.1:48080/graphql` (`proxy_manager.py:25`). Bearer token from env `CAIDO_API_TOKEN`. Tools: `list_requests`, `view_request`, `send_request`, `repeat_request`, `scope_rules`, `list_sitemap`, `view_sitemap_entry`. - -Supports HTTPQL filter syntax for request queries. Pagination (`offset`, `limit`). `view_request` supports regex search through captured request/response pairs. Caido all-traffic capture is enabled because `/etc/profile.d/proxy.sh` sets `http_proxy`/`https_proxy` system-wide and the Caido CA cert is installed into the system + NSS trust stores. - -Hardcoded port `48080`. Caido v0.56.0 pinned in `containers/Dockerfile` (override via `--build-arg CAIDO_VERSION=...`). - -#### Notes (`strix/tools/notes/`) — `create_note`, `list_notes`, `get_note`, `update_note`, `delete_note` -Pure in-memory dict, shared across every agent in the same scan for the lifetime of the process. **Not persisted** — process exit clears the lot. - -Categories: `general | findings | methodology | questions | plan | wiki`. IDs are 5-char UUID hex (collision-retry up to 20 attempts). Thread-safe via RLock. List preview defaults to 280 chars per note. - -#### Todos (`strix/tools/todo/`) — 6 tools -Per-agent in-memory storage; **not persisted**. Priorities `critical | high | normal | low`; statuses `pending | in_progress | done`. Bulk create/update via JSON list. IDs are 6-char UUID hex. - -#### Reporting (`strix/tools/reporting/`) — `create_vulnerability_report` -Saves a CVSS-scored vulnerability to the run. Required fields: title, description, impact, target, technical_analysis, poc_description, **poc_script_code** (mandatory), remediation_steps, cvss_breakdown (XML with AV/AC/PR/UI/S/C/I/A enums). - -Optional fields: endpoint, method, cve (`CVE-\d{4}-\d{4,}`), cwe (`CWE-\d+`), code_locations (XML with file/start_line/end_line/snippet/label/fix_before/fix_after — relative paths only, no `..`). - -CVSS computed via the `cvss` library. Deduplication via LLM similarity check (`llm/dedupe.py`). Persisted via tracer to `{run_dir}/vulnerabilities/vuln_{id}.json`. - -#### Web Search (`strix/tools/web_search/`) — `web_search` -Perplexity API (`sonar-reasoning-pro` model). 300 s timeout. System prompt tailored for security professionals — vuln details, CVEs, OWASP, exploit info. Skipped from registry if `PERPLEXITY_API_KEY` not set. - -#### File Edit (`strix/tools/file_edit/`) — `str_replace_editor`, `list_files`, `search_files` -Wraps openhands-aci's file_editor (`>=0.3.0`). Commands `create | str_replace | view | insert`. Relative paths auto-prefixed with `/workspace/`. `search_files` uses ripgrep (`rg`); recursive listings capped at 500 results. - -#### Finish (`strix/tools/finish/`) — `finish_scan` -Root-only. Validates: caller is root, all child agents not running/stopping, all four narrative fields non-empty (executive_summary, methodology, technical_analysis, recommendations). On success: tracer writes the final report. - -#### Thinking (`strix/tools/thinking/`) — `think` -Minimal — records a thought string, validates non-empty, returns char count. Acts as the "free turn" escape hatch for planning so the agent can satisfy the per-message tool-call requirement (see §10) without doing real work. - -#### Agents Graph (`strix/tools/agents_graph/`) — 6 tools -`view_agent_graph`, `create_agent`, `agent_status`, `agent_finish`, `wait_for_message`, `send_message_to_agent`. Mechanics covered in §5.3. - -#### Load Skill (`strix/tools/load_skill/`) — `load_skill` -Runtime injection of additional skill content into the agent's context. Validates names against the registry. Replaces the `{{DYNAMIC_SKILLS_DESCRIPTION}}` placeholder. Max 5 skills per agent context. - -### 7.4 Result Sanitization (commit `4934bb8`) - -Three layers of defense before tool output reaches the model or telemetry: - -1. **Screenshot extraction** (`executor.py:345-353`): if a result dict has key `screenshot` whose value is a base64 string, pull it out into a separate image attachment, replace its dict value with `"[Image data extracted - see attached image]"`. -2. **Length truncation** (`:246-249`): >10 KB results are split head + truncation marker + tail, 4 KB each side. -3. **Error truncation** (`:182-183`): error strings capped at 500 chars with `[truncated]` suffix. -4. **Telemetry sanitization** (`telemetry/utils.py:67-150`): scrubadub + regex on dict/list/tuple keys/values, redacting `screenshot`, sensitive-key patterns (`api[-_]?key|token|secret|password|...`), and bearer-style tokens (`ghp_`, `ghs_`, `xox*`). -5. **Content cleaning before logging** (`llm/utils.py:135-160`, `clean_content`): strips tool XML, inter-agent control blocks, agent-identity blocks before they hit the JSONL events log — keeps the audit log readable and prevents log-injection of tool schemas. - -### 7.5 Agent Context Propagation - -`strix/tools/context.py` defines a single `ContextVar` `current_agent_id`. Set on every tool invocation in `tool_server._run_tool` (`runtime/tool_server.py:71-83`) and used by stateful tools (browser, terminal, python, todos) to silo per-agent state without explicit threading. - ---- - -## 8. Runtime & Sandbox - -### 8.1 Image (`containers/Dockerfile`) - -- Base: `kalilinux/kali-rolling:latest` (line 1). -- Non-root `pentester` user with NOPASSWD sudo (lines 10-13) — needed for raw-socket pentest tools. -- Pre-installed: `nmap`, `nuclei`, `subfinder`, `naabu`, `ffuf`, `sqlmap`, `zaproxy`, `wapiti`, `caido-cli` (v0.56.0); Go tools `httpx`, `katana`, `gospider`, `interactsh`; Python `arjun`, `dirsearch`, `wafw00f`, `semgrep`, `bandit`, `trufflehog`; JS `retire`, `eslint`, `js-beautify`, `jshint`, `@ast-grep/cli`, `tree-sitter-cli`; tree-sitter parsers for Java/JS/Python/Go/Bash/JSON/YAML/TS; `gitleaks`, `trivy`. -- Sandbox-extra Python deps: `fastapi`, `uvicorn`, `ipython`, `playwright`, `pyte`, `libtmux`, `gql`, `openhands-aci`. -- Self-signed CA chain at `/app/certs/{ca.key,ca.crt,ca.p12}`, 3650-day validity (lines 52-71). -- Workspace `/workspace` owned by pentester (line 194). -- Ports `48080` (Caido), `48081` (tool server) exposed. -- Image pin: `ghcr.io/usestrix/strix-sandbox:0.2.0` (`strix/config/settings.py:64`). - -### 8.2 Boot Sequence (`containers/docker-entrypoint.sh`) - -1. **Caido start** (lines 12-17): `caido-cli --listen 0.0.0.0:48080 --allow-guests --no-logging --import-ca-cert /app/certs/ca.p12`. -2. **Caido readiness poll** (24-38): GET `/graphql/` until 200/400, 30 attempts × 1 s. -3. **Login** (50-74): GraphQL `loginAsGuest` mutation → bearer token, 5 retries with backoff. Exported as `CAIDO_API_TOKEN`. -4. **Project setup** (79-109): create + select temp Caido project for capture. -5. **System-wide proxy** (113-146): write `/etc/profile.d/proxy.sh` setting `http_proxy/https_proxy/HTTP_PROXY/HTTPS_PROXY/ALL_PROXY=127.0.0.1:48080`; mirror into `/etc/environment`, `/etc/wgetrc`. Import CA into NSS db so Chromium trusts it. -6. **Tool server start** (154-180): `sudo -u pentester python -m strix.runtime.tool_server` with token, port, timeout. Wait for `/health` 200, 10 retries × 1 s. -7. **Ready** (182): emit "✅ Container ready" and `exec` the trailing args (typically `sleep infinity`). - -### 8.3 Host-Side Runtime (`strix/runtime/docker_runtime.py`) - -- **One container per scan** (not per agent). All agents in a scan share the same container, the same `/workspace`, the same Caido proxy capture, the same browser/terminal/python sessions (keyed by `agent_id` contextvar). -- **Port allocation** (`:43-46`): `socket.bind(("", 0))` to grab two free host ports, mapped to container 48080 and 48081. -- **Token** (`:131`): `secrets.token_urlsafe(32)` per container. -- **Container creation** (`:111-173`): name `strix-scan-{scan_id}`, label `strix-scan-id={scan_id}`, capabilities `NET_ADMIN | NET_RAW`, `extra_hosts={"host.docker.internal": "host-gateway"}`, env passthrough including `TOOL_SERVER_PORT` / `TOOL_SERVER_TOKEN` / `STRIX_SANDBOX_EXECUTION_TIMEOUT` / `HOST_GATEWAY`. Reuses a running container if one exists for the same scan_id. -- **Healthcheck** (`:87-109`): poll `/health` for 30 s with backoff before declaring ready. -- **Local source mount** (`:222-269`): tar-pipe local sources into `/workspace`. (Not a Docker bind mount — copy on init.) -- **Reattach** (`:72-85`): on existing container, re-extract token+ports from `docker inspect` env. -- **Cleanup** (`:322-352`): `docker stop` + `docker rm` spawned as detached subprocess so the host doesn't block on shutdown. - -**No CPU/memory/network egress limits configured.** Container has full host outbound access. Kill switches are: tool server `asyncio.wait_for` request timeout (default 120 s), and host-driven `docker stop`. There is no seccomp/AppArmor profile beyond Docker defaults. - -### 8.4 Tool Server (`strix/runtime/tool_server.py`) - -FastAPI app, served via Uvicorn on `0.0.0.0:{TOOL_SERVER_PORT}`. Auth via `HTTPBearer` (`:36-37, 42-57`); the `/health` endpoint is unauth. - -- `POST /execute` (`:86-127`): JSON `{agent_id, tool_name, kwargs}` → `_run_tool` (`:71-83`). Sets `current_agent_id` contextvar, looks up registry, calls function via `asyncio.to_thread()`. Per-agent task tracking in `agent_tasks: dict[agent_id, asyncio.Task]` (`:39`) — a new request for the same agent **cancels the previous task** (`:94-97`). Hard timeout `asyncio.wait_for(REQUEST_TIMEOUT)` default 120 s. -- `POST /register_agent` (`:130-135`): registers an agent_id (used by host to pre-allocate state). -- `GET /health` (`:138-147`): readiness/liveness. -- Signal handling (`:150-162`): SIGTERM/SIGINT cancel all in-flight tasks; SIGPIPE ignored. -- Returns `{"result": ..., "error": ...}` shape; HTTP 401 on bad token. - -### 8.5 openhands-aci - -Listed as sandbox dep (`pyproject.toml:54`). Used by `strix/tools/file_edit/` to back `str_replace_editor` (the same primitive as in OpenHands / Claude Code's `Edit` semantics — view/create/str_replace/insert with strict matching). - -### 8.6 Multi-Agent in One Sandbox - -Subagents inherit `sandbox_id`/`sandbox_token`/`sandbox_info` via the parent's state (passed implicitly through the LLMConfig copy in `create_agent`). They share `/workspace`, the Caido proxy capture, and stateful tools (each agent gets its own browser tab manager / terminal session / python kernel keyed by `agent_id`). **No per-agent process or container isolation.** - ---- - -## 9. Interface (CLI / TUI / Headless) - -### 9.1 CLI Args (`strix/interface/main.py:267-426`) - -| Flag | Purpose | -|---|---| -| `-t / --target` (multi) | Target — URL / repo / local dir / domain / IP. Type inferred via `infer_target_type()`. | -| `--instruction` | Inline directive. Mutex with `--instruction-file`. | -| `--instruction-file` | File path. Read into `args.instruction`. | -| `-n / --non-interactive` | Headless mode (`cli.py`) instead of TUI. | -| `-m / --scan-mode {quick,standard,deep}` | Default `deep`. Controls breadth/depth via prompt-injected skill. | -| `--scope-mode {auto,diff,full}` | Default `auto`. In CI/headless, `auto`→`diff`. | -| `--diff-base` | Branch/commit to diff against (e.g. `origin/main`). Auto-detected if missing. | -| `--config` | Path to custom `cli-config.json` — **fully overrides** `~/.strix/cli-config.json` (commit `9fb1012`). | - -`localhost` targets are rewritten to `host.docker.internal` so the sandbox can reach host-served apps. - -### 9.2 Scan Modes - -Implemented as **prompt content**, not control-flow branching. `LLMConfig.scan_mode` flows through to the agent's loaded skill set: - -- **quick** (`strix/skills/scan_modes/quick.md`): time-boxed, prioritize high-impact (auth, IDOR, RCE, SQLi, SSRF, secrets), skip exhaustive enumeration, breadth>depth, minimal PoC validation. -- **standard** (`strix/skills/scan_modes/standard.md`): balanced. Whitebox = repo map → semgrep → AST → secrets/deps. Blackbox = crawl, fingerprint, capture proxy traffic. Phase 2 = business logic; Phase 3 = systematic input/auth/access tests. -- **deep** (default): exhaustive — every file, every endpoint, every parameter, every edge case, every user role, complete state-machine and trust-boundary modeling, maximum chaining. - -Reasoning effort defaults flow off scan mode (`llm/llm.py:74-82`): quick→`medium`, else→`high`. - -### 9.3 Scope Modes - -`resolve_diff_scope_context()` (`interface/utils.py:40+`): computes `DiffScopeResult` from `git diff ...HEAD`. The result's `instruction_block` is **injected into the user instruction** rather than filtering files on disk — the agent decides prioritization. Used heavily for CI/PR workflows where you only want to test what changed. - -### 9.4 Textual TUI (`strix/interface/tui.py`) - -`StrixTUIApp` with modal screens: `SplashScreen`, `HelpScreen`, `StopAgentScreen`, `VulnerabilityDetailScreen`, plus the main agent-tree + log widgets. Multi-line `ChatTextArea` (Shift+Enter = newline, Enter = send). Keys: F1 help, Ctrl+Q/C quit, ESC stop, Tab cycle panels. - -Live updates: an updater thread polls the tracer at 2 Hz and refreshes `reactive` widgets. Vulnerability discoveries trigger the modal popup via `tracer.vulnerability_found_callback`. - -### 9.5 Headless / CLI (`strix/interface/cli.py`) - -Async run loop. Rich panels for vuln-found events, live stats panel updated every 2 s. Exit codes: `0` clean, `2` if `tracer.vulnerability_reports` is non-empty (used to fail CI). Final completion panel includes target, duration, stats, output path. - -### 9.6 Run Directory Layout (`strix_runs//`) - -Created and managed by `telemetry/tracer.py` + `orchestration/scan.py`. Contents: -- `events.jsonl` — every span/event in append-only JSONL (thread-safe writes). -- `vulnerabilities/vuln_{id}.md` — one file per finding, sorted by severity, dedup-checked. -- `vulnerabilities.csv` — index of every finding for spreadsheet consumption. -- `vulnerabilities.json` — machine-readable mirror, used by `Tracer.hydrate_from_run_dir` to repopulate vuln state on resume so id allocation doesn't collide. -- `penetration_test_report.md` — final markdown report. -- `session.db` — SDK `SQLiteSession` for the **root** agent's conversation history. -- `sessions/{child_id}.db` — per-subagent `SQLiteSession`, one file per spawned child. -- `bus.json` — atomic snapshot of the orchestration bus (topology, statuses, inboxes, per-agent stats, per-agent metadata `{task, skills, is_whitebox, scan_mode, diff_scope}`). Written after every `bus.register` / `finalize` / `park` / `mark_llm_failed`, plus a final dump at scan teardown. -- `.lock` — advisory `flock`-style file lock; prevents two `strix` processes from running on the same `scan_id` concurrently. -- `strix.log` — per-scan log file (DEBUG to file, ERROR to stderr). -- `/` — local source clones, per-target. - -### 9.7 Resume - -Resume is **always on**: presence of `bus.json` triggers it. Fresh runs simply have no `bus.json` to begin with. The CLI exposes `--resume ` as the canonical way to opt back into an existing run. - -What survives a process restart with the same `scan_id`: -- Root agent's full LLM conversation (replayed by SDK from `session.db`). -- Every non-terminal subagent's full LLM conversation (replayed from `sessions/{child_id}.db`). -- Bus topology: `parent_of`, `statuses`, `names`, `stats_live`, `stats_completed`, pending `inboxes`, `metadata` (task + skills per agent). -- Vulnerability reports (hydrated from `vulnerabilities.json`). -- Run log (appended to existing `strix.log`). - -What does **not** survive: -- The sandbox container itself — fresh container per process. Files agents wrote under `/workspace/scratch/` or scanner outputs to `/workspace/.strix-source-aware/` are lost. `/workspace/sources` re-mounts from the host so source code is unchanged. -- Caido proxy state (request log, scopes, replay sessions). - -On resume, every subagent in `running` / `waiting` / `llm_failed` status is respawned via `_respawn_subagents` with `initial_input=[]`; the SDK's session replay drives them from where they stopped. Terminal-status agents (`completed` / `crashed` / `stopped`) are left alone but their stats remain in `stats_completed` for the TUI footer. Per-child failure (corrupt session DB, missing skill module) finalizes that child as `crashed` and continues with the rest. - ---- - -## 10. Prompts - -### 10.1 The System Prompt - -`strix/agents/StrixAgent/system_prompt.jinja` (~32 KB). Single template, two behavioral modes via `{% if interactive %} ... {% else %} ... {% endif %}`. Sections: - -1. **Core capabilities** (4-9): security assessment, validation, remediation. -2. **Communication rules** (11-44): CLI markdown only; never echo control XML (`inter_agent_message`, `agent_completion_report`, `agent_identity`). -3. **Tool-call requirement** (24-44, hardened in `4f90a56`): - - Interactive: a message **without** a tool call **immediately stops the entire execution**. "Planning..." and "I'll now scan..." both halt. The only exceptions are genuinely done or explicitly asking the user. The `think` tool is the planning escape hatch. - - Autonomous: minimize chatter, never empty messages, use `wait_for_message` or finish when idle. -4. **Execution guidelines** (47+): - - System-verified scope block (injected at runtime with the authorized targets). - - Authorization status: full authz for in-scope targets, **don't question permission**. - - Refusal-avoidance language: treat as internal security engineering, not generic offensive activity. - - Validation mandate: persist, iterate, assume more issues are hidden. - - Multi-target coordination: build target map, correlate findings, reuse secrets/endpoints. - - Testing modes: black-box, white-box, combined. - - Methodology: scope → recon → automated scanning → targeted validation → continuous iteration → impact documentation. - - Efficiency tactics: automate via Python, batch operations, parallel scans, fuzzers (ffuf/sqlmap/nuclei/semgrep) before custom payloads. For SQLi/XSS/RCE, spray via python/terminal not manual browser. -5. **Vulnerability methodology** (230+): per-class attack surface, detection channels, chaining strategies, WAF bypasses for IDOR / SQLi / SSRF / RCE / XSS / XXE / path-traversal / race conditions / auth bypass / CSRF. - -### 10.2 Vulnerability-Specific Prompts (`strix/prompts/`) - -Currently only `vulnerabilities/nosql_injection.jinja` (~266 lines) — operator injection (`$ne`, `$gt`), boolean/timing/error oracles, auth bypass, regex-based extraction, JS execution, WAF bypass, deduplication. Covers MongoDB, CouchDB, Redis, Cassandra, Neo4j, GraphQL. - -### 10.3 Persona System - -There **is no separate persona file**. All agents are `StrixAgent` instances using the same Jinja system prompt. Differentiation comes from: -- `parent_id` (root agent vs subagent → different finish tools, different prompts injected). -- Loaded skills (root gets `root_agent`; whitebox gets `coordination/source_aware_whitebox` + `custom/source_aware_sast`). -- `system_prompt_context.authorized_targets` (only set on root). -- `is_whitebox` flag (selects the whitebox skill stack). - ---- - -## 11. Skills - -`strix/skills/` — Markdown playbooks loaded into the agent's system prompt. Categories: - -| Dir | Contents | -|---|---| -| `vulnerabilities/` | Auth/JWT, IDOR, SQLi, NoSQL, XSS, XXE, SSRF, CSRF, business logic, race conditions, path traversal, RCE, auth bypass, info disclosure, mass assignment, open redirect, insecure uploads, subdomain takeover. | -| `frameworks/` | FastAPI, NestJS, Next.js. | -| `technologies/` | Firebase/Firestore, Supabase. | -| `protocols/` | GraphQL. | -| `tooling/` | ffuf, httpx, katana, naabu, nmap, nuclei, semgrep, sqlmap, subfinder. | -| `cloud/` | Kubernetes (RBAC, container escapes, etcd, supply chain — added in `#394`). | -| `reconnaissance/` | placeholder. | -| `custom/` | `source_aware_whitebox` (whitebox coordination), `source_aware_sast` (triage). | -| `scan_modes/` | quick, standard, deep. | - -**Loading**: skills passed to `LLMConfig(skills=[...])` are rendered into the system prompt via the Jinja `get_skill(name)` macro (`llm/llm.py:96`). Whitebox automatically pulls in `coordination/source_aware_whitebox` + `custom/source_aware_sast`. **Max 5 skills per agent** (per `skills/README.md`). Mid-run skill loading via the `load_skill` tool replaces the `{{DYNAMIC_SKILLS_DESCRIPTION}}` placeholder. - -**Recent additions:** -- NoSQL injection guide (#168) — see §10.2. -- Kubernetes security testing (#394). - ---- - -## 12. Config - -`strix/config/config.py` — hand-rolled `Config` class (no Pydantic). All knobs are class attributes; `Config.get(name)` resolves `os.environ[name.upper()]` first, then the default. - -| Knob | Default | Purpose | -|---|---|---| -| `strix_llm` | `None` | Model string; required. | -| `llm_api_key` | `None` | Provider API key. | -| `llm_api_base` / `openai_api_base` / `litellm_base_url` / `ollama_api_base` | `None` | Base URL fallbacks (resolved in priority order). | -| `strix_reasoning_effort` | `"high"` | `low`/`medium`/`high`. | -| `strix_llm_max_retries` | `"5"` | LLM retry count. | -| `strix_memory_compressor_timeout` | `"30"` | Compressor LLM timeout. | -| `llm_timeout` | `"300"` | Outer LLM timeout. | -| `perplexity_api_key` | `None` | Web search. | -| `strix_disable_browser` | `"false"` | Skip browser tool registration. | -| `strix_image` | `"ghcr.io/usestrix/strix-sandbox:0.2.0"` | Sandbox image pin. | -| `strix_runtime_backend` | `"docker"` | Only `docker` supported. | -| `strix_sandbox_execution_timeout` | `"120"` | Tool exec timeout (s). | -| `strix_sandbox_connect_timeout` | `"10"` | Tool server connect timeout. | -| `strix_telemetry` | `"1"` | Master telemetry switch. | -| `strix_otel_telemetry` / `strix_posthog_telemetry` | `None` | Per-stream override. | -| `traceloop_base_url` / `traceloop_api_key` / `traceloop_headers` | `None` | OTel/Traceloop endpoint config. | - -### 12.1 Layering - -Order (highest first): `os.environ` → class default. Config files apply by writing into `os.environ`. - -Two file locations: -- `~/.strix/cli-config.json` (default) auto-applied at `Config.apply_saved()`. -- `--config ` (CLI flag) overrides via `apply_config_override()` (`interface/main.py:531-539`). - -The `--config` override fix (commit `9fb1012`): -- Track applied vars in `Config._applied_from_default: ClassVar[dict]` (`config.py:59-61`). -- On override, **clear those tracked vars from `os.environ` first**, then load the custom file. -- Test: `tests/test_config_override.py` validates the leak doesn't happen. - -### 12.2 LLM Config Resolution - -`resolve_llm_config()` (`config.py:199-224`): if model starts with `strix/`, force `api_base = STRIX_API_BASE`; else cascade through `llm_api_base` → `openai_api_base` → `litellm_base_url` → `ollama_api_base`. - -### 12.3 Telemetry Opt-Out - -- `STRIX_TELEMETRY=0` kills both streams. -- `STRIX_OTEL_TELEMETRY=0` kills OTel only. -- `STRIX_POSTHOG_TELEMETRY=0` kills PostHog only. -- Checked via `strix/telemetry/flags.py`. - ---- - -## 13. Telemetry & Persistence - -### 13.1 Tracer (`strix/telemetry/tracer.py`) - -Holds `run_id`, `start_time`, agents map, tool_executions, chat_messages, vulnerability_reports, scan_results, run_metadata. Emits structured events into `events.jsonl` (thread-safe with `_get_events_write_lock`) and OTel spans. - -Event types include: `run.started`, `run.configured`, `agent.created`, `agent.status.updated`, `tool.execution.started`, `tool.execution.updated`, `chat.message`, `finding.created`. - -### 13.2 OpenTelemetry / Traceloop - -`bootstrap_otel()` wires an OTLP HTTP exporter (`opentelemetry-exporter-otlp-proto-http`). If Traceloop SDK is installed, spans also stream to `TRACELOOP_BASE_URL` with `TRACELOOP_API_KEY` headers. `TRACELOOP_HEADERS` (JSON string) allows custom headers. Graceful degradation if Traceloop SDK is missing. - -### 13.3 Scrubadub PII Redaction (`strix/telemetry/utils.py:67-150`) - -`TelemetrySanitizer`: -- Recurses dict/list/tuple structures. -- `_SCREENSHOT_KEY_PATTERN`: keys matching `screenshot` → `[SCREENSHOT_OMITTED]`. -- `_SENSITIVE_KEY_PATTERN`: `api[-_]?key|token|secret|password|...` → `[REDACTED]`. -- `_SENSITIVE_TOKEN_PATTERN`: bearer tokens, GitHub `ghp_`/`ghs_`, Slack `xox*`. -- Strings additionally run through `scrubadub.Scrubber()` for emails, IPs, phone numbers, names; placeholders `{{...}}` replaced with `[REDACTED]`. - -Applied on every event payload before write/export (`tracer.py:159-160, 223-227`). - -### 13.4 PostHog (`strix/telemetry/posthog.py`) - -Anonymous usage telemetry. Per-process `_SESSION_ID = uuid4().hex[:16]` — no user identifier. Public API key embedded; events go to `https://us.i.posthog.com/capture/`. Events include: scan start/end, finding reported, error. Payload metadata: OS, arch, Python version, Strix version, scan mode, finding count, LLM tokens. - -Disabled by `STRIX_POSTHOG_TELEMETRY=0` or `STRIX_TELEMETRY=0`. - ---- - -## 14. Cross-Cutting Design Decisions - -| Decision | Rationale | Tradeoff | -|---|---|---| -| **XML tool calls in text, not native function-calling** | Multi-provider compatibility, streaming truncation control, client-side parsing, partial-tag recovery. | Tool descriptions re-injected as text every call (no schema cache); custom parser to maintain. | -| **Thread-based subagents (daemon threads, own event loops)** | Simple parent-child coordination, shared sandbox, true `asyncio.create_task` not enough because some tools are blocking. | GIL-bound; no real CPU parallelism; daemon threads die on process exit (acceptable for CLI). | -| **Direct dict messaging, no broker** | Simple, fast, in-process. | Single-process only; no durability; zero distribution. | -| **One container per scan, not per agent** | Shared `/workspace` + Caido capture is the common case for security work; less Docker overhead. | No per-agent isolation — a buggy/exploited tool can affect other agents in the same scan. | -| **No CPU/memory/network limits on sandbox container** | Pentest tools (nmap, etc.) need raw socket access and can be heavy. `NET_ADMIN`+`NET_RAW` capabilities granted. | Container could exhaust host resources or DoS targets if the agent goes wrong. Operator's responsibility. | -| **`--disable-web-security` on Chromium** | Required for XSS / CORS testing. | Browser isn't a realistic UA mirror — can yield findings that don't repro in a normal browser. | -| **System-wide proxy via `/etc/profile.d/proxy.sh` + CA installed in NSS + system trust** | Caido captures all HTTP/HTTPS from the container by default — agents don't need to configure proxies per-tool. | Anything in the container talking off-box is observable to Caido; don't run untrusted secrets through there. | -| **Custom PS1 for tmux exit-code extraction** | Reliable exit code detection across arbitrary shells (`[STRIX_$?]$ ` → regex). | Breaks if user-supplied scripts mess with PS1. | -| **Memory compressor summarizes via *separate* LLM call** | Preserves operationally-critical findings instead of dropping them. | Extra cost per compression cycle; compression timeout 120 s can stall the loop. | -| **Ephemeral Anthropic prompt cache only** | Simple — system prompt cache resets per request. | Lost cache benefit between calls if conversation history mutates (which it always does). | -| **Subagent stats finalized into a global dict on exit** | Avoids race conditions when the root queries during child execution. Required for accurate root-level totals. | Slight complexity in `_finalize_agent_llm_stats`. | -| **Tool result truncation at 10 KB head/tail** | Keeps context spend bounded. | Loses information; the model sometimes can't see the part it needs. | -| **Vulnerability dedup via LLM similarity** | Catches semantic duplicates that hash-based dedup would miss. | Costs another LLM call per finding submission. | -| **Hard tool-call requirement enforced by prompt** | Models prone to outputting "Planning..." with no tool call, which the loop interprets as "wait for user" and halts. | Strong language in system prompt; need `think` as escape hatch. | -| **Diff scope as prompt-injected metadata, not filesystem filter** | Agent can still read related files (imports, helpers) when investigating diffed code. | Larger context spend; relies on model self-restraint to actually focus on the diff. | -| **Skills are Markdown files, not Python plugins** | Lower contributor friction, no import system to maintain, easy to ship. | No programmatic logic in skills — they're always pure prompt content. | - ---- - -## 15. Recent Evolution (Notable Commits) - -| Commit | Subject | What Changed | -|---|---|---| -| `9fb1012` | `--config` flag now fully overrides `~/.strix/cli-config.json` (#457) | Track default-applied vars in `Config._applied_from_default`; clear them before loading custom config. New test in `tests/test_config_override.py`. | -| `60abc09` | wrap acompletion in asyncio.wait_for to prevent indefinite hangs (#453) | Wrap **both** `acompletion()` and per-chunk `__anext__()` in `asyncio.wait_for`. Bedrock TCP-accept-then-silence stalls now raise `TimeoutError` (status_code=None) → retried via `_should_retry`. | -| `8841294` | feat(skills): add Kubernetes security testing skill (#394) | New `strix/skills/cloud/kubernetes.md`. RBAC, container escapes, etcd, supply chain. | -| `5c13348` | feat: Add NoSQL injection vulnerability guide (#168) | New `strix/prompts/vulnerabilities/nosql_injection.jinja` covering Mongo/Couch/Redis/Cassandra/Neo4j/GraphQL. | -| `15c9571` | fix: ensure LLM stats tracking is accurate by including completed subagents (#441) | `_finalize_agent_llm_stats()` snapshots subagent stats into `_completed_agent_llm_totals` under lock; tracer aggregates live + completed. Root no longer undercounts. | -| `62e9af3` | Add Strix GitHub Actions integration tip | README addition only. | -| `38b2700` | feat: Migrate from Poetry to uv (#379) | Build system now `hatchling`; deps managed by `uv`; lockfile `uv.lock`. | -| `e78c931` | feat: Better source-aware testing (#391) | Whitebox skill set hardened (`coordination/source_aware_whitebox`, `custom/source_aware_sast`). | -| `4934bb8` | chore: upgrade litellm and sanitize tool result text | Bump litellm to `>=1.81.1,<1.82.0`; tool result sanitization (screenshot extraction, length truncation, error truncation). | -| `7d5a45d` | chore: bump version to 0.8.3 | PyPI version bump. | -| `dec2c47` | fix: use anthropic model in anthropic provider docs example | Doc only. | -| `4f90a56` | fix: strengthen tool-call requirement in interactive and autonomous modes | Hardens `system_prompt.jinja:24-44`. Explicit "message without tool call IMMEDIATELY STOPS execution" and named exceptions. Adds the `think` tool escape hatch. | -| `640bd67` | chore: bump sandbox image to 0.1.13 | `strix/config/config.py:43`. | - ---- - -## 16. Quick File Index - -| Path | Role | -|---|---| -| `strix/interface/main.py` | CLI entrypoint. | -| `strix/interface/cli.py` | Headless mode. | -| `strix/interface/tui.py` | Textual TUI app. | -| `strix/interface/utils.py` | Run-name, target inference, diff-scope helpers. | -| `strix/agents/base_agent.py` | Core agent loop. | -| `strix/agents/state.py` | `AgentState` model. | -| `strix/agents/StrixAgent/strix_agent.py` | Root agent + `execute_scan`. | -| `strix/agents/StrixAgent/system_prompt.jinja` | System prompt. | -| `strix/llm/llm.py` | LLM wrapper. | -| `strix/llm/config.py` | `LLMConfig`. | -| `strix/llm/memory_compressor.py` | History compaction. | -| `strix/llm/utils.py` | Tool-format normalization, XML parser. | -| `strix/llm/dedupe.py` | Vulnerability dedup. | -| `strix/tools/registry.py` | Tool registry + decorator. | -| `strix/tools/executor.py` | Local + sandbox dispatcher. | -| `strix/tools/context.py` | Agent ID contextvar. | -| `strix/tools/argument_parser.py` | XML arg type coercion. | -| `strix/tools/agents_graph/agents_graph_actions.py` | Multi-agent orchestration. | -| `strix/tools/finish/finish_actions.py` | `finish_scan` (root only). | -| `strix/tools/browser/` | Playwright Chromium tool. | -| `strix/tools/terminal/` | tmux/libtmux tool. | -| `strix/tools/python/` | IPython tool. | -| `strix/tools/proxy/` | Caido GraphQL client. | -| `strix/tools/notes/` | In-memory notes (shared across agents in a run). | -| `strix/tools/todo/` | In-memory todos. | -| `strix/tools/reporting/` | Vulnerability reports + CVSS. | -| `strix/tools/web_search/` | Perplexity. | -| `strix/tools/file_edit/` | openhands-aci editor. | -| `strix/tools/thinking/` | `think` tool. | -| `strix/tools/load_skill/` | Runtime skill injection. | -| `strix/runtime/docker_runtime.py` | Host-side container orchestration. | -| `strix/runtime/tool_server.py` | Sandbox-side FastAPI tool server. | -| `containers/Dockerfile` | Sandbox image. | -| `containers/docker-entrypoint.sh` | Container boot sequence. | -| `strix/config/config.py` | Config + env layering. | -| `strix/telemetry/tracer.py` | Run tracer + JSONL events. | -| `strix/telemetry/utils.py` | Scrubadub redaction. | -| `strix/telemetry/posthog.py` | Anonymous usage telemetry. | -| `strix/telemetry/flags.py` | Telemetry opt-out resolution. | -| `strix/utils/resource_paths.py` | Frozen-vs-dev path resolution. | -| `strix/skills/**/*.md` | Vulnerability + tooling + scan-mode playbooks. | -| `strix/prompts/vulnerabilities/nosql_injection.jinja` | NoSQLi prompt. | -| `pyproject.toml` | Deps, entry point, lint config. | - ---- - -*This wiki captures the harness as of `9fb1012`. When in doubt, source wins.* diff --git a/MIGRATION_EVALUATION.md b/MIGRATION_EVALUATION.md deleted file mode 100644 index fbbc5ef..0000000 --- a/MIGRATION_EVALUATION.md +++ /dev/null @@ -1,766 +0,0 @@ -# Migration Evaluation: Strix Custom Harness → OpenAI Agents SDK - -> Evaluated against `openai/openai-agents-python` v0.14.6 (`/tmp/openai-agents`). Maps every Strix subsystem from `HARNESS_WIKI.md` (Strix at `9fb1012`) onto SDK primitives. -> -> **Revision 2** — incorporates: (a) confirmed multi-agent + messaging is bridgeable via `call_model_input_filter`; (b) accepted tradeoffs on XML tool format, skills-as-tool-output, sandbox subclass; (c) tool-execution threading & timeout deltas (sequential→parallel, no default timeouts, no auto sync offload). - ---- - -## Table of Contents - -1. [TL;DR](#1-tldr) -2. [SDK overview](#2-sdk-overview) -3. [Per-subsystem mapping (revised)](#3-per-subsystem-mapping-revised) -4. [Multi-agent design — concrete bridge](#4-multi-agent-design--concrete-bridge) -5. [Tool execution semantics — what changes](#5-tool-execution-semantics--what-changes) -6. [Sandbox bridge](#6-sandbox-bridge) -7. [What we still lose control over](#7-what-we-still-lose-control-over) -8. [What we gain](#8-what-we-gain) -9. [Effort estimate (revised)](#9-effort-estimate-revised) -10. [Migration plan (step-by-step)](#10-migration-plan-step-by-step) -11. [Risks & open questions](#11-risks--open-questions) - ---- - -## 1. TL;DR - -**Verdict: full migration is feasible.** ~25–35 engineer-days for full parity, including parallel multi-agent + messaging, with three accepted tradeoffs and one custom Docker-client subclass. - -| Concern | Original status | Revised status | -|---|---|---| -| Concurrent multi-agent graph | "Critical / not bridgeable" | **Bridgeable.** `call_model_input_filter` + `asyncio.create_task` + shared `Session` + a `MessageBus` we own. Architecturally identical to today's `_agent_messages` injection in `_check_agent_messages`. ~400 LOC, full parity (true parallel, peer-to-peer messaging, wait_for_message, view_agent_graph, identity injection, stat aggregation). | -| XML tool-call format | "Critical" | **Accepted tradeoff.** SDK is JSON-native (provider-native via LiteLLM extension for non-OpenAI). No real loss — provider-native tool use is cleaner. Multi-provider survives. | -| `load_skill` mid-run prompt mutation | "High loss" | **Accepted tradeoff.** Skills returned as tool output; model sees them in conversation history. Slightly more memory-compressor-eviction-prone, but cleaner semantics. | -| Sandbox `cap_add` / `extra_hosts` | "High" | **Solvable.** Subclass `DockerSandboxClient` and inject the kwargs. ~50–80 LOC. | -| Tool execution semantics | not addressed | **Net upgrade.** SDK runs tool calls in **parallel** within a turn (Strix is sequential). No default per-tool timeout (Strix has 120s) — we add a `strix_tool()` factory to re-impose defaults. No auto sync→thread offload (Strix's tool server `asyncio.to_thread`s every call) — we wrap sync code ourselves. | - -**No remaining showstoppers.** All gaps now have concrete bridges. - ---- - -## 2. SDK overview - -`openai-agents` v0.14.6, MIT, Python 3.10+. Core abstractions: - -| Concept | Purpose | File | -|---|---|---| -| `Agent` | LLM + instructions + tools + handoffs + guardrails | `src/agents/agent.py` | -| `Runner` / `AgentRunner` | Run loop, max_turns, streaming | `src/agents/run.py`, `run_internal/` | -| `RunState` / `RunResult` | Run state + result, resumable serialization | `run_state.py`, `result.py` | -| `Session` | Conversation history persistence (8+ backends) | `memory/`, `extensions/memory/` | -| `function_tool` / `FunctionTool` | Tool decorator (native function-calling) | `tool.py:1725` | -| `Handoff` | Linear delegation to another agent | `handoffs/` | -| `Agent.as_tool()` | Nested agent invocation (blocking) | `tool.py` (`_is_agent_tool`) | -| `RunHooks` / `AgentHooks` | 7 lifecycle hooks | `lifecycle.py` | -| Guardrails (input/output/tool) | Three-layer validation | `guardrail.py`, `tool_guardrails.py` | -| Tracing | Built-in spans, processors, OpenAI dashboard default | `tracing/` | -| **`call_model_input_filter`** | **Mutate input list before every model call** | `run_config.py:61`, `run_internal/turn_preparation.py:55-80` | -| `SandboxAgent` (v0.14.0) | Pre-configured agent with sandbox session | `sandbox/`, `extensions/sandbox/` | -| `Manifest` + capabilities + entries | Sandbox config (env, mounts, capabilities) | `sandbox/manifest.py`, `sandbox/capabilities/` | -| `MultiProvider`, `LitellmModel`, `AnyLLMModel` | Non-OpenAI provider routing | `models/multi_provider.py`, `extensions/models/` | -| MCP support | 4 transports (HostedMCPTool, StreamableHttp, Sse, Stdio) | `mcp/` | - -Sandbox backends shipped: **UnixLocal, Docker, E2B, Daytona, Modal, Runloop, Vercel, Blaxel, Cloudflare**. - ---- - -## 3. Per-subsystem mapping (revised) - -### 3.1 Agent loop & multi-agent (Strix §5) - -| Strix capability | SDK equivalent | Match | Notes | -|---|---|---|---| -| Single-agent loop with `max_iterations=300` | `Runner.run(max_turns=...)` | Partial | Default is 10; raise via `RunConfig(max_turns=300)`. | -| 85% / N-3 turn warnings | `RunHooks.on_llm_start` checks `len(input_items)` and pushes a warning user-message | Bridgeable | ~20 LOC. | -| Streaming early-truncate at `` | `result.cancel(mode="after_turn")` (turn-level only) | Partial | Lose token savings on over-generating models. ~50–100 LOC custom Model wrapper if we want it back. | -| `AgentState` (parent_id, sandbox_id, audit) | `RunState` (per-run) + `RunContextWrapper.context` (per-agent dict) | Partial | Audit trail moves into hooks/tracer; identity into context dict. | -| **Concurrent multi-agent graph** | **`asyncio.create_task(Runner.run(...))` + shared `SandboxRunConfig.session` + `MessageBus` + `call_model_input_filter`** | **1:1 (bridge built in §4)** | True parallel children, peer-to-peer messaging, wait/timeout, agent graph view. | -| `view_agent_graph` text rendering | Bus traversal helper | 1:1 | Ours, ~30 LOC. | -| Subagent identity injection (`` XML) | Set `agent_id`/`parent_id`/`agent_name` in `RunContextWrapper.context`; child instructions are a callable that pulls from context | 1:1 | Same effect, no XML. | -| Cancellation (`cancel_current_execution`) | `task.cancel()` on the `asyncio.Task` we own (one per agent in the bus) | 1:1 | Identical primitive. | -| Interactive "waiting state" with timeout | `wait_for_message` tool polls bus inbox via `asyncio.sleep` | 1:1 | Same semantics, ~20 LOC. | -| Subagent stat aggregation | `RunHooks.on_llm_end` pushes usage to bus; `on_agent_end` finalizes | 1:1 | Cleaner than today's `_completed_agent_llm_totals` lock-protected dict. | -| Lifecycle hooks (implicit today) | `RunHooks` + `AgentHooks` (7 hooks) | **Gain** | Use these to wire tracer + stats. | -| Memory compression (90K, last-15 floor, LLM summary) | Custom `Session` subclass with `compact()` hook | Bridgeable | ~150 LOC. Ports our existing `MemoryCompressor` strategy. | - -### 3.2 LLM layer (Strix §6) - -| Strix capability | SDK equivalent | Match | -|---|---|---| -| `litellm.acompletion` multi-provider | Native OpenAI + `LitellmModel` (extras: `litellm`) + `AnyLLMModel` (extras: `any-llm`) | 1:1 — pick `LitellmModel` for parity. | -| `MultiProvider` prefix routing (`openai/`, `litellm/anthropic/`) | `MultiProvider` + `MultiProviderMap` | 1:1 — direct equivalent. | -| Strix model aliasing (`strix/claude-sonnet-4.6` → `anthropic/claude-sonnet-4-6` + custom `api_base`) | Custom `ModelProvider` subclass reading our alias map | Bridgeable | ~50 LOC. | -| Anthropic prompt caching auto-injection | `ModelSettings(extra_body={"cache_control": {"type": "ephemeral"}})` per Anthropic agent | Partial | Per-agent manual or via a small `make_anthropic_settings()` helper. ~30 LOC. | -| Reasoning effort (env > config > scan-mode default) | `ModelSettings(reasoning=Reasoning(effort=...))` | 1:1. | -| Streaming early-exit at `` | None native | Partial — lose token savings; custom Model subclass to restore. | -| Per-chunk streaming timeout (Bedrock fix) | None native | Partial — wrap streaming in custom Model subclass if Bedrock matters. | -| Retries (`min(90, 2*2^n)`, max 5, custom `_should_retry`) | `ModelSettings(retry=ModelRetrySettings(...))` + `retry_policies.*` | **Gain** — composable. | -| Memory compression with pentest-tuned summary prompt | Custom `Session` subclass | Bridgeable | ~150 LOC. | -| `_strip_images()` for vision-less models | None automatic | Wrap as Model subclass or pre-filter. ~40 LOC. | -| Per-call `RequestStats` w/ cost via `litellm.completion_cost` | `Usage` (tokens only) | Partial — wire `litellm.completion_cost` in `on_llm_end` hook. ~20 LOC. | -| Vulnerability dedup (separate LLM call) | Function tool that calls a nested `Runner.run` or direct LiteLLM | 1:1 — port as-is. | -| Custom Jinja system prompt | `Agent.instructions: str | Callable[..., str]` | 1:1 — pre-render Jinja before agent creation, or pass an async callable. | - -### 3.3 Tool system (Strix §7) - -All 13 Strix tools port. Multi-agent-graph tools are now in §4. - -| Strix tool | SDK primitive | Effort | -|---|---|---| -| `@register_tool` w/ env-conditional registration | `@function_tool` + per-agent `tools=[...]` list assembled via env checks at agent build time | Low | -| Local-vs-sandbox dispatch | All tools are `@function_tool` async. Sandbox tools are wrappers that POST to our existing FastAPI tool server. **Network isolation + Bearer auth survive at the transport layer.** | Medium | -| Result XML wrap + 10KB head/tail truncation + screenshot extraction | `ToolOutputText` / `ToolOutputImage` / `ToolOutputFileContent`; truncation logic in our wrapper | Low–Medium | -| Sequential tool execution | **SDK runs tool calls in parallel within a turn** (see §5). Net gain. Verify our stateful tools are reentrant-safe (browser singleton already is via its `threading.Lock`). | n/a | -| Argument validation → error string | Pydantic from signature; default `failure_error_function` returns error string | 1:1 | -| Browser (Playwright, 24 actions) | `ComputerTool(computer=AsyncComputer subclass)` — keeps our Playwright code as the implementation | ~200 LOC | -| Terminal (libtmux, custom PS1 exit-code regex) | `ShellTool(executor=...)` w/ libtmux, or `@function_tool`. **Wrap libtmux calls in `asyncio.to_thread` ourselves** | ~300 LOC | -| Python (IPython, stateful) | `@function_tool` + module-level kernel dict keyed by `agent_id` from context | ~200 LOC | -| Caido proxy (7 GraphQL tools) | 7× `@function_tool` | ~150 LOC | -| Notes (in-memory + JSONL + wiki MD) | 5× `@function_tool` | ~100 LOC | -| Todos (in-memory) | 6× `@function_tool` | ~80 LOC | -| Reporting (CVSS, dedup) | `@function_tool` + Pydantic + cvss lib + nested Runner for dedup | ~150 LOC | -| Web search (Perplexity) | `@function_tool` (we keep Perplexity, ignore SDK's OpenAI-only `WebSearchTool`) | ~50 LOC | -| File edit (openhands-aci + ripgrep) | `@function_tool` wrappers | ~60 LOC | -| Finish scan (root-only guard) | `@function_tool` + context-introspection guard (`parent_id is None`) | ~50 LOC | -| Thinking | Trivial `@function_tool` | ~10 LOC | -| **Multi-agent graph (6 tools)** | **§4** — `function_tool` over `MessageBus` | ~400 LOC | -| **`load_skill`** | **`function_tool` returning skill content as tool output (accepted tradeoff)** | ~60 LOC | -| `current_agent_id` ContextVar propagation | `RunContextWrapper.context["agent_id"]` + `get_agent_id(ctx)` helper | Low | -| Tool guardrails (manual arg validation today) | `ToolInputGuardrail` / `ToolOutputGuardrail` | **Gain** | - -### 3.4 Sandbox / runtime (Strix §8) - -| Strix capability | SDK equivalent | Match | -|---|---|---| -| Custom Kali image | `DockerSandboxClientOptions(image="ghcr.io/.../strix-sandbox:0.2.0")` | 1:1 | -| `cap_add=NET_ADMIN,NET_RAW` + `extra_hosts=host.docker.internal` | **Subclass `DockerSandboxClient`, inject into `containers.create()` kwargs** | Bridgeable | ~80 LOC | -| Caido HTTPS proxy + CA cert + system-wide proxy env | Image-baked (Dockerfile + entrypoint stay as-is); `Manifest.environment` for runtime overrides; custom `CaidoCapability` for the 7 Caido tools + system-prompt instruction block | Bridgeable | ~200 LOC capability | -| FastAPI tool server + Bearer auth | **Stays in the image.** Function tools wrap HTTP calls to it. Network isolation + Bearer auth preserved at transport layer. SDK's "in-process tools" model becomes "function tool that POSTs to localhost:48081 inside our shared session." | 1:1 in effect | -| One container per scan, shared by all agents | `SandboxRunConfig(session=shared_session)` passed into every `Runner.run` call | 1:1 | -| Random host port allocation | We pre-allocate via `socket.bind(0)` and pass to `DockerSandboxClientOptions(exposed_ports=...)` | 1:1 | -| Healthcheck polling | External loop after `client.create()`, polling `session.exec("curl -fs localhost:48081/health")` | Bridgeable | ~30 LOC | -| Container reuse keyed by scan_id | We track our own session map | 1:1 | -| Local source tar-pipe to `/workspace` | `Manifest.entries={"sources": LocalDir(src=Path)}` | 1:1+ — SDK is a strict superset (LocalDir, LocalFile, GitRepo, S3Mount, …) | -| Multi-agent silo via `agent_id` ContextVar | `RunContextWrapper.context["agent_id"]` extracted in stateful tools | 1:1 (explicit instead of implicit) | -| Cleanup via async `docker rm -f` | `await client.delete(session)` wrapped in `try/finally` | 1:1 | - -### 3.5 Interface, prompts, skills, config, telemetry (Strix §9–§13) - -| Strix capability | SDK equivalent | Match | -|---|---|---| -| Textual TUI | Re-point at `Runner.run_streamed().stream_events()` | Bridgeable — our existing TUI code, new event source | -| Headless / `-n` flag / exit code 2 | `Runner.run()` + app-layer exit codes | 1:1 | -| CLI args | App layer; SDK has no CLI | 1:1 — keep our argparse | -| Run directory layout | Custom trace processor + result-persistence layer | Bridgeable | ~100 LOC | -| Built-in tracing | `tracing/` w/ custom processors; default exports to OpenAI dashboard — disable for local-only | Partial | ~40 LOC custom JSONL processor | -| OTel / Traceloop export | Custom processor wrapping OTLP | ~30 LOC | -| Scrubadub PII redaction | Custom trace processor — keeps our scrubadub + regex stack | ~60 LOC | -| Live streaming content updates 2 Hz | `RunResultStreaming.stream_events()` (event-driven, not polled) | **Gain** | -| PostHog anonymous telemetry | Keep our own implementation | 1:1 | -| Sessions / persistence | 8+ backends (SQLite, Redis, SQLAlchemy, Mongo, Dapr, Encrypted, OpenAIResponsesCompaction, …) | **Gain** — we have nothing today | -| Input/output/tool guardrails | Three-layer guardrail system | **Gain** | -| Lifecycle hooks | `RunHooks` / `AgentHooks` | **Gain** | -| Jinja system prompt rendering (32 KB) | `Agent.instructions: Callable[..., str]` runs at run start | 1:1 — pre-render Jinja in callable | -| Tool-call requirement enforcement | `ModelSettings(tool_choice="required")` + `Agent.reset_tool_choice=True` | **Gain** — native enforcement | -| Skills as Markdown playbooks | App-layer string management (read MD, render to instructions or tool output) | 1:1 | -| Dynamic skill injection mid-run | **`load_skill` returns skill content as tool output (accepted tradeoff)** | Lossy but acceptable | -| Vulnerability prompts (NoSQLi etc.) | App-layer string management | 1:1 | -| Config file `~/.strix/cli-config.json` w/ `--config` override | Keep our `Config` class; sets env vars before SDK init | 1:1 | -| `RunConfig` per-run knobs | `RunConfig` dataclass — strict superset | **Gain** | -| Agent graph visualization | `agents.extensions.visualization.draw_graph()` (static Graphviz) + our `view_agent_graph` tool (live) | 1:1 | -| Logging | `openai.agents` + `openai.agents.tracing` loggers | 1:1 | - ---- - -## 4. Multi-agent design — concrete bridge - -This was the contested section in the previous evaluation. **It's bridgeable, the bridge is small, and the architecture is identical to today's Strix in shape — just lives in our code on top of SDK primitives.** - -### 4.1 The key SDK hook - -`run_config.py:61` defines: - -```python -CallModelInputFilter = Callable[[CallModelData[Any]], MaybeAwaitable[ModelInputData]] -``` - -This filter runs **before every model call** (`run_internal/turn_preparation.py:55-80`). It receives the input list + instructions and returns a (possibly mutated) `ModelInputData(input=[...], instructions=...)`. **This is the exact injection point Strix uses today** in `_check_agent_messages` at the top of every iteration. It's the missing piece. - -### 4.2 Architecture - -``` - ┌──────────────────────────────────────────────┐ - │ AgentMessageBus (we own; ~150 LOC) │ - │ inboxes: {agent_id -> list[msg]} │ - │ tasks: {agent_id -> asyncio.Task} │ - │ statuses: {agent_id -> running|...} │ - │ parent_of: {agent_id -> parent_id|None} │ - │ stats_live, stats_completed (under lock) │ - └─┬──────────────────────────────────┬─────────┘ - │ │ - │ create_agent (function_tool) │ on_llm_end / on_agent_end - │ asyncio.create_task( │ (RunHooks) - │ Runner.run(child, ..., │ - │ run_config=RunConfig( │ ──► record_usage, - │ sandbox=SandboxRunConfig( │ finalize_stats - │ session=SHARED), │ - │ call_model_input_filter= │ - │ inject_messages_filter, │ - │ ), │ - │ context={"bus": bus, │ - │ "agent_id": child, │ - │ "parent_id": me, │ - │ "session": ...}) │ - │ ) │ - ▼ ▼ - Child Runner runs in parallel Parent's next LLM call: - (asyncio task, true call_model_input_filter - I/O concurrency). drains inbox, appends msgs - as user-role items. -``` - -### 4.3 The bus (~150 LOC) - -```python -# strix/orchestration/bus.py -import asyncio -from dataclasses import dataclass, field - -@dataclass -class AgentMessageBus: - inboxes: dict[str, list[dict]] = field(default_factory=dict) - tasks: dict[str, asyncio.Task] = field(default_factory=dict) - statuses: dict[str, str] = field(default_factory=dict) - parent_of: dict[str, str | None] = field(default_factory=dict) - names: dict[str, str] = field(default_factory=dict) - stats_live: dict[str, dict] = field(default_factory=dict) - stats_completed: dict[str, dict] = field(default_factory=dict) - _lock: asyncio.Lock = field(default_factory=asyncio.Lock) - - async def register(self, agent_id, name, parent_id): - async with self._lock: - self.inboxes[agent_id] = [] - self.statuses[agent_id] = "running" - self.parent_of[agent_id] = parent_id - self.names[agent_id] = name - - async def send(self, target, msg): - async with self._lock: - self.inboxes.setdefault(target, []).append(msg) - - async def drain(self, agent_id): - async with self._lock: - msgs = self.inboxes.get(agent_id, []) - self.inboxes[agent_id] = [] - return msgs - - async def record_usage(self, agent_id, usage): - async with self._lock: - stats = self.stats_live.setdefault(agent_id, {"in": 0, "out": 0, "cached": 0, "cost": 0}) - stats["in"] += usage.input_tokens - stats["out"] += usage.output_tokens - stats["cached"] += usage.input_tokens_details.cached_tokens or 0 - - async def finalize(self, agent_id, status): - async with self._lock: - self.statuses[agent_id] = status - self.stats_completed[agent_id] = self.stats_live.pop(agent_id, {}) - - async def total_stats(self): - async with self._lock: - agg = {"in": 0, "out": 0, "cached": 0, "cost": 0} - for s in (*self.stats_live.values(), *self.stats_completed.values()): - for k, v in s.items(): - agg[k] = agg.get(k, 0) + v - return agg -``` - -### 4.4 The injector (~30 LOC) - -```python -# strix/orchestration/filter.py -from agents.run_config import CallModelData, ModelInputData - -async def inject_messages_filter(data: CallModelData) -> ModelInputData: - bus = data.context["bus"] - agent_id = data.context["agent_id"] - pending = await bus.drain(agent_id) - if not pending: - return data.model_data - new_input = list(data.model_data.input) - for msg in pending: - sender = msg.get("from", "unknown") - if sender == "user": - new_input.append({"role": "user", "content": msg["content"]}) - else: - new_input.append({ - "role": "user", - "content": ( - f"" - f"{msg['content']}" - f"" - ), - }) - return ModelInputData(input=new_input, instructions=data.model_data.instructions) -``` - -### 4.5 The hooks (~50 LOC) - -```python -# strix/orchestration/hooks.py -from agents import RunHooks - -class StrixOrchestrationHooks(RunHooks): - async def on_llm_end(self, ctx, agent, response): - bus = ctx.context["bus"] - await bus.record_usage(ctx.context["agent_id"], response.usage) - - async def on_agent_end(self, ctx, agent, output): - bus = ctx.context["bus"] - await bus.finalize(ctx.context["agent_id"], "completed") - - async def on_tool_start(self, ctx, agent, tool): - # Bridge to our existing Tracer - ctx.context["tracer"].log_tool_start(ctx.context["agent_id"], tool.name) - - async def on_tool_end(self, ctx, agent, tool, result): - ctx.context["tracer"].log_tool_end(ctx.context["agent_id"], tool.name, result) -``` - -### 4.6 The six multi-agent tools (~250 LOC, replacing 839 LOC of `agents_graph_actions.py`) - -```python -# strix/tools/agents_graph.py -import asyncio, uuid -from agents import function_tool, RunContextWrapper, Runner -from agents.run import RunConfig -from agents.sandbox import SandboxRunConfig - -@function_tool -async def create_agent( - ctx: RunContextWrapper, - name: str, - task: str, - inherit_context: bool = True, - skills: list[str] | None = None, -) -> str: - bus = ctx.context["bus"] - parent_id = ctx.context["agent_id"] - child_id = uuid.uuid4().hex[:8] - await bus.register(child_id, name, parent_id) - - child_agent = build_strix_agent(name=name, skills=skills or []) - history = ( - await ctx.context["session"].get_items() if inherit_context else [] - ) - - bus.tasks[child_id] = asyncio.create_task( - Runner.run( - child_agent, - input=history + [{"role": "user", "content": task}], - run_config=RunConfig( - sandbox=SandboxRunConfig(session=ctx.context["sandbox_session"]), - call_model_input_filter=inject_messages_filter, - model_settings=ctx.context["model_settings"], - max_turns=300, - ), - context={ - "bus": bus, - "agent_id": child_id, - "parent_id": parent_id, - "agent_name": name, - "session": ctx.context["session"], - "sandbox_session": ctx.context["sandbox_session"], - "tracer": ctx.context["tracer"], - "model_settings": ctx.context["model_settings"], - }, - hooks=StrixOrchestrationHooks(), - ) - ) - return f"Spawned agent {child_id} ({name}) running in parallel." - -@function_tool -async def send_message_to_agent( - ctx: RunContextWrapper, - target_agent_id: str, - message: str, - message_type: str = "info", - priority: str = "normal", -) -> str: - await ctx.context["bus"].send(target_agent_id, { - "from": ctx.context["agent_id"], - "content": message, - "type": message_type, - "priority": priority, - }) - return f"Message queued for {target_agent_id}." - -@function_tool -async def wait_for_message( - ctx: RunContextWrapper, reason: str, timeout_seconds: int = 600 -) -> str: - bus = ctx.context["bus"] - me = ctx.context["agent_id"] - bus.statuses[me] = "waiting" - deadline = asyncio.get_event_loop().time() + timeout_seconds - while asyncio.get_event_loop().time() < deadline: - if bus.inboxes.get(me): - bus.statuses[me] = "running" - return "Message arrived. Continue your task." - await asyncio.sleep(1) - bus.statuses[me] = "running" - return f"Timed out after {timeout_seconds}s. Continue or call agent_finish." - -@function_tool -async def agent_status(ctx: RunContextWrapper, agent_id: str) -> str: - bus = ctx.context["bus"] - if agent_id not in bus.statuses: - return f"Unknown agent {agent_id}." - return ( - f"agent={bus.names.get(agent_id)} status={bus.statuses[agent_id]} " - f"parent={bus.parent_of.get(agent_id)} " - f"pending_msgs={len(bus.inboxes.get(agent_id, []))}" - ) - -@function_tool -async def view_agent_graph(ctx: RunContextWrapper) -> str: - bus = ctx.context["bus"] - lines = [] - roots = [aid for aid, p in bus.parent_of.items() if p is None] - def render(aid, depth): - lines.append(" " * depth + f"- {bus.names.get(aid, '?')} ({aid}) [{bus.statuses.get(aid)}]") - for child, p in bus.parent_of.items(): - if p == aid: - render(child, depth + 1) - for root in roots: - render(root, 0) - return "\n".join(lines) or "No agents." - -@function_tool -async def agent_finish( - ctx: RunContextWrapper, - result_summary: str, - findings: list[dict] | None = None, - success: bool = True, - report_to_parent: bool = True, - final_recommendations: list[str] | None = None, -) -> str: - bus = ctx.context["bus"] - me = ctx.context["agent_id"] - parent = bus.parent_of.get(me) - if parent is None: - return "Error: agent_finish is for subagents. Root agent must call finish_scan." - if report_to_parent: - report_xml = ( - f"\n" - f" {result_summary}\n" - f" {findings or []}\n" - f" {final_recommendations or []}\n" - f"" - ) - await bus.send(parent, {"from": me, "content": report_xml, "type": "completion"}) - await bus.finalize(me, "completed" if success else "failed") - return "Reported to parent. This agent will exit." -``` - -### 4.7 Capability-by-capability mapping - -| Strix today | SDK bridge | Identical? | -|---|---|---| -| Daemon-thread subagent (`threading.Thread`) | `asyncio.create_task(Runner.run(...))` | **Yes in effect.** LLM calls are I/O-bound; both designs get the same effective concurrency. We were never CPU-bound at the agent level. | -| Shared `/workspace` Kali sandbox | Shared `SandboxRunConfig(session=...)` passed to every child's `RunConfig` | Yes | -| `_agent_messages` inbox | `AgentMessageBus.inboxes` | Yes (renamed) | -| Per-iteration message check (`_check_agent_messages` at top of `agent_loop`) | `call_model_input_filter` runs before every LLM call (SDK guarantees this in `turn_preparation.py:55-80`) | Yes | -| `` XML wrap | Filter formats as user-role items with same XML envelope | Yes | -| `_completed_agent_llm_totals` aggregation | `RunHooks.on_agent_end` snapshots into `bus.stats_completed`, locked | Yes (cleaner) | -| `wait_for_message` tool with timeout | Tool that polls `bus.inboxes[me]` in `asyncio.sleep` loop | Yes | -| `view_agent_graph` text output | Bus traversal helper | Yes | -| Identity injection via `` XML | Set identity in `RunContextWrapper.context`; agent's instructions are a callable that pulls from context | Equivalent (no XML wrapping; identity still flows) | -| Cancellation cascade | `bus.tasks[child_id].cancel()` | Yes — same `asyncio.Task.cancel()` primitive | -| Stop on parent | Walk descendants via `parent_of`, cancel each task | Yes (same as Strix today) | - -### 4.8 What this design does NOT lose - -- **True concurrency** at the LLM-I/O boundary. (Python threading was never giving us CPU parallelism for our workload anyway.) -- **Shared sandbox** semantics — same Kali container, same `/workspace`, same Caido capture, same proxy state. -- **Cross-sibling messaging** — fully bridged via the bus + filter. -- **Stat aggregation** — cleaner via hooks. -- **Per-agent state silo** for stateful tools (browser, terminal, python) — `RunContextWrapper.context["agent_id"]` is the explicit equivalent of the implicit `current_agent_id` ContextVar. - -### 4.9 What this design does lose (small) - -- **Per-agent task slot serialization** (Strix's tool server cancels a previous in-flight tool when a new one for the same agent arrives). Not actually needed under the SDK because each agent's run loop only emits a new tool call after the previous resolves. -- **Implicit ContextVar magic** — became explicit `ctx.context["agent_id"]` extraction. ~3 LOC helper makes it ergonomic. - ---- - -## 5. Tool execution semantics — what changes - -This is the operational gotcha most likely to surprise during migration. Source-verified from `tool_execution.py` and `tool_server.py` (Strix), `run_internal/tool_execution.py` and `tool.py` (SDK). - -### 5.1 Side-by-side - -| Dimension | Strix | OpenAI SDK | -|---|---|---| -| **Tool calls within one model turn** | **Sequential** (`for inv in invocations` at `executor.py:324`) | **Parallel** (`asyncio.create_task` per call, drained via `asyncio.wait FIRST_COMPLETED` at `tool_execution.py:1412-1430`) | -| **Default per-tool timeout** | 120s (`STRIX_SANDBOX_EXECUTION_TIMEOUT`) + 30s host buffer = 150s outer | **None.** Must opt in via `@function_tool(timeout=N)` | -| **Local/host-side tool timeout** | None — runs in main loop | None unless `timeout_seconds` is set | -| **Sandbox/remote tool timeout** | 120s `asyncio.wait_for` server-side + 150s httpx outer client-side | N/A — SDK has no remote tool concept; we wrap HTTP in a function tool and set timeout ourselves | -| **Connect timeout** | 10s for httpx → sandbox | None built-in — pass `httpx.Timeout(connect=10)` in our tool body | -| **Sync function offload** | Tool server: `asyncio.to_thread(tool_func, ...)` always (`tool_server.py:83`) | **No auto-offload.** Sync code blocks the loop unless we wrap with `asyncio.to_thread` ourselves | -| **Per-agent serialization** | Yes — `agent_tasks[agent_id]`; new request cancels previous (`tool_server.py:94-97`) | No — concurrent calls allowed; not needed anyway since SDK only emits next tool after current resolves | -| **One-failure-cancels-siblings** | N/A (sequential) | `isolate_parallel_failures=True` by default for multi-call turns (`tool_execution.py:1370`) | -| **Cancellation primitive** | `task.cancel()` on host; SIGTERM cancels all server tasks | `asyncio.shield(invoke_task)` (`tool_execution.py:1766`) + outer cancellation; `result.cancel()` for whole-run | -| **Timeout error format** | Returns `"Tool timed out after 120s"` string to the LLM | `default_tool_timeout_error_message(...)` string (or `ToolTimeoutError` if `timeout_behavior="raise_exception"`) | -| **Stateful-tool threading (browser)** | Dedicated daemon thread + own event loop, lock-serialized (`browser_instance.py:34-48`) | Whatever we build inside the tool function (we keep our existing approach) | - -### 5.2 Migration implications - -1. **Parallel tool calls become a feature.** Strix is sequential; SDK runs them concurrently. For the model emitting `terminal_execute("nmap ...")` + `web_search("CVE-X")` in one turn, this is faster. We verify reentrancy on: - - Browser singleton (already lock-serialized — fine). - - Terminal: per-`(agent_id, terminal_id)` tmux session (fine). - - Python: per-`(agent_id, session_id)` IPython kernel (fine). - - Notes/Todos: thread-safe via existing RLocks (fine). - -2. **We re-impose default timeouts via a small factory.** - - ```python - # strix/tools/_decorator.py - from agents import function_tool - - def strix_tool(*, timeout: float = 120, **kwargs): - """Strix-flavored function_tool with our defaults.""" - return function_tool( - timeout=timeout, - timeout_behavior="error_as_result", - **kwargs, - ) - ``` - - Used everywhere we'd write `@function_tool` today. - -3. **Sync code wraps in `asyncio.to_thread`.** Our existing libtmux / IPython / Caido sync code goes inside an `async def` tool body: - - ```python - @strix_tool(timeout=30) - async def terminal_execute(ctx, command: str, ...) -> str: - def _run(): - # libtmux sync code here - return session.send_keys(...) - return await asyncio.to_thread(_run) - ``` - - We lose the tool server's auto-offload-everything trick, but we gain explicit control. - -4. **Connect timeout becomes our responsibility** for sandbox-bound function tools: - - ```python - _SANDBOX_TIMEOUT = httpx.Timeout(timeout=150, connect=10) - - @strix_tool(timeout=160) # outer SDK timeout > inner httpx - async def _post_to_sandbox(tool_name, kwargs, ctx): - async with httpx.AsyncClient() as client: - r = await client.post(..., timeout=_SANDBOX_TIMEOUT) - return r.json() - ``` - -5. **Tool error formatting** — set a default `failure_error_function` on `RunConfig` to keep our existing `...` shape if we want it; otherwise the SDK's default error string is acceptable. - ---- - -## 6. Sandbox bridge - -### 6.1 Custom DockerSandboxClient (~80 LOC) - -The SDK's `DockerSandboxClient.create()` doesn't expose `cap_add` or `extra_hosts`. Subclass and inject: - -```python -# strix/runtime/strix_docker_client.py -from agents.sandbox.sandboxes.docker import DockerSandboxClient - -class StrixDockerSandboxClient(DockerSandboxClient): - """Adds NET_ADMIN, NET_RAW capabilities and host.docker.internal mapping - needed for raw-socket pentest tools and host-served-app testing.""" - - async def _create_container_kwargs(self, *args, **kwargs): - create_kwargs = await super()._create_container_kwargs(*args, **kwargs) - create_kwargs.setdefault("cap_add", []).extend(["NET_ADMIN", "NET_RAW"]) - create_kwargs.setdefault("extra_hosts", {})["host.docker.internal"] = "host-gateway" - return create_kwargs -``` - -(Exact override point depends on SDK internals — may need to wrap `containers.create` directly. ~80 LOC including verification + tests.) - -### 6.2 Caido as a Capability (~200 LOC) - -Caido stays in the image (Dockerfile + entrypoint don't change). On the SDK side, it becomes a custom `Capability`: - -```python -# strix/runtime/caido_capability.py -from agents.sandbox.capabilities import Capability - -class CaidoCapability(Capability): - async def process_manifest(self, manifest): - manifest.environment.update({ - "http_proxy": "http://127.0.0.1:48080", - "https_proxy": "http://127.0.0.1:48080", - "ALL_PROXY": "http://127.0.0.1:48080", - }) - - def tools(self): - return [list_requests, view_request, send_request, - repeat_request, scope_rules, list_sitemap, view_sitemap_entry] - - async def instructions(self, manifest): - return "All HTTP/HTTPS traffic in this sandbox is captured by Caido. ..." -``` - -### 6.3 Tool server stays put - -The FastAPI tool server keeps running on `:48081` inside the container. Each Strix tool becomes an `@strix_tool` that POSTs to it with our existing Bearer token. **Network isolation, Bearer auth, and the entire image build pipeline are unchanged.** What changes is only the host-side dispatcher: instead of `tools/executor.py`, it's `function_tool` bodies that call the same endpoint. - ---- - -## 7. What we still lose control over - -Smaller list than before. All accepted as tradeoffs. - -1. **Streaming early-truncate at ``.** Token waste on over-generating models. Custom Model wrapper if Bedrock economics matter; otherwise live with it. -2. **Per-chunk streaming timeout** (the Bedrock `60abc09` fix). Same answer — wrap if Bedrock matters. -3. **Mid-run system prompt mutation (`load_skill`).** Skills become tool outputs (model sees them in conversation history). Slightly more memory-compressor-eviction-prone. Acceptable. -4. **Anthropic prompt cache auto-injection.** Becomes per-agent manual `extra_body` setting via a small `make_anthropic_settings()` helper. -5. **Cost tracking.** SDK tracks tokens, not cost. Wire `litellm.completion_cost` in `on_llm_end` hook (~20 LOC). -6. **Vision-less model image stripping.** No automatic fallback. Wrap as Model subclass if non-vision providers matter. -7. **Identity injection in delegation** moves from XML to context dict. Equivalent — no real loss. - ---- - -## 8. What we gain - -Net upgrades from the SDK. Things we don't have today: - -| Gain | Detail | -|---|---| -| **Sessions / persistence** | 8+ backends (`SQLiteSession`, `RedisSession`, `SQLAlchemySession`, `MongoDBSession`, `DaprSession`, `EncryptedSession`, `OpenAIConversationsSession`, `OpenAIResponsesCompactionSession`). `RunState.to_json()` resumable runs. We currently have nothing. | -| **Three-layer guardrails** | `@input_guardrail` / `@output_guardrail` / `@tool_input_guardrail` / `@tool_output_guardrail` with `allow / reject_content / raise_exception` semantics. Our existing manual arg validation becomes a tool guardrail. | -| **Formal lifecycle hooks** | 7 explicit hooks (`on_llm_start/end`, `on_agent_start/end`, `on_handoff`, `on_tool_start/end`). Replaces our implicit tracer integration. | -| **Composable retry policies** | `retry_policies.any/provider_suggested/network_error/http_status(...)`. Cleaner than our hard-coded `min(90, 2*2^n)` loop. | -| **HITL approvals** | `@function_tool(needs_approval=True)`, `state.approve()/reject()` resume flow. We don't have this. | -| **Parallel tool calls within a turn** | Free speedup for multi-tool model turns. | -| **Native `tool_choice="required"` enforcement** | The hardened tool-call requirement (commit `4f90a56`) becomes a model setting. | -| **MCP support** | 4 transports — useful if we ever want to expose Strix tools to other agents (Claude.com, etc.). | -| **Built-in tracing dashboard** | When we send to the OpenAI backend (off by default for us). | -| **Active maintenance** | Backed by OpenAI; Strix's harness layer becomes mostly glue. | - ---- - -## 9. Effort estimate (revised) - -Wrapper / extension approach (no SDK forking). - -| Area | LOC | Days | -|---|---:|---:| -| `MultiProvider` config + Strix model alias `ModelProvider` | 60 | 0.5 | -| Anthropic cache-control helper (`make_anthropic_settings`) | 30 | 0.25 | -| Streaming early-truncate Model wrapper *(optional, if Bedrock matters)* | 100 | 1.5 | -| Per-chunk timeout Model wrapper *(optional)* | 100 | 1.5 | -| Vision-less `_strip_images` Model wrapper *(optional)* | 50 | 0.5 | -| Cost tracking via `on_llm_end` hook | 30 | 0.5 | -| Custom `Session` w/ memory compression + pentest summary prompt | 150 | 2 | -| `strix_tool` decorator (re-imposes our defaults) | 30 | 0.25 | -| Sandbox tool wrapper (httpx → tool server, Bearer auth, connect timeout) | 80 | 0.5 | -| Tool ports: browser (AsyncComputer), terminal (libtmux executor + asyncio.to_thread), python (IPython), proxy (7×), notes (5×), todos (6×), reporting (CVSS+dedup), web_search (Perplexity), file_edit (openhands-aci+rg), finish, think | 1500 | 7 | -| **Multi-agent: `MessageBus` + `inject_messages_filter` + 6 graph tools + `StrixOrchestrationHooks`** | **400** | **4** | -| `StrixDockerSandboxClient` subclass (`cap_add` + `extra_hosts`) | 80 | 0.5 | -| `CaidoCapability` (env vars + 7 Caido tools + instructions block) | 200 | 1 | -| Healthcheck polling layer | 30 | 0.25 | -| Per-agent state silo helper + ports of stateful tools to use it | 100 | 1 | -| Custom JSONL trace processor + OTel + scrubadub | 150 | 1.5 | -| Run-directory persistence (vulns/, notes/, wiki/, penetration_test_report.md) | 100 | 1 | -| Jinja-rendered `Agent.instructions` callable builder | 60 | 0.5 | -| Skill-loading workaround (skill content as tool output) | 60 | 0.5 | -| Config file → env-var bridge | 30 | 0.25 | -| TUI re-pointing at `Runner.run_streamed().stream_events()` | 200 | 2 | -| End-to-end tests against migrated harness (smoke + multi-agent + sandbox) | 400 | 4 | -| **Core (single-provider, no streaming optimizations)** | **~3,800** | **~25 days** | -| **Full parity (multi-provider + streaming optimizations)** | **~4,000–4,500** | **~30–35 days** | - ---- - -## 10. Migration plan (step-by-step) - -Branch: `harness-migration` (already cut). Spike-first; mainline-last. - -### Phase 1 — Foundation (~5 days) - -1. **Provider layer.** Wire `MultiProvider` + `LitellmModel` for Anthropic. Custom `ModelProvider` for Strix model aliases. Verify our existing models all resolve. -2. **`strix_tool` decorator.** Re-imposes our 120s default timeout + `error_as_result` behavior + structured error formatting. -3. **`StrixDockerSandboxClient`.** Subclass injecting `cap_add` + `extra_hosts`. Verify `nmap` works inside a session. -4. **Custom `Session`.** Port `MemoryCompressor` strategy. Validate against current production transcripts. -5. **Trace processor.** Custom JSONL exporter + scrubadub PII filter. Wire into `set_default_trace_processors()`. - -### Phase 2 — Tool ports (~8 days) - -Port one tool category at a time, with end-to-end tests after each: - -1. **Sandbox dispatcher** — single function tool that POSTs to FastAPI server. All sandbox-resident tools share this transport. -2. **Browser** as `ComputerTool` + `AsyncComputer` subclass that wraps existing Playwright code. -3. **Terminal** — `@strix_tool` wrapping libtmux behind `asyncio.to_thread`. -4. **Python** — `@strix_tool` wrapping IPython. -5. **Caido proxy** — 7 GraphQL tools. -6. **Notes / Todos / Reporting / Web search / File edit / Finish / Thinking** — straightforward `@strix_tool` ports. - -### Phase 3 — Multi-agent orchestration (~4 days) - -1. **`AgentMessageBus`** + tests. -2. **`inject_messages_filter`** + tests against synthetic message streams. -3. **`StrixOrchestrationHooks`** for stat aggregation + tracer wiring. -4. **6 graph tools** (`create_agent`, `send_message_to_agent`, `wait_for_message`, `agent_status`, `view_agent_graph`, `agent_finish`). -5. **End-to-end test**: root spawns 2 children in parallel, children exchange messages, both finish, root aggregates stats. Compare to today's baseline. - -### Phase 4 — Sandbox + Caido capability (~2 days) - -1. **`CaidoCapability`** wires env vars + 7 Caido tools + system-prompt instruction block. -2. **Healthcheck polling** loop after `client.create()`. -3. **Container reuse** keyed by scan_id (we own this map; SDK just gives us the session primitive). - -### Phase 5 — Interface + persistence (~3 days) - -1. **TUI** re-pointed at `Runner.run_streamed().stream_events()`. -2. **Run-directory layout** rebuilt as a custom processor + result-persistence layer. -3. **CLI flags** unchanged (we keep our argparse). -4. **Config file → env-var bridge** unchanged (we keep our `Config` class). - -### Phase 6 — Validation (~4 days) - -1. Smoke: every tool runs in a sandbox. -2. Multi-agent: parallel children + messaging + cancel. -3. Bedrock + Anthropic + OpenAI parity test. -4. Memory compression at 90K tokens. -5. PII redaction in traces. -6. Run an existing pentest end-to-end and diff outputs against the Strix baseline. - ---- - -## 11. Risks & open questions - -1. **`CallModelInputFilter` re-runs on every model call.** If we drain the inbox in the filter and the model call retries (e.g. retryable HTTP error), do we lose messages? Need to verify SDK retry behavior — does `call_model_input_filter` re-run on retry, or does the filtered input get cached for the retry? **Action: read `run_internal/turn_preparation.py:55-80` + retry path before Phase 3.** If messages would be lost, the fix is to drain into a per-call buffer that only commits on successful response. - -2. **`Session` semantics under parallel children.** When children share the same `Session` for sandbox state, do their LLM histories cross-contaminate? Children should use distinct logical sessions for history (per-agent) but share the sandbox session. **Action: verify `Session` and `SandboxRunConfig.session` are independent — they are, but write a test.** - -3. **`isolate_parallel_failures=True` default.** When the model emits multiple tool calls in one turn and one fails, all siblings get cancelled. We may want `False` for our use case (a failed `nmap` shouldn't kill an in-flight `web_search`). **Action: configure per `RunConfig` once we see real behavior.** - -4. **Sandbox tool concurrency under parallel calls.** Today's tool server has per-agent task slot serialization (one tool in flight per agent). Under SDK's parallel-tool-calls model, we'd issue multiple POSTs concurrently for the same agent. Tool server's current behavior is to **cancel the previous task** (`tool_server.py:94-97`), which would break us. **Action: relax tool server to allow concurrent same-agent tool calls, OR set `parallel_tool_calls=False` on `ModelSettings` and stay sequential.** The latter is the safer migration default; revisit later. - -5. **Bedrock per-chunk-timeout regression.** Without the custom Model wrapper, Bedrock TCP-stalls return as a class of failure. **Action: decide whether Bedrock matters enough to invest the 1.5 days. If it does, build the wrapper in Phase 1.** - -6. **Streaming early-exit at `` cost.** Wasted tokens on every multi-turn for over-generating models. Quantify against a representative scan; if cost delta is small, skip the wrapper. - -7. **Memory-compressor eviction risk for tool-output skills.** When `load_skill` returns content as tool output, the compressor may summarize the skill content into oblivion after 15+ messages. **Action: tag skill-load tool outputs in the conversation and configure the compressor to preserve them.** - ---- - -*Revision 2 — incorporates: (a) `call_model_input_filter`-based multi-agent bridge; (b) accepted tradeoffs on XML / skills / sandbox subclass; (c) tool execution semantic deltas (parallel by default, no default timeouts, no auto-offload).* diff --git a/PLAYBOOK.md b/PLAYBOOK.md deleted file mode 100644 index 436cd97..0000000 --- a/PLAYBOOK.md +++ /dev/null @@ -1,1401 +0,0 @@ -# Strix → OpenAI Agents SDK Migration Playbook - -> The day-1 engineering reference. Distillation of two audit rounds and four implementation deep-dives into actionable specs. All SDK references verified against `openai-agents` v0.14.6 at `/tmp/openai-agents`. Strix references at `9fb1012`. - ---- - -## Reading order - -1. `HARNESS_WIKI.md` — what we have today. -2. `MIGRATION_EVALUATION.md` rev 2 — the architectural plan. -3. `AUDIT.md` — first audit; five plan corrections (C1–C5). -4. `AUDIT_R2.md` — Round 1 audit; seven additional corrections (C6–C12). -5. **This file** — file-by-file specs, per-tool contracts, test plans, standards. - ---- - -## Table of contents - -1. [Consolidated corrections register](#1-consolidated-corrections-register) -2. [Foundation files (concrete skeletons)](#2-foundation-files-concrete-skeletons) -3. [Sandbox + Caido capability](#3-sandbox--caido-capability) -4. [Per-tool migration contracts](#4-per-tool-migration-contracts) -5. [Standards: logging, error formatting, observability](#5-standards-logging-error-formatting-observability) -6. [Test plans per phase](#6-test-plans-per-phase) -7. [Rollback, CI, cross-cutting](#7-rollback-ci-cross-cutting) -8. [Phase ordering and day-1 commit list](#8-phase-ordering-and-day-1-commit-list) - ---- - -## 1. Consolidated corrections register - -Twelve corrections to apply, ordered by phase. Every fix is bounded and source-verified. - -| # | Severity | Defect | Fix shape | Apply in | -|---|---|---|---|---| -| **C1** | Blocker | SDK runs tools in parallel within a turn (`run_internal/tool_execution.py:1414, 1424`); Strix tool server cancels previous task for same agent (`tool_server.py:94-97`). | Phase 1 safe default: `ModelSettings(parallel_tool_calls=False)` + `RunConfig(isolate_parallel_failures=False)`. Phase 6 relaxation: remove the cancel logic, allow concurrent same-agent tool calls. | Phase 1 / 6 | -| **C2** | Blocker | Plan §3.2 said `extra_body["cache_control"]` injects Anthropic prompt cache. Verified — that lands at request level, not on system message. Caches nothing. | `AnthropicCachingLitellmModel` subclass — override `get_response`/`stream_response` to inject `cache_control` on the system message before super delegation. | Phase 0 | -| **C3** | Blocker | `DockerSandboxClient._create_container()` (`sandbox/sandboxes/docker.py:1434-1477`) has no kwarg-injection hook. | Subclass + duplicate parent body verbatim, append `create_kwargs.setdefault("cap_add", []).extend(["NET_ADMIN","NET_RAW"])` and `create_kwargs.setdefault("extra_hosts", {})["host.docker.internal"] = "host-gateway"`. Pin SDK version. | Phase 0 | -| **C4** | Blocker | Subagent `agent_finish` returns from a tool; SDK loop continues to `max_turns` unless told to stop. | Every child Agent: `tool_use_behavior={"stop_at_tool_names": ["agent_finish"]}`. Root Agent: `tool_use_behavior={"stop_at_tool_names": ["finish_scan"]}`. | Phase 3 | -| **C5** | High | TUI today polls `tracer.streaming_content` per-chunk; SDK's `Runner.run_streamed().stream_events()` is event-driven. | `StrixStreamAccumulator` consumes `RawResponsesStreamEvent` + `RunItemStreamEvent` and synthesizes legacy tracer API. Hooks bridge for non-streamed children. | Phase 5 | -| **C6** | Critical | `notes/notes_actions.py:_append_note_event` writes JSONL without lock. Two concurrent agents corrupt file. | Wrap `f.write(...)` in `with _notes_lock:`. Same for `_persist_wiki_note()`. | Phase 2 | -| **C7** | Critical | `telemetry/tracer.py:_append_event_record` calls `append_jsonl_record` without acquiring `_get_events_write_lock()`. | Wrap append in the existing lock. Apply identically in our custom `TracingProcessor`. | Phase 1 | -| **C8** | High | Subagent crash in daemon thread is silent — parent's `wait_for_message` polls forever. Same shape post-migration if a child `Runner.run` task raises. | `StrixOrchestrationHooks.on_agent_end` detects crash (output is None or `agent_finish` flag absent in context); pushes synthetic `` message to parent inbox so filter surfaces it. | Phase 3 | -| **C9** | High | Root cancellation does NOT cascade to children spawned via `asyncio.create_task` in `create_agent` tool. | `cancel_run_with_descendants(bus, root_id)` walks `bus.parent_of` tree leaf-first and `task.cancel()`s each. Wired to CLI signal handler + TUI stop. | Phase 3 | -| **C10** | Medium | Memory compressor LLM call exception bubbles to agent loop, killing the whole iteration. Defeats the purpose. | Custom `Session` wraps compressor invocation in try/except; on failure, returns uncompressed history. Downstream context-window error is itself retryable. | Phase 1 | -| **C11** | Medium | Strix today fails fast on 401/403/400. SDK retry policy default may include them. | Explicit `ModelRetrySettings.policy = retry_policies.any(network_error(), http_status([429,500,502,503,504]))` — note 401/403/400 NOT included. Bake into `make_run_config()` factory. | Phase 1 | -| **C12** | Medium | `_completed_agent_llm_totals` read by tracer without lock. | Bus `total_stats()` reads under `asyncio.Lock`; tracer goes through bus. | Phase 3 (in design) | - ---- - -## 2. Foundation files (concrete skeletons) - -Seven load-bearing modules, plus three supporting ones. Every skeleton is non-stub — copy-edit, fill, ship. - -### 2.1 `strix/llm/anthropic_cache_wrapper.py` - -```python -from typing import Any -from agents.extensions.models.litellm_model import LitellmModel -from agents.items import ModelResponse, TResponseInputItem -from agents.models.interface import ModelTracing -from agents.model_settings import ModelSettings -from agents.tool import Tool -from agents.handoffs import Handoff -from agents.agent_output import AgentOutputSchemaBase -from openai.types.responses.response_prompt_param import ResponsePromptParam - - -class AnthropicCachingLitellmModel(LitellmModel): - """Inject cache_control on the system message for Anthropic models. - - Why: SDK ModelSettings.extra_body lands cache_control at request level, - not on the system message — Anthropic only caches when cache_control is - attached to the message itself. - """ - - def _is_anthropic(self) -> bool: - m = self.model.lower() - return "anthropic/" in m or "claude" in m - - def _patch(self, items: list[TResponseInputItem]) -> list[TResponseInputItem]: - if not self._is_anthropic(): - return items - out: list[TResponseInputItem] = [] - for item in items: - if isinstance(item, dict) and item.get("role") == "system": - content = item["content"] - if isinstance(content, str): - content = [{ - "type": "text", "text": content, - "cache_control": {"type": "ephemeral"}, - }] - out.append({**item, "content": content}) - else: - out.append(item) - return out - - # F1 (AUDIT_R3): SDK Model.get_response declares the first 7 params positional-first. - async def get_response( - self, - system_instructions, - input, - model_settings, - tools, - output_schema, - handoffs, - tracing, - *, - previous_response_id=None, - conversation_id=None, - prompt=None, - ) -> ModelResponse: - patched = self._patch(input if isinstance(input, list) else [input]) - return await super().get_response( - system_instructions, - patched, - model_settings, - tools, - output_schema, - handoffs, - tracing, - previous_response_id=previous_response_id, - conversation_id=conversation_id, - prompt=prompt, - ) - - async def stream_response( - self, - system_instructions, - input, - model_settings, - tools, - output_schema, - handoffs, - tracing, - *, - previous_response_id=None, - conversation_id=None, - prompt=None, - ): - patched = self._patch(input if isinstance(input, list) else [input]) - async for ev in super().stream_response( - system_instructions, - patched, - model_settings, - tools, - output_schema, - handoffs, - tracing, - previous_response_id=previous_response_id, - conversation_id=conversation_id, - prompt=prompt, - ): - yield ev -``` - -**Tests:** assert system message acquires `cache_control` for Anthropic; assert non-Anthropic passes through; `cache_read_input_tokens > 0` on call 2 of identical-prompt sequence. - ---- - -### 2.2 `strix/runtime/strix_docker_client.py` - -```python -import uuid -from typing import Any -from docker.models.containers import Container -from agents.sandbox.sandboxes.docker import ( - DockerSandboxClient, - _build_docker_volume_mounts, - _manifest_requires_fuse, - _manifest_requires_sys_admin, - _docker_port_key, - parse_repository_tag, -) -from agents.sandbox.manifest import Manifest - - -class StrixDockerSandboxClient(DockerSandboxClient): - """Adds NET_ADMIN, NET_RAW capabilities and host.docker.internal mapping. - - Body is a verbatim copy of DockerSandboxClient._create_container - (sandbox/sandboxes/docker.py:1434-1477) with two appends before the - final containers.create() call. SDK has no kwarg-injection hook; - pin openai-agents version, re-merge on bump. - """ - - async def _create_container( - self, - image: str, - *, - manifest: Manifest | None = None, - exposed_ports: tuple[int, ...] = (), - session_id: uuid.UUID | None = None, - ) -> Container: - if not self.image_exists(image): - repo, tag = parse_repository_tag(image) - self.docker_client.images.pull(repo, tag=tag or None, all_tags=False) - - environment: dict[str, str] | None = None - if manifest: - environment = await manifest.environment.resolve() - - create_kwargs: dict[str, Any] = { - "entrypoint": ["tail"], - "image": image, - "detach": True, - "command": ["-f", "/dev/null"], - "environment": environment, - } - - if manifest is not None: - mounts = _build_docker_volume_mounts(manifest, session_id=session_id) - if mounts: - create_kwargs["mounts"] = mounts - if _manifest_requires_fuse(manifest): - create_kwargs.update( - devices=["/dev/fuse"], - cap_add=["SYS_ADMIN"], - security_opt=["apparmor:unconfined"], - ) - elif _manifest_requires_sys_admin(manifest): - create_kwargs.update( - cap_add=["SYS_ADMIN"], - security_opt=["apparmor:unconfined"], - ) - - if exposed_ports: - create_kwargs["ports"] = { - _docker_port_key(p): ("127.0.0.1", None) for p in exposed_ports - } - - # ---- STRIX additions ---- - create_kwargs.setdefault("cap_add", []).extend(["NET_ADMIN", "NET_RAW"]) - create_kwargs.setdefault("extra_hosts", {})["host.docker.internal"] = "host-gateway" - # ------------------------- - - return self.docker_client.containers.create(**create_kwargs) -``` - -**Tests:** mock `docker_client.containers.create`; assert kwargs contain `cap_add ⊇ {NET_ADMIN, NET_RAW}` and `extra_hosts["host.docker.internal"] == "host-gateway"`. Live test: spin up real container, run `nmap -sS scanme.nmap.org` via `session.exec`, assert exit 0. - ---- - -### 2.3 `strix/orchestration/bus.py` - -```python -import asyncio -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class AgentMessageBus: - inboxes: dict[str, list[dict]] = field(default_factory=dict) - tasks: dict[str, asyncio.Task] = field(default_factory=dict) - statuses: dict[str, str] = field(default_factory=dict) # running|waiting|completed|crashed|stopped - parent_of: dict[str, str | None] = field(default_factory=dict) - names: dict[str, str] = field(default_factory=dict) - stats_live: dict[str, dict[str, Any]] = field(default_factory=dict) - stats_completed: dict[str, dict[str, Any]] = field(default_factory=dict) - _lock: asyncio.Lock = field(default_factory=asyncio.Lock) - - async def register(self, agent_id: str, name: str, parent_id: str | None) -> None: - async with self._lock: - self.inboxes[agent_id] = [] - self.statuses[agent_id] = "running" - self.parent_of[agent_id] = parent_id - self.names[agent_id] = name - self.stats_live[agent_id] = {"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0} - - async def send(self, target: str, msg: dict) -> None: - async with self._lock: - self.inboxes.setdefault(target, []).append(msg) - - async def drain(self, agent_id: str) -> list[dict]: - async with self._lock: - msgs = self.inboxes.get(agent_id, []) - self.inboxes[agent_id] = [] - return msgs - - async def record_usage(self, agent_id: str, usage) -> None: - if usage is None: - return - async with self._lock: - s = self.stats_live.setdefault(agent_id, {"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0}) - s["in"] += getattr(usage, "input_tokens", 0) or 0 - s["out"] += getattr(usage, "output_tokens", 0) or 0 - details = getattr(usage, "input_tokens_details", None) - s["cached"] += getattr(details, "cached_tokens", 0) or 0 if details else 0 - s["calls"] += 1 - - async def finalize(self, agent_id: str, status: str) -> None: - # C13 (AUDIT_R3): clear inbox/parent/name to avoid orphaned-message memory leak - # when sibling agents try to send to a finished agent. - async with self._lock: - self.statuses[agent_id] = status - self.stats_completed[agent_id] = self.stats_live.pop(agent_id, {}) - self.inboxes.pop(agent_id, None) - self.parent_of.pop(agent_id, None) - self.names.pop(agent_id, None) - - async def total_stats(self) -> dict[str, Any]: - async with self._lock: - agg = {"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0} - for s in (*self.stats_live.values(), *self.stats_completed.values()): - for k, v in s.items(): - agg[k] = agg.get(k, 0) + v - return agg - - async def cancel_descendants(self, root_agent_id: str) -> None: - """Walk parent_of tree leaf-first; cancel each task. (C9)""" - async with self._lock: - queue = [root_agent_id] - order: list[str] = [] - while queue: - aid = queue.pop() - order.append(aid) - queue.extend(c for c, p in self.parent_of.items() if p == aid) - tasks = [self.tasks[a] for a in reversed(order) if a in self.tasks] - for t in tasks: - if not t.done(): - t.cancel() - await asyncio.gather(*[t for t in tasks if not t.done()], return_exceptions=True) -``` - -**Tests:** stress concurrent `send`/`drain`; FIFO ordering; `cancel_descendants` cancels children before parent; `total_stats` snapshot is consistent. - ---- - -### 2.4 `strix/orchestration/filter.py` - -```python -import logging -from agents.run_config import CallModelData, ModelInputData - -logger = logging.getLogger(__name__) - - -async def inject_messages_filter(data: CallModelData) -> ModelInputData: - """Drain bus inbox; append as user-role items wrapped in . - - Filter runs once per turn; output captured for retries (verified) — safe to drain. - C14 (AUDIT_R3): wrap whole body in try/except so a filter bug never tears down - the run. On any exception, return unmodified data.model_data. - """ - try: - if not isinstance(data.context, dict): - return data.model_data - bus = data.context.get("bus") - agent_id = data.context.get("agent_id") - if bus is None or agent_id is None: - return data.model_data - pending = await bus.drain(agent_id) - if not pending: - return data.model_data - - new_input = list(data.model_data.input) - for msg in pending: - sender = msg.get("from", "unknown") - if sender == "user": - new_input.append({"role": "user", "content": msg["content"]}) - else: - new_input.append({ - "role": "user", - "content": ( - f"" - f"{msg['content']}" - f"" - ), - }) - return ModelInputData(input=new_input, instructions=data.model_data.instructions) - except Exception: - logger.exception("inject_messages_filter failed; proceeding without injection") - return data.model_data -``` - -**Tests:** N pending messages → N items appended in FIFO; user-from-user skips XML wrap; empty inbox passes through; messages preserved across forced LLM retry. - ---- - -### 2.5 `strix/orchestration/hooks.py` - -```python -import logging -from agents.lifecycle import RunHooks -# F2 (AUDIT_R3): on_agent_start/on_agent_end receive AgentHookContext, NOT RunContextWrapper. -from agents.lifecycle import AgentHookContext # type: ignore[attr-defined] -from agents.run_context import RunContextWrapper - -logger = logging.getLogger(__name__) - - -class StrixOrchestrationHooks(RunHooks): - # C15: every hook body wrapped in try/except so a hook bug never tears down the run. - - async def on_llm_start( - self, - context: RunContextWrapper, - agent, - system_prompt: str | None, - input_items: list, - ) -> None: - try: - if not isinstance(input_items, list): - return - max_turns = context.context.get("max_turns", 300) - cur = context.context.get("turn_count", 0) - if cur == int(max_turns * 0.85): - input_items.append({ - "role": "user", - "content": "You are at 85% of your iteration " - "budget. Begin consolidating findings.", - }) - elif cur == max_turns - 3: - input_items.append({ - "role": "user", - "content": "You have 3 iterations left. Your next " - "tool call MUST be the finish tool.", - }) - except Exception: - logger.exception("on_llm_start failed") - - async def on_llm_end(self, context: RunContextWrapper, agent, response) -> None: - try: - bus = context.context.get("bus") - if bus and (aid := context.context.get("agent_id")): - await bus.record_usage(aid, getattr(response, "usage", None)) - context.context["turn_count"] = context.context.get("turn_count", 0) + 1 - except Exception: - logger.exception("on_llm_end failed") - - async def on_agent_start(self, context: AgentHookContext, agent) -> None: - # F2: context is AgentHookContext, not RunContextWrapper. - try: - cap = next( - (c for c in (getattr(agent, "capabilities", None) or []) - if hasattr(c, "_healthcheck_task")), - None, - ) - if cap and getattr(cap, "_healthcheck_task", None) is not None: - await cap._healthcheck_task - except Exception: - logger.exception("on_agent_start failed") - - async def on_agent_end(self, context: AgentHookContext, agent, output) -> None: - # F2: context is AgentHookContext. - try: - bus = context.context.get("bus") - if bus is None or (me := context.context.get("agent_id")) is None: - return - crashed = (output is None) or not context.context.get("agent_finish_called", False) - parent = bus.parent_of.get(me) - if crashed and parent is not None: - await bus.send(parent, { - "from": me, - "content": ( - f"" - "Agent terminated without calling agent_finish. " - "Stop waiting on this child." - "" - ), - "type": "crash", - }) - await bus.finalize(me, "crashed" if crashed else "completed") - except Exception: - logger.exception("on_agent_end failed") - - async def on_tool_start(self, context: RunContextWrapper, agent, tool) -> None: - try: - if tracer := context.context.get("tracer"): - tracer.log_tool_start(context.context.get("agent_id", "?"), tool.name) - except Exception: - logger.exception("on_tool_start failed") - - # F2: on_tool_end's `result` param is typed `str` in the SDK. - async def on_tool_end( - self, context: RunContextWrapper, agent, tool, result: str, - ) -> None: - try: - if tool.name in ("agent_finish", "finish_scan"): - context.context["agent_finish_called"] = True - if tracer := context.context.get("tracer"): - tracer.log_tool_end(context.context.get("agent_id", "?"), tool.name, result) - except Exception: - logger.exception("on_tool_end failed") - - async def on_handoff(self, context: RunContextWrapper, from_agent, to_agent) -> None: - # We don't use SDK handoffs in Strix; multi-agent goes through bus. - pass -``` - -**Tests:** crash detection fires when agent_finish not called; warnings injected at thresholds; usage recording aggregates. - ---- - -### 2.6 `strix/tools/_decorator.py` - -```python -from agents import function_tool - - -def strix_tool(*, timeout: float = 120.0, timeout_behavior: str = "error_as_result", **kwargs): - """function_tool with Strix defaults: 120s timeout, error-as-result behavior. - - SDK auto-threads sync function bodies via asyncio.to_thread (tool.py:1820-1829), - so writing `def foo(...)` (sync) works for libtmux/IPython/etc. - """ - return function_tool( - timeout=timeout, - timeout_behavior=timeout_behavior, - **kwargs, - ) -``` - -**Tests:** decorated tool gets timeout; timeout returns error string not exception; sync function auto-threads. - ---- - -### 2.7 `strix/llm/multi_provider_setup.py` - -```python -from agents.models.multi_provider import MultiProvider, MultiProviderMap -from agents.models.interface import ModelProvider, Model -from agents.extensions.models.litellm_model import LitellmModel -from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel - -# Strix model alias map -STRIX_MODEL_MAP = { - "claude-sonnet-4.6": ("anthropic/claude-sonnet-4-5-20250929", "claude-sonnet-4-5"), - # ... port from strix/llm/utils.py:STRIX_MODEL_MAP -} -STRIX_API_BASE = "https://models.strix.ai/api/v1" - - -class StrixModelProvider(ModelProvider): - """Resolve `strix/` aliases via STRIX_MODEL_MAP, route to proxy.""" - - def get_model(self, model_name: str | None) -> Model: - if model_name is None: - raise ValueError("Model name required") - api_model, _canonical = STRIX_MODEL_MAP.get(model_name, (model_name, model_name)) - # Anthropic-prefixed → cache wrapper; otherwise vanilla - if api_model.startswith("anthropic/") or "claude" in api_model.lower(): - return AnthropicCachingLitellmModel(model=api_model, base_url=STRIX_API_BASE) - return LitellmModel(model=api_model, base_url=STRIX_API_BASE) - - -class LitellmAnthropicProvider(ModelProvider): - """Resolve `litellm/anthropic/...` directly to AnthropicCachingLitellmModel.""" - - def get_model(self, model_name: str | None) -> Model: - # model_name arrives post-prefix-strip, e.g. "anthropic/claude-3-5-sonnet" - return AnthropicCachingLitellmModel(model=model_name) - - -def build_multi_provider() -> MultiProvider: - pmap = MultiProviderMap() - pmap.add_provider("strix", StrixModelProvider()) - pmap.add_provider("litellm/anthropic", LitellmAnthropicProvider()) - # default openai/* and others fall through to MultiProvider's built-in routing - return MultiProvider(provider_map=pmap) -``` - -**Tests:** alias resolution; route `strix/claude-sonnet-4.6` → `AnthropicCachingLitellmModel("anthropic/claude-sonnet-4-5-20250929", base_url=STRIX_API_BASE)`; unknown prefix falls through. - ---- - -### 2.8 `strix/llm/strix_session.py` - -```python -import logging -from typing import Any -from agents.memory.session import SessionABC - -logger = logging.getLogger(__name__) - - -class StrixSession(SessionABC): - """Wraps an underlying Session; injects memory compression at 90K tokens. - - On compressor failure: returns uncompressed history (C10) and logs warning. - Downstream context-window error is itself retryable. - """ - - def __init__(self, underlying: SessionABC, compressor): - self._underlying = underlying - self._compressor = compressor # Strix's existing MemoryCompressor - - async def get_items(self, limit: int | None = None) -> list[Any]: - items = await self._underlying.get_items(limit=limit) - try: - return await self._compressor.compress_history(items) - except Exception as e: - logger.warning("Memory compression failed (%s) — returning uncompressed", e) - return items - - async def add_items(self, items: list[Any]) -> None: - await self._underlying.add_items(items) - - async def pop_item(self) -> Any | None: - return await self._underlying.pop_item() - - async def clear_session(self) -> None: - await self._underlying.clear_session() -``` - -**Tests:** compression triggers > 90K tokens; failure returns uncompressed; underlying contract satisfied. - ---- - -### 2.9 `strix/telemetry/strix_processor.py` - -```python -import json -import logging -import threading -from pathlib import Path -from typing import Any -from agents.tracing.processor_interface import TracingProcessor -from agents.tracing.spans import Span -from agents.tracing.traces import Trace -from strix.telemetry.utils import TelemetrySanitizer - -logger = logging.getLogger(__name__) - -_FILE_LOCKS: dict[Path, threading.Lock] = {} -_GUARD = threading.Lock() - - -def _lock_for(path: Path) -> threading.Lock: - with _GUARD: - return _FILE_LOCKS.setdefault(path, threading.Lock()) - - -class StrixTracingProcessor(TracingProcessor): - """Custom processor: writes events.jsonl in our schema, scrubs PII, supports OTel. - - C7 — JSONL writes locked via per-path threading.Lock. - C16 — every write wrapped in try/except so disk-full doesn't tear down the run. - F3 — every hook method is SYNC (def, not async def) per SDK contract. - """ - - def __init__(self, run_dir: Path, sanitizer: TelemetrySanitizer | None = None): - self.events_path = run_dir / "events.jsonl" - self.run_dir = run_dir - self.sanitizer = sanitizer or TelemetrySanitizer() - - def _emit(self, event: dict[str, Any]) -> None: - try: - clean = self.sanitizer.sanitize(event) - with _lock_for(self.events_path): - with self.events_path.open("a", encoding="utf-8") as f: - f.write(json.dumps(clean, ensure_ascii=True) + "\n") - except OSError: - logger.exception("Failed to write event to JSONL") - - def on_trace_start(self, trace: Trace) -> None: # SYNC — F3 - self._emit({ - "event_type": "run.started", - "trace_id": trace.trace_id, - "metadata": getattr(trace, "metadata", {}), - }) - - def on_trace_end(self, trace: Trace) -> None: # SYNC — F3 - self._emit({"event_type": "run.completed", "trace_id": trace.trace_id}) - - def on_span_start(self, span: Span[Any]) -> None: # SYNC — F3 - sd = type(span.span_data).__name__ - if sd in ("AgentSpanData", "GenerationSpanData", "FunctionSpanData"): - self._emit({ - "event_type": f"{sd.replace('SpanData', '').lower()}.started", - "span_id": span.span_id, - "trace_id": span.trace_id, - "data": span.span_data.export(), - }) - - def on_span_end(self, span: Span[Any]) -> None: # SYNC — F3 - sd = type(span.span_data).__name__ - self._emit({ - "event_type": f"{sd.replace('SpanData', '').lower()}.completed", - "span_id": span.span_id, - "trace_id": span.trace_id, - "data": span.span_data.export(), - }) - - def force_flush(self) -> None: # SYNC — F3 - # All writes are sync; nothing to flush. - pass - - def shutdown(self) -> None: # SYNC — F3 - pass -``` - -**Tests:** concurrent writes don't corrupt; PII scrubbed; OTel passthrough optional. - ---- - -### 2.10 `strix/run_config_factory.py` - -```python -from pathlib import Path -from agents import RunConfig -from agents.sandbox import SandboxRunConfig -from agents.model_settings import ModelSettings -from agents.retry import ModelRetrySettings, ModelRetryBackoffSettings, retry_policies - -from strix.runtime.strix_docker_client import StrixDockerSandboxClient -from strix.orchestration.bus import AgentMessageBus -from strix.orchestration.filter import inject_messages_filter -from strix.llm.multi_provider_setup import build_multi_provider - - -# Phase 1-5 default. Phase 6 flips parallel_tool_calls=True after relaxing tool server. -_PHASE1_PARALLEL_DEFAULT = False - - -def make_run_config( - *, - sandbox_session, - bus: AgentMessageBus, - model: str = "strix/claude-sonnet-4.6", - max_turns: int = 300, -) -> RunConfig: - return RunConfig( - model=model, - model_provider=build_multi_provider(), - model_settings=ModelSettings( - parallel_tool_calls=_PHASE1_PARALLEL_DEFAULT, # C1 safe default - tool_choice="required", - retry=ModelRetrySettings( - max_retries=5, - backoff=ModelRetryBackoffSettings( - initial_delay=2.0, multiplier=2.0, max_delay=90.0, jitter=0.0, - ), - # C11: explicitly does NOT include 401/403/400 - policy=retry_policies.any( - retry_policies.network_error(), - retry_policies.http_status([429, 500, 502, 503, 504]), - ), - ), - ), - sandbox=SandboxRunConfig( - client=StrixDockerSandboxClient(), - session=sandbox_session, # shared across all agents in the run - ), - call_model_input_filter=inject_messages_filter, - isolate_parallel_failures=False, # C1 — don't cascade-cancel siblings - tracing_disabled=False, - trace_include_sensitive_data=False, - max_turns=max_turns, - ) - - -def make_agent_context( - *, - bus: AgentMessageBus, - sandbox_session, - sandbox_token: str, - tool_server_host_port: int, - caido_host_port: int, - agent_id: str, - agent_name: str, - parent_id: str | None, - tracer, - model_settings, - max_turns: int = 300, -) -> dict: - return { - "bus": bus, - "sandbox_session": sandbox_session, - "sandbox_token": sandbox_token, - "tool_server_host_port": tool_server_host_port, - "caido_host_port": caido_host_port, - "agent_id": agent_id, - "agent_name": agent_name, - "parent_id": parent_id, - "tracer": tracer, - "model_settings": model_settings, - "max_turns": max_turns, - "turn_count": 0, - "agent_finish_called": False, - } -``` - -**Tests:** RunConfig has all defaults; retry policy excludes 401; safe defaults for parallel. - ---- - -## 3. Sandbox + Caido capability - -### 3.1 `strix/sandbox/healthcheck.py` - -```python -import asyncio -import httpx - - -class SandboxNotReadyError(Exception): - pass - - -async def wait_for_ports_ready(ports: list[int], timeout: float = 30.0, interval: float = 0.5) -> None: - deadline = asyncio.get_event_loop().time() + timeout - while asyncio.get_event_loop().time() < deadline: - all_ok = True - async with httpx.AsyncClient(timeout=5.0) as client: - for port in ports: - try: - r = await client.get(f"http://localhost:{port}/health") - if r.status_code != 200: - all_ok = False - break - except (httpx.RequestError, httpx.TimeoutException): - all_ok = False - break - if all_ok: - return - await asyncio.sleep(interval) - raise SandboxNotReadyError(f"Ports {ports} not ready after {timeout}s") -``` - -### 3.2 `strix/sandbox/caido_capability.py` - -```python -import asyncio -from typing import Literal -from pydantic import Field -from agents.sandbox.capabilities.capability import Capability -from agents.sandbox.manifest import Manifest -from agents.sandbox.session.base_sandbox_session import BaseSandboxSession -from agents.tool import Tool - -# 7 Caido tools defined in strix/tools/caido/*.py and imported here -from strix.tools.caido import ( - list_requests, view_request, send_request, repeat_request, - scope_rules, list_sitemap, view_sitemap_entry, -) - - -class CaidoCapability(Capability): - """Caido HTTPS proxy + 7 GraphQL function tools + system prompt block.""" - - type: Literal["caido"] = "caido" - _healthcheck_task: asyncio.Task | None = Field(default=None, exclude=True) - - def process_manifest(self, manifest: Manifest) -> Manifest: - env = dict(manifest.environment.value or {}) - env.update({ - "http_proxy": "http://127.0.0.1:48080", - "https_proxy": "http://127.0.0.1:48080", - "ALL_PROXY": "http://127.0.0.1:48080", - }) - manifest.environment.value = env - return manifest - - def tools(self) -> list[Tool]: - return [list_requests, view_request, send_request, repeat_request, - scope_rules, list_sitemap, view_sitemap_entry] - - async def instructions(self, manifest: Manifest) -> str | None: - return ( - "\n" - "All HTTP/HTTPS traffic in this sandbox is captured by Caido (localhost:48080).\n" - "Available tools: list_requests, view_request, send_request, repeat_request, " - "scope_rules, list_sitemap, view_sitemap_entry.\n" - "HTTPQL filter syntax: request.method == 'POST' && response.status >= 400.\n" - "" - ) - - def bind(self, session: BaseSandboxSession) -> None: - super().bind(session) - from strix.sandbox.healthcheck import wait_for_ports_ready - # Phase 4: caido :48080 + tool server :48081 - self._healthcheck_task = asyncio.create_task(wait_for_ports_ready([48080, 48081])) -``` - -`StrixOrchestrationHooks.on_agent_start` awaits `cap._healthcheck_task` (already in 2.5 above). - -### 3.3 `strix/sandbox/session_manager.py` - -```python -import socket -import secrets -from pathlib import Path -from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions -from agents.sandbox.manifest import Manifest -from agents.sandbox.entries import LocalDir -from strix.runtime.strix_docker_client import StrixDockerSandboxClient -from strix.sandbox.caido_capability import CaidoCapability -from strix.sandbox.healthcheck import wait_for_ports_ready - -_session_cache = {} # scan_id -> session - - -def _alloc_port() -> int: - s = socket.socket() - s.bind(("127.0.0.1", 0)) - p = s.getsockname()[1] - s.close() - return p - - -async def create_or_reuse(scan_id: str, image: str, sources_path: Path): - if scan_id in _session_cache: - return _session_cache[scan_id] - - bearer = secrets.token_urlsafe(32) - manifest = Manifest( - entries={"sources": LocalDir(src=sources_path)}, - environment={ - "TOOL_SERVER_TOKEN": bearer, - "TOOL_SERVER_PORT": "48081", - "CAIDO_PORT": "48080", - "STRIX_SANDBOX_EXECUTION_TIMEOUT": "120", - "PYTHONUNBUFFERED": "1", - }, - capabilities=[CaidoCapability()], - ) - - client = StrixDockerSandboxClient() - options = DockerSandboxClientOptions(image=image, exposed_ports=(48080, 48081)) - session = await client.create(options=options, manifest=manifest) - - # Resolve mapped host ports (sandbox/sandboxes/docker.py:211-260) - tool_server_endpoint = await session._resolve_exposed_port(48081) - caido_endpoint = await session._resolve_exposed_port(48080) - await wait_for_ports_ready([tool_server_endpoint.port, caido_endpoint.port]) - - bundle = { - "client": client, - "session": session, - "tool_server_host_port": tool_server_endpoint.port, - "caido_host_port": caido_endpoint.port, - "bearer": bearer, - } - _session_cache[scan_id] = bundle - return bundle - - -async def cleanup(scan_id: str) -> None: - bundle = _session_cache.pop(scan_id, None) - if bundle is None: - return - try: - await bundle["client"].delete(bundle["session"]) - except Exception: - pass # best-effort orphan reaping -``` - -### 3.4 `strix/tools/_sandbox_dispatch.py` - -```python -import httpx -from typing import Any -from agents import RunContextWrapper - -_TIMEOUT = httpx.Timeout(connect=10.0, read=150.0, write=150.0, pool=150.0) - - -async def post_to_sandbox( - ctx: RunContextWrapper, tool_name: str, kwargs: dict[str, Any], -) -> dict[str, Any]: - """POST tool invocation to FastAPI tool server via host-mapped port. - - Returns {"result": ...} or {"error": ...}; never raises. - """ - port = ctx.context.get("tool_server_host_port") - token = ctx.context.get("sandbox_token") - agent_id = ctx.context.get("agent_id", "unknown") - if not (port and token): - return {"error": "Sandbox not initialized"} - - url = f"http://127.0.0.1:{port}/execute" - body = {"agent_id": agent_id, "tool_name": tool_name, "kwargs": kwargs} - headers = {"Authorization": f"Bearer {token}"} - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - r = await client.post(url, json=body, headers=headers) - if r.status_code == 401: - return {"error": "Sandbox auth failed"} - if r.status_code >= 400: - return {"error": f"Sandbox HTTP {r.status_code}: {r.text[:300]}"} - try: - return r.json() - except ValueError: - return {"error": f"Invalid sandbox response: {r.text[:200]}"} - except httpx.TimeoutException: - return {"error": f"Sandbox timeout (>{_TIMEOUT.read}s)"} - except httpx.RequestError as e: - return {"error": f"Sandbox request failed: {e!s}"[:300]} -``` - ---- - -## 4. Per-tool migration contracts - -Compact table — one row per tool, expanded notes for non-trivial ones. - -### 4.1 Sandbox tools (POST to tool server) - -Every sandbox tool follows the same shape: - -```python -from strix.tools._decorator import strix_tool -from strix.tools._sandbox_dispatch import post_to_sandbox -from agents import RunContextWrapper, ToolOutputText, ToolOutputImage - - -@strix_tool(timeout=160) # outer SDK > inner httpx (150) -async def (ctx: RunContextWrapper, ...args) -> str | list: - result = await post_to_sandbox(ctx, "", {}) - if "error" in result: - return result["error"] - # Tool-specific result shaping (extract screenshot, truncate, etc.) - return _shape_result(result) -``` - -| Tool | Server-side name | Args | Return shape | Notes | -|---|---|---|---|---| -| `browser_action` | `browser_action` | `action: str, **action_kwargs` | `[ToolOutputImage(base64), ToolOutputText(state)]` | Extract `screenshot` key as image; rest as text. 24 actions. Truncate page-source/console at server. | -| `terminal_execute` | `terminal_execute` | `command: str, is_input=False, timeout=30, terminal_id="default", no_enter=False` | `ToolOutputText(content + status + exit_code)` | Per-`(agent_id, terminal_id)` libtmux session at server. | -| `python_action` | `python_action` | `action: str, code: str | None, session_id="default", timeout=30` | `ToolOutputText(stdout + stderr + result_repr)` | Per-`(agent_id, session_id)` IPython kernel. | -| `list_requests` | `list_requests` | `httpql_filter: str | None, start_page=1, end_page=1, page_size=50, sort_by="timestamp", sort_order="desc"` | `ToolOutputText(json)` | Caido. | -| `view_request` | `view_request` | `request_id: str, search_pattern: str | None, part="response"` | `ToolOutputText(json)` | Caido. | -| `send_request` | `send_request` | `method: str, url: str, headers={}, body=""` | `ToolOutputText(json)` | Caido. | -| `repeat_request` | `repeat_request` | `request_id: str, modifications: dict` | `ToolOutputText(json)` | Caido. | -| `scope_rules` | `scope_rules` | `action: Literal["list","create","update","delete"], **kwargs` | `ToolOutputText(json)` | Caido. | -| `list_sitemap` | `list_sitemap` | `parent_id: str | None, page_size=50` | `ToolOutputText(json)` | Caido. | -| `view_sitemap_entry` | `view_sitemap_entry` | `node_id: str` | `ToolOutputText(json)` | Caido. | - -### 4.2 Local tools (in-process) - -| Tool | Module | Signature | Notes | -|---|---|---|---| -| `create_note` / `list_notes` / `get_note` / `update_note` / `delete_note` | `strix/tools/notes/` | per Strix today | **Apply C6**: lock JSONL writes. Per-agent state silo via `ctx.context["agent_id"]`. | -| `create_todo` / `list_todos` / `update_todo` / `mark_todo_done` / `mark_todo_pending` / `delete_todo` | `strix/tools/todo/` | per Strix today | In-memory only; not persisted. | -| `create_vulnerability_report` | `strix/tools/reporting/` | All current required+optional fields | Calls existing `llm/dedupe.py` via nested `Runner.run` or direct `litellm.acompletion`. Wires `vulnerability_found_callback` via `ToolOutputGuardrail` for TUI popup. | -| `web_search` | `strix/tools/web_search/` | `query: str` | Direct Perplexity API; do NOT use SDK's `WebSearchTool` (Responses-only, OpenAI-only). | -| `str_replace_editor` / `list_files` / `search_files` | `strix/tools/file_edit/` | per Strix today | Wrap openhands-aci + ripgrep. SDK auto-threads sync. | -| `finish_scan` | `strix/tools/finish/` | per Strix today | Root-only guard: `if ctx.context["parent_id"] is not None: return error`. Sets `ctx.context["agent_finish_called"] = True`. **Root agent uses `tool_use_behavior={"stop_at_tool_names": ["finish_scan"]}`.** | -| `think` | `strix/tools/thinking/` | `thought: str` | Trivial; `return f"Recorded {len(thought)} chars"`. | -| `load_skill` | `strix/tools/load_skill/` | `skills: list[str]` (max 5) | Returns skill content as `ToolOutputText` (accepted tradeoff). Validates against skill registry. | - -### 4.3 Multi-agent graph tools - -All in `strix/tools/agents_graph.py`. Use `bus = ctx.context["bus"]`, `me = ctx.context["agent_id"]`. - -```python -@strix_tool(timeout=30) -async def view_agent_graph(ctx) -> str: - bus = ctx.context["bus"] - lines = [] - roots = [aid for aid, p in bus.parent_of.items() if p is None] - - def render(aid, depth): - st = bus.statuses.get(aid, "?") - lines.append(" " * depth + f"- {bus.names.get(aid, aid)} ({aid}) [{st}]") - for c, p in bus.parent_of.items(): - if p == aid: - render(c, depth + 1) - - for r in roots: - render(r, 0) - return "\n".join(lines) or "(no agents)" - - -@strix_tool(timeout=30) -async def agent_status(ctx, agent_id: str) -> str: - bus = ctx.context["bus"] - if agent_id not in bus.statuses: - return f"Unknown agent {agent_id}" - return ( - f"agent={bus.names.get(agent_id)} status={bus.statuses[agent_id]} " - f"parent={bus.parent_of.get(agent_id)} " - f"pending_msgs={len(bus.inboxes.get(agent_id, []))}" - ) - - -@strix_tool(timeout=30) -async def send_message_to_agent(ctx, target_agent_id: str, message: str, - message_type: str = "info", priority: str = "normal") -> str: - bus = ctx.context["bus"] - if target_agent_id not in bus.statuses: - return f"Unknown agent {target_agent_id}" - await bus.send(target_agent_id, { - "from": ctx.context["agent_id"], - "content": message, - "type": message_type, - "priority": priority, - }) - return f"Queued for {target_agent_id}" - - -@strix_tool(timeout=601) -async def wait_for_message(ctx, reason: str, timeout_seconds: int = 600) -> str: - import asyncio - bus = ctx.context["bus"] - me = ctx.context["agent_id"] - bus.statuses[me] = "waiting" - end = asyncio.get_event_loop().time() + timeout_seconds - while asyncio.get_event_loop().time() < end: - if bus.inboxes.get(me): - bus.statuses[me] = "running" - return "Message arrived. Continue." - await asyncio.sleep(1.0) - bus.statuses[me] = "running" - return f"Timed out after {timeout_seconds}s. Continue or call agent_finish." - - -@strix_tool(timeout=120) -async def create_agent( - ctx, name: str, task: str, - inherit_context: bool = True, skills: list[str] | None = None, -) -> str: - import asyncio, uuid - from agents import Runner - from strix.run_config_factory import make_run_config, make_agent_context - from strix.orchestration.hooks import StrixOrchestrationHooks - from strix.agents.factory import build_strix_agent # build child Agent w/ tool_use_behavior - - bus = ctx.context["bus"] - parent = ctx.context["agent_id"] - cid = uuid.uuid4().hex[:8] - await bus.register(cid, name, parent) - - child_agent = build_strix_agent(name=name, skills=skills or [], is_root=False) - - # Inherited context: parent's session items - parent_session = ctx.context.get("session") - history = await parent_session.get_items() if (inherit_context and parent_session) else [] - initial_input = history + [ - # Identity injection (replaces Strix's XML) - {"role": "user", "content": f"You are agent {name} ({cid}). " - f"Parent is {parent}. Do not echo this block."}, - {"role": "user", "content": task}, - ] - - child_ctx = make_agent_context( - bus=bus, - sandbox_session=ctx.context["sandbox_session"], - sandbox_token=ctx.context["sandbox_token"], - tool_server_host_port=ctx.context["tool_server_host_port"], - caido_host_port=ctx.context["caido_host_port"], - agent_id=cid, - agent_name=name, - parent_id=parent, - tracer=ctx.context["tracer"], - model_settings=ctx.context["model_settings"], - max_turns=ctx.context["max_turns"], - ) - - bus.tasks[cid] = asyncio.create_task( - Runner.run( - child_agent, - input=initial_input, - run_config=make_run_config( - sandbox_session=ctx.context["sandbox_session"], - bus=bus, - model=ctx.context.get("model", "strix/claude-sonnet-4.6"), - max_turns=ctx.context["max_turns"], - ), - context=child_ctx, - hooks=StrixOrchestrationHooks(), - ) - ) - return f"Spawned {name} ({cid}) running in parallel" - - -@strix_tool(timeout=30) -async def agent_finish( - ctx, result_summary: str, findings: list[dict] | None = None, - success: bool = True, report_to_parent: bool = True, - final_recommendations: list[str] | None = None, -) -> str: - bus = ctx.context["bus"] - me = ctx.context["agent_id"] - parent = bus.parent_of.get(me) - if parent is None: - return "Error: agent_finish is for subagents. Root must call finish_scan." - ctx.context["agent_finish_called"] = True - - if report_to_parent: - report_xml = ( - f"\n" - f" {result_summary}\n" - f" {findings or []}\n" - f" {final_recommendations or []}\n" - f"" - ) - await bus.send(parent, {"from": me, "content": report_xml, "type": "completion"}) - - # Whitebox wiki update (M10 — preserved) - if ctx.context.get("is_whitebox") and findings: - # Append delta to shared wiki note via existing notes API - from strix.tools.notes.notes_actions import append_note_content - append_note_content("wiki", f"\n## Update from {bus.names.get(me)}\n{result_summary}\n") - - return "Reported to parent. This agent will exit." -``` - -**Every child agent built by `build_strix_agent`:** `tool_use_behavior={"stop_at_tool_names": ["agent_finish"]}` (C4). - ---- - -## 5. Standards: logging, error formatting, observability - -### 5.1 Logging - -| Logger | Level | When | -|---|---|---| -| `strix.orchestration.bus` | DEBUG | every send/drain/finalize | -| `strix.orchestration.hooks` | INFO | agent start/end; WARNING on crash detection | -| `strix.tools.` | DEBUG | tool entry/exit; WARNING on user-recoverable error | -| `strix.runtime.strix_docker_client` | INFO | container created; ERROR on creation failure | -| `strix.sandbox.caido_capability` | INFO | bind/healthcheck-task lifecycle | -| `strix.sandbox.session_manager` | INFO | session create/reuse/cleanup | -| `strix.llm.anthropic_cache_wrapper` | DEBUG | cache_control injection events | -| `strix.telemetry.strix_processor` | WARNING | sanitization mismatches | - -Verbose mode flag: `--verbose` → `agents.set_default_logging("DEBUG")` + `enable_verbose_stdout_logging()`. Production keeps `OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1` (default) so model I/O isn't logged. - -Never logged anywhere: `LLM_API_KEY`, `TOOL_SERVER_TOKEN`, anything the existing `TelemetrySanitizer` patterns match. - -### 5.2 Error formatting - -| Layer | Recipient | Format | -|---|---|---| -| Tool body | LLM | Plain `str` returned from tool. Convention: `"Error: "` for failures. | -| Tool body | User | Visible only via TUI when popup-worthy (vulnerability popup hooks via `ToolOutputGuardrail`). | -| Sandbox dispatch | LLM | `{"error": "..."}` → returned as `Error: ...` string. | -| Hook | Logs only | `logger.exception(...)` for unhandled in hooks; never propagated (would tear down the run). | -| Agent crash | Parent | `...` user message via bus. | -| Run-level exception | CLI | Exit code 1 (general failure), 2 (vulns found in headless), 130 (Ctrl+C). | -| Run-level exception | Trace | `error.kind`, `error.message`, `error.traceback` span attributes (post-scrub). | - -### 5.3 Observability during migration - -While both old (Strix) and new (SDK-based) live in parallel: - -- **Comparison metrics**: per-run, log `total_input_tokens`, `total_output_tokens`, `cost`, `findings_count`, `wall_clock_seconds`. Diff old vs new with same target set. -- **Drift detection**: identical `--target` should produce equivalent (not byte-identical) findings. Track findings-by-CWE histogram. -- **Canary mode**: feature flag `STRIX_USE_SDK_HARNESS=1` or separate CLI command (`strix-sdk`) until cut-over. Two CLIs let users opt in. -- **Event compatibility**: `events.jsonl` schema must remain compatible — the custom `TracingProcessor` emits the same `event_type` values as today's tracer. - ---- - -## 6. Test plans per phase - -### Phase 0 — Spike & corrections - -**Smoke tests** (must all pass before Phase 1 starts): -- `test_anthropic_cache_wrapper_injection` — system message has `cache_control`; non-Anthropic passes through. -- `test_anthropic_cache_hit_rate` — three identical-prompt calls; call 2+ shows `cache_read_input_tokens > 0`. -- `test_strix_docker_client_caps` — mock `containers.create`; assert `cap_add ⊇ {NET_ADMIN, NET_RAW}` and `extra_hosts["host.docker.internal"]=="host-gateway"`. -- `test_strix_docker_client_live` — real Kali image; `session.exec(["nmap", "-sS", "scanme.nmap.org"])` returns exit 0. -- `test_message_bus_two_children_exchange` — minimal `MessageBus` with two synthetic agents; verify FIFO message delivery via filter injection. -- `test_tool_use_behavior_stop_on_finish` — toy agent with `agent_finish`-equivalent + `stop_at_tool_names`; assert SDK terminates exactly when tool is called. -- `test_tool_server_concurrent_same_agent_calls` — issue two simultaneous POSTs to local tool server with same `agent_id`; current code cancels first; observe behavior; choose Phase 1 default (`parallel_tool_calls=False`). - -### Phase 1 — Foundation - -**Unit tests:** -- `MultiProvider` resolution: `strix/claude-sonnet-4.6` → `AnthropicCachingLitellmModel` with correct base URL. -- `strix_tool` decorator: timeout applied; sync auto-threaded. -- `StrixSession`: 100K-token history triggers compression; compressor failure returns uncompressed. -- `StrixTracingProcessor`: 100 concurrent span writes, JSONL is valid (no interleaved bytes); PII patterns scrubbed. -- `RunConfig` factory: retry policy excludes 401 explicitly; `parallel_tool_calls=False` default. - -**Integration tests:** -- Full single-agent run with `LitellmModel` against an Anthropic mock; verify cache_control reaches the model. -- Cost tracking via `RunHooks.on_llm_end` matches `litellm.cost_per_token` calculation within 1%. - -### Phase 2 — Tool ports - -**Per-tool smoke tests** (one per `@strix_tool`): -- Browser: `launch` → `goto("https://example.com")` → screenshot returned as `ToolOutputImage`; viewport 1280x720; per-agent tab keying. -- Terminal: `terminal_execute("echo hello")` → status=completed, exit_code=0. Special key: `terminal_execute("C-c", is_input=True)` no error. -- Python: `python_action(action="execute", code="x=1; x+1")` → result `"2"`. -- Caido: `list_requests(httpql_filter="request.method == 'GET'")` returns JSON with `requests` array. -- Notes: concurrent `create_note` from two agents; assert JSONL parses line-by-line (C6 fix). -- Reporting: full `create_vulnerability_report` payload; CVSS computed; persisted to `vulnerabilities/vuln_*.json`; dedup roundtrip works. -- File edit: `str_replace_editor("create", "/workspace/foo.txt", file_text="x")` → file exists. - -**Reentrancy tests:** if/when Phase 6 enables parallel: assert two parallel `terminal_execute` calls on same agent with different `terminal_id` don't collide (different libtmux sessions). - -### Phase 3 — Multi-agent - -**Unit tests:** -- `AgentMessageBus`: 1000 concurrent `send`/`drain` ops; FIFO maintained; `total_stats` snapshot consistent. -- `inject_messages_filter`: pending messages appended in order; user-from-user skips XML wrap; idempotent under retry simulation. -- `StrixOrchestrationHooks.on_agent_end`: crash detection fires when `agent_finish_called` False; sends `` to parent. -- `bus.cancel_descendants`: tree of N=10 agents; all cancelled leaf-first. - -**Integration:** -- Root spawns 2 children in parallel; children exchange messages; both finish via `agent_finish`; root reads completion reports; `bus.total_stats` aggregates correctly. -- Root + 1 child; user Ctrl+C mid-run; both tasks cancelled; processor flushes all pending events. -- Crashed child scenario: child `Runner.run` raises `RuntimeError`; parent receives `` within next turn. - -### Phase 4 — Sandbox + Caido - -**Integration:** -- `create_or_reuse(scan_id="x", ...)` twice → same session; `session.exec(["echo", "hi"])` works. -- Caido capability: container has `http_proxy=http://127.0.0.1:48080` env; `curl https://example.com` is captured by Caido (verify via `list_requests` shows the request). -- Healthcheck: stop tool server inside container; `wait_for_ports_ready` raises after 30s. - -### Phase 5 — Interface + persistence - -**Tests:** -- `StrixStreamAccumulator`: feed mock `RunResultStreaming` events; tracer's `streaming_content[agent_id]` updates. -- TUI: under simulated agent run, refresh latency < 500ms after each LLM chunk. -- Run-directory: post-run, `events.jsonl` valid; `vulnerabilities/` populated; `penetration_test_report.md` non-empty. - -### Phase 6 — Validation + tool server relaxation - -**End-to-end:** -- Full pentest against a stable target (a vulnerable webapp pinned in tests). Compare findings (count + CVSS distribution + CWE histogram) vs Strix baseline within agreed tolerance. -- Stress: 3 child agents, parallel tool calls within turn, no JSONL corruption, no message loss. -- After tool server relaxation: `parallel_tool_calls=True`; multi-tool turn fires correctly; sibling failure isolation works. - ---- - -## 7. Rollback, CI, cross-cutting - -### 7.1 Branching & cutover - -- Branch: `harness-migration` (already created). -- Cutover trigger: Phase 6 acceptance criteria met (golden-output diff within tolerance). -- Pre-cutover: SDK-based path lives behind `STRIX_USE_SDK_HARNESS=1`. Old path unchanged. -- Cutover: flip default to SDK-based; legacy path stays for one release as fallback. -- Removal of legacy: after one release with no rollback signals. - -### 7.2 Rollback procedure - -If post-cut regression discovered: -1. Set `STRIX_USE_SDK_HARNESS=0` in deployed config (re-uses legacy harness). -2. Investigate via traces/events.jsonl from the failed run. -3. Fix, re-run end-to-end suite, re-cut. - -### 7.3 Data compatibility - -- `events.jsonl` schema preserved (custom processor emits same `event_type`s). -- `vulnerabilities/vuln_*.json` schema preserved (same Pydantic shape). -- `penetration_test_report.md` schema preserved. -- `notes/notes.jsonl` schema preserved. -- Old runs are still analyzable by new tooling (no migration script needed). - -### 7.4 SDK version pinning - -- `pyproject.toml`: `openai-agents==0.14.6` (exact pin while we duplicate `_create_container`). -- CI nightly: re-run our Phase 0 spike against SDK `latest`. If green, bump pin and re-test. -- Track upstream PR to add `additional_create_kwargs` hook that would let us drop the body duplication. - -### 7.5 CI changes - -- New test buckets: `tests/orchestration/`, `tests/sandbox/`, `tests/llm_wrappers/`, `tests/integration/`. -- Snapshot tests for golden outputs (one or two stable targets). -- Mypy strict on new SDK types; install `openai-agents` package types. -- Lint rules for tool decorators (`@strix_tool` not bare `@function_tool` for new tools). - -### 7.6 Drop / add deps - -- Drop direct: `litellm[proxy]>=1.81.1,<1.82.0` (becomes transitive via `openai-agents[litellm]`). -- Add: `openai-agents[litellm]==0.14.6`. -- Keep: `tenacity`, `pydantic`, `rich`, `docker`, `textual`, `cvss`, `traceloop-sdk`, `opentelemetry-exporter-otlp-proto-http`, `scrubadub`, `defusedxml`. - ---- - -## 8. Phase ordering and day-1 commit list - -### Phase ordering (compressed) - -- **Phase 0** — Foundations + spikes. Land the 3 wrapper classes, run all Phase 0 smoke tests. -- **Phase 1** — Foundation files (rest), provider layer, `strix_tool`, custom `Session`, custom `TracingProcessor`, `RunConfig` factory. -- **Phase 2** — Tool ports. Sandbox dispatcher first, then all 30+ tools incrementally. -- **Phase 3** — Multi-agent: bus, filter, hooks, six graph tools. End-to-end parallel-children test. -- **Phase 4** — `CaidoCapability` + healthcheck + session cache by scan_id. -- **Phase 5** — TUI / streaming accumulator / run-directory persistence / turn warnings. -- **Phase 6** — Validation + tool server relaxation + parallel tool calls flip. - -### Day-1 commits (Phase 0) - -In order; each is independent until linked at Phase 0 acceptance. - -1. `strix/llm/anthropic_cache_wrapper.py` — `AnthropicCachingLitellmModel`. Tests: cache injection + non-Anthropic passthrough. -2. `strix/runtime/strix_docker_client.py` — `StrixDockerSandboxClient`. Tests: kwarg injection mock + live nmap. -3. `strix/orchestration/bus.py` — `AgentMessageBus`. Tests: concurrency stress + cancel_descendants. -4. `strix/orchestration/filter.py` — `inject_messages_filter`. Tests: FIFO + retry-stable. -5. `strix/orchestration/hooks.py` — `StrixOrchestrationHooks`. Tests: crash detection + warning injection. -6. `strix/tools/_decorator.py` — `strix_tool`. Tests: timeout + sync auto-threading. -7. `strix/llm/multi_provider_setup.py` — `MultiProviderMap` + `StrixModelProvider`. Tests: alias resolution. - -### Phase 0 acceptance gate - -All seven module unit tests green. The seven Phase 0 smoke tests in §6 green. Tool server concurrent-call test executed; result documented. Only then proceed to Phase 1. - ---- - -*This playbook merges every audit finding into a single engineering reference. When implementation discovers something the playbook missed, treat it as a bug in the playbook — update this file, then write the code.* diff --git a/TESTING_STRATEGY.md b/TESTING_STRATEGY.md deleted file mode 100644 index 501f9d9..0000000 --- a/TESTING_STRATEGY.md +++ /dev/null @@ -1,597 +0,0 @@ -# Migration Testing Strategy - -> Seven-layer safety net to prove the SDK-migrated harness produces behavior identical to the legacy harness, with no silent feature loss. Centered on **behavioral parity diffing** (the only test that doesn't require knowing in advance what could break) and a **feature inventory matrix** (every feature has a row, every row has a test, no row → no proof). - ---- - -## Table of contents - -1. [Threat model — what we're afraid of](#1-threat-model) -2. [Testing layers (cheap → expensive)](#2-testing-layers) -3. [Feature inventory matrix](#3-feature-inventory-matrix) -4. [Behavioral parity diffing — how it actually works](#4-behavioral-parity-diffing) -5. [Replay infrastructure (deterministic LLM + sandbox)](#5-replay-infrastructure) -6. [Live shadow mode (production-grade canary)](#6-live-shadow-mode) -7. [Per-correction test mapping](#7-per-correction-test-mapping) -8. [CI gating and cutover criteria](#8-ci-gating-and-cutover-criteria) -9. [Manual smoke checklist](#9-manual-smoke-checklist) -10. [Post-cutover monitoring](#10-post-cutover-monitoring) -11. [What to do when a parity diff fails](#11-what-to-do-when-a-parity-diff-fails) - ---- - -## 1. Threat model - -Concrete categories of regression we want to catch — every test below targets at least one row. - -| # | Threat | Detection difficulty | Where it hides | -|---|---|---|---| -| T1 | A tool stops being invocable (typo, registration miss) | Easy | Agent runs, never calls it | -| T2 | A tool's args don't validate the same way | Medium | LLM gets different error string; no exception | -| T3 | A tool's output format changes (XML vs structured, truncation cap, screenshot extraction) | Hard | Findings still happen, but different shape | -| T4 | An LLM provider quirk lost (Anthropic cache, Bedrock timeout, vision strip) | Hard | Cost+latency drift; no functional break | -| T5 | A side effect lost (wiki note auto-update, vulnerability dedup, PII scrub) | **Critical-silent** | Reports persist but a feature stops firing | -| T6 | An event type stops being emitted to events.jsonl | Hard | Run completes, dashboards have gaps | -| T7 | Cancellation cascade incomplete (Ctrl+C leaves orphan tasks) | Medium | Looks fine on success path; only visible on cancel | -| T8 | Memory leak / resource leak (orphan inboxes, stale sessions) | Hard | Long runs degrade | -| T9 | Concurrency regression (parallel tools collide, message ordering breaks) | **Critical-silent** | Pass once, fail on second concurrent run | -| T10 | Schema drift in persisted artifacts (events.jsonl, vuln JSON, report MD) | Medium | Downstream consumers break | -| T11 | Config / env var stops being read | Easy | Default kicks in instead of user override | -| T12 | Exit-code change | Easy | CI integrations break | -| T13 | TUI / CLI output format change | Easy | User experience drift | -| T14 | A skill / prompt section silently dropped | **Critical-silent** | Agent's behavior subtly worse on specific vuln types | -| T15 | Subagent crash silent (parent waits forever) | **Critical-silent** | Run hangs without diagnosis | -| T16 | LLM provider routing wrong (`strix/foo` → wrong model) | Easy | API error within seconds | - -**Critical-silent** rows are the dangerous ones — the run *looks* fine but a feature is gone. Layer 4 (parity diffing) is specifically designed to catch these. - ---- - -## 2. Testing layers - -Bottom-up. Each layer catches a different class of bug; together they're complementary. - -``` -┌────────────────────────────────────────────┐ -│ Layer 7: Manual smoke (humans, TUI, etc.) │ rare, high signal -├────────────────────────────────────────────┤ -│ Layer 6: Live shadow / canary │ prod-grade, expensive -├────────────────────────────────────────────┤ -│ Layer 5: Recorded replay (deterministic) │ end-to-end, reproducible -├────────────────────────────────────────────┤ -│ Layer 4: Behavioral parity diffing │ **most powerful** -├────────────────────────────────────────────┤ -│ Layer 3: Integration (modules together) │ -├────────────────────────────────────────────┤ -│ Layer 2: Unit (one module, mocked deps) │ -├────────────────────────────────────────────┤ -│ Layer 1: Static (mypy, ruff, signatures) │ cheapest, fastest -└────────────────────────────────────────────┘ -``` - -### Layer 1 — Static / pre-runtime - -Catches type-level mismatches without running the code. - -- **`mypy --strict`** against `strix/` after migration. With `openai-agents[litellm]==0.14.6` installed, mypy verifies our subclasses honor SDK's ABC contracts. **Catches: F1, F2, F3 type-fix regressions, future SDK signature drift.** -- **`ruff` + `pyright`** as secondary type checkers; they sometimes catch what mypy misses (e.g., Pyright's stricter overload resolution). -- **Import-surface test** (`tests/static/test_imports.py`): a test that just imports every module in `strix/` and instantiates every `RunHooks`/`Capability`/`Model`/`Session` subclass with valid kwargs. If any abstract method is unimplemented, instantiation raises. Catches T2, T11. -- **Inventory completeness test** (`tests/static/test_inventory.py`): asserts every row in `tests/inventory/features.csv` (see §3) has a non-empty `test_id` field. Prevents adding a feature without a test. -- **SDK version pin guard** (`tests/static/test_sdk_version.py`): asserts `agents.__version__ == "0.14.6"` exactly. We duplicate `_create_container` body; an SDK bump must be intentional. Fails CI on accidental upgrade. - -### Layer 2 — Unit - -Each module tested in isolation with mocked dependencies. One file per source file. - -- `tests/orchestration/test_bus.py` — `AgentMessageBus` register/send/drain/cancel_descendants/total_stats. Concurrency stress: 1000 concurrent send/drain ops, FIFO assertion. -- `tests/orchestration/test_filter.py` — `inject_messages_filter` with synthetic `CallModelData`. Empty inbox → passthrough; 3 messages → 3 user items; user-from-user no XML wrap; bus exception → return unchanged (C14). -- `tests/orchestration/test_hooks.py` — `StrixOrchestrationHooks`. Crash detection (output=None or `agent_finish_called=False`); turn warnings at 85% / N-3; bus errors don't propagate (C15). -- `tests/llm/test_anthropic_cache.py` — `AnthropicCachingLitellmModel._patch`. Anthropic model → `cache_control` present; non-Anthropic → passthrough; system message wrapped correctly. -- `tests/llm/test_multi_provider.py` — `StrixModelProvider.get_model`. Known alias → correct concrete model + base URL; unknown alias → `UserError` (C17). -- `tests/llm/test_session.py` — `StrixSession`. Compression triggers above 90K; compressor exception → uncompressed history (C10); subsequent calls skip compression after first failure. -- `tests/runtime/test_strix_docker_client.py` — `StrixDockerSandboxClient._create_container` with mocked `docker_client`. Assert `cap_add ⊇ {NET_ADMIN, NET_RAW}` and `extra_hosts["host.docker.internal"] == "host-gateway"`. -- `tests/sandbox/test_caido_capability.py` — `CaidoCapability.process_manifest` injects proxy env; `tools()` returns 7 tools; `instructions()` returns non-empty string; `bind()` spawns healthcheck task. -- `tests/sandbox/test_session_manager.py` — `create_or_reuse` cache hit returns same session; `cleanup` removes container. -- `tests/telemetry/test_processor.py` — `StrixTracingProcessor`. Concurrent writes, JSONL is line-valid; PII patterns scrubbed; `OSError` doesn't propagate (C16). -- `tests/tools/test_decorator.py` — `strix_tool` factory. Default 120s timeout applied; sync function auto-threaded; `error_as_result` returns string. -- `tests/tools/test_sandbox_dispatch.py` — `post_to_sandbox`. 401 → error string; size cap enforced (C18); timeout returns error string. -- `tests/tools/test_.py` — one per ported tool. Each: smoke test (valid args → expected output shape) + one edge case (bad args, timeout, network error). - -### Layer 3 — Integration - -Multiple modules wired together; LLM and sandbox still mocked. - -- `tests/integration/test_single_agent_mocked.py` — Build root `Agent`, run with mocked `Model` that emits scripted tool calls; assert tracer captured expected events; assert `RunResult.final_output` populated. -- `tests/integration/test_multi_agent_mocked.py` — Root spawns 2 children via `create_agent` tool; mocked Model scripts each child's responses; bus messaging between children; both `agent_finish`; root `finish_scan`. Assert: stat aggregation correct; messages delivered FIFO; `bus.statuses` all `completed`. -- `tests/integration/test_cancellation_cascade.py` — Build a tree, then `bus.cancel_descendants(root)`; assert all child tasks `cancelled()`; assert leaves cancelled before parents (C9). -- `tests/integration/test_subagent_crash.py` — Mock child raising `RuntimeError`; assert `` arrives in parent inbox via filter (C8). -- `tests/integration/test_compressor_fallback.py` — Mock compressor LLM raising; assert run continues with uncompressed history (C10). -- `tests/integration/test_finish_scan_blocks_with_running_children.py` — Root calls `finish_scan` while child still `running`; assert error returned (C22). -- `tests/integration/test_jsonl_concurrent_writes.py` — 50 concurrent agents writing to events.jsonl; assert every line is valid JSON (C7); same for notes (C6). - -### Layer 4 — Behavioral parity diffing - -**The most important layer.** Run the same scenario through legacy and SDK harness; diff every artifact. Detail in §4 below. - -### Layer 5 — Recorded replay - -Deterministic end-to-end tests using captured LLM and sandbox traces. Detail in §5. - -### Layer 6 — Live shadow / canary - -Run both harnesses in production for a small slice of real users; diff results in real time. Detail in §6. - -### Layer 7 — Manual smoke - -Humans operating the TUI, headless mode, multi-target scans. Detail in §9. - ---- - -## 3. Feature inventory matrix - -The single artifact that prevents silent feature loss. **One CSV file (`tests/inventory/features.csv`) with one row per feature.** CI fails if any row is missing a test reference. - -### Schema - -```csv -feature_id,subsystem,description,source_ref,test_id,owner,phase,status -``` - -- `feature_id` — stable opaque identifier (e.g., `F-AGENT-LOOP-001`). -- `subsystem` — `agents | llm | tools | sandbox | telemetry | interface | config`. -- `description` — one sentence. -- `source_ref` — `path:line` to the legacy implementation OR `path:line` to the migration playbook spec. -- `test_id` — name of the test (or test marker) that proves it survives. Empty = blocker. -- `owner` — engineer responsible. -- `phase` — Phase 0–6 in which the feature lands. -- `status` — `legacy-only | both | sdk-only | parity-verified`. - -### Sample rows (~250 features expected) - -```csv -F-AGENT-LOOP-001,agents,Agent loop with max_iterations=300,strix/agents/base_agent.py:152,test_runner_max_turns_300,allam,1,parity-verified -F-AGENT-LOOP-002,agents,85%/N-3 turn warnings,strix/agents/base_agent.py:186,test_turn_warnings_injection,allam,3,parity-verified -F-AGENT-LOOP-003,agents,Streaming early-truncate at ,strix/llm/llm.py:212,,allam,defer,legacy-only -F-LLM-001,llm,Anthropic prompt cache control,strix/llm/llm.py:371,test_anthropic_cache_present,allam,0,parity-verified -F-TOOL-BROWSER-001,tools,launch action,strix/tools/browser/browser_actions.py:75,test_browser_launch,allam,2,parity-verified -F-TOOL-BROWSER-002,tools,goto action,strix/tools/browser/browser_actions.py:80,test_browser_goto,allam,2,parity-verified -... (24 browser actions) -F-TOOL-CAIDO-001,tools,list_requests with HTTPQL filter,strix/tools/proxy/proxy_actions.py:9,test_caido_list_requests,allam,2,parity-verified -... (7 caido tools) -F-MULTIAGENT-MSG-001,orchestration,send_message_to_agent FIFO,strix/tools/agents_graph/agents_graph_actions.py:495,test_bus_send_drain_fifo,allam,3,parity-verified -F-MULTIAGENT-MSG-002,orchestration,inter_agent_message XML wrap,base_agent.py:491,test_filter_xml_wrap,allam,3,parity-verified -F-EVENT-001,telemetry,run.started event in events.jsonl,strix/telemetry/tracer.py:87,test_event_run_started_emitted,allam,1,parity-verified -F-EVENT-002,telemetry,tool.execution.started event,strix/telemetry/tracer.py:300,test_event_tool_execution_started,allam,1,parity-verified -... (every event type) -F-CLI-FLAG-target,interface,--target,--t accepts URL/repo/path,strix/interface/main.py:267,test_cli_target_inference,allam,5,parity-verified -F-CLI-FLAG-scan-mode,interface,--scan-mode quick|standard|deep,strix/interface/main.py:295,test_cli_scan_mode,allam,5,parity-verified -... (every CLI flag) -F-OUTPUT-EXIT-CODE-2,interface,exit code 2 on findings in headless,strix/interface/main.py:640,test_headless_exit_code_2,allam,5,parity-verified -F-OUTPUT-VULN-JSON,telemetry,vulnerabilities/vuln_*.json schema,strix/telemetry/tracer.py:365,test_vuln_json_schema,allam,2,parity-verified -F-OUTPUT-REPORT-MD,interface,penetration_test_report.md template,strix/telemetry/tracer.py:400,test_report_md_template,allam,5,parity-verified -F-PII-001,telemetry,scrubadub OpenAI key pattern,strix/telemetry/utils.py:87,test_pii_scrub_openai_key,allam,1,parity-verified -F-PII-002,telemetry,scrubadub bearer token pattern,strix/telemetry/utils.py:91,test_pii_scrub_bearer,allam,1,parity-verified -... (every regex pattern) -F-SKILL-NoSQL,prompts,NoSQL injection skill,strix/prompts/vulnerabilities/nosql_injection.jinja,test_skill_nosql_loadable,allam,2,parity-verified -F-SKILL-K8s,skills,Kubernetes security skill,strix/skills/cloud/kubernetes.md,test_skill_k8s_loadable,allam,2,parity-verified -... (every skill file) -``` - -### Workflow - -1. **Bootstrap**: a script (`scripts/build_inventory.py`) walks the legacy code and emits a draft CSV. Engineer fills `test_id` per row. -2. **CI gate**: `tests/static/test_inventory_completeness.py` asserts every row has `test_id` non-empty. Empty row → CI fails. -3. **Coverage check**: a separate test asserts every `test_id` listed in the inventory actually exists as a test function (no typos, no orphans). -4. **PR review rule**: any code change touching `strix/` must update the inventory if it adds/removes/modifies a feature. -5. **Cutover gate**: ≥98% of inventory rows must be `parity-verified`. The remaining ≤2% are `legacy-only` (intentionally dropped) with explicit owner approval recorded in the row. - -This matrix is the single artifact that proves "we didn't lose anything." It's the audit trail. - ---- - -## 4. Behavioral parity diffing - -**The strongest non-trivial test we have.** Same input, same env, both harnesses, diff every output. If the diff is empty, parity is proved. - -### What we diff - -For a fixed scenario (same target, same instruction, same model, same env): - -| Artifact | Diff strategy | -|---|---| -| `events.jsonl` | Per-line JSON normalized + sorted by `event_type` + key fields; some fields ignored (timestamps, UUIDs, span IDs); deep diff | -| `vulnerabilities/*.json` | Group by stable identifier (target+endpoint+CVE), normalize, deep-diff each group's contents | -| `penetration_test_report.md` | Length within ±10%; heading set identical; CWE histogram identical | -| `notes/notes.jsonl` | Sorted by category + title; content body fuzzy-match (Levenshtein > 0.95) | -| `wiki/*.md` | File set identical; per-file content fuzzy-match | -| Tool call sequence (extracted from events) | Tool name multiset identical; arg signature shapes identical | -| Token usage | Within ±5% (LLM nondeterminism + caching variance) | -| Wall-clock duration | Within ±50% (allow for SDK overhead and parallel tool gains) | - -### Normalization (the careful part) - -LLMs are nondeterministic. We **must** normalize away noise before diffing or every diff fails. - -```python -# tests/parity/normalize.py -def normalize_events(events_jsonl_path: Path) -> list[dict]: - out = [] - for line in events_jsonl_path.read_text().splitlines(): - evt = json.loads(line) - # Strip noise - evt.pop("timestamp", None) - evt.pop("trace_id", None) - evt.pop("span_id", None) - evt.pop("agent_id", None) # different IDs in old vs new - evt.pop("scan_id", None) - # Normalize content fields - if "payload" in evt and "content" in evt["payload"]: - evt["payload"]["content"] = _normalize_text(evt["payload"]["content"]) - out.append(evt) - # Sort by event_type, then by stable shape hash - out.sort(key=lambda e: (e.get("event_type", ""), _shape_hash(e))) - return out -``` - -The diff library is `deepdiff`; failures point to specific keys. - -### The runner - -```python -# tests/parity/run_parity.py -def run_parity(scenario_id: str) -> ParityResult: - """Run scenario through both harnesses with identical inputs and recorded LLM.""" - inputs = load_scenario(scenario_id) # target, instruction, env, model - - legacy_run = run_legacy(inputs, recorded_llm=RECORDED[scenario_id]) - sdk_run = run_sdk(inputs, recorded_llm=RECORDED[scenario_id]) - - return ParityResult( - events_diff=diff_events(legacy_run.events_jsonl, sdk_run.events_jsonl), - vulns_diff=diff_vulns(legacy_run.vulns, sdk_run.vulns), - report_diff=diff_report(legacy_run.report, sdk_run.report), - tool_call_diff=diff_tool_calls(legacy_run.events, sdk_run.events), - usage_drift=compute_usage_drift(legacy_run.usage, sdk_run.usage), - ) -``` - -### Scenarios to fix as parity baselines - -A small, hand-picked, *stable* set: - -| Scenario | Target | Mode | Why | -|---|---|---|---| -| `S01-static-blackbox` | https://juice-shop.local | quick | Standard OWASP target; many findings; broad tool exercise | -| `S02-static-whitebox` | ./examples/vulnerable-flask-app | deep | Whitebox path; semgrep+ast paths; wiki notes | -| `S03-multi-target` | repo + url combined | standard | Multi-target coordination | -| `S04-diff-mode` | ./examples/vulnerable-flask-app `--scope-mode=diff` | quick | Diff scope injection | -| `S05-cancellation` | juice-shop, kill at iter 10 | quick | Cancellation cascade | -| `S06-multi-agent-explicit` | DVWA, instruction triggers `create_agent` | deep | Subagent flow | -| `S07-empty-target` | nonexistent.local | quick | Failure path | -| `S08-large-codebase` | ./examples/big-repo | standard | Compression triggered | - -Every scenario has a recorded LLM trace (§5). Every scenario runs in CI. Failure on any scenario blocks cutover. - -### What "diff is empty" means - -After normalization, an empty diff on every artifact means: every event the legacy harness emits, the SDK harness emits; every finding the legacy harness reports, the SDK harness reports; every tool call sequence is the same set; the report has the same structure. **It does NOT mean every byte is identical** — that's impossible with LLMs. - -### Bidirectional comparison - -We diff in both directions: -- "What does legacy have that SDK doesn't?" — the dangerous direction (silent feature loss). -- "What does SDK have that legacy doesn't?" — safer (new behavior), but worth review. - -A row like `event_type=agent.created` missing in SDK → blocker. A row like `event_type=tool.guardrail.rejected` only in SDK → review. - ---- - -## 5. Replay infrastructure - -LLM and sandbox calls are nondeterministic (LLM) or environment-dependent (sandbox). For deterministic CI, we record once and replay forever. - -### LLM recording - -A `RecordedLLM` model that intercepts `Model.get_response`/`stream_response`, looks up the request in a fixture file, returns the recorded response. - -```python -# tests/replay/recorded_llm.py -from agents.models.interface import Model - -class RecordedLLM(Model): - def __init__(self, recording_path: Path): - self.recordings = json.loads(recording_path.read_text()) - self.cursor = 0 - - async def get_response(self, system_instructions, input, model_settings, tools, ...): - key = _hash_request(system_instructions, input, model_settings) - if key not in self.recordings: - raise ValueError(f"No recording for request hash {key[:8]}; capture with --record") - recording = self.recordings[key] - return ModelResponse( - output=[_deserialize_item(it) for it in recording["output"]], - usage=Usage(**recording["usage"]), - response_id=recording.get("response_id"), - ) - - async def stream_response(self, ...): - # Same lookup; replay chunks one by one - ... -``` - -**How requests are keyed:** hash of `(system_instructions, input_items, model, tools, model_settings excerpts)`. PII-stripped before hashing. Hash collisions are vanishingly rare; if they happen, append a sequence counter. - -**Capture mode:** `pytest --record-llm` runs scenarios against a real LLM with a flag set; all `acompletion` calls are intercepted at a wrapper layer and serialized to `tests/replay/recordings/.json`. PII scrubbed via existing `TelemetrySanitizer` before write. - -**Replay mode (default):** `pytest` uses `RecordedLLM` instead of real LLM; deterministic. - -### Sandbox recording - -Same pattern for sandbox HTTP calls. Wrap `post_to_sandbox`: - -```python -class RecordedSandbox: - def __init__(self, recording_path: Path): - self.recordings = json.loads(recording_path.read_text()) - - async def post(self, agent_id, tool_name, kwargs): - key = _hash_call(agent_id, tool_name, kwargs) - if key not in self.recordings: - raise ValueError(f"No sandbox recording for {tool_name} hash {key[:8]}") - return self.recordings[key] -``` - -Inject via test fixture; production code path unchanged. - -### Recording vs replay in CI - -- **Replay** (default, fast): every PR runs scenarios against recorded LLM + sandbox. Catches behavioral regressions in the harness itself. -- **Re-record** (manual, scheduled): a recurring CI job (or operator command) re-records scenarios against real LLM + real sandbox. Generates fresh recordings if the model output drifts. -- **Drift detection**: if a re-record produces output that fails the parity diff, the migration broke (or the legacy harness changed in main; check git history). - -This is exactly the pattern the SDK uses internally (`inline_snapshot` library). We can adopt it directly. - ---- - -## 6. Live shadow mode - -For the highest-confidence pre-cutover signal: run both harnesses in production, on real user runs, diff results. - -### Shadow runner - -A CLI mode `strix --target ... --shadow` that: - -1. Spawns the legacy harness against the target. -2. Spawns the SDK harness against the same target (separate sandbox container). -3. Both run to completion independently. -4. After both finish, computes parity diff and emits a report. - -User sees the legacy result (no UX disruption); engineering team sees the diff. - -```python -# strix/interface/shadow.py -async def run_shadow(args): - legacy_task = asyncio.create_task(run_legacy(args, run_dir=Path("strix_runs/shadow_legacy"))) - sdk_task = asyncio.create_task(run_sdk(args, run_dir=Path("strix_runs/shadow_sdk"))) - legacy_result, sdk_result = await asyncio.gather(legacy_task, sdk_task) - - diff = run_parity(legacy_result.run_dir, sdk_result.run_dir) - write_diff_report(diff, Path("strix_runs/shadow_diff.json")) - upload_diff_to_telemetry(diff) - return legacy_result # user gets the legacy answer; safe rollback -``` - -### Sampling - -Not every run shadows — too expensive in tokens. Configurable sampling: - -```bash -STRIX_SHADOW_SAMPLE_RATE=0.1 # 10% of runs go through shadow mode -STRIX_SHADOW_FORCE=1 # always (for engineering / staging) -``` - -### What we look for - -- **Parity rate**: % of shadow runs where diff is empty. Target: ≥99% before cutover. -- **Drift class histograms**: which fields differ most? Track per-field over time. -- **Resource drift**: token cost, wall-clock, sandbox memory. Plot distributions. - -Shadow runs **don't gate cutover** by themselves (too noisy with 1 sample), but a sustained drop in parity rate is a stop signal. - ---- - -## 7. Per-correction test mapping - -Every one of the 25 corrections from `AUDIT.md` + `AUDIT_R2.md` + `AUDIT_R3.md` needs a test that would have caught the bug. Defensive code without proof-of-defense is theater. - -| # | Correction | Test | Layer | -|---|---|---|---| -| C1 | Tool-server slot serialization vs SDK parallel calls | `test_parallel_tool_calls_safe_default_no_collision` (Layer 3) + `test_tool_server_relax_phase6_concurrent_works` (Layer 3) | 3 | -| C2 | Anthropic cache_control on system message | `test_anthropic_cache_control_on_system_message` (Layer 2) + `test_anthropic_cache_hit_rate` (Layer 5, recorded) | 2, 5 | -| C3 | DockerSandboxClient subclass injects caps | `test_strix_docker_client_caps_injected` (Layer 2, mocked) + `test_strix_docker_client_nmap_works` (Layer 7, live) | 2, 7 | -| C4 | Subagent `tool_use_behavior` | `test_subagent_exits_on_agent_finish` (Layer 3) | 3 | -| C5 | StrixStreamAccumulator parity | `test_stream_accumulator_event_coverage` (Layer 4 vs Layer 5 baseline) | 4, 5 | -| C6 | Notes JSONL write lock | `test_notes_jsonl_concurrent_writes_no_corruption` (Layer 3) | 3 | -| C7 | events.jsonl write lock | `test_events_jsonl_concurrent_writes_no_corruption` (Layer 3) | 3 | -| C8 | Subagent crash detection | `test_subagent_crash_emits_agent_crash_to_parent` (Layer 3) | 3 | -| C9 | Cancellation cascade | `test_cancel_descendants_walks_tree_leaf_first` (Layer 3) | 3 | -| C10 | Compressor try/except | `test_compressor_failure_returns_uncompressed` (Layer 2) | 2 | -| C11 | Retry policy excludes 401/403/400 | `test_retry_policy_does_not_retry_401` (Layer 2) | 2 | -| C12 | Stats snapshot under lock | `test_total_stats_consistent_under_concurrent_writes` (Layer 2) | 2 | -| F1 | LitellmModel signature positional-first | mypy / `test_anthropic_caching_litellm_model_overrides_correctly` | 1, 2 | -| F2 | RunHooks AgentHookContext + result:str | mypy / `test_hooks_signatures_match_sdk` | 1, 2 | -| F3 | TracingProcessor methods sync | mypy / `test_processor_methods_are_sync` | 1, 2 | -| C13 | Bus.finalize cleans up state | `test_finalize_clears_inbox_parent_name` (Layer 2) | 2 | -| C14 | Filter try/except | `test_filter_exception_returns_unmodified` (Layer 2) | 2 | -| C15 | Hooks try/except | `test_hooks_exception_does_not_propagate` (Layer 2) | 2 | -| C16 | Processor catches OSError | `test_processor_oserror_caught_run_continues` (Layer 2) | 2 | -| C17 | Model alias validation | `test_unknown_alias_raises_user_error` (Layer 2) | 2 | -| C18 | Sandbox response size cap | `test_sandbox_response_too_large_returns_error` (Layer 2) | 2 | -| C19 | Assert ≥1 enabled tool | `test_agent_build_fails_with_no_tools` (Layer 2) | 2 | -| C20 | Per-tool timeout_behavior | `test_critical_tool_timeout_raises` + `test_idempotent_tool_timeout_returns_string` | 2 | -| C21 | RunConfig override + context fields | `test_run_config_override_merges` + `test_context_has_is_whitebox` | 2 | -| C22 | finish_scan checks children | `test_finish_scan_blocks_with_running_children` (Layer 3) | 3 | -| C23 | Diff-scope injection | `test_diff_scope_in_first_user_message` (Layer 4) | 4 | -| C24 | Run-name + Docker preflight | `test_collision_detected` + `test_docker_unavailable_clear_error` (Layer 7) | 7 | -| C25 | Cancel mode mapping | `test_ctrl_c_immediate` + `test_tui_stop_after_turn` (Layer 7) | 7 | - -Every test name above is a placeholder for a real test function. The grid is the contract. CI runs all of these. - ---- - -## 8. CI gating and cutover criteria - -### Per-PR CI (every commit) - -| Check | Layer | Failure means | -|---|---|---| -| `mypy --strict` | 1 | Type contract drift | -| `ruff check + format` | 1 | Lint failure | -| `pytest tests/static/` | 1 | Inventory incomplete or imports broken | -| `pytest tests//` (unit) | 2 | Module-level regression | -| `pytest tests/integration/` | 3 | Cross-module regression | -| `pytest tests/parity/` against recorded scenarios | 4, 5 | Behavioral drift | -| Inventory completeness | 1 | A feature row has no test_id | -| Inventory test existence | 1 | A test_id is missing the test | -| SDK version pin | 1 | Accidental SDK upgrade | - -### Nightly CI (scheduled, longer-running) - -| Check | Purpose | -|---|---| -| Re-record scenarios against real LLM+sandbox | Detect drift in legacy or SDK behavior over time | -| Mutation testing on critical modules (bus, filter, hooks) | Verify tests actually catch bugs | -| Multi-platform PyInstaller build + smoke | Catch packaging regressions on macOS arm64/x86_64, Linux, Windows | -| Memory-pressure soak (300-turn run) | Catch leaks | - -### Cutover criteria - -To flip `STRIX_USE_SDK_HARNESS` default from `0` to `1`: - -- [ ] All 25 corrections (C1–C25 + F1–F3) have green tests in the grid above. -- [ ] Inventory matrix: ≥98% of rows are `parity-verified`. Remaining ≤2% are explicitly `legacy-only` with owner sign-off. -- [ ] Layer 4 parity diffing: 8 baseline scenarios all empty-diff (post-normalization). -- [ ] Layer 5 replay: all recorded scenarios green. -- [ ] Layer 7 manual smoke: TUI works on macOS + Linux; headless mode produces correct exit codes. -- [ ] Shadow mode (Layer 6): ≥99% parity rate over a 7-run sample at minimum. -- [ ] Mutation testing: ≥80% mutation kill rate on critical modules. -- [ ] Memory soak: 300-turn run completes; memory growth < 1 GB; no orphan containers. -- [ ] Engineering team signoff via PR review. - -### Post-cutover gates (kept for one release after flip) - -- Legacy harness still ships (gated by `STRIX_USE_SDK_HARNESS=0`). -- CI continues to run parity diffing on every PR. -- Nightly re-record runs detect any post-cutover legacy/SDK drift. -- Production telemetry on parity rate from real users (sampled). - -If parity rate drops below 95% in production: emergency rollback. - ---- - -## 9. Manual smoke checklist - -Things a human verifies before cutover. Run these on macOS and Linux at minimum. - -### TUI mode -- [ ] `strix --target https://juice-shop.local` launches splash screen. -- [ ] Agent tree renders; root expands to show subagents when spawned. -- [ ] Streaming text appears in real time as agent generates. -- [ ] F1 opens help screen; ESC closes. -- [ ] Vulnerability popup appears when first finding logged. -- [ ] Ctrl+C → confirm dialog → ESC dismisses, Y stops agent. -- [ ] Tab cycles panels; arrow keys navigate agent tree. -- [ ] Ctrl+Q quits cleanly; container removed; run dir intact. -- [ ] After agent completes, prompt for follow-up message; user can type. - -### Headless mode -- [ ] `strix -n --target ./examples/vulnerable-flask-app --scan-mode quick` runs. -- [ ] Rich panels render: startup, live stats, vuln-found. -- [ ] Final summary panel shows. -- [ ] Exit code 2 if findings; 0 if none. -- [ ] Ctrl+C exits 130 cleanly; container removed. - -### Multi-target -- [ ] `strix -t ./repo -t https://app.local` handles both. -- [ ] Each target gets its own `/workspace/` mount. -- [ ] Findings tagged by target. - -### Diff scope -- [ ] `strix -n --target ./repo --scope-mode diff --diff-base origin/main` includes diff block in instruction. -- [ ] Agent's first turn references the diff. - -### Config override -- [ ] `strix --config /path/to/custom.json --target ...` overrides defaults. -- [ ] Env vars from custom config apply; default config vars cleared. - -### Resilience -- [ ] Kill the sandbox container mid-run (`docker stop`); agent surfaces error, exits gracefully. -- [ ] Run with invalid `STRIX_LLM=strix/typo`; clear error message naming valid aliases. -- [ ] Run without Docker daemon; preflight error message tells user to start Docker. - ---- - -## 10. Post-cutover monitoring - -Once SDK harness is the default, watch for slow regressions. - -### Daily metrics - -- Parity rate from shadow sample (rolling 7-day). -- Token cost per scan (rolling 7-day per scan_mode). -- Wall-clock per scan (rolling 7-day). -- Findings count distribution by CWE. -- Crash rate by category (LLM, sandbox, tool, agent). - -### Alerts - -- Parity rate drops below 95% → page engineer. -- Token cost rises >20% week-over-week (with same scan mix) → review. -- Crash rate rises >2× baseline → page. -- Any new error class appears in events.jsonl that wasn't present pre-cutover → review. - -### Telemetry (existing PostHog + custom) - -- Per-scan: legacy-vs-sdk path used, total cost, total findings, exit code, duration, scan_mode. -- Per-tool: invocation count, error rate, p50/p99 latency. -- Per-error: category, count, first/last seen. - ---- - -## 11. What to do when a parity diff fails - -Standard incident playbook. Don't let a failed diff sit; chase root cause. - -1. **Examine the diff** — `tests/parity/run_parity.py` outputs a structured diff. Identify the specific field/event that drifted. -2. **Classify the drift**: - - **Code bug** in our migration → fix, re-test. - - **Acceptable behavior change** (e.g., SDK emits a new event the legacy didn't) → update normalizer to ignore the field, document in `tests/parity/normalization_notes.md`. - - **Recording staleness** → re-record affected scenario; investigate why output changed. -3. **Add a new test** if the drift wasn't caught by an existing assertion. Update inventory matrix. -4. **Don't suppress** — never `# noqa` a parity failure. The matrix is the contract. - -### Common drift causes (anticipated) - -| Drift | Root cause | Fix | -|---|---|---| -| Tool call sequence differs | LLM nondeterminism on retries | Recording captures multiple valid sequences; diff accepts any | -| Event count off by 1 | SDK emits an extra `agent.start` for handoff | Normalizer filters handoff events | -| Token count drift > 5% | Anthropic cache hit/miss timing | Replay against recorded; if still drifts, investigate cache wrapper | -| Vulnerability missing | Dedup decided differently | Check dedup LLM call recording; verify same prompt produces same answer | -| Wiki note shape differs | Update format changed | Normalize whitespace; check `append_note_content` invocation | - ---- - -## TL;DR - -Five mechanisms catch the categories of regression we're worried about: - -1. **Layer 4 parity diffing on 8 baseline scenarios** — catches every silent regression in tool calls, events, findings, reports. The diff itself is the signal; we don't need to enumerate failures. -2. **Feature inventory matrix (~250 rows)** — every feature has a test; CI fails if the matrix has gaps. Prevents adding features without tests; prevents losing features without notice. -3. **Recorded LLM + sandbox replay (Layer 5)** — deterministic CI; same input always produces same output; no token burn per PR. -4. **Shadow mode in production (Layer 6)** — real-world parity validation; sampled at 10%; strongest signal pre-cutover. -5. **Per-correction tests (25+3 = 28 specific tests)** — every audit finding has a test that proves the fix works. - -CI gates everything. Cutover criteria are explicit and measurable. Rollback is a flag flip. Post-cutover monitoring catches slow drift. - -The migration cannot silently lose a feature unless: (a) the feature isn't in the inventory matrix, AND (b) it isn't exercised by any of the 8 baseline scenarios, AND (c) it doesn't appear in production within the shadow sampling window. That's a narrow gap, and it gets narrower with every scenario added. diff --git a/strix/interface/cli.py b/strix/interface/cli.py index 00de20f..7f1c9c7 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -95,9 +95,9 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 "resume_instruction": getattr(args, "user_explicit_instruction", None) or "", } - scan_store = ReportState(args.run_name) - scan_store.set_scan_config(scan_config) - scan_store.hydrate_from_run_dir() + report_state = ReportState(args.run_name) + report_state.set_scan_config(scan_config) + report_state.hydrate_from_run_dir() def display_vulnerability(report: dict[str, Any]) -> None: report_id = report.get("id", "unknown") @@ -115,13 +115,13 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 console.print(vuln_panel) console.print() - scan_store.vulnerability_found_callback = display_vulnerability + report_state.vulnerability_found_callback = display_vulnerability def cleanup_on_exit() -> None: - scan_store.cleanup() + report_state.cleanup() def signal_handler(_signum: int, _frame: Any) -> None: - scan_store.cleanup() + report_state.cleanup() sys.exit(1) atexit.register(cleanup_on_exit) @@ -130,14 +130,14 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 if hasattr(signal, "SIGHUP"): signal.signal(signal.SIGHUP, signal_handler) - set_global_report_state(scan_store) + set_global_report_state(report_state) def create_live_status() -> Panel: status_text = Text() status_text.append("Penetration test in progress", style="bold #22c55e") status_text.append("\n\n") - stats_text = build_live_stats_text(scan_store) + stats_text = build_live_stats_text(report_state) if stats_text: status_text.append(stats_text) @@ -196,7 +196,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 console.print(f"[bold red]Error during penetration test:[/] {e}") raise - if scan_store.final_scan_result: + if report_state.final_scan_result: console.print() final_report_text = Text() @@ -206,7 +206,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 Text.assemble( final_report_text, "\n\n", - scan_store.final_scan_result, + report_state.final_scan_result, ), title="[bold white]STRIX", title_align="left", diff --git a/strix/interface/main.py b/strix/interface/main.py index d526961..cc8505a 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -558,11 +558,11 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser def display_completion_message(args: argparse.Namespace, results_path: Path) -> None: console = Console() - scan_store = get_global_report_state() + report_state = get_global_report_state() scan_completed = False - if scan_store and scan_store.scan_results: - scan_completed = scan_store.scan_results.get("scan_completed", False) + if report_state and report_state.scan_results: + scan_completed = report_state.scan_results.get("scan_completed", False) completion_text = Text() if scan_completed: @@ -581,7 +581,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) -> target_text.append("\n ") target_text.append(target_info["original"], style="white") - stats_text = build_final_stats_text(scan_store) + stats_text = build_final_stats_text(report_state) panel_parts: list[Text | str] = [completion_text, "\n\n", target_text] @@ -765,16 +765,16 @@ def main() -> None: posthog.error("unhandled_exception", str(e)) raise finally: - scan_store = get_global_report_state() - if scan_store: - posthog.end(scan_store, exit_reason=exit_reason) + report_state = get_global_report_state() + if report_state: + posthog.end(report_state, exit_reason=exit_reason) results_path = Path("strix_runs") / args.run_name display_completion_message(args, results_path) if args.non_interactive: - scan_store = get_global_report_state() - if scan_store and scan_store.vulnerability_reports: + report_state = get_global_report_state() + if report_state and report_state.vulnerability_reports: sys.exit(2) diff --git a/strix/interface/tui/app.py b/strix/interface/tui/app.py index cecf8f1..e9865bb 100644 --- a/strix/interface/tui/app.py +++ b/strix/interface/tui/app.py @@ -707,12 +707,12 @@ class StrixTUIApp(App): # type: ignore[misc] self.args = args self.scan_config = self._build_scan_config(args) - self.scan_store = ReportState(self.scan_config["run_name"]) - self.scan_store.set_scan_config(self.scan_config) - self.scan_store.hydrate_from_run_dir() - set_global_report_state(self.scan_store) + self.report_state = ReportState(self.scan_config["run_name"]) + self.report_state.set_scan_config(self.scan_config) + self.report_state.hydrate_from_run_dir() + set_global_report_state(self.report_state) self.live_view = TuiLiveView() - self.live_view.hydrate_from_run_dir(self.scan_store.get_run_dir()) + 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 @@ -766,10 +766,10 @@ class StrixTUIApp(App): # type: ignore[misc] def _setup_cleanup_handlers(self) -> None: def cleanup_on_exit() -> None: - self.scan_store.cleanup() + self.report_state.cleanup() def signal_handler(_signum: int, _frame: Any) -> None: - self.scan_store.cleanup() + self.report_state.cleanup() sys.exit(0) atexit.register(cleanup_on_exit) @@ -1229,7 +1229,7 @@ class StrixTUIApp(App): # type: ignore[misc] stats_content = Text() - stats_text = build_tui_stats_text(self.scan_store) + stats_text = build_tui_stats_text(self.report_state) if stats_text: stats_content.append(stats_text) @@ -1248,7 +1248,7 @@ class StrixTUIApp(App): # type: ignore[misc] if not self._is_widget_safe(vuln_panel): return - vulnerabilities = self.scan_store.vulnerability_reports + vulnerabilities = self.report_state.vulnerability_reports if not vulnerabilities: self._safe_widget_operation(vuln_panel.add_class, "hidden") @@ -1348,7 +1348,7 @@ class StrixTUIApp(App): # type: ignore[misc] def _agent_vulnerability_count(self, agent_id: str) -> int: return sum( - 1 for vuln in self.scan_store.vulnerability_reports if vuln.get("agent_id") == agent_id + 1 for vuln in self.report_state.vulnerability_reports if vuln.get("agent_id") == agent_id ) def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]: @@ -1734,7 +1734,7 @@ class StrixTUIApp(App): # type: ignore[misc] self._scan_thread.join(timeout=1.0) - self.scan_store.cleanup() + self.report_state.cleanup() self.exit() diff --git a/strix/interface/utils.py b/strix/interface/utils.py index 7afec91..fa208a9 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -196,13 +196,13 @@ def format_vulnerability_report(report: dict[str, Any]) -> Text: # noqa: PLR091 return text -def _build_vulnerability_stats(stats_text: Text, scan_store: Any) -> None: +def _build_vulnerability_stats(stats_text: Text, report_state: Any) -> None: """Build vulnerability section of stats text.""" - vuln_count = len(scan_store.vulnerability_reports) + vuln_count = len(report_state.vulnerability_reports) if vuln_count > 0: severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} - for report in scan_store.vulnerability_reports: + for report in report_state.vulnerability_reports: severity = report.get("severity", "").lower() if severity in severity_counts: severity_counts[severity] += 1 @@ -235,20 +235,20 @@ def _build_vulnerability_stats(stats_text: Text, scan_store: Any) -> None: stats_text.append("\n") -def build_final_stats_text(scan_store: Any) -> Text: +def build_final_stats_text(report_state: Any) -> Text: """Build final stats from Strix-owned scan artifacts.""" stats_text = Text() - if not scan_store: + if not report_state: return stats_text - _build_vulnerability_stats(stats_text, scan_store) + _build_vulnerability_stats(stats_text, report_state) return stats_text -def build_live_stats_text(scan_store: Any) -> Text: +def build_live_stats_text(report_state: Any) -> Text: stats_text = Text() - if not scan_store: + if not report_state: return stats_text model = load_settings().llm.model or "unknown" @@ -256,13 +256,13 @@ def build_live_stats_text(scan_store: Any) -> Text: stats_text.append(str(model), style="white") stats_text.append("\n") - vuln_count = len(scan_store.vulnerability_reports) + vuln_count = len(report_state.vulnerability_reports) stats_text.append("Vulnerabilities ", style="dim") stats_text.append(f"{vuln_count}", style="white") stats_text.append("\n") if vuln_count > 0: severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} - for report in scan_store.vulnerability_reports: + for report in report_state.vulnerability_reports: severity = report.get("severity", "").lower() if severity in severity_counts: severity_counts[severity] += 1 @@ -287,15 +287,15 @@ def build_live_stats_text(scan_store: Any) -> Text: return stats_text -def build_tui_stats_text(scan_store: Any) -> Text: +def build_tui_stats_text(report_state: Any) -> Text: stats_text = Text() - if not scan_store: + if not report_state: return stats_text model = load_settings().llm.model or "unknown" stats_text.append(str(model), style="white") - caido_url = getattr(scan_store, "caido_url", None) + caido_url = getattr(report_state, "caido_url", None) if caido_url: stats_text.append("\n") stats_text.append("Caido: ", style="bold white") diff --git a/strix/telemetry/posthog.py b/strix/telemetry/posthog.py index 5135502..e8f4481 100644 --- a/strix/telemetry/posthog.py +++ b/strix/telemetry/posthog.py @@ -114,9 +114,9 @@ def finding(severity: str) -> None: ) -def end(scan_store: "ReportState", exit_reason: str = "completed") -> None: +def end(report_state: "ReportState", exit_reason: str = "completed") -> None: vulnerabilities_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} - for v in scan_store.vulnerability_reports: + for v in report_state.vulnerability_reports: sev = v.get("severity", "info").lower() if sev in vulnerabilities_counts: vulnerabilities_counts[sev] += 1 @@ -125,8 +125,8 @@ def end(scan_store: "ReportState", exit_reason: str = "completed") -> None: try: from datetime import datetime - start = datetime.fromisoformat(scan_store.start_time.replace("Z", "+00:00")) - end_iso = scan_store.end_time or datetime.now(start.tzinfo).isoformat() + start = datetime.fromisoformat(report_state.start_time.replace("Z", "+00:00")) + end_iso = report_state.end_time or datetime.now(start.tzinfo).isoformat() duration = (datetime.fromisoformat(end_iso.replace("Z", "+00:00")) - start).total_seconds() except (ValueError, TypeError, AttributeError): pass @@ -137,7 +137,7 @@ def end(scan_store: "ReportState", exit_reason: str = "completed") -> None: **_base_props(), "exit_reason": exit_reason, "duration_seconds": round(duration), - "vulnerabilities_total": len(scan_store.vulnerability_reports), + "vulnerabilities_total": len(report_state.vulnerability_reports), **{f"vulnerabilities_{k}": v for k, v in vulnerabilities_counts.items()}, }, ) diff --git a/strix/tools/finish/tool.py b/strix/tools/finish/tool.py index c70c4f1..e160cae 100644 --- a/strix/tools/finish/tool.py +++ b/strix/tools/finish/tool.py @@ -46,22 +46,22 @@ def _do_finish( try: from strix.report.state import get_global_report_state - scan_store = get_global_report_state() - if scan_store is None: - logger.warning("No global scan store; scan results not persisted") + report_state = get_global_report_state() + if report_state is None: + logger.warning("No global report state; scan results not persisted") return { "success": True, "scan_completed": True, "message": "Scan completed (not persisted)", - "warning": "Results could not be persisted - scan store unavailable", + "warning": "Results could not be persisted - report state unavailable", } - scan_store.update_scan_final_fields( + report_state.update_scan_final_fields( executive_summary=executive_summary.strip(), methodology=methodology.strip(), technical_analysis=technical_analysis.strip(), recommendations=recommendations.strip(), ) - vuln_count = len(scan_store.vulnerability_reports) + vuln_count = len(report_state.vulnerability_reports) except (ImportError, AttributeError) as e: logger.exception("finish_scan persistence failed") return {"success": False, "message": f"Failed to complete scan: {e!s}"} diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index 01deddf..16a56a9 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -216,18 +216,18 @@ async def _do_create( # noqa: PLR0912 try: from strix.report.state import get_global_report_state - scan_store = get_global_report_state() - if scan_store is None: - logger.warning("No global scan store; vulnerability report not persisted") + report_state = get_global_report_state() + if report_state is None: + logger.warning("No global report state; vulnerability report not persisted") return { "success": True, "message": f"Vulnerability report '{title}' created (not persisted)", - "warning": "Report could not be persisted - scan store unavailable", + "warning": "Report could not be persisted - report state unavailable", } from strix.report.dedupe import check_duplicate - existing = scan_store.get_existing_vulnerabilities() + existing = report_state.get_existing_vulnerabilities() candidate = { "title": title, "description": description, @@ -258,7 +258,7 @@ async def _do_create( # noqa: PLR0912 "reason": dedupe.get("reason", ""), } - report_id = scan_store.add_vulnerability_report( + report_id = report_state.add_vulnerability_report( title=title, description=description, severity=severity,