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>
23 KiB
Migration Audit — Pre-Execution Verification
Source-verified against
openai-agentsv0.14.6 at/tmp/openai-agentsand Strix at9fb1012. Five parallel deep-dives covering: agent loop, tool execution, sandbox, LLM/sessions/tracing, and Strix internals.Verdict: GO. No architectural blockers. Five concrete corrections to the plan must be applied before Phase 1; all are <300 LOC total. Migration today is feasible if we apply the corrections below in the listed order.
1. Verified bridges (no change needed)
These claims from MIGRATION_EVALUATION.md were confirmed against source — proceed as planned:
| Plan claim | Verification | Source |
|---|---|---|
call_model_input_filter runs before every model call |
✓ Confirmed at turn_preparation.py:55-80. Bonus: filter runs ONCE per turn — its output is captured in a lambda closure for retries (model_retry.py:34-35). Inbox messages will NOT be drained twice on retry. Open question #1 in plan §11 is resolved. |
run_internal/turn_preparation.py:48-82, run_internal/run_loop.py:1363-1369, 1803-1809 |
asyncio.create_task(Runner.run(...)) is isolation-safe |
✓ Each task gets own RunContextWrapper; contextvars properly isolated. No global state mutation inside Runner.run. |
run.py:486-615, run_context.py:42-51 |
Shared SandboxRunConfig(session=...) across parallel runs |
✓ SDK does NOT tear down sandbox sessions at run end; caller owns lifecycle. Safe to reuse one session across N children. | run_config.py:115-138, docker.py:1372-1401 |
RunContextWrapper.context mutable across turns |
✓ Dict is by-reference; mutations persist. Bus + agent_id stash will work as designed. | run_context.py:42-51, run.py:615 |
RunHooks.on_agent_end fires once per Runner.run |
✓ Single fire when final_output established. |
run_internal/turn_resolution.py:204-255 |
| Custom Docker image accepted | ✓ DockerSandboxClientOptions(image=str) is verbatim pass-through to containers.create(image=...). No assumed binaries. |
sandbox/sandboxes/docker.py:106-122, 1340, 1444-1456 |
Manifest.environment reaches container |
✓ Resolved via await manifest.environment.resolve() and passed to containers.create(environment=...). |
sandbox/sandboxes/docker.py:1448-1450 |
Manifest entries are a strict superset (LocalDir, GitRepo, mounts) |
✓ Direct LocalDir maps to our tar-pipe with concurrency limits. | sandbox/entries/__init__.py, artifacts.py:127-179 |
| Capability lifecycle (clone, bind, tools, instructions, process_manifest) | ✓ Per-run cloned; bound after container start; can hold mutable state. CaidoCapability viable. | sandbox/capabilities/capability.py:15-100, sandbox/runtime.py:180-256 |
| MultiProvider with custom prefix routing | ✓ MultiProviderMap.add_provider("strix", StrixModelProvider()) works exactly. |
models/multi_provider.py:16-49, 138-232 |
Custom ModelProvider interface |
✓ Just get_model(model_name) -> Model. Post-prefix-strip name received. |
models/interface.py:127-151 |
| LitellmModel reasoning effort priority | ✓ Exact: reasoning.effort > extra_body["reasoning_effort"] > extra_args["reasoning_effort"]. |
extensions/models/litellm_model.py:162-199 |
| LitellmModel streaming + tool-call assembly across providers | ✓ ChatCmplStreamHandler.handle_stream() unifies provider-native streaming (Anthropic, OpenAI, etc.) into common stream format. |
extensions/models/litellm_model.py:315-351 |
add_trace_processor() / set_trace_processors() |
✓ Both exist; can disable defaults entirely. | tracing/__init__.py:94-130 |
RunHooks 7-hook surface area |
✓ All 7 hooks fire as documented. RunHooks + AgentHooks both fire (gathered). | lifecycle.py:13-99, 102-199 |
Per-tool timeout default is None |
✓ Confirmed. Our strix_tool() factory will re-impose 120s. |
tool.py:337-338 |
RunState.to_json()/from_json() resumable across processes |
✓ Schema v1.9; full serialization. | run_state.py:1-200 |
tracing_disabled per-RunConfig |
✓ Disables ALL tracing for that run. | run_config.py:186-188 |
OPENAI_AGENTS_DONT_LOG_MODEL_DATA env |
✓ Logging only; independent from tracing. | _debug.py:12-21 |
Sync function tools auto-offload via asyncio.to_thread |
✓ Plan was wrong — SDK DOES auto-thread sync @function_tool bodies. We can drop the manual asyncio.to_thread wrapping in our libtmux/IPython tools and just write sync functions. ~30 LOC saved. |
tool.py:1820-1829 |
ToolGuardrailFunctionOutput.reject_content("nope") continues run |
✓ Model sees "nope" as tool output and proceeds. Run NOT halted. | tool_guardrails.py:79-105 |
| Multi-agent stat aggregation via hooks | ✓ on_llm_end + on_agent_end fire on each child Runner.run; bus aggregation works. |
lifecycle.py, run_internal/turn_resolution.py:204-255 |
2. Critical corrections (must apply before / during Phase 1)
Five corrections to the plan. All are concrete and small.
2.1 [BLOCKER] Strix tool server slot serialization vs SDK parallel tool calls
The collision. SDK fires N tool calls in one turn as N concurrent asyncio.create_task (run_internal/tool_execution.py:1414, :1424-1430). Strix tool server cancels the previous in-flight task for the same agent on every new request (tool_server.py:94-97). When SDK issues terminal_execute + web_search simultaneously for the same agent, the second cancels the first.
Fix (Phase 1 — safe default). Add to the default RunConfig for every Strix run:
RunConfig(
model_settings=ModelSettings(
parallel_tool_calls=False, # model-side hint: emit one tool call per turn
...
),
isolate_parallel_failures=False, # if model emits multiple anyway, don't cascade-cancel
...
)
Caveat. parallel_tool_calls is a provider hint (model_settings.py:89-96), not enforced SDK-side. The model may still emit multiple. With isolate_parallel_failures=False, sibling tools survive a single failure; but the tool server still cancels prev-task on same-agent collision.
Fix (Phase 2 — proper). Relax tool_server.py:94-97 to allow concurrent same-agent tool calls. The cancellation logic was Strix's old serialization; we don't need it under the SDK's orchestration. ~10 LOC removal. Re-test multi-agent end-to-end.
Effort: 0.5 day (safe default in Phase 1) + 0.5 day (proper fix + tests in Phase 2).
2.2 [BLOCKER] Anthropic prompt cache placement is wrong in plan
The defect. Plan §3.2 said: set ModelSettings(extra_body={"cache_control": {"type": "ephemeral"}}). Verified at extensions/models/litellm_model.py:509-516 — this lands cache_control in the request-level extra_body, not on the system message. Anthropic requires cache_control on the system message itself (per Anthropic API spec). Plan would silently cache nothing.
Fix. Build a thin LitellmModel subclass that injects cache_control into the message list before delegating to parent:
# strix/llm/anthropic_cache_wrapper.py (~40 LOC)
from agents.extensions.models.litellm_model import LitellmModel
class AnthropicCachingLitellmModel(LitellmModel):
def _patch_system_message_for_cache(self, input_items: list) -> list:
if not _is_anthropic(self.model):
return input_items
patched = []
for item in input_items:
if isinstance(item, dict) and item.get("role") == "system":
content = item["content"]
if isinstance(content, str):
content = [{"type": "text", "text": content,
"cache_control": {"type": "ephemeral"}}]
patched.append({**item, "content": content})
else:
patched.append(item)
return patched
async def get_response(self, *, input, **kwargs):
return await super().get_response(input=self._patch_system_message_for_cache(input), **kwargs)
async def stream_response(self, *, input, **kwargs):
async for ev in super().stream_response(input=self._patch_system_message_for_cache(input), **kwargs):
yield ev
Wire into our MultiProviderMap so any litellm/anthropic/... route uses this wrapper.
Effort: 0.5 day (~40 LOC + tests).
2.3 [BLOCKER] DockerSandboxClient subclass requires full method duplication
The reality. Audit #2 verified that _create_container() (sandbox/sandboxes/docker.py:1434-1477) builds create_kwargs locally and does not expose a hook for kwarg injection. Subclass must reimplement the method body. ~100-120 LOC duplication. Plan said "~80 LOC" — bump to ~120 LOC.
Fix. Subclass and copy the parent body verbatim, adding our injections before the final containers.create(**create_kwargs) line:
# strix/runtime/strix_docker_client.py (~120 LOC)
from agents.sandbox.sandboxes.docker import (
DockerSandboxClient, _build_docker_volume_mounts,
_manifest_requires_fuse, _manifest_requires_sys_admin,
_docker_port_key, parse_repository_tag,
)
class StrixDockerSandboxClient(DockerSandboxClient):
async def _create_container(self, image, *, manifest=None, exposed_ports=(), session_id=None):
# --- copy of parent _create_container body (lines 1442-1476) ---
if not self.image_exists(image):
repo, tag = parse_repository_tag(image)
self.docker_client.images.pull(repo, tag=tag or None, all_tags=False)
environment = None
if manifest:
environment = await manifest.environment.resolve()
create_kwargs = {
"entrypoint": ["tail"],
"image": image,
"detach": True,
"command": ["-f", "/dev/null"],
"environment": environment,
}
if manifest is not None:
mounts = _build_docker_volume_mounts(manifest, session_id=session_id)
if mounts:
create_kwargs["mounts"] = mounts
if _manifest_requires_fuse(manifest):
create_kwargs.setdefault("devices", []).append("/dev/fuse")
create_kwargs.setdefault("cap_add", []).append("SYS_ADMIN")
create_kwargs.setdefault("security_opt", []).append("apparmor:unconfined")
if _manifest_requires_sys_admin(manifest):
create_kwargs.setdefault("cap_add", []).append("SYS_ADMIN")
if exposed_ports:
create_kwargs["ports"] = {
_docker_port_key(p): ("127.0.0.1", None) for p in exposed_ports
}
# --- STRIX INJECTIONS ---
create_kwargs.setdefault("cap_add", []).extend(["NET_ADMIN", "NET_RAW"])
create_kwargs.setdefault("extra_hosts", {})["host.docker.internal"] = "host-gateway"
return self.docker_client.containers.create(**create_kwargs)
Risk. SDK upstream changes to _create_container won't propagate. Pin SDK version; track upstream in CI; consider upstream PR for additional_create_kwargs hook.
Effort: 0.5 day (~120 LOC + integration test).
2.4 [BLOCKER] Subagent must exit cleanly via agent_finish — needs tool_use_behavior configuration
The risk. When subagent calls agent_finish and we return a result string from the tool, SDK's loop checks Agent.tool_use_behavior (turn_resolution.py:512-544). If not configured to permit early exit, the loop continues until max_turns. Children would burn budget instead of finishing.
Fix. On every Strix subagent's Agent, configure:
child_agent = Agent(
name=name,
instructions=...,
tools=[..., agent_finish, ...],
tool_use_behavior={
"stop_at_tool_names": ["agent_finish"],
},
...
)
This tells the SDK: as soon as agent_finish returns, treat that as final output. Same pattern for root agent + finish_scan:
root_agent = Agent(
name="strix-root",
tool_use_behavior={"stop_at_tool_names": ["finish_scan"]},
...
)
Effort: Trivial — one config line per agent factory.
2.5 [HIGH] Streaming TUI integration needs a planned shape
The reality. Plan §10 phase 5 said "TUI re-pointed at Runner.run_streamed().stream_events()" — true, but Strix's current TUI polls tracer.streaming_content at 2 Hz with per-chunk granularity. SDK stream_events() exposes RawResponsesStreamEvent (raw chunks) and RunItemStreamEvent (semantic items) — sufficient, but we lose Strix's update_streaming_content(agent_id, accumulated_text) API that aggregates incremental text.
Fix. Build a StrixStreamAccumulator that consumes Runner.run_streamed().stream_events() and synthesizes the same shape Strix's tracer used to expose:
async for event in result.stream_events():
if event.type == "raw_response_event":
delta = _extract_text_delta(event.data)
if delta:
tracer.append_streaming_content(agent_id, delta)
elif event.type == "run_item_stream_event":
if event.name == "tool_called":
tracer.log_tool_start(agent_id, event.item.tool_name)
elif event.name == "tool_output":
tracer.log_tool_end(agent_id, event.item.tool_name, event.item.output)
Plus, RunHooks.on_llm_start/on_llm_end/on_tool_start/on_tool_end fire regardless of streaming mode, so child agents launched via Runner.run (not streamed) still feed the tracer through hooks. The TUI subscribes to the same tracer.
Effort: 1.5 days for both stream accumulator + hook bridge + TUI repoint.
3. Medium-severity adjustments (Phase 1-2)
| # | Issue | Source | Fix | Effort |
|---|---|---|---|---|
| M1 | Cost tracking — SDK has Usage(input/output/cached_tokens) but no cost field. litellm.completion_cost() requires raw litellm response, not SDK's ModelResponse. |
usage.py, extensions/models/litellm_model.py:254-293 |
Inside our AnthropicCachingLitellmModel and a similar light wrapper for OpenAI, capture the litellm response and store cost in ModelResponse.usage via litellm.cost_per_token(...) (which takes tokens, not response). Then RunHooks.on_llm_end reads it. |
0.5 day |
| M2 | Vision-less model image stripping — SDK has none, will pass-through and provider rejects. | None | If we end up routing to a non-vision model, build a wrapper Model that strips images. Defer; current models (Claude Sonnet 4.6, GPT-5, Gemini) are all vision-capable. | Defer (0 day) |
| M3 | SQLiteSession uses threading.RLock, not asyncio.Lock. Concurrent async writes from parallel children may interleave. |
memory/sqlite_session.py:17-175 |
Use a separate Session per child (history is per-agent anyway); only share SandboxRunConfig.session. Plan §4.7 already says this — emphasize it in code review. |
0 day (already in plan) |
| M4 | Trace processor memory pressure on 300-turn runs. | tracing/processor_interface.py |
Custom processor batches every 100 spans + force_flush() periodically. |
0.5 day |
| M5 | Streaming events don't expose token deltas — only raw chunks. | stream_events.py |
Parse RawResponsesStreamEvent.data chunks for token text manually in our accumulator. |
(rolled into 2.5) |
| M6 | trace_include_sensitive_data is binary, no field-level. |
run_config.py:193-199 |
Custom trace processor scrubs PII via existing TelemetrySanitizer. Plan already says this. |
0 day (already in plan) |
| M7 | Caido + tool server readiness check needs a place to await — Capability.bind() is sync. | sandbox/capabilities/capability.py:29-31 |
Spawn a background task in bind() (_healthcheck_task); await it inside RunHooks.on_agent_start. ~30 LOC. |
0.5 day |
| M8 | vulnerability_found_callback (TUI popup trigger) — no SDK-native equivalent. |
Strix telemetry/tracer.py:89 |
Wrap create_vulnerability_report tool with an output guardrail that fires the callback on success. |
0.5 day |
| M9 | <agent_delegation> XML wrapper today contains structured identity that the system prompt has rules to ignore. |
Strix system_prompt.jinja:19-22, agents_graph_actions.py:238-266 |
Replicate exact XML envelope when inject_messages_filter adds the parent's task message OR when create_agent builds the child's initial input. Keeps system prompt rules intact unchanged. |
0.5 day |
| M10 | Whitebox wiki note auto-update on subagent finish (side effect on agent_finish tool). |
Strix agents_graph_actions.py:161-202 |
Implement directly inside our agent_finish function tool body, just like today. |
0 day (free port) |
| M11 | _force_stop mid-turn soft-interrupt has no SDK equivalent. |
Strix base_agent.py:84 |
Use result.cancel(mode="after_turn") for cooperative cancel; for mid-turn hard cancel, .cancel(mode="immediate"). |
0 day (use result.cancel) |
| M12 | 85% / N-3 turn warnings as user messages. | Strix base_agent.py:186-211 |
RunHooks.on_llm_start checks ctx.usage turn count; if at threshold, mutate input_items (passed by reference per lifecycle.py:18-26). Verify mutation visibility in source: hook signature shows input_items is the list; mutations propagate. |
0.5 day |
4. Pre-Phase-1 spike (1 day)
Before writing production code, validate the assumptions in a tiny throwaway script:
- Two-children messaging smoke test. Build minimal
MessageBus+inject_messages_filter+ 2 child agents that exchange one message each. Run withLitellmModel("anthropic/claude-sonnet-4-5-20250929")(or whatever Anthropic alias is current). Verify: messages arrive, hooks fire, no deadlock, no message duplication on retry. - Anthropic cache wrapper smoke test. Send 3 requests with identical system prompt; check Anthropic response usage
cache_creation_input_tokenson call 1 andcache_read_input_tokenson calls 2-3. StrixDockerSandboxClientsmoke test. Pull our Kali image, create a session, runnmap -sS scanme.nmap.orgviasession.exec()to verify NET_RAW works.tool_use_behavior={"stop_at_tool_names": [...]}smoke test. Toy agent withagent_finish-equivalent; verify SDK terminates exactly when expected.- Tool server parallel-call smoke test. Issue two POSTs to local tool server with same
agent_idsimultaneously; observe whether second cancels first under current code.
If any spike fails, fix before Phase 1. If all pass, proceed.
Effort: 1 day.
5. Updated migration plan (rev 3 sequencing)
Replaces MIGRATION_EVALUATION.md §10. Same scope, with corrections folded in.
Phase 0 — Spike & corrections (1.5 days)
- Run the 5 spikes above.
- Build
AnthropicCachingLitellmModel(~40 LOC). Smoke-tested. - Build
StrixDockerSandboxClient(~120 LOC). Smoke-tested. - Decide tool server fix: relax serialization (recommended) OR set
parallel_tool_calls=False+isolate_parallel_failures=False(safe default).
Phase 1 — Foundation (4 days)
MultiProvider+MultiProviderMapwithStrixModelProviderfor our aliases.- Wire
AnthropicCachingLitellmModelinto the provider map. strix_tooldecorator (~30 LOC; justfunction_toolwith defaulttimeout=120, timeout_behavior="error_as_result").- Custom
Sessionsubclass with our memory compressor strategy. - Custom
TracingProcessorwith JSONL + scrubadub PII scrub.set_trace_processors([StrixProcessor()])to disable defaults. RunConfigfactory that bakes in:tracing_disabled=False,isolate_parallel_failures=False,model_settings.parallel_tool_calls=False(until Phase 6 relaxes), our processors.- Cost tracking inside the model wrapper (M1).
Phase 2 — Tool ports (8 days)
- Sandbox dispatcher: one helper that POSTs to FastAPI tool server with httpx (
Timeout(connect=10, total=150)) + Bearer auth. - All 30+ tools as
@strix_tool. Sync ones usedef, SDK auto-threads them. - Browser as
ComputerTool+AsyncComputersubclass (or as a single@strix_toolifComputerToolsemantics don't match). - Stateful tools key off
RunContextWrapper.context["agent_id"](helper:get_agent_id(ctx)). create_vulnerability_reportwrapsToolOutputGuardrailto fire the TUI popup callback (M8).- Verify reentrancy on browser singleton, tmux sessions, IPython kernels.
Phase 3 — Multi-agent orchestration (4 days)
AgentMessageBus+ tests.inject_messages_filter+ tests including retry simulation (verified safe by audit; no de-dup needed).StrixOrchestrationHooksfor stat aggregation + tracer wiring.- Six graph tools (
create_agent,send_message_to_agent,wait_for_message,agent_status,view_agent_graph,agent_finish). - Every child Agent:
tool_use_behavior={"stop_at_tool_names": ["agent_finish"]}. - Root Agent:
tool_use_behavior={"stop_at_tool_names": ["finish_scan"]}. - Identity injection via
<agent_delegation>XML in the first user message of the child Runner (M9). - Wiki auto-update on whitebox
agent_finish(M10).
Phase 4 — Sandbox + Caido capability (2 days)
StrixDockerSandboxClient(already in Phase 0).CaidoCapabilitywithprocess_manifest(env vars),tools()(7 Caido tools),instructions()(proxy-aware system block),bind()(spawn healthcheck task).RunHooks.on_agent_startawaits Caido + tool server readiness via the capability's healthcheck task (M7).- Container reuse keyed by scan_id in our session map.
Phase 5 — Interface + persistence (3 days)
- Streaming accumulator wires
Runner.run_streamed().stream_events()→ tracer (replaces today's per-chunkupdate_streaming_content). - TUI keeps its 2 Hz polling against the tracer; tracer is now event-driven from the accumulator.
- 85% / N-3 turn warnings via
RunHooks.on_llm_startmutatinginput_items(M12). - Run-directory layout via custom
TracingProcessorwritingevents.jsonl,vulnerabilities/, etc. - CLI / config / argparse layer unchanged.
Phase 6 — Validation + tool server relaxation (4 days)
- Smoke: every tool runs in sandbox.
- Multi-agent: 2+ parallel children + messaging + cancel.
- Bedrock + Anthropic + OpenAI parity.
- Memory compression at 90K.
- PII redaction.
- Real pentest end-to-end vs Strix baseline diff.
- Now relax tool_server.py:94-97 (remove per-agent task cancellation), set
parallel_tool_calls=Trueandisolate_parallel_failures=True. Re-run multi-agent test. If clean → ship parallelism.
Buffer (2 days)
For unforeseen issues from spike feedback or test failures.
Total: ~28.5 days (vs plan's 25–35). Within budget.
6. Final go/no-go
✅ GO.
Why:
- All architectural assumptions validated. No showstoppers found.
- The 5 corrections in §2 are concrete, small, and isolated to specific phases.
- The 12 medium adjustments in §3 are sensible and most are already implicit in the plan.
- The plan's rev-2 effort estimate (25–35 days) holds with corrections (~28.5 days).
Day-1 first commits (in order):
strix/llm/anthropic_cache_wrapper.py—AnthropicCachingLitellmModel.strix/runtime/strix_docker_client.py—StrixDockerSandboxClient.strix/orchestration/bus.py—AgentMessageBus.strix/orchestration/filter.py—inject_messages_filter.strix/orchestration/hooks.py—StrixOrchestrationHooks.strix/tools/_decorator.py—strix_toolfactory.strix/llm/multi_provider_setup.py—MultiProviderMapwiring +StrixModelProvider.
These seven files (~600 LOC total) form the migration's load-bearing foundation. Everything else is incremental ports onto this foundation.
Branch is already on harness-migration. Ready when you are.