docs: harness wiki + SDK migration plan + audits + playbook + testing strategy

Seven internal documents that frame the migration to the OpenAI Agents SDK:

- HARNESS_WIKI.md      legacy harness deep-dive (every subsystem, file:line refs)
- MIGRATION_EVALUATION.md  architectural plan (rev 2 — bridges + tradeoffs)
- AUDIT.md             pre-execution audit; 5 plan corrections (C1-C5)
- AUDIT_R2.md          round 1 audit; 7 more corrections (C6-C12)
- AUDIT_R3.md          round 3 audit; 13 more corrections (C13-C25) + 3 type fixes
- PLAYBOOK.md          file-by-file specs, per-tool contracts, day-1 commit list
- TESTING_STRATEGY.md  layered testing strategy + feature inventory matrix

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-24 23:37:41 -07:00
parent 9fb101282f
commit a35a4a22b1
7 changed files with 4613 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
# 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 | `<agent_delegation>` 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 `<agent_delegation>` 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 2535). 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 (2535 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.
+243
View File
@@ -0,0 +1,243 @@
# 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/<slug>.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"<agent_crash agent_id='{me}' name='{bus.names.get(me)}'>"
f"Agent terminated without calling agent_finish. "
f"Parent should not wait further on this child."
f"</agent_crash>",
"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 (M1M12).
---
## 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.
+476
View File
@@ -0,0 +1,476 @@
# 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 (C13C25) 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 (C13C25)
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 C1C12 | Use new C13C25 |
| **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/<subdir>` |
| `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. C13C25 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.
+780
View File
@@ -0,0 +1,780 @@
# 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 <path>` 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.1.13` (`config/config.py:43`).
5. **Run name + run dir**: `strix_runs/<run-name>/`. 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 `<inter_agent_message>` 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 `<agent_delegation>...<agent_identity>` 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 `<agent_completion_report>` XML with summary/findings/recommendations and pushes it onto the parent's inbox. If the agent is whitebox, the wiki note is updated with a delta.
**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 `</function>` 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/<short>` 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 `<context_summary message_count='N'>...</context_summary>`.
- 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
<function=tool_name>
<parameter=key>value</parameter>
<parameter=other>value</parameter>
</function>
```
Parser (`llm/utils.py`):
- `normalize_tool_format` (`:12-31`): converts Anthropic-style `<invoke name="X">` 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 `</function>`), 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 `<meta>Continue the task.</meta>` 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 `<group>_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 `<tool_result><tool_name>...</tool_name><result>...</result></tool_result>`. 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.48.0 pinned in `containers/Dockerfile`.
#### Notes (`strix/tools/notes/`) — `create_note`, `list_notes`, `get_note`, `update_note`, `delete_note` (+ internal `append_note_content`)
In-memory dict + JSONL persistence at `{run_dir}/notes/notes.jsonl`. Wiki-category notes additionally rendered as Markdown to `{run_dir}/wiki/{note_id}-{title}.md` so they're human-readable artifacts of the run.
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.
The wiki note in particular is **the shared whitebox knowledge base** between root and subagents; `agent_finish` for whitebox subagents auto-appends a delta (see §5.3).
#### 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.48.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.1.13` (`strix/config/config.py:43`, bumped in `640bd67`).
### 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 <base>...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/<run_name>/`)
Created and managed by `telemetry/tracer.py`. Contents:
- `events.jsonl` — every span/event in append-only JSONL (thread-safe writes).
- `vulnerabilities/vuln_{id}.json` — one file per finding, sorted by severity, dedup-checked.
- `penetration_test_report.md` — final markdown report (executive summary + methodology + technical analysis + recommendations).
- `notes/notes.jsonl` — note ops audit log.
- `wiki/{note_id}-{slug}.md` — human-readable wiki notes.
- `<target_subdir>/` — local source clones, per-target.
There is **no execution checkpointing** — if the process crashes mid-run, the agent restarts from scratch on retry. Resumability is limited to the interactive-mode wait/resume on inbound messages.
---
## 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 (toggles wiki-note auto-update on subagent finish).
---
## 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.1.13"` | 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 <path>` (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/` | Notes + wiki. |
| `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.*
+766
View File
@@ -0,0 +1,766 @@
# 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.** ~2535 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. ~5080 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 `</function>` | `result.cancel(mode="after_turn")` (turn-level only) | Partial | Lose token savings on over-generating models. ~50100 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 (`<agent_delegation>` 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 `</function>` | 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 | LowMedium |
| 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.1.13")` | 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"<inter_agent_message from='{sender}' "
f"type='{msg.get('type', 'info')}' "
f"priority='{msg.get('priority', 'normal')}'>"
f"{msg['content']}"
f"</inter_agent_message>"
),
})
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"<agent_completion_report from='{bus.names.get(me)}' agent_id='{me}' "
f"success='{success}'>\n"
f" <summary>{result_summary}</summary>\n"
f" <findings>{findings or []}</findings>\n"
f" <recommendations>{final_recommendations or []}</recommendations>\n"
f"</agent_completion_report>"
)
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 |
| `<inter_agent_message>` 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 `<agent_delegation>` 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 `<tool_result><error>...</error></tool_result>` 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 "<caido_proxy>All HTTP/HTTPS traffic in this sandbox is captured by Caido. ...</caido_proxy>"
```
### 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 `</function>`.** 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,0004,500** | **~3035 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 `</function>` 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).*
+1401
View File
File diff suppressed because it is too large Load Diff
+597
View File
@@ -0,0 +1,597 @@
# 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_<each>.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 `<agent_crash>` 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 06 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 </function>,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/<scenario_id>.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/<module>/` (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 (C1C25 + F1F3) 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/<subdir>` 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.