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>
22 KiB
Migration Audit — Round 3 Findings
Five new deep-dives covering scenario walkthroughs, type/signature compatibility, CLI/interface migration, pathological edge cases, and build/deps/deployment. Adds 13 more concrete corrections (C13–C25) and 3 critical type-signature fixes that must be applied to
PLAYBOOK.mdbefore Phase 0 starts.
1. Critical type/signature fixes (PLAYBOOK skeletons are wrong)
These three fixes are applied inline to PLAYBOOK.md in this round. The skeletons as written would not compile against SDK v0.14.6.
F1 — AnthropicCachingLitellmModel method signature wrong
PLAYBOOK §2.1 was: async def get_response(self, *, system_instructions, input, model_settings, ...)
SDK reality (models/interface.py:57-89): The first 7 params (system_instructions, input, model_settings, tools, output_schema, handoffs, tracing) are positional-first, then *, then keyword-only (previous_response_id, conversation_id, prompt).
Same fix applies to stream_response. Both must drop the leading *, and pass the first 7 params positionally to super().
Status: Inline-corrected in PLAYBOOK.md §2.1 below.
F2 — RunHooks lifecycle hook signatures wrong
PLAYBOOK §2.5 was: on_agent_start(self, ctx, agent) and on_agent_end(self, ctx, agent, output) typed ctx as RunContextWrapper.
SDK reality (lifecycle.py:37-59): These two specific hooks receive context: AgentHookContext[TContext], not RunContextWrapper. AgentHookContext is a different generic wrapper with the same .context attribute pattern but a distinct type.
Also: on_tool_end(self, ctx, agent, tool, result) — the result parameter is typed str, not Any.
Status: Inline-corrected in PLAYBOOK.md §2.5 below.
F3 — TracingProcessor hook methods are SYNC
PLAYBOOK §2.9 was: All hook methods (on_trace_start, on_trace_end, on_span_start, on_span_end, force_flush, shutdown) shown without explicit async keyword — implementation accidentally implied async.
SDK reality (tracing/processor_interface.py:53-129): All hooks are synchronous (def, not async def). Our processor must use sync methods. JSONL writes are sync I/O which is fine; if we ever want async export, we'd need to schedule via asyncio.run_coroutine_threadsafe() from the sync hook.
Status: Inline-corrected in PLAYBOOK.md §2.9 below.
2. New corrections (C13–C25)
Additive to the 12 corrections in AUDIT.md + AUDIT_R2.md.
C13 [HIGH] Bus must clear inbox/parent_of/names on finalize (Round 3.4)
Defect. When an agent finishes, bus.finalize only updates statuses and stats — but children whose parent already finished may still call bus.send(parent_id, msg), accumulating messages in bus.inboxes[parent_id] forever. Memory leak bounded by agent count × messages per cycle.
Fix. Update AgentMessageBus.finalize():
async def finalize(self, agent_id: str, status: str) -> None:
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) # NEW
self.parent_of.pop(agent_id, None) # NEW
self.names.pop(agent_id, None) # NEW
Apply in Phase 3 (in strix/orchestration/bus.py).
C14 [HIGH] inject_messages_filter must be defensive
Defect. If a bug in the filter raises, SDK treats it as a model invocation failure and retries. Filter raises on every retry → run fails after max_retries exhausted.
Fix. Wrap filter body in try/except; return unmodified data.model_data on exception:
async def inject_messages_filter(data: CallModelData) -> ModelInputData:
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:
# ... XML wrapping
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
Apply in Phase 3 (in strix/orchestration/filter.py).
C15 [HIGH] RunHooks must be defensive
Defect. If our hook bodies raise (e.g., bus operation fails, tracer disk error), exception propagates and tears down the run.
Fix. Each hook wraps its body in try/except; logs and continues:
async def on_llm_start(self, context, agent, system_prompt, input_items):
try:
# ... mutate input_items, increment turn count, etc.
except Exception:
logger.exception("on_llm_start failed")
# Same for on_llm_end, on_agent_start, on_agent_end, on_tool_start, on_tool_end, on_handoff
Apply in Phase 3 (in strix/orchestration/hooks.py).
C16 [HIGH] Custom TracingProcessor must catch disk errors
Defect. PLAYBOOK §2.9 _emit() opens file with "a" mode; OSError (disk full, permission denied) propagates from sync hook. SDK's hook caller may not gracefully handle. Run dies.
Fix. Wrap _emit body in try/except; log and continue:
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")
Apply in Phase 1 (in strix/telemetry/strix_processor.py).
C17 [MEDIUM] StrixModelProvider must validate model alias
Defect. If STRIX_LLM=strix/typo-model-name (alias not in STRIX_MODEL_MAP), our provider falls through to (model_name, model_name) and the LLM call later fails with provider's "model not found" — opaque diagnostic.
Fix. Validate at get_model() entry; raise UserError with the list of valid aliases:
def get_model(self, model_name: str | None) -> Model:
if model_name is None:
raise UserError("Model name required for StrixModelProvider")
if model_name not in STRIX_MODEL_MAP:
raise UserError(
f"Unknown Strix alias '{model_name}'. "
f"Valid: {list(STRIX_MODEL_MAP.keys())}"
)
api_model, _ = STRIX_MODEL_MAP[model_name]
if "anthropic/" in api_model 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)
Apply in Phase 1 (in strix/llm/multi_provider_setup.py).
C18 [MEDIUM] Model output size cap on sandbox tools
Defect. A tool returning 50MB binary output (e.g., browser screenshot of a huge map) gets base64-encoded into JSON; httpx loads the response into RAM; OOM on small hosts.
Fix. Configure httpx.Limits(max_content_size=...) in post_to_sandbox:
_TIMEOUT = httpx.Timeout(connect=10.0, read=150.0, write=150.0, pool=150.0)
_LIMITS = httpx.Limits(max_connections=10, max_keepalive_connections=5)
async def post_to_sandbox(ctx, tool_name, kwargs) -> dict:
# ...
async with httpx.AsyncClient(timeout=_TIMEOUT, limits=_LIMITS) as client:
r = await client.post(url, json=body, headers=headers)
if int(r.headers.get("content-length", 0)) > 50_000_000:
return {"error": "Sandbox response too large (>50MB)"}
# ...
Plus: cap on the sandbox side too (tool server limits its own response payload).
Apply in Phase 2 (in strix/tools/_sandbox_dispatch.py).
C19 [MEDIUM] tool_choice="required" requires at least one enabled tool
Defect. If is_enabled callbacks gate out all tools and ModelSettings(tool_choice="required"), model has no legal response. SDK raises ModelBehaviorError. Run fails opaquely.
Fix. Assert at agent build time:
def build_strix_agent(name, tools, ...) -> Agent:
enabled_count = len([t for t in tools if not _statically_disabled(t)])
if enabled_count == 0:
raise UserError(f"Agent {name} has no enabled tools but tool_choice='required'")
return Agent(name=name, tools=tools, ...)
Apply in Phase 1 (in agent factory).
C20 [MEDIUM] Per-tool timeout_behavior discrimination
Defect. If timeout_behavior="error_as_result" on a critical sandbox tool (e.g., terminal_execute), model sees the timeout error string and may retry the same tool with same args → infinite loop.
Fix. For critical sandbox tools, use timeout_behavior="raise_exception" so the model is told via SDK's error machinery that the tool genuinely failed (not just timed out gracefully). For idempotent local tools (notes, todos), error_as_result is fine.
Apply in Phase 2 — when porting each tool, pick the appropriate behavior.
C21 [MEDIUM] make_run_config and make_agent_context need overrides
Defect. Plan §H1: today there's no path for per-run override of model_settings (e.g., user wants tool_choice="auto" for a specific run). And is_whitebox flag isn't propagated to context — wiki auto-update on subagent finish (M10) reads ctx.context.get("is_whitebox") but it's never set.
Fix.
def make_run_config(*, sandbox_session, bus, model="strix/claude-sonnet-4.6",
max_turns=300, model_settings_override: dict | None = None) -> RunConfig:
base_settings = ModelSettings(parallel_tool_calls=False, tool_choice="required", retry=...)
if model_settings_override:
base_settings = base_settings.model_copy(update=model_settings_override)
return RunConfig(model_settings=base_settings, ...)
def make_agent_context(*, bus, sandbox_session, sandbox_token,
tool_server_host_port, caido_host_port, agent_id, agent_name,
parent_id, tracer, model_settings, max_turns=300,
is_whitebox: bool = False, # NEW
diff_scope: dict | None = None, # NEW (J1)
run_id: str | None = None) -> dict: # NEW (run-id propagation)
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,
"is_whitebox": is_whitebox,
"diff_scope": diff_scope,
"run_id": run_id,
}
Apply in Phase 1 (in strix/run_config_factory.py).
C22 [MEDIUM] finish_scan must check children status before exit
Defect. Strix today's finish_scan validates that all child agents are not running/stopping (tools/finish/finish_actions.py:98). PLAYBOOK §4.2 didn't carry this forward. Without the check, root could finish while children are still in-flight.
Fix. Inside finish_scan tool body:
@strix_tool(timeout=30)
async def finish_scan(ctx, executive_summary: str, methodology: str,
technical_analysis: str, recommendations: str) -> str:
if ctx.context.get("parent_id") is not None:
return "Error: finish_scan is for the root agent only. Subagents must call agent_finish."
bus = ctx.context["bus"]
me = ctx.context["agent_id"]
async with bus._lock:
in_flight = [
child_id for child_id, parent in bus.parent_of.items()
if parent == me and bus.statuses.get(child_id) in ("running", "waiting")
]
if in_flight:
names = [bus.names.get(c, c) for c in in_flight]
return (
f"Cannot finish: subagents still running: {names}. "
f"Wait for completion (or call stop_agent) before finishing the scan."
)
ctx.context["agent_finish_called"] = True
# ... write narrative fields, persist final report
return "Scan completed. Report written."
Apply in Phase 2 (when porting finish_scan).
C23 [MEDIUM] Diff-scope context injection point
Defect. Plan §J1: PLAYBOOK doesn't say where the diff scope context (from resolve_diff_scope_context()) is injected post-migration.
Fix. Two-part:
- CLI parses
--scope-mode=diff+--diff-base=...and computesDiffScopeResult(same as today). - The
instruction_blockfrom the result is prepended to the user's instruction in the first message ofRunner.run. (Same as Strix today; the agent sees it as part of its task.)
# strix/interface/cli.py (or main.py)
diff_scope = resolve_diff_scope_context(args)
user_instruction = args.instruction or ""
if diff_scope.instruction_block:
user_instruction = f"{diff_scope.instruction_block}\n\n{user_instruction}".strip()
context = make_agent_context(..., diff_scope=diff_scope.metadata, ...)
result = await Runner.run(
agent,
input=[{"role": "user", "content": user_instruction or "Conduct a thorough penetration test."}],
...
)
Apply in Phase 5 (CLI/interface migration).
C24 [MEDIUM] Run-name uniqueness + Docker availability checks
Defect. Plan §28 + §32: nothing prevents two parallel strix invocations colliding on run_name and competing for the same container name. And nothing surfaces a clear error when Docker daemon isn't running.
Fix. Pre-flight checks at CLI startup:
def main():
args = parse_arguments()
apply_config_override(args.config)
if args.use_sdk_harness:
if not _docker_daemon_available():
sys.exit("Docker daemon unavailable. Start Docker Desktop / dockerd and try again.")
run_dir = Path("strix_runs") / args.run_name
if run_dir.exists() and (run_dir / "events.jsonl").exists():
sys.exit(
f"Run '{args.run_name}' already exists at {run_dir}. "
f"Use a different --name or rm the directory."
)
# ... continue with scan
Apply in Phase 5 (in strix/interface/main.py).
C25 [MEDIUM] Hook cancel mode mapping + cleanup
Defect. Plan §C8: PLAYBOOK §C9 mentions result.cancel(mode=...) but doesn't specify which mode for which trigger.
Fix.
- Ctrl+C from user →
result.cancel(mode="immediate")+await bus.cancel_descendants(root_id). - TUI "stop agent" button (graceful) →
result.cancel(mode="after_turn")+await bus.cancel_descendants(root_id). stop_agent(child_id)tool called by parent → directlybus.tasks[child_id].cancel().- Run finished naturally → no cancellation needed;
on_agent_endhooks finalize.
Apply in Phase 5 (signal handler + TUI binding).
3. New scenario gaps from walkthrough audit (Round 3.1)
| # | Scenario | Gap | Fix |
|---|---|---|---|
| W1 | Cold-start single-agent | Most gaps already in C1–C12 | Use new C13–C25 |
| W2 | Multi-agent parallel | finish_scan had no children-running check |
C22 |
| W3 | Mid-run Ctrl+C | Cancel-mode mapping ambiguous | C25 |
| W4 | Subagent silent crash | Background post-invoke task exceptions don't trigger crash detection | Document — exceptions in post_invoke_task are logged async, not via on_agent_end. Bus watchdog optional. |
| W5 | Compressor cascade fail | After compression fails once, next iteration retries forever | Set _compression_disabled=True in context after first failure; subsequent calls skip. Apply in Phase 1 custom Session. |
| W6 | Container dies mid-run | No periodic liveness check | Optional: background asyncio task pings /health every N turns. Phase 5 / Phase 6 enhancement. |
| W7 | Whitebox wiki on finish | is_whitebox not propagated to context |
C21 |
| W8 | RunConfig override | No injection point | C21 |
| W9 | Resume from RunState | Out of scope for MVP | Defer; document as Phase 7+ |
| W10 | Diff-scope mode | Injection point unspecified | C23 |
4. CLI/interface migration spec (Round 3.3 highlights)
Full spec in the audit; key takeaways folded into PLAYBOOK and corrections above.
Survives unchanged: All argparse flags, Config class, target inference, run-name generation, scope-diff resolution, helper utilities (format_*, infer_target_type, clone_repository, etc.), exit code logic.
Refactor needed:
run_cli()headless — wrapRunner.run_streamed()withStrixStreamAccumulator; same Rich panels, same exit codes.run_tui()interactive — Textual app subscribes to tracer reactive fields; tracer fed by accumulator + hooks.- Vulnerability popup — direct call from
create_vulnerability_reporttool body totracer.vulnerability_found_callback(simpler thanToolOutputGuardrail; both viable). - Live stats —
build_live_stats_text(tracer, agent_config)reads tracer; tracer fed via hooks; no change to read path. - Interactive resume — after
Runner.run()returns, re-run with appended history + new user message; SDKSessionmakes this clean.
infer_target_type() → Manifest entries:
| Inferred type | Manifest action |
|---|---|
local_code |
LocalDir(src=Path(...)) mounted under /workspace/<subdir> |
repository (git URL) |
Pre-clone via existing clone_repository(); mount cloned dir as LocalDir. (Keep pre-clone logic; don't use SDK's GitRepo until after MVP.) |
web_application / domain / ip_address |
No mount; agent reaches via Caido proxy |
5. Build/deps/deployment (Round 3.5 highlights)
pyproject.toml diff
Drop:
- "litellm[proxy]>=1.81.1,<1.82.0",
Add:
+ "openai-agents[litellm]==0.14.6",
Transitive new (no action): griffelib, mcp, websockets, types-requests, openai>=2.26.0. Pulled in by openai-agents.
Container image (containers/Dockerfile): NO changes. [sandbox] extras stay in image. SDK's host-side code does not run inside the container.
strix.spec (PyInstaller): Add SDK hidden imports:
hiddenimports += [
"agents", "agents.agent", "agents.run", "agents.run_config",
"agents.memory.session", "agents.memory.sqlite_session",
"agents.sandbox.sandboxes.docker", "agents.sandbox.manifest",
"agents.sandbox.capabilities.capability", "agents.sandbox.entries",
"agents.extensions.models.litellm_model",
"agents.tool", "agents.tool_context", "agents.tool_guardrails",
"agents.lifecycle", "agents.guardrail",
"agents.tracing.processor_interface", "agents.tracing.spans", "agents.tracing.traces",
"agents.models.interface", "agents.models.multi_provider",
"agents.retry", "mcp", "websockets",
]
Config bridge
strix/config/config.py adds an env-var bridge before SDK init:
def bridge_to_sdk_env() -> None:
"""Map legacy Strix env vars to SDK-native names where applicable."""
if Config.get("llm_api_key") and not os.environ.get("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = Config.get("llm_api_key")
if Config.get("llm_api_base") and not os.environ.get("OPENAI_BASE_URL"):
os.environ["OPENAI_BASE_URL"] = Config.get("llm_api_base")
Call from main.py before any SDK import.
CI
.github/workflows/build-release.yml — unchanged. Add new test workflow (tests.yml) for pytest + mypy + ruff on PRs (recommended but not blocking for cutover).
Feature flag
STRIX_USE_SDK_HARNESS env var, default 0. CLI entry checks; routes to legacy or SDK harness implementation.
6. Consolidated correction register (full)
After Rounds 1, 2, 3 — twenty-five corrections to apply.
| # | Severity | Phase | Source | Topic |
|---|---|---|---|---|
| C1 | Blocker | 1/6 | AUDIT.md §2.1 | Tool-server slot serialization vs SDK parallel calls |
| C2 | Blocker | 0 | AUDIT.md §2.2 | Anthropic cache-control on system message |
| C3 | Blocker | 0 | AUDIT.md §2.3 | DockerSandboxClient subclass |
| C4 | Blocker | 3 | AUDIT.md §2.4 | Subagent tool_use_behavior |
| C5 | High | 5 | AUDIT.md §2.5 | StrixStreamAccumulator |
| C6 | Critical | 2 | AUDIT_R2.md §1.1 | Notes JSONL write lock |
| C7 | Critical | 1 | AUDIT_R2.md §1.2 | events.jsonl write lock |
| C8 | High | 3 | AUDIT_R2.md §1.3 | Subagent crash detection |
| C9 | High | 3 | AUDIT_R2.md §1.4 | Cancellation cascade |
| C10 | Medium | 1 | AUDIT_R2.md §1.5 | Compressor try/except |
| C11 | Medium | 1 | AUDIT_R2.md §1.6 | Retry policy excludes 401/403/400 |
| C12 | Medium | 3 | AUDIT_R2.md §1.7 | Stats snapshot under lock |
| F1 | Critical | 0 | AUDIT_R3 §1 | AnthropicCachingLitellmModel signature |
| F2 | Critical | 3 | AUDIT_R3 §1 | RunHooks signature (AgentHookContext, result: str) |
| F3 | Critical | 1 | AUDIT_R3 §1 | TracingProcessor methods are sync |
| C13 | High | 3 | AUDIT_R3 §2 | Bus.finalize cleans up stale state |
| C14 | High | 3 | AUDIT_R3 §2 | Filter try/except |
| C15 | High | 3 | AUDIT_R3 §2 | Hooks try/except |
| C16 | High | 1 | AUDIT_R3 §2 | TracingProcessor catches OSError |
| C17 | Medium | 1 | AUDIT_R3 §2 | Model alias validation |
| C18 | Medium | 2 | AUDIT_R3 §2 | Sandbox response size cap |
| C19 | Medium | 1 | AUDIT_R3 §2 | Assert ≥1 enabled tool when tool_choice='required' |
| C20 | Medium | 2 | AUDIT_R3 §2 | Per-tool timeout_behavior discrimination |
| C21 | Medium | 1 | AUDIT_R3 §2 | RunConfig override + context fields (is_whitebox, diff_scope, run_id) |
| C22 | Medium | 2 | AUDIT_R3 §2 | finish_scan checks children running |
| C23 | Medium | 5 | AUDIT_R3 §2 | Diff-scope injection in user message |
| C24 | Medium | 5 | AUDIT_R3 §2 | Run-name + Docker preflight |
| C25 | Medium | 5 | AUDIT_R3 §2 | Cancel mode mapping (immediate/after_turn) |
7. Outcome
No new architectural blockers. All corrections are bounded.
The three F-fixes (type/signature corrections) are inline-applied to PLAYBOOK.md in this round. C13–C25 are added to the playbook's correction register and the relevant phases. After this round, the playbook's load-bearing skeletons compile against SDK v0.14.6, and defensive error handling is wired through filter, hooks, processor, and bus.
Ready for Phase 0.