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