Files
strix/PLAYBOOK.md
T

1402 lines
60 KiB
Markdown
Raw Normal View History

# Strix → OpenAI Agents SDK Migration Playbook
> The day-1 engineering reference. Distillation of two audit rounds and four implementation deep-dives into actionable specs. All SDK references verified against `openai-agents` v0.14.6 at `/tmp/openai-agents`. Strix references at `9fb1012`.
---
## Reading order
1. `HARNESS_WIKI.md` — what we have today.
2. `MIGRATION_EVALUATION.md` rev 2 — the architectural plan.
3. `AUDIT.md` — first audit; five plan corrections (C1C5).
4. `AUDIT_R2.md` — Round 1 audit; seven additional corrections (C6C12).
5. **This file** — file-by-file specs, per-tool contracts, test plans, standards.
---
## Table of contents
1. [Consolidated corrections register](#1-consolidated-corrections-register)
2. [Foundation files (concrete skeletons)](#2-foundation-files-concrete-skeletons)
3. [Sandbox + Caido capability](#3-sandbox--caido-capability)
4. [Per-tool migration contracts](#4-per-tool-migration-contracts)
5. [Standards: logging, error formatting, observability](#5-standards-logging-error-formatting-observability)
6. [Test plans per phase](#6-test-plans-per-phase)
7. [Rollback, CI, cross-cutting](#7-rollback-ci-cross-cutting)
8. [Phase ordering and day-1 commit list](#8-phase-ordering-and-day-1-commit-list)
---
## 1. Consolidated corrections register
Twelve corrections to apply, ordered by phase. Every fix is bounded and source-verified.
| # | Severity | Defect | Fix shape | Apply in |
|---|---|---|---|---|
| **C1** | Blocker | SDK runs tools in parallel within a turn (`run_internal/tool_execution.py:1414, 1424`); Strix tool server cancels previous task for same agent (`tool_server.py:94-97`). | Phase 1 safe default: `ModelSettings(parallel_tool_calls=False)` + `RunConfig(isolate_parallel_failures=False)`. Phase 6 relaxation: remove the cancel logic, allow concurrent same-agent tool calls. | Phase 1 / 6 |
| **C2** | Blocker | Plan §3.2 said `extra_body["cache_control"]` injects Anthropic prompt cache. Verified — that lands at request level, not on system message. Caches nothing. | `AnthropicCachingLitellmModel` subclass — override `get_response`/`stream_response` to inject `cache_control` on the system message before super delegation. | Phase 0 |
| **C3** | Blocker | `DockerSandboxClient._create_container()` (`sandbox/sandboxes/docker.py:1434-1477`) has no kwarg-injection hook. | Subclass + duplicate parent body verbatim, append `create_kwargs.setdefault("cap_add", []).extend(["NET_ADMIN","NET_RAW"])` and `create_kwargs.setdefault("extra_hosts", {})["host.docker.internal"] = "host-gateway"`. Pin SDK version. | Phase 0 |
| **C4** | Blocker | Subagent `agent_finish` returns from a tool; SDK loop continues to `max_turns` unless told to stop. | Every child Agent: `tool_use_behavior={"stop_at_tool_names": ["agent_finish"]}`. Root Agent: `tool_use_behavior={"stop_at_tool_names": ["finish_scan"]}`. | Phase 3 |
| **C5** | High | TUI today polls `tracer.streaming_content` per-chunk; SDK's `Runner.run_streamed().stream_events()` is event-driven. | `StrixStreamAccumulator` consumes `RawResponsesStreamEvent` + `RunItemStreamEvent` and synthesizes legacy tracer API. Hooks bridge for non-streamed children. | Phase 5 |
| **C6** | Critical | `notes/notes_actions.py:_append_note_event` writes JSONL without lock. Two concurrent agents corrupt file. | Wrap `f.write(...)` in `with _notes_lock:`. Same for `_persist_wiki_note()`. | Phase 2 |
| **C7** | Critical | `telemetry/tracer.py:_append_event_record` calls `append_jsonl_record` without acquiring `_get_events_write_lock()`. | Wrap append in the existing lock. Apply identically in our custom `TracingProcessor`. | Phase 1 |
| **C8** | High | Subagent crash in daemon thread is silent — parent's `wait_for_message` polls forever. Same shape post-migration if a child `Runner.run` task raises. | `StrixOrchestrationHooks.on_agent_end` detects crash (output is None or `agent_finish` flag absent in context); pushes synthetic `<agent_crash>` message to parent inbox so filter surfaces it. | Phase 3 |
| **C9** | High | Root cancellation does NOT cascade to children spawned via `asyncio.create_task` in `create_agent` tool. | `cancel_run_with_descendants(bus, root_id)` walks `bus.parent_of` tree leaf-first and `task.cancel()`s each. Wired to CLI signal handler + TUI stop. | Phase 3 |
| **C10** | Medium | Memory compressor LLM call exception bubbles to agent loop, killing the whole iteration. Defeats the purpose. | Custom `Session` wraps compressor invocation in try/except; on failure, returns uncompressed history. Downstream context-window error is itself retryable. | Phase 1 |
| **C11** | Medium | Strix today fails fast on 401/403/400. SDK retry policy default may include them. | Explicit `ModelRetrySettings.policy = retry_policies.any(network_error(), http_status([429,500,502,503,504]))` — note 401/403/400 NOT included. Bake into `make_run_config()` factory. | Phase 1 |
| **C12** | Medium | `_completed_agent_llm_totals` read by tracer without lock. | Bus `total_stats()` reads under `asyncio.Lock`; tracer goes through bus. | Phase 3 (in design) |
---
## 2. Foundation files (concrete skeletons)
Seven load-bearing modules, plus three supporting ones. Every skeleton is non-stub — copy-edit, fill, ship.
### 2.1 `strix/llm/anthropic_cache_wrapper.py`
```python
from typing import Any
from agents.extensions.models.litellm_model import LitellmModel
from agents.items import ModelResponse, TResponseInputItem
from agents.models.interface import ModelTracing
from agents.model_settings import ModelSettings
from agents.tool import Tool
from agents.handoffs import Handoff
from agents.agent_output import AgentOutputSchemaBase
from openai.types.responses.response_prompt_param import ResponsePromptParam
class AnthropicCachingLitellmModel(LitellmModel):
"""Inject cache_control on the system message for Anthropic models.
Why: SDK ModelSettings.extra_body lands cache_control at request level,
not on the system message — Anthropic only caches when cache_control is
attached to the message itself.
"""
def _is_anthropic(self) -> bool:
m = self.model.lower()
return "anthropic/" in m or "claude" in m
def _patch(self, items: list[TResponseInputItem]) -> list[TResponseInputItem]:
if not self._is_anthropic():
return items
out: list[TResponseInputItem] = []
for item in items:
if isinstance(item, dict) and item.get("role") == "system":
content = item["content"]
if isinstance(content, str):
content = [{
"type": "text", "text": content,
"cache_control": {"type": "ephemeral"},
}]
out.append({**item, "content": content})
else:
out.append(item)
return out
# F1 (AUDIT_R3): SDK Model.get_response declares the first 7 params positional-first.
async def get_response(
self,
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
*,
previous_response_id=None,
conversation_id=None,
prompt=None,
) -> ModelResponse:
patched = self._patch(input if isinstance(input, list) else [input])
return await super().get_response(
system_instructions,
patched,
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
)
async def stream_response(
self,
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
*,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
patched = self._patch(input if isinstance(input, list) else [input])
async for ev in super().stream_response(
system_instructions,
patched,
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
):
yield ev
```
**Tests:** assert system message acquires `cache_control` for Anthropic; assert non-Anthropic passes through; `cache_read_input_tokens > 0` on call 2 of identical-prompt sequence.
---
### 2.2 `strix/runtime/strix_docker_client.py`
```python
import uuid
from typing import Any
from docker.models.containers import Container
from agents.sandbox.sandboxes.docker import (
DockerSandboxClient,
_build_docker_volume_mounts,
_manifest_requires_fuse,
_manifest_requires_sys_admin,
_docker_port_key,
parse_repository_tag,
)
from agents.sandbox.manifest import Manifest
class StrixDockerSandboxClient(DockerSandboxClient):
"""Adds NET_ADMIN, NET_RAW capabilities and host.docker.internal mapping.
Body is a verbatim copy of DockerSandboxClient._create_container
(sandbox/sandboxes/docker.py:1434-1477) with two appends before the
final containers.create() call. SDK has no kwarg-injection hook;
pin openai-agents version, re-merge on bump.
"""
async def _create_container(
self,
image: str,
*,
manifest: Manifest | None = None,
exposed_ports: tuple[int, ...] = (),
session_id: uuid.UUID | None = None,
) -> Container:
if not self.image_exists(image):
repo, tag = parse_repository_tag(image)
self.docker_client.images.pull(repo, tag=tag or None, all_tags=False)
environment: dict[str, str] | None = None
if manifest:
environment = await manifest.environment.resolve()
create_kwargs: dict[str, Any] = {
"entrypoint": ["tail"],
"image": image,
"detach": True,
"command": ["-f", "/dev/null"],
"environment": environment,
}
if manifest is not None:
mounts = _build_docker_volume_mounts(manifest, session_id=session_id)
if mounts:
create_kwargs["mounts"] = mounts
if _manifest_requires_fuse(manifest):
create_kwargs.update(
devices=["/dev/fuse"],
cap_add=["SYS_ADMIN"],
security_opt=["apparmor:unconfined"],
)
elif _manifest_requires_sys_admin(manifest):
create_kwargs.update(
cap_add=["SYS_ADMIN"],
security_opt=["apparmor:unconfined"],
)
if exposed_ports:
create_kwargs["ports"] = {
_docker_port_key(p): ("127.0.0.1", None) for p in exposed_ports
}
# ---- STRIX additions ----
create_kwargs.setdefault("cap_add", []).extend(["NET_ADMIN", "NET_RAW"])
create_kwargs.setdefault("extra_hosts", {})["host.docker.internal"] = "host-gateway"
# -------------------------
return self.docker_client.containers.create(**create_kwargs)
```
**Tests:** mock `docker_client.containers.create`; assert kwargs contain `cap_add ⊇ {NET_ADMIN, NET_RAW}` and `extra_hosts["host.docker.internal"] == "host-gateway"`. Live test: spin up real container, run `nmap -sS scanme.nmap.org` via `session.exec`, assert exit 0.
---
### 2.3 `strix/orchestration/bus.py`
```python
import asyncio
from dataclasses import dataclass, field
from typing import Any
@dataclass
class AgentMessageBus:
inboxes: dict[str, list[dict]] = field(default_factory=dict)
tasks: dict[str, asyncio.Task] = field(default_factory=dict)
statuses: dict[str, str] = field(default_factory=dict) # running|waiting|completed|crashed|stopped
parent_of: dict[str, str | None] = field(default_factory=dict)
names: dict[str, str] = field(default_factory=dict)
stats_live: dict[str, dict[str, Any]] = field(default_factory=dict)
stats_completed: dict[str, dict[str, Any]] = field(default_factory=dict)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def register(self, agent_id: str, name: str, parent_id: str | None) -> None:
async with self._lock:
self.inboxes[agent_id] = []
self.statuses[agent_id] = "running"
self.parent_of[agent_id] = parent_id
self.names[agent_id] = name
self.stats_live[agent_id] = {"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0}
async def send(self, target: str, msg: dict) -> None:
async with self._lock:
self.inboxes.setdefault(target, []).append(msg)
async def drain(self, agent_id: str) -> list[dict]:
async with self._lock:
msgs = self.inboxes.get(agent_id, [])
self.inboxes[agent_id] = []
return msgs
async def record_usage(self, agent_id: str, usage) -> None:
if usage is None:
return
async with self._lock:
s = self.stats_live.setdefault(agent_id, {"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0})
s["in"] += getattr(usage, "input_tokens", 0) or 0
s["out"] += getattr(usage, "output_tokens", 0) or 0
details = getattr(usage, "input_tokens_details", None)
s["cached"] += getattr(details, "cached_tokens", 0) or 0 if details else 0
s["calls"] += 1
async def finalize(self, agent_id: str, status: str) -> None:
# C13 (AUDIT_R3): clear inbox/parent/name to avoid orphaned-message memory leak
# when sibling agents try to send to a finished agent.
async with self._lock:
self.statuses[agent_id] = status
self.stats_completed[agent_id] = self.stats_live.pop(agent_id, {})
self.inboxes.pop(agent_id, None)
self.parent_of.pop(agent_id, None)
self.names.pop(agent_id, None)
async def total_stats(self) -> dict[str, Any]:
async with self._lock:
agg = {"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0}
for s in (*self.stats_live.values(), *self.stats_completed.values()):
for k, v in s.items():
agg[k] = agg.get(k, 0) + v
return agg
async def cancel_descendants(self, root_agent_id: str) -> None:
"""Walk parent_of tree leaf-first; cancel each task. (C9)"""
async with self._lock:
queue = [root_agent_id]
order: list[str] = []
while queue:
aid = queue.pop()
order.append(aid)
queue.extend(c for c, p in self.parent_of.items() if p == aid)
tasks = [self.tasks[a] for a in reversed(order) if a in self.tasks]
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*[t for t in tasks if not t.done()], return_exceptions=True)
```
**Tests:** stress concurrent `send`/`drain`; FIFO ordering; `cancel_descendants` cancels children before parent; `total_stats` snapshot is consistent.
---
### 2.4 `strix/orchestration/filter.py`
```python
import logging
from agents.run_config import CallModelData, ModelInputData
logger = logging.getLogger(__name__)
async def inject_messages_filter(data: CallModelData) -> ModelInputData:
"""Drain bus inbox; append as user-role items wrapped in <inter_agent_message>.
Filter runs once per turn; output captured for retries (verified) — safe to drain.
C14 (AUDIT_R3): wrap whole body in try/except so a filter bug never tears down
the run. On any exception, return unmodified data.model_data.
"""
try:
if not isinstance(data.context, dict):
return data.model_data
bus = data.context.get("bus")
agent_id = data.context.get("agent_id")
if bus is None or agent_id is None:
return data.model_data
pending = await bus.drain(agent_id)
if not pending:
return data.model_data
new_input = list(data.model_data.input)
for msg in pending:
sender = msg.get("from", "unknown")
if sender == "user":
new_input.append({"role": "user", "content": msg["content"]})
else:
new_input.append({
"role": "user",
"content": (
f"<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)
except Exception:
logger.exception("inject_messages_filter failed; proceeding without injection")
return data.model_data
```
**Tests:** N pending messages → N items appended in FIFO; user-from-user skips XML wrap; empty inbox passes through; messages preserved across forced LLM retry.
---
### 2.5 `strix/orchestration/hooks.py`
```python
import logging
from agents.lifecycle import RunHooks
# F2 (AUDIT_R3): on_agent_start/on_agent_end receive AgentHookContext, NOT RunContextWrapper.
from agents.lifecycle import AgentHookContext # type: ignore[attr-defined]
from agents.run_context import RunContextWrapper
logger = logging.getLogger(__name__)
class StrixOrchestrationHooks(RunHooks):
# C15: every hook body wrapped in try/except so a hook bug never tears down the run.
async def on_llm_start(
self,
context: RunContextWrapper,
agent,
system_prompt: str | None,
input_items: list,
) -> None:
try:
if not isinstance(input_items, list):
return
max_turns = context.context.get("max_turns", 300)
cur = context.context.get("turn_count", 0)
if cur == int(max_turns * 0.85):
input_items.append({
"role": "user",
"content": "<system_warning>You are at 85% of your iteration "
"budget. Begin consolidating findings.</system_warning>",
})
elif cur == max_turns - 3:
input_items.append({
"role": "user",
"content": "<system_warning>You have 3 iterations left. Your next "
"tool call MUST be the finish tool.</system_warning>",
})
except Exception:
logger.exception("on_llm_start failed")
async def on_llm_end(self, context: RunContextWrapper, agent, response) -> None:
try:
bus = context.context.get("bus")
if bus and (aid := context.context.get("agent_id")):
await bus.record_usage(aid, getattr(response, "usage", None))
context.context["turn_count"] = context.context.get("turn_count", 0) + 1
except Exception:
logger.exception("on_llm_end failed")
async def on_agent_start(self, context: AgentHookContext, agent) -> None:
# F2: context is AgentHookContext, not RunContextWrapper.
try:
cap = next(
(c for c in (getattr(agent, "capabilities", None) or [])
if hasattr(c, "_healthcheck_task")),
None,
)
if cap and getattr(cap, "_healthcheck_task", None) is not None:
await cap._healthcheck_task
except Exception:
logger.exception("on_agent_start failed")
async def on_agent_end(self, context: AgentHookContext, agent, output) -> None:
# F2: context is AgentHookContext.
try:
bus = context.context.get("bus")
if bus is None or (me := context.context.get("agent_id")) is None:
return
crashed = (output is None) or not context.context.get("agent_finish_called", False)
parent = bus.parent_of.get(me)
if crashed and parent is not None:
await bus.send(parent, {
"from": me,
"content": (
f"<agent_crash agent_id='{me}' name='{bus.names.get(me, me)}'>"
"Agent terminated without calling agent_finish. "
"Stop waiting on this child."
"</agent_crash>"
),
"type": "crash",
})
await bus.finalize(me, "crashed" if crashed else "completed")
except Exception:
logger.exception("on_agent_end failed")
async def on_tool_start(self, context: RunContextWrapper, agent, tool) -> None:
try:
if tracer := context.context.get("tracer"):
tracer.log_tool_start(context.context.get("agent_id", "?"), tool.name)
except Exception:
logger.exception("on_tool_start failed")
# F2: on_tool_end's `result` param is typed `str` in the SDK.
async def on_tool_end(
self, context: RunContextWrapper, agent, tool, result: str,
) -> None:
try:
if tool.name in ("agent_finish", "finish_scan"):
context.context["agent_finish_called"] = True
if tracer := context.context.get("tracer"):
tracer.log_tool_end(context.context.get("agent_id", "?"), tool.name, result)
except Exception:
logger.exception("on_tool_end failed")
async def on_handoff(self, context: RunContextWrapper, from_agent, to_agent) -> None:
# We don't use SDK handoffs in Strix; multi-agent goes through bus.
pass
```
**Tests:** crash detection fires when agent_finish not called; warnings injected at thresholds; usage recording aggregates.
---
### 2.6 `strix/tools/_decorator.py`
```python
from agents import function_tool
def strix_tool(*, timeout: float = 120.0, timeout_behavior: str = "error_as_result", **kwargs):
"""function_tool with Strix defaults: 120s timeout, error-as-result behavior.
SDK auto-threads sync function bodies via asyncio.to_thread (tool.py:1820-1829),
so writing `def foo(...)` (sync) works for libtmux/IPython/etc.
"""
return function_tool(
timeout=timeout,
timeout_behavior=timeout_behavior,
**kwargs,
)
```
**Tests:** decorated tool gets timeout; timeout returns error string not exception; sync function auto-threads.
---
### 2.7 `strix/llm/multi_provider_setup.py`
```python
from agents.models.multi_provider import MultiProvider, MultiProviderMap
from agents.models.interface import ModelProvider, Model
from agents.extensions.models.litellm_model import LitellmModel
from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel
# Strix model alias map
STRIX_MODEL_MAP = {
"claude-sonnet-4.6": ("anthropic/claude-sonnet-4-5-20250929", "claude-sonnet-4-5"),
# ... port from strix/llm/utils.py:STRIX_MODEL_MAP
}
STRIX_API_BASE = "https://models.strix.ai/api/v1"
class StrixModelProvider(ModelProvider):
"""Resolve `strix/<short>` aliases via STRIX_MODEL_MAP, route to proxy."""
def get_model(self, model_name: str | None) -> Model:
if model_name is None:
raise ValueError("Model name required")
api_model, _canonical = STRIX_MODEL_MAP.get(model_name, (model_name, model_name))
# Anthropic-prefixed → cache wrapper; otherwise vanilla
if api_model.startswith("anthropic/") or "claude" in api_model.lower():
return AnthropicCachingLitellmModel(model=api_model, base_url=STRIX_API_BASE)
return LitellmModel(model=api_model, base_url=STRIX_API_BASE)
class LitellmAnthropicProvider(ModelProvider):
"""Resolve `litellm/anthropic/...` directly to AnthropicCachingLitellmModel."""
def get_model(self, model_name: str | None) -> Model:
# model_name arrives post-prefix-strip, e.g. "anthropic/claude-3-5-sonnet"
return AnthropicCachingLitellmModel(model=model_name)
def build_multi_provider() -> MultiProvider:
pmap = MultiProviderMap()
pmap.add_provider("strix", StrixModelProvider())
pmap.add_provider("litellm/anthropic", LitellmAnthropicProvider())
# default openai/* and others fall through to MultiProvider's built-in routing
return MultiProvider(provider_map=pmap)
```
**Tests:** alias resolution; route `strix/claude-sonnet-4.6``AnthropicCachingLitellmModel("anthropic/claude-sonnet-4-5-20250929", base_url=STRIX_API_BASE)`; unknown prefix falls through.
---
### 2.8 `strix/llm/strix_session.py`
```python
import logging
from typing import Any
from agents.memory.session import SessionABC
logger = logging.getLogger(__name__)
class StrixSession(SessionABC):
"""Wraps an underlying Session; injects memory compression at 90K tokens.
On compressor failure: returns uncompressed history (C10) and logs warning.
Downstream context-window error is itself retryable.
"""
def __init__(self, underlying: SessionABC, compressor):
self._underlying = underlying
self._compressor = compressor # Strix's existing MemoryCompressor
async def get_items(self, limit: int | None = None) -> list[Any]:
items = await self._underlying.get_items(limit=limit)
try:
return await self._compressor.compress_history(items)
except Exception as e:
logger.warning("Memory compression failed (%s) — returning uncompressed", e)
return items
async def add_items(self, items: list[Any]) -> None:
await self._underlying.add_items(items)
async def pop_item(self) -> Any | None:
return await self._underlying.pop_item()
async def clear_session(self) -> None:
await self._underlying.clear_session()
```
**Tests:** compression triggers > 90K tokens; failure returns uncompressed; underlying contract satisfied.
---
### 2.9 `strix/telemetry/strix_processor.py`
```python
import json
import logging
import threading
from pathlib import Path
from typing import Any
from agents.tracing.processor_interface import TracingProcessor
from agents.tracing.spans import Span
from agents.tracing.traces import Trace
from strix.telemetry.utils import TelemetrySanitizer
logger = logging.getLogger(__name__)
_FILE_LOCKS: dict[Path, threading.Lock] = {}
_GUARD = threading.Lock()
def _lock_for(path: Path) -> threading.Lock:
with _GUARD:
return _FILE_LOCKS.setdefault(path, threading.Lock())
class StrixTracingProcessor(TracingProcessor):
"""Custom processor: writes events.jsonl in our schema, scrubs PII, supports OTel.
C7 — JSONL writes locked via per-path threading.Lock.
C16 — every write wrapped in try/except so disk-full doesn't tear down the run.
F3 — every hook method is SYNC (def, not async def) per SDK contract.
"""
def __init__(self, run_dir: Path, sanitizer: TelemetrySanitizer | None = None):
self.events_path = run_dir / "events.jsonl"
self.run_dir = run_dir
self.sanitizer = sanitizer or TelemetrySanitizer()
def _emit(self, event: dict[str, Any]) -> None:
try:
clean = self.sanitizer.sanitize(event)
with _lock_for(self.events_path):
with self.events_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(clean, ensure_ascii=True) + "\n")
except OSError:
logger.exception("Failed to write event to JSONL")
def on_trace_start(self, trace: Trace) -> None: # SYNC — F3
self._emit({
"event_type": "run.started",
"trace_id": trace.trace_id,
"metadata": getattr(trace, "metadata", {}),
})
def on_trace_end(self, trace: Trace) -> None: # SYNC — F3
self._emit({"event_type": "run.completed", "trace_id": trace.trace_id})
def on_span_start(self, span: Span[Any]) -> None: # SYNC — F3
sd = type(span.span_data).__name__
if sd in ("AgentSpanData", "GenerationSpanData", "FunctionSpanData"):
self._emit({
"event_type": f"{sd.replace('SpanData', '').lower()}.started",
"span_id": span.span_id,
"trace_id": span.trace_id,
"data": span.span_data.export(),
})
def on_span_end(self, span: Span[Any]) -> None: # SYNC — F3
sd = type(span.span_data).__name__
self._emit({
"event_type": f"{sd.replace('SpanData', '').lower()}.completed",
"span_id": span.span_id,
"trace_id": span.trace_id,
"data": span.span_data.export(),
})
def force_flush(self) -> None: # SYNC — F3
# All writes are sync; nothing to flush.
pass
def shutdown(self) -> None: # SYNC — F3
pass
```
**Tests:** concurrent writes don't corrupt; PII scrubbed; OTel passthrough optional.
---
### 2.10 `strix/run_config_factory.py`
```python
from pathlib import Path
from agents import RunConfig
from agents.sandbox import SandboxRunConfig
from agents.model_settings import ModelSettings
from agents.retry import ModelRetrySettings, ModelRetryBackoffSettings, retry_policies
from strix.runtime.strix_docker_client import StrixDockerSandboxClient
from strix.orchestration.bus import AgentMessageBus
from strix.orchestration.filter import inject_messages_filter
from strix.llm.multi_provider_setup import build_multi_provider
# Phase 1-5 default. Phase 6 flips parallel_tool_calls=True after relaxing tool server.
_PHASE1_PARALLEL_DEFAULT = False
def make_run_config(
*,
sandbox_session,
bus: AgentMessageBus,
model: str = "strix/claude-sonnet-4.6",
max_turns: int = 300,
) -> RunConfig:
return RunConfig(
model=model,
model_provider=build_multi_provider(),
model_settings=ModelSettings(
parallel_tool_calls=_PHASE1_PARALLEL_DEFAULT, # C1 safe default
tool_choice="required",
retry=ModelRetrySettings(
max_retries=5,
backoff=ModelRetryBackoffSettings(
initial_delay=2.0, multiplier=2.0, max_delay=90.0, jitter=0.0,
),
# C11: explicitly does NOT include 401/403/400
policy=retry_policies.any(
retry_policies.network_error(),
retry_policies.http_status([429, 500, 502, 503, 504]),
),
),
),
sandbox=SandboxRunConfig(
client=StrixDockerSandboxClient(),
session=sandbox_session, # shared across all agents in the run
),
call_model_input_filter=inject_messages_filter,
isolate_parallel_failures=False, # C1 — don't cascade-cancel siblings
tracing_disabled=False,
trace_include_sensitive_data=False,
max_turns=max_turns,
)
def make_agent_context(
*,
bus: AgentMessageBus,
sandbox_session,
sandbox_token: str,
tool_server_host_port: int,
caido_host_port: int,
agent_id: str,
agent_name: str,
parent_id: str | None,
tracer,
model_settings,
max_turns: int = 300,
) -> dict:
return {
"bus": bus,
"sandbox_session": sandbox_session,
"sandbox_token": sandbox_token,
"tool_server_host_port": tool_server_host_port,
"caido_host_port": caido_host_port,
"agent_id": agent_id,
"agent_name": agent_name,
"parent_id": parent_id,
"tracer": tracer,
"model_settings": model_settings,
"max_turns": max_turns,
"turn_count": 0,
"agent_finish_called": False,
}
```
**Tests:** RunConfig has all defaults; retry policy excludes 401; safe defaults for parallel.
---
## 3. Sandbox + Caido capability
### 3.1 `strix/sandbox/healthcheck.py`
```python
import asyncio
import httpx
class SandboxNotReadyError(Exception):
pass
async def wait_for_ports_ready(ports: list[int], timeout: float = 30.0, interval: float = 0.5) -> None:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
all_ok = True
async with httpx.AsyncClient(timeout=5.0) as client:
for port in ports:
try:
r = await client.get(f"http://localhost:{port}/health")
if r.status_code != 200:
all_ok = False
break
except (httpx.RequestError, httpx.TimeoutException):
all_ok = False
break
if all_ok:
return
await asyncio.sleep(interval)
raise SandboxNotReadyError(f"Ports {ports} not ready after {timeout}s")
```
### 3.2 `strix/sandbox/caido_capability.py`
```python
import asyncio
from typing import Literal
from pydantic import Field
from agents.sandbox.capabilities.capability import Capability
from agents.sandbox.manifest import Manifest
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.tool import Tool
# 7 Caido tools defined in strix/tools/caido/*.py and imported here
from strix.tools.caido import (
list_requests, view_request, send_request, repeat_request,
scope_rules, list_sitemap, view_sitemap_entry,
)
class CaidoCapability(Capability):
"""Caido HTTPS proxy + 7 GraphQL function tools + system prompt block."""
type: Literal["caido"] = "caido"
_healthcheck_task: asyncio.Task | None = Field(default=None, exclude=True)
def process_manifest(self, manifest: Manifest) -> Manifest:
env = dict(manifest.environment.value or {})
env.update({
"http_proxy": "http://127.0.0.1:48080",
"https_proxy": "http://127.0.0.1:48080",
"ALL_PROXY": "http://127.0.0.1:48080",
})
manifest.environment.value = env
return manifest
def tools(self) -> list[Tool]:
return [list_requests, view_request, send_request, repeat_request,
scope_rules, list_sitemap, view_sitemap_entry]
async def instructions(self, manifest: Manifest) -> str | None:
return (
"<caido_proxy>\n"
"All HTTP/HTTPS traffic in this sandbox is captured by Caido (localhost:48080).\n"
"Available tools: list_requests, view_request, send_request, repeat_request, "
"scope_rules, list_sitemap, view_sitemap_entry.\n"
"HTTPQL filter syntax: request.method == 'POST' && response.status >= 400.\n"
"</caido_proxy>"
)
def bind(self, session: BaseSandboxSession) -> None:
super().bind(session)
from strix.sandbox.healthcheck import wait_for_ports_ready
# Phase 4: caido :48080 + tool server :48081
self._healthcheck_task = asyncio.create_task(wait_for_ports_ready([48080, 48081]))
```
`StrixOrchestrationHooks.on_agent_start` awaits `cap._healthcheck_task` (already in 2.5 above).
### 3.3 `strix/sandbox/session_manager.py`
```python
import socket
import secrets
from pathlib import Path
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
from agents.sandbox.manifest import Manifest
from agents.sandbox.entries import LocalDir
from strix.runtime.strix_docker_client import StrixDockerSandboxClient
from strix.sandbox.caido_capability import CaidoCapability
from strix.sandbox.healthcheck import wait_for_ports_ready
_session_cache = {} # scan_id -> session
def _alloc_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
async def create_or_reuse(scan_id: str, image: str, sources_path: Path):
if scan_id in _session_cache:
return _session_cache[scan_id]
bearer = secrets.token_urlsafe(32)
manifest = Manifest(
entries={"sources": LocalDir(src=sources_path)},
environment={
"TOOL_SERVER_TOKEN": bearer,
"TOOL_SERVER_PORT": "48081",
"CAIDO_PORT": "48080",
"STRIX_SANDBOX_EXECUTION_TIMEOUT": "120",
"PYTHONUNBUFFERED": "1",
},
capabilities=[CaidoCapability()],
)
client = StrixDockerSandboxClient()
options = DockerSandboxClientOptions(image=image, exposed_ports=(48080, 48081))
session = await client.create(options=options, manifest=manifest)
# Resolve mapped host ports (sandbox/sandboxes/docker.py:211-260)
tool_server_endpoint = await session._resolve_exposed_port(48081)
caido_endpoint = await session._resolve_exposed_port(48080)
await wait_for_ports_ready([tool_server_endpoint.port, caido_endpoint.port])
bundle = {
"client": client,
"session": session,
"tool_server_host_port": tool_server_endpoint.port,
"caido_host_port": caido_endpoint.port,
"bearer": bearer,
}
_session_cache[scan_id] = bundle
return bundle
async def cleanup(scan_id: str) -> None:
bundle = _session_cache.pop(scan_id, None)
if bundle is None:
return
try:
await bundle["client"].delete(bundle["session"])
except Exception:
pass # best-effort orphan reaping
```
### 3.4 `strix/tools/_sandbox_dispatch.py`
```python
import httpx
from typing import Any
from agents import RunContextWrapper
_TIMEOUT = httpx.Timeout(connect=10.0, read=150.0, write=150.0, pool=150.0)
async def post_to_sandbox(
ctx: RunContextWrapper, tool_name: str, kwargs: dict[str, Any],
) -> dict[str, Any]:
"""POST tool invocation to FastAPI tool server via host-mapped port.
Returns {"result": ...} or {"error": ...}; never raises.
"""
port = ctx.context.get("tool_server_host_port")
token = ctx.context.get("sandbox_token")
agent_id = ctx.context.get("agent_id", "unknown")
if not (port and token):
return {"error": "Sandbox not initialized"}
url = f"http://127.0.0.1:{port}/execute"
body = {"agent_id": agent_id, "tool_name": tool_name, "kwargs": kwargs}
headers = {"Authorization": f"Bearer {token}"}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(url, json=body, headers=headers)
if r.status_code == 401:
return {"error": "Sandbox auth failed"}
if r.status_code >= 400:
return {"error": f"Sandbox HTTP {r.status_code}: {r.text[:300]}"}
try:
return r.json()
except ValueError:
return {"error": f"Invalid sandbox response: {r.text[:200]}"}
except httpx.TimeoutException:
return {"error": f"Sandbox timeout (>{_TIMEOUT.read}s)"}
except httpx.RequestError as e:
return {"error": f"Sandbox request failed: {e!s}"[:300]}
```
---
## 4. Per-tool migration contracts
Compact table — one row per tool, expanded notes for non-trivial ones.
### 4.1 Sandbox tools (POST to tool server)
Every sandbox tool follows the same shape:
```python
from strix.tools._decorator import strix_tool
from strix.tools._sandbox_dispatch import post_to_sandbox
from agents import RunContextWrapper, ToolOutputText, ToolOutputImage
@strix_tool(timeout=160) # outer SDK > inner httpx (150)
async def <name>(ctx: RunContextWrapper, ...args) -> str | list:
result = await post_to_sandbox(ctx, "<server_tool_name>", {<kwargs>})
if "error" in result:
return result["error"]
# Tool-specific result shaping (extract screenshot, truncate, etc.)
return _shape_result(result)
```
| Tool | Server-side name | Args | Return shape | Notes |
|---|---|---|---|---|
| `browser_action` | `browser_action` | `action: str, **action_kwargs` | `[ToolOutputImage(base64), ToolOutputText(state)]` | Extract `screenshot` key as image; rest as text. 24 actions. Truncate page-source/console at server. |
| `terminal_execute` | `terminal_execute` | `command: str, is_input=False, timeout=30, terminal_id="default", no_enter=False` | `ToolOutputText(content + status + exit_code)` | Per-`(agent_id, terminal_id)` libtmux session at server. |
| `python_action` | `python_action` | `action: str, code: str | None, session_id="default", timeout=30` | `ToolOutputText(stdout + stderr + result_repr)` | Per-`(agent_id, session_id)` IPython kernel. |
| `list_requests` | `list_requests` | `httpql_filter: str | None, start_page=1, end_page=1, page_size=50, sort_by="timestamp", sort_order="desc"` | `ToolOutputText(json)` | Caido. |
| `view_request` | `view_request` | `request_id: str, search_pattern: str | None, part="response"` | `ToolOutputText(json)` | Caido. |
| `send_request` | `send_request` | `method: str, url: str, headers={}, body=""` | `ToolOutputText(json)` | Caido. |
| `repeat_request` | `repeat_request` | `request_id: str, modifications: dict` | `ToolOutputText(json)` | Caido. |
| `scope_rules` | `scope_rules` | `action: Literal["list","create","update","delete"], **kwargs` | `ToolOutputText(json)` | Caido. |
| `list_sitemap` | `list_sitemap` | `parent_id: str | None, page_size=50` | `ToolOutputText(json)` | Caido. |
| `view_sitemap_entry` | `view_sitemap_entry` | `node_id: str` | `ToolOutputText(json)` | Caido. |
### 4.2 Local tools (in-process)
| Tool | Module | Signature | Notes |
|---|---|---|---|
| `create_note` / `list_notes` / `get_note` / `update_note` / `delete_note` | `strix/tools/notes/` | per Strix today | **Apply C6**: lock JSONL writes. Per-agent state silo via `ctx.context["agent_id"]`. |
| `create_todo` / `list_todos` / `update_todo` / `mark_todo_done` / `mark_todo_pending` / `delete_todo` | `strix/tools/todo/` | per Strix today | In-memory only; not persisted. |
| `create_vulnerability_report` | `strix/tools/reporting/` | All current required+optional fields | Calls existing `llm/dedupe.py` via nested `Runner.run` or direct `litellm.acompletion`. Wires `vulnerability_found_callback` via `ToolOutputGuardrail` for TUI popup. |
| `web_search` | `strix/tools/web_search/` | `query: str` | Direct Perplexity API; do NOT use SDK's `WebSearchTool` (Responses-only, OpenAI-only). |
| `str_replace_editor` / `list_files` / `search_files` | `strix/tools/file_edit/` | per Strix today | Wrap openhands-aci + ripgrep. SDK auto-threads sync. |
| `finish_scan` | `strix/tools/finish/` | per Strix today | Root-only guard: `if ctx.context["parent_id"] is not None: return error`. Sets `ctx.context["agent_finish_called"] = True`. **Root agent uses `tool_use_behavior={"stop_at_tool_names": ["finish_scan"]}`.** |
| `think` | `strix/tools/thinking/` | `thought: str` | Trivial; `return f"Recorded {len(thought)} chars"`. |
| `load_skill` | `strix/tools/load_skill/` | `skills: list[str]` (max 5) | Returns skill content as `ToolOutputText` (accepted tradeoff). Validates against skill registry. |
### 4.3 Multi-agent graph tools
All in `strix/tools/agents_graph.py`. Use `bus = ctx.context["bus"]`, `me = ctx.context["agent_id"]`.
```python
@strix_tool(timeout=30)
async def view_agent_graph(ctx) -> str:
bus = ctx.context["bus"]
lines = []
roots = [aid for aid, p in bus.parent_of.items() if p is None]
def render(aid, depth):
st = bus.statuses.get(aid, "?")
lines.append(" " * depth + f"- {bus.names.get(aid, aid)} ({aid}) [{st}]")
for c, p in bus.parent_of.items():
if p == aid:
render(c, depth + 1)
for r in roots:
render(r, 0)
return "\n".join(lines) or "(no agents)"
@strix_tool(timeout=30)
async def agent_status(ctx, agent_id: str) -> str:
bus = ctx.context["bus"]
if agent_id not in bus.statuses:
return f"Unknown agent {agent_id}"
return (
f"agent={bus.names.get(agent_id)} status={bus.statuses[agent_id]} "
f"parent={bus.parent_of.get(agent_id)} "
f"pending_msgs={len(bus.inboxes.get(agent_id, []))}"
)
@strix_tool(timeout=30)
async def send_message_to_agent(ctx, target_agent_id: str, message: str,
message_type: str = "info", priority: str = "normal") -> str:
bus = ctx.context["bus"]
if target_agent_id not in bus.statuses:
return f"Unknown agent {target_agent_id}"
await bus.send(target_agent_id, {
"from": ctx.context["agent_id"],
"content": message,
"type": message_type,
"priority": priority,
})
return f"Queued for {target_agent_id}"
@strix_tool(timeout=601)
async def wait_for_message(ctx, reason: str, timeout_seconds: int = 600) -> str:
import asyncio
bus = ctx.context["bus"]
me = ctx.context["agent_id"]
bus.statuses[me] = "waiting"
end = asyncio.get_event_loop().time() + timeout_seconds
while asyncio.get_event_loop().time() < end:
if bus.inboxes.get(me):
bus.statuses[me] = "running"
return "Message arrived. Continue."
await asyncio.sleep(1.0)
bus.statuses[me] = "running"
return f"Timed out after {timeout_seconds}s. Continue or call agent_finish."
@strix_tool(timeout=120)
async def create_agent(
ctx, name: str, task: str,
inherit_context: bool = True, skills: list[str] | None = None,
) -> str:
import asyncio, uuid
from agents import Runner
from strix.run_config_factory import make_run_config, make_agent_context
from strix.orchestration.hooks import StrixOrchestrationHooks
from strix.agents.factory import build_strix_agent # build child Agent w/ tool_use_behavior
bus = ctx.context["bus"]
parent = ctx.context["agent_id"]
cid = uuid.uuid4().hex[:8]
await bus.register(cid, name, parent)
child_agent = build_strix_agent(name=name, skills=skills or [], is_root=False)
# Inherited context: parent's session items
parent_session = ctx.context.get("session")
history = await parent_session.get_items() if (inherit_context and parent_session) else []
initial_input = history + [
# Identity injection (replaces Strix's <agent_delegation> XML)
{"role": "user", "content": f"<agent_delegation>You are agent {name} ({cid}). "
f"Parent is {parent}. Do not echo this block.</agent_delegation>"},
{"role": "user", "content": task},
]
child_ctx = make_agent_context(
bus=bus,
sandbox_session=ctx.context["sandbox_session"],
sandbox_token=ctx.context["sandbox_token"],
tool_server_host_port=ctx.context["tool_server_host_port"],
caido_host_port=ctx.context["caido_host_port"],
agent_id=cid,
agent_name=name,
parent_id=parent,
tracer=ctx.context["tracer"],
model_settings=ctx.context["model_settings"],
max_turns=ctx.context["max_turns"],
)
bus.tasks[cid] = asyncio.create_task(
Runner.run(
child_agent,
input=initial_input,
run_config=make_run_config(
sandbox_session=ctx.context["sandbox_session"],
bus=bus,
model=ctx.context.get("model", "strix/claude-sonnet-4.6"),
max_turns=ctx.context["max_turns"],
),
context=child_ctx,
hooks=StrixOrchestrationHooks(),
)
)
return f"Spawned {name} ({cid}) running in parallel"
@strix_tool(timeout=30)
async def agent_finish(
ctx, result_summary: str, findings: list[dict] | None = None,
success: bool = True, report_to_parent: bool = True,
final_recommendations: list[str] | None = None,
) -> str:
bus = ctx.context["bus"]
me = ctx.context["agent_id"]
parent = bus.parent_of.get(me)
if parent is None:
return "Error: agent_finish is for subagents. Root must call finish_scan."
ctx.context["agent_finish_called"] = True
if report_to_parent:
report_xml = (
f"<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"})
# Whitebox wiki update (M10 — preserved)
if ctx.context.get("is_whitebox") and findings:
# Append delta to shared wiki note via existing notes API
from strix.tools.notes.notes_actions import append_note_content
append_note_content("wiki", f"\n## Update from {bus.names.get(me)}\n{result_summary}\n")
return "Reported to parent. This agent will exit."
```
**Every child agent built by `build_strix_agent`:** `tool_use_behavior={"stop_at_tool_names": ["agent_finish"]}` (C4).
---
## 5. Standards: logging, error formatting, observability
### 5.1 Logging
| Logger | Level | When |
|---|---|---|
| `strix.orchestration.bus` | DEBUG | every send/drain/finalize |
| `strix.orchestration.hooks` | INFO | agent start/end; WARNING on crash detection |
| `strix.tools.<name>` | DEBUG | tool entry/exit; WARNING on user-recoverable error |
| `strix.runtime.strix_docker_client` | INFO | container created; ERROR on creation failure |
| `strix.sandbox.caido_capability` | INFO | bind/healthcheck-task lifecycle |
| `strix.sandbox.session_manager` | INFO | session create/reuse/cleanup |
| `strix.llm.anthropic_cache_wrapper` | DEBUG | cache_control injection events |
| `strix.telemetry.strix_processor` | WARNING | sanitization mismatches |
Verbose mode flag: `--verbose``agents.set_default_logging("DEBUG")` + `enable_verbose_stdout_logging()`. Production keeps `OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1` (default) so model I/O isn't logged.
Never logged anywhere: `LLM_API_KEY`, `TOOL_SERVER_TOKEN`, anything the existing `TelemetrySanitizer` patterns match.
### 5.2 Error formatting
| Layer | Recipient | Format |
|---|---|---|
| Tool body | LLM | Plain `str` returned from tool. Convention: `"Error: <reason>"` for failures. |
| Tool body | User | Visible only via TUI when popup-worthy (vulnerability popup hooks via `ToolOutputGuardrail`). |
| Sandbox dispatch | LLM | `{"error": "..."}` → returned as `Error: ...` string. |
| Hook | Logs only | `logger.exception(...)` for unhandled in hooks; never propagated (would tear down the run). |
| Agent crash | Parent | `<agent_crash agent_id='X' name='Y'>...</agent_crash>` user message via bus. |
| Run-level exception | CLI | Exit code 1 (general failure), 2 (vulns found in headless), 130 (Ctrl+C). |
| Run-level exception | Trace | `error.kind`, `error.message`, `error.traceback` span attributes (post-scrub). |
### 5.3 Observability during migration
While both old (Strix) and new (SDK-based) live in parallel:
- **Comparison metrics**: per-run, log `total_input_tokens`, `total_output_tokens`, `cost`, `findings_count`, `wall_clock_seconds`. Diff old vs new with same target set.
- **Drift detection**: identical `--target` should produce equivalent (not byte-identical) findings. Track findings-by-CWE histogram.
- **Canary mode**: feature flag `STRIX_USE_SDK_HARNESS=1` or separate CLI command (`strix-sdk`) until cut-over. Two CLIs let users opt in.
- **Event compatibility**: `events.jsonl` schema must remain compatible — the custom `TracingProcessor` emits the same `event_type` values as today's tracer.
---
## 6. Test plans per phase
### Phase 0 — Spike & corrections
**Smoke tests** (must all pass before Phase 1 starts):
- `test_anthropic_cache_wrapper_injection` — system message has `cache_control`; non-Anthropic passes through.
- `test_anthropic_cache_hit_rate` — three identical-prompt calls; call 2+ shows `cache_read_input_tokens > 0`.
- `test_strix_docker_client_caps` — mock `containers.create`; assert `cap_add ⊇ {NET_ADMIN, NET_RAW}` and `extra_hosts["host.docker.internal"]=="host-gateway"`.
- `test_strix_docker_client_live` — real Kali image; `session.exec(["nmap", "-sS", "scanme.nmap.org"])` returns exit 0.
- `test_message_bus_two_children_exchange` — minimal `MessageBus` with two synthetic agents; verify FIFO message delivery via filter injection.
- `test_tool_use_behavior_stop_on_finish` — toy agent with `agent_finish`-equivalent + `stop_at_tool_names`; assert SDK terminates exactly when tool is called.
- `test_tool_server_concurrent_same_agent_calls` — issue two simultaneous POSTs to local tool server with same `agent_id`; current code cancels first; observe behavior; choose Phase 1 default (`parallel_tool_calls=False`).
### Phase 1 — Foundation
**Unit tests:**
- `MultiProvider` resolution: `strix/claude-sonnet-4.6``AnthropicCachingLitellmModel` with correct base URL.
- `strix_tool` decorator: timeout applied; sync auto-threaded.
- `StrixSession`: 100K-token history triggers compression; compressor failure returns uncompressed.
- `StrixTracingProcessor`: 100 concurrent span writes, JSONL is valid (no interleaved bytes); PII patterns scrubbed.
- `RunConfig` factory: retry policy excludes 401 explicitly; `parallel_tool_calls=False` default.
**Integration tests:**
- Full single-agent run with `LitellmModel` against an Anthropic mock; verify cache_control reaches the model.
- Cost tracking via `RunHooks.on_llm_end` matches `litellm.cost_per_token` calculation within 1%.
### Phase 2 — Tool ports
**Per-tool smoke tests** (one per `@strix_tool`):
- Browser: `launch``goto("https://example.com")` → screenshot returned as `ToolOutputImage`; viewport 1280x720; per-agent tab keying.
- Terminal: `terminal_execute("echo hello")` → status=completed, exit_code=0. Special key: `terminal_execute("C-c", is_input=True)` no error.
- Python: `python_action(action="execute", code="x=1; x+1")` → result `"2"`.
- Caido: `list_requests(httpql_filter="request.method == 'GET'")` returns JSON with `requests` array.
- Notes: concurrent `create_note` from two agents; assert JSONL parses line-by-line (C6 fix).
- Reporting: full `create_vulnerability_report` payload; CVSS computed; persisted to `vulnerabilities/vuln_*.json`; dedup roundtrip works.
- File edit: `str_replace_editor("create", "/workspace/foo.txt", file_text="x")` → file exists.
**Reentrancy tests:** if/when Phase 6 enables parallel: assert two parallel `terminal_execute` calls on same agent with different `terminal_id` don't collide (different libtmux sessions).
### Phase 3 — Multi-agent
**Unit tests:**
- `AgentMessageBus`: 1000 concurrent `send`/`drain` ops; FIFO maintained; `total_stats` snapshot consistent.
- `inject_messages_filter`: pending messages appended in order; user-from-user skips XML wrap; idempotent under retry simulation.
- `StrixOrchestrationHooks.on_agent_end`: crash detection fires when `agent_finish_called` False; sends `<agent_crash>` to parent.
- `bus.cancel_descendants`: tree of N=10 agents; all cancelled leaf-first.
**Integration:**
- Root spawns 2 children in parallel; children exchange messages; both finish via `agent_finish`; root reads completion reports; `bus.total_stats` aggregates correctly.
- Root + 1 child; user Ctrl+C mid-run; both tasks cancelled; processor flushes all pending events.
- Crashed child scenario: child `Runner.run` raises `RuntimeError`; parent receives `<agent_crash>` within next turn.
### Phase 4 — Sandbox + Caido
**Integration:**
- `create_or_reuse(scan_id="x", ...)` twice → same session; `session.exec(["echo", "hi"])` works.
- Caido capability: container has `http_proxy=http://127.0.0.1:48080` env; `curl https://example.com` is captured by Caido (verify via `list_requests` shows the request).
- Healthcheck: stop tool server inside container; `wait_for_ports_ready` raises after 30s.
### Phase 5 — Interface + persistence
**Tests:**
- `StrixStreamAccumulator`: feed mock `RunResultStreaming` events; tracer's `streaming_content[agent_id]` updates.
- TUI: under simulated agent run, refresh latency < 500ms after each LLM chunk.
- Run-directory: post-run, `events.jsonl` valid; `vulnerabilities/` populated; `penetration_test_report.md` non-empty.
### Phase 6 — Validation + tool server relaxation
**End-to-end:**
- Full pentest against a stable target (a vulnerable webapp pinned in tests). Compare findings (count + CVSS distribution + CWE histogram) vs Strix baseline within agreed tolerance.
- Stress: 3 child agents, parallel tool calls within turn, no JSONL corruption, no message loss.
- After tool server relaxation: `parallel_tool_calls=True`; multi-tool turn fires correctly; sibling failure isolation works.
---
## 7. Rollback, CI, cross-cutting
### 7.1 Branching & cutover
- Branch: `harness-migration` (already created).
- Cutover trigger: Phase 6 acceptance criteria met (golden-output diff within tolerance).
- Pre-cutover: SDK-based path lives behind `STRIX_USE_SDK_HARNESS=1`. Old path unchanged.
- Cutover: flip default to SDK-based; legacy path stays for one release as fallback.
- Removal of legacy: after one release with no rollback signals.
### 7.2 Rollback procedure
If post-cut regression discovered:
1. Set `STRIX_USE_SDK_HARNESS=0` in deployed config (re-uses legacy harness).
2. Investigate via traces/events.jsonl from the failed run.
3. Fix, re-run end-to-end suite, re-cut.
### 7.3 Data compatibility
- `events.jsonl` schema preserved (custom processor emits same `event_type`s).
- `vulnerabilities/vuln_*.json` schema preserved (same Pydantic shape).
- `penetration_test_report.md` schema preserved.
- `notes/notes.jsonl` schema preserved.
- Old runs are still analyzable by new tooling (no migration script needed).
### 7.4 SDK version pinning
- `pyproject.toml`: `openai-agents==0.14.6` (exact pin while we duplicate `_create_container`).
- CI nightly: re-run our Phase 0 spike against SDK `latest`. If green, bump pin and re-test.
- Track upstream PR to add `additional_create_kwargs` hook that would let us drop the body duplication.
### 7.5 CI changes
- New test buckets: `tests/orchestration/`, `tests/sandbox/`, `tests/llm_wrappers/`, `tests/integration/`.
- Snapshot tests for golden outputs (one or two stable targets).
- Mypy strict on new SDK types; install `openai-agents` package types.
- Lint rules for tool decorators (`@strix_tool` not bare `@function_tool` for new tools).
### 7.6 Drop / add deps
- Drop direct: `litellm[proxy]>=1.81.1,<1.82.0` (becomes transitive via `openai-agents[litellm]`).
- Add: `openai-agents[litellm]==0.14.6`.
- Keep: `tenacity`, `pydantic`, `rich`, `docker`, `textual`, `cvss`, `traceloop-sdk`, `opentelemetry-exporter-otlp-proto-http`, `scrubadub`, `defusedxml`.
---
## 8. Phase ordering and day-1 commit list
### Phase ordering (compressed)
- **Phase 0** — Foundations + spikes. Land the 3 wrapper classes, run all Phase 0 smoke tests.
- **Phase 1** — Foundation files (rest), provider layer, `strix_tool`, custom `Session`, custom `TracingProcessor`, `RunConfig` factory.
- **Phase 2** — Tool ports. Sandbox dispatcher first, then all 30+ tools incrementally.
- **Phase 3** — Multi-agent: bus, filter, hooks, six graph tools. End-to-end parallel-children test.
- **Phase 4** — `CaidoCapability` + healthcheck + session cache by scan_id.
- **Phase 5** — TUI / streaming accumulator / run-directory persistence / turn warnings.
- **Phase 6** — Validation + tool server relaxation + parallel tool calls flip.
### Day-1 commits (Phase 0)
In order; each is independent until linked at Phase 0 acceptance.
1. `strix/llm/anthropic_cache_wrapper.py``AnthropicCachingLitellmModel`. Tests: cache injection + non-Anthropic passthrough.
2. `strix/runtime/strix_docker_client.py``StrixDockerSandboxClient`. Tests: kwarg injection mock + live nmap.
3. `strix/orchestration/bus.py``AgentMessageBus`. Tests: concurrency stress + cancel_descendants.
4. `strix/orchestration/filter.py``inject_messages_filter`. Tests: FIFO + retry-stable.
5. `strix/orchestration/hooks.py``StrixOrchestrationHooks`. Tests: crash detection + warning injection.
6. `strix/tools/_decorator.py``strix_tool`. Tests: timeout + sync auto-threading.
7. `strix/llm/multi_provider_setup.py``MultiProviderMap` + `StrixModelProvider`. Tests: alias resolution.
### Phase 0 acceptance gate
All seven module unit tests green. The seven Phase 0 smoke tests in §6 green. Tool server concurrent-call test executed; result documented. Only then proceed to Phase 1.
---
*This playbook merges every audit finding into a single engineering reference. When implementation discovers something the playbook missed, treat it as a bug in the playbook — update this file, then write the code.*