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>
60 KiB
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-agentsv0.14.6 at/tmp/openai-agents. Strix references at9fb1012.
Reading order
HARNESS_WIKI.md— what we have today.MIGRATION_EVALUATION.mdrev 2 — the architectural plan.AUDIT.md— first audit; five plan corrections (C1–C5).AUDIT_R2.md— Round 1 audit; seven additional corrections (C6–C12).- This file — file-by-file specs, per-tool contracts, test plans, standards.
Table of contents
- Consolidated corrections register
- Foundation files (concrete skeletons)
- Sandbox + Caido capability
- Per-tool migration contracts
- Standards: logging, error formatting, observability
- Test plans per phase
- Rollback, CI, cross-cutting
- 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.6 → AnthropicCachingLitellmModel("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: --verbose → agents.set_default_logging("DEBUG") + enable_verbose_stdout_logging(). Production keeps OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 (default) so model I/O isn't logged.
Never logged anywhere: LLM_API_KEY, TOOL_SERVER_TOKEN, anything the existing TelemetrySanitizer patterns match.
5.2 Error formatting
| Layer | Recipient | Format |
|---|---|---|
| Tool body | LLM | Plain str returned from tool. Convention: "Error: <reason>" for failures. |
| Tool body | User | Visible only via TUI when popup-worthy (vulnerability popup hooks via ToolOutputGuardrail). |
| Sandbox dispatch | LLM | {"error": "..."} → returned as Error: ... string. |
| Hook | Logs only | logger.exception(...) for unhandled in hooks; never propagated (would tear down the run). |
| Agent crash | Parent | <agent_crash agent_id='X' name='Y'>...</agent_crash> user message via bus. |
| Run-level exception | CLI | Exit code 1 (general failure), 2 (vulns found in headless), 130 (Ctrl+C). |
| Run-level exception | Trace | error.kind, error.message, error.traceback span attributes (post-scrub). |
5.3 Observability during migration
While both old (Strix) and new (SDK-based) live in parallel:
- Comparison metrics: per-run, log
total_input_tokens,total_output_tokens,cost,findings_count,wall_clock_seconds. Diff old vs new with same target set. - Drift detection: identical
--targetshould produce equivalent (not byte-identical) findings. Track findings-by-CWE histogram. - Canary mode: feature flag
STRIX_USE_SDK_HARNESS=1or separate CLI command (strix-sdk) until cut-over. Two CLIs let users opt in. - Event compatibility:
events.jsonlschema must remain compatible — the customTracingProcessoremits the sameevent_typevalues 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 hascache_control; non-Anthropic passes through.test_anthropic_cache_hit_rate— three identical-prompt calls; call 2+ showscache_read_input_tokens > 0.test_strix_docker_client_caps— mockcontainers.create; assertcap_add ⊇ {NET_ADMIN, NET_RAW}andextra_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— minimalMessageBuswith two synthetic agents; verify FIFO message delivery via filter injection.test_tool_use_behavior_stop_on_finish— toy agent withagent_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 sameagent_id; current code cancels first; observe behavior; choose Phase 1 default (parallel_tool_calls=False).
Phase 1 — Foundation
Unit tests:
MultiProviderresolution:strix/claude-sonnet-4.6→AnthropicCachingLitellmModelwith correct base URL.strix_tooldecorator: 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.RunConfigfactory: retry policy excludes 401 explicitly;parallel_tool_calls=Falsedefault.
Integration tests:
- Full single-agent run with
LitellmModelagainst an Anthropic mock; verify cache_control reaches the model. - Cost tracking via
RunHooks.on_llm_endmatcheslitellm.cost_per_tokencalculation within 1%.
Phase 2 — Tool ports
Per-tool smoke tests (one per @strix_tool):
- Browser:
launch→goto("https://example.com")→ screenshot returned asToolOutputImage; 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 withrequestsarray. - Notes: concurrent
create_notefrom two agents; assert JSONL parses line-by-line (C6 fix). - Reporting: full
create_vulnerability_reportpayload; CVSS computed; persisted tovulnerabilities/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 concurrentsend/drainops; FIFO maintained;total_statssnapshot 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 whenagent_finish_calledFalse; 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_statsaggregates correctly. - Root + 1 child; user Ctrl+C mid-run; both tasks cancelled; processor flushes all pending events.
- Crashed child scenario: child
Runner.runraisesRuntimeError; 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:48080env;curl https://example.comis captured by Caido (verify vialist_requestsshows the request). - Healthcheck: stop tool server inside container;
wait_for_ports_readyraises after 30s.
Phase 5 — Interface + persistence
Tests:
StrixStreamAccumulator: feed mockRunResultStreamingevents; tracer'sstreaming_content[agent_id]updates.- TUI: under simulated agent run, refresh latency < 500ms after each LLM chunk.
- Run-directory: post-run,
events.jsonlvalid;vulnerabilities/populated;penetration_test_report.mdnon-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:
- Set
STRIX_USE_SDK_HARNESS=0in deployed config (re-uses legacy harness). - Investigate via traces/events.jsonl from the failed run.
- Fix, re-run end-to-end suite, re-cut.
7.3 Data compatibility
events.jsonlschema preserved (custom processor emits sameevent_types).vulnerabilities/vuln_*.jsonschema preserved (same Pydantic shape).penetration_test_report.mdschema preserved.notes/notes.jsonlschema 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_kwargshook 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-agentspackage types. - Lint rules for tool decorators (
@strix_toolnot bare@function_toolfor new tools).
7.6 Drop / add deps
- Drop direct:
litellm[proxy]>=1.81.1,<1.82.0(becomes transitive viaopenai-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, customSession, customTracingProcessor,RunConfigfactory. - 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.
strix/llm/anthropic_cache_wrapper.py—AnthropicCachingLitellmModel. Tests: cache injection + non-Anthropic passthrough.strix/runtime/strix_docker_client.py—StrixDockerSandboxClient. Tests: kwarg injection mock + live nmap.strix/orchestration/bus.py—AgentMessageBus. Tests: concurrency stress + cancel_descendants.strix/orchestration/filter.py—inject_messages_filter. Tests: FIFO + retry-stable.strix/orchestration/hooks.py—StrixOrchestrationHooks. Tests: crash detection + warning injection.strix/tools/_decorator.py—strix_tool. Tests: timeout + sync auto-threading.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.