Files
strix/PLAYBOOK.md
T
0xallam a35a4a22b1 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>
2026-04-24 23:37:41 -07:00

60 KiB
Raw Blame 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
  2. Foundation files (concrete skeletons)
  3. Sandbox + Caido capability
  4. Per-tool migration contracts
  5. Standards: logging, error formatting, observability
  6. Test plans per phase
  7. Rollback, CI, cross-cutting
  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

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

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

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

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

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

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

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.6AnthropicCachingLitellmModel("anthropic/claude-sonnet-4-5-20250929", base_url=STRIX_API_BASE); unknown prefix falls through.


2.8 strix/llm/strix_session.py

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

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

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

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

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

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

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:

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)
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)
view_request view_request `request_id: str, search_pattern: str None, part="response"` ToolOutputText(json)
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)
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"].

@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: --verboseagents.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.6AnthropicCachingLitellmModel 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: launchgoto("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_types).
  • 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 4CaidoCapability + 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.pyAnthropicCachingLitellmModel. Tests: cache injection + non-Anthropic passthrough.
  2. strix/runtime/strix_docker_client.pyStrixDockerSandboxClient. Tests: kwarg injection mock + live nmap.
  3. strix/orchestration/bus.pyAgentMessageBus. Tests: concurrency stress + cancel_descendants.
  4. strix/orchestration/filter.pyinject_messages_filter. Tests: FIFO + retry-stable.
  5. strix/orchestration/hooks.pyStrixOrchestrationHooks. Tests: crash detection + warning injection.
  6. strix/tools/_decorator.pystrix_tool. Tests: timeout + sync auto-threading.
  7. strix/llm/multi_provider_setup.pyMultiProviderMap + 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.