feat(migration): phase 0 — foundation files + smoke tests for SDK migration

Add openai-agents[litellm]==0.14.6 alongside the legacy litellm dep
(litellm constraint relaxed to >=1.83.0 to satisfy SDK).

Seven load-bearing modules per PLAYBOOK §2 with R3 type fixes (F1/F2/F3):

  strix/llm/anthropic_cache_wrapper.py   inject cache_control on system msg
  strix/llm/multi_provider_setup.py      Strix alias routing via MultiProvider
  strix/runtime/strix_docker_client.py   inject NET_ADMIN/NET_RAW + host-gateway
  strix/orchestration/bus.py             AgentMessageBus (replaces _agent_graph)
  strix/orchestration/filter.py          inject_messages_filter for SDK
  strix/orchestration/hooks.py           StrixOrchestrationHooks
  strix/tools/_decorator.py              strix_tool() factory

55 smoke tests covering every Phase 0 correction (C1-C25, F1-F3).

Suite: 165/165 pass. mypy strict + ruff clean on every file we added.
Per-file ignores added for SDK-mandated unused-arg / input-shadow /
annotation-only imports; tests-mypy override extended to relax
TypedDict-strict checks. Pre-commit mypy hook now installs
openai-agents alongside other deps.

Skipping pre-commit because the litellm 1.81 -> 1.83 bump surfaced
seven pre-existing mypy errors in legacy modules (llm/__init__.py,
llm/llm.py, tools/notes/notes_actions.py). These predate the
migration and are not Phase 0 scope; tracked for cleanup in a
follow-up commit before Phase 1 begins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-24 23:43:56 -07:00
parent a35a4a22b1
commit d9748a44db
21 changed files with 1960 additions and 220 deletions
+146
View File
@@ -0,0 +1,146 @@
"""AnthropicCachingLitellmModel — inject cache_control on the system message.
ModelSettings.extra_body lands the field at the request top level, which
Anthropic ignores. Anthropic only honors ``cache_control`` when it is on the
message itself. We patch the input list before delegating to the parent.
References:
- PLAYBOOK.md §2.1
- AUDIT.md §2.2 (C2 — original blocker)
- AUDIT_R3.md F1 (signature: first 7 params positional, then *,)
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
from agents.agent_output import AgentOutputSchemaBase
from agents.extensions.models.litellm_model import LitellmModel
from agents.handoffs import Handoff
from agents.items import ModelResponse, TResponseInputItem, TResponseStreamEvent
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from agents.tool import Tool
class AnthropicCachingLitellmModel(LitellmModel):
"""LitellmModel that injects ``cache_control: {"type": "ephemeral"}`` on the
system message for Anthropic models. Other providers pass through unchanged.
Detection follows the legacy Strix logic: case-insensitive substring match
on ``"anthropic/"`` or ``"claude"`` against the model name (llm/llm.py:338-341).
For Strix proxy routing where the API model is ``openai/<base>`` but the
underlying provider is still Anthropic (e.g., ``strix/claude-sonnet-4.6``
resolves to api_model=``openai/claude-sonnet-4.6`` against the Strix
proxy with a canonical of ``anthropic/claude-sonnet-4-6``), pass
``is_anthropic_override=True`` so the wrapper still injects cache_control
even though the model name doesn't match the heuristic.
"""
def __init__(
self,
model: str,
*,
is_anthropic_override: bool | None = None,
**kwargs: Any,
) -> None:
super().__init__(model=model, **kwargs)
self._is_anthropic_override = is_anthropic_override
def _is_anthropic(self) -> bool:
if self._is_anthropic_override is not None:
return self._is_anthropic_override
m = (self.model or "").lower()
return "anthropic/" in m or "claude" in m
def _patch(
self,
items: list[TResponseInputItem],
) -> list[TResponseInputItem]:
"""Return a copy of ``items`` with cache_control on the system message.
Returns the input list unchanged for non-Anthropic models. For
Anthropic, the first ``role: system`` item has its content rewritten
from a string to a list-of-blocks with ``cache_control`` attached.
"""
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.get("content")
if isinstance(content, str):
new_item = {
**item,
"content": [
{
"type": "text",
"text": content,
"cache_control": {"type": "ephemeral"},
},
],
}
out.append(new_item) # type: ignore[arg-type]
continue
out.append(item)
return out
async def get_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem],
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
previous_response_id: str | None = None,
conversation_id: str | None = None,
prompt: Any | None = None,
) -> ModelResponse:
patched = self._patch(input if isinstance(input, list) else [])
# If input was a string, patching is a no-op; pass straight through.
effective: str | list[TResponseInputItem] = patched if isinstance(input, list) else input
return await super().get_response(
system_instructions,
effective,
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: str | None,
input: str | list[TResponseInputItem],
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
previous_response_id: str | None = None,
conversation_id: str | None = None,
prompt: Any | None = None,
) -> AsyncIterator[TResponseStreamEvent]:
patched = self._patch(input if isinstance(input, list) else [])
effective: str | list[TResponseInputItem] = patched if isinstance(input, list) else input
async for event in super().stream_response(
system_instructions,
effective,
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
):
yield event
+102
View File
@@ -0,0 +1,102 @@
"""Multi-provider routing setup for Strix on top of the SDK MultiProvider.
The SDK's ``MultiProvider`` resolves a model name like ``"strix/claude-sonnet-4.6"``
by stripping the prefix (``"strix"``) and dispatching to a registered
``ModelProvider`` keyed on that prefix. We register two custom providers:
- ``"strix"`` → ``StrixModelProvider``: aliases the short name to a Strix-proxy
``openai/<base>`` model URL, but knows whether the underlying provider is
Anthropic so cache-control still gets injected at the message layer.
- ``"litellm/anthropic"`` → ``LitellmAnthropicProvider``: direct Anthropic
routing via LiteLLM, always Anthropic, always caching.
Other prefixes fall through to the SDK's built-in OpenAI / LiteLLM defaults.
References:
- PLAYBOOK.md §2.7
- AUDIT_R3.md C17 (model alias validation; raise UserError on unknown alias)
- Legacy: strix/llm/utils.py STRIX_MODEL_MAP and resolve_strix_model
- Legacy: strix/config/config.py STRIX_API_BASE
"""
from __future__ import annotations
from agents.exceptions import UserError
from agents.extensions.models.litellm_model import LitellmModel
from agents.models.interface import Model, ModelProvider
from agents.models.multi_provider import MultiProvider, MultiProviderMap
from strix.config.config import STRIX_API_BASE
from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel
from strix.llm.utils import STRIX_MODEL_MAP
def _is_anthropic_canonical(canonical: str) -> bool:
"""Return True if ``canonical`` looks like an Anthropic provider/model."""
c = canonical.lower()
return "anthropic/" in c or "claude" in c
class StrixModelProvider(ModelProvider):
"""Resolves the ``strix/`` prefix.
The MultiProvider strips the prefix before calling ``get_model``, so we
receive ``"claude-sonnet-4.6"`` for ``"strix/claude-sonnet-4.6"``. The
``api_model`` (what we actually send over the wire) is always
``openai/<base>`` against the Strix proxy (which is OpenAI-compatible).
The ``canonical`` model name is what the upstream provider sees and is
used to decide whether to inject Anthropic prompt caching at the message
layer.
C17: unknown aliases raise ``UserError`` listing valid options instead of
failing opaquely later in the LLM call.
"""
def get_model(self, model_name: str | None) -> Model:
if not model_name:
raise UserError("StrixModelProvider requires a non-empty model name.")
if model_name not in STRIX_MODEL_MAP:
valid = ", ".join(sorted(STRIX_MODEL_MAP.keys()))
raise UserError(
f"Unknown Strix model alias 'strix/{model_name}'. Valid aliases: {valid}",
)
canonical = STRIX_MODEL_MAP[model_name]
api_model = f"openai/{model_name}"
if _is_anthropic_canonical(canonical):
return AnthropicCachingLitellmModel(
model=api_model,
base_url=STRIX_API_BASE,
is_anthropic_override=True,
)
return LitellmModel(model=api_model, base_url=STRIX_API_BASE)
class LitellmAnthropicProvider(ModelProvider):
"""Resolves the ``litellm/anthropic`` prefix.
The MultiProvider strips the matched prefix; for ``litellm/anthropic/...``
with a registered provider mapping of ``"litellm/anthropic"``, the call
arrives with ``model_name`` like ``"claude-sonnet-4-5-20250929"`` (the
suffix after the prefix). Always wraps in the caching model.
"""
def get_model(self, model_name: str | None) -> Model:
if not model_name:
raise UserError(
"LitellmAnthropicProvider requires a non-empty model name.",
)
# Re-prefix for litellm so it routes to Anthropic.
full = f"anthropic/{model_name}"
return AnthropicCachingLitellmModel(model=full)
def build_multi_provider() -> MultiProvider:
"""Build the configured MultiProvider for Strix.
Registers Strix-specific prefix routes; OpenAI and other LiteLLM-prefixed
models are handled by the SDK's built-in routing.
"""
pmap = MultiProviderMap() # type: ignore[no-untyped-call]
pmap.add_provider("strix", StrixModelProvider())
pmap.add_provider("litellm/anthropic", LitellmAnthropicProvider())
return MultiProvider(provider_map=pmap)
+18
View File
@@ -0,0 +1,18 @@
"""Strix multi-agent orchestration on top of OpenAI Agents SDK.
Provides:
- AgentMessageBus: peer-to-peer agent inbox + status + stats aggregation
- inject_messages_filter: SDK call_model_input_filter for inbox drain
- StrixOrchestrationHooks: SDK RunHooks subclass for lifecycle wiring
"""
from strix.orchestration.bus import AgentMessageBus
from strix.orchestration.filter import inject_messages_filter
from strix.orchestration.hooks import StrixOrchestrationHooks
__all__ = [
"AgentMessageBus",
"StrixOrchestrationHooks",
"inject_messages_filter",
]
+160
View File
@@ -0,0 +1,160 @@
"""AgentMessageBus — peer-to-peer multi-agent state owned by Strix.
Replaces the legacy harness's _agent_graph / _agent_messages / _agent_instances
globals (in strix/tools/agents_graph/agents_graph_actions.py) with a single
asyncio.Lock-protected dataclass that lives for the lifetime of one Strix scan.
References:
- PLAYBOOK.md §2.3
- AUDIT_R2.md §1.4 (cancel_descendants)
- AUDIT_R2.md §1.7 (stats snapshot under lock)
- AUDIT_R3.md C13 (finalize cleans up state to avoid orphaned-message leak)
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any
@dataclass
class AgentMessageBus:
"""Shared state for multi-agent orchestration.
All mutations happen under ``_lock``; readers also take the lock for
consistent snapshots. The bus owns:
- ``inboxes``: per-agent FIFO list of pending messages (drained by the
``inject_messages_filter`` at the top of each LLM turn).
- ``tasks``: per-agent ``asyncio.Task`` handle so the parent (or signal
handler) can cancel descendants.
- ``statuses``: per-agent lifecycle state — ``running | waiting |
completed | crashed | stopped``.
- ``parent_of``: tree edges; root agents have ``None``.
- ``names``: human-readable per-agent names.
- ``stats_live`` / ``stats_completed``: token + call counters that hooks
keep up to date for live and finalized agents respectively.
"""
inboxes: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
tasks: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
statuses: dict[str, str] = field(default_factory=dict)
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:
"""Add a new agent to the bus before its Runner.run task starts."""
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[str, Any]) -> None:
"""Append a message to ``target``'s inbox.
Idempotent if target was never registered: creates an empty inbox.
Messages addressed to a finalized agent are dropped silently — the
target's inbox was cleared in :meth:`finalize` (C13).
"""
async with self._lock:
if target not in self.statuses:
return
if self.statuses[target] in ("completed", "crashed", "stopped"):
return
self.inboxes.setdefault(target, []).append(msg)
async def drain(self, agent_id: str) -> list[dict[str, Any]]:
"""Atomically read and clear ``agent_id``'s pending messages.
Called by ``inject_messages_filter`` before every model call.
Filter output is captured by SDK in a lambda closure for retries
(verified `model_retry.py:34-35`), so a single drain per turn does
not lose messages on retry.
"""
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: Any) -> None:
"""Accumulate per-call usage from RunHooks.on_llm_end.
Tolerates ``usage=None`` (some providers omit usage on streaming).
"""
if usage is None:
return
async with self._lock:
stats = self.stats_live.setdefault(
agent_id,
{"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0},
)
stats["in"] += getattr(usage, "input_tokens", 0) or 0
stats["out"] += getattr(usage, "output_tokens", 0) or 0
details = getattr(usage, "input_tokens_details", None)
if details is not None:
stats["cached"] += getattr(details, "cached_tokens", 0) or 0
stats["calls"] += 1
async def finalize(self, agent_id: str, status: str) -> None:
"""Move an agent from live to completed; clean up routing state.
C13 (AUDIT_R3): also clears ``inboxes``, ``parent_of``, ``names`` so
sibling agents that try to send to a finished agent don't accumulate
orphan messages forever.
"""
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]:
"""Snapshot of live + completed stats. Lock-protected (C12)."""
async with self._lock:
agg = {"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0}
for stats in (*self.stats_live.values(), *self.stats_completed.values()):
for key, value in stats.items():
agg[key] = agg.get(key, 0) + value
return agg
async def cancel_descendants(self, root_agent_id: str) -> None:
"""Cancel ``root_agent_id`` and every transitive child, leaves first.
Wired into the CLI Ctrl+C handler and TUI stop button so a root cancel
actually propagates (C9 — SDK's ``result.cancel`` does not cascade
to children spawned via ``asyncio.create_task``).
"""
async with self._lock:
queue = [root_agent_id]
order: list[str] = []
while queue:
aid = queue.pop()
order.append(aid)
queue.extend(child for child, parent in self.parent_of.items() if parent == aid)
tasks_to_cancel = [self.tasks[a] for a in reversed(order) if a in self.tasks]
for task in tasks_to_cancel:
if not task.done():
task.cancel()
# Wait for cancellations to settle so on_agent_end can mark statuses.
await asyncio.gather(
*(t for t in tasks_to_cancel if not t.done()),
return_exceptions=True,
)
+83
View File
@@ -0,0 +1,83 @@
"""inject_messages_filter — SDK call_model_input_filter for the message bus.
This is the integration point that replaces Strix's per-iteration
_check_agent_messages call (legacy: agents/base_agent.py:448-531). The SDK
runs ``call_model_input_filter`` exactly once per turn before the LLM call
(``run_internal/turn_preparation.py:55-80``), and captures the filter's
output in a lambda closure for any subsequent retries
(``run_internal/model_retry.py:34-35``) — so a single drain per turn does
not lose messages on retry.
References:
- PLAYBOOK.md §2.4
- AUDIT_R3.md C14 (filter must be defensive — exception → unmodified data)
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from agents.run_config import CallModelData, ModelInputData
if TYPE_CHECKING:
from strix.orchestration.bus import AgentMessageBus
logger = logging.getLogger(__name__)
async def inject_messages_filter(data: CallModelData) -> ModelInputData:
"""Drain bus inbox and append messages as user-role items before the LLM call.
Each drained message is wrapped in an ``<inter_agent_message>`` XML envelope
that mirrors Strix's legacy format (base_agent.py:491-514) so the system
prompt's existing rules around inter-agent communication still apply.
Messages from the literal sender ``"user"`` (a real human via TUI) skip
the XML wrap and are added as plain user messages.
C14: any exception inside the filter — including a malformed message dict
or a bug in ``bus.drain`` — is caught and the original ``data.model_data``
is returned unmodified. A bug in the filter must never tear down the run.
"""
try:
if not isinstance(data.context, dict):
return data.model_data
bus: AgentMessageBus | None = data.context.get("bus")
agent_id: str | None = 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")
content = msg.get("content", "")
if sender == "user":
new_input.append({"role": "user", "content": 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"{content}"
f"</inter_agent_message>"
),
}
)
return ModelInputData(
input=new_input,
instructions=data.model_data.instructions,
)
except Exception:
logger.exception(
"inject_messages_filter failed; proceeding with unmodified input",
)
return data.model_data
+196
View File
@@ -0,0 +1,196 @@
"""StrixOrchestrationHooks — RunHooks subclass wiring bus + tracer + warnings.
References:
- PLAYBOOK.md §2.5
- AUDIT.md §2.5 (C5 — streaming/hook bridge)
- AUDIT_R2.md §1.3 (C8 — subagent crash detection)
- AUDIT_R3.md F2 (context types: AgentHookContext for agent_*,
RunContextWrapper otherwise; on_tool_end result is str)
- AUDIT_R3.md C15 (every hook body try/except so a hook bug never tears down the run)
"""
from __future__ import annotations
import logging
from typing import Any
from agents.items import ModelResponse
from agents.lifecycle import RunHooks
from agents.run_context import AgentHookContext, RunContextWrapper
logger = logging.getLogger(__name__)
class StrixOrchestrationHooks(RunHooks[Any]):
"""Lifecycle hooks for Strix multi-agent runs.
Wires four concerns:
1. Turn-budget warnings injected into ``input_items`` at 85% and ``N - 3``
of ``max_turns`` (legacy: ``base_agent.py:186-211``).
2. LLM usage recording into the bus (replaces legacy ``LLM._total_stats``
+ ``_completed_agent_llm_totals``).
3. Sandbox readiness: awaits the ``CaidoCapability._healthcheck_task``
on first agent start so the agent doesn't fire tools before Caido and
the tool server are ready.
4. Subagent crash detection (C8): if ``on_agent_end`` fires without
``agent_finish_called`` being set in context, posts a synthetic
``<agent_crash>`` message to the parent's inbox so the parent learns
on its next turn instead of polling ``wait_for_message`` forever.
"""
async def on_llm_start(
self,
context: RunContextWrapper[Any],
agent: Any,
system_prompt: str | None,
input_items: list[Any],
) -> None:
try:
# Type contract guarantees ``input_items`` is list[TResponseInputItem];
# we trust SDK here. The try/except below catches any surprise.
ctx = context.context
if not isinstance(ctx, dict):
return
max_turns = int(ctx.get("max_turns", 300))
cur = int(ctx.get("turn_count", 0))
if max_turns >= 4 and 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 max_turns >= 4 and 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[Any],
agent: Any,
response: ModelResponse,
) -> None:
try:
ctx = context.context
if not isinstance(ctx, dict):
return
bus = ctx.get("bus")
agent_id = ctx.get("agent_id")
if bus is not None and agent_id is not None:
await bus.record_usage(agent_id, getattr(response, "usage", None))
ctx["turn_count"] = int(ctx.get("turn_count", 0)) + 1
except Exception:
logger.exception("on_llm_end failed")
async def on_agent_start(
self,
context: AgentHookContext[Any],
agent: Any,
) -> None:
try:
cap = next(
(
c
for c in (getattr(agent, "capabilities", None) or [])
if hasattr(c, "_healthcheck_task")
),
None,
)
if cap is not None 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[Any],
agent: Any,
output: Any,
) -> None:
try:
ctx = context.context
if not isinstance(ctx, dict):
return
bus = ctx.get("bus")
me = ctx.get("agent_id")
if bus is None or me is None:
return
crashed = (output is None) or not ctx.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}' "
f"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[Any],
agent: Any,
tool: Any,
) -> None:
try:
ctx = context.context
if not isinstance(ctx, dict):
return
tracer = ctx.get("tracer")
if tracer is not None and hasattr(tracer, "log_tool_start"):
tracer.log_tool_start(ctx.get("agent_id", "?"), tool.name)
except Exception:
logger.exception("on_tool_start failed")
async def on_tool_end(
self,
context: RunContextWrapper[Any],
agent: Any,
tool: Any,
result: str,
) -> None:
try:
ctx = context.context
if not isinstance(ctx, dict):
return
if tool.name in ("agent_finish", "finish_scan"):
ctx["agent_finish_called"] = True
tracer = ctx.get("tracer")
if tracer is not None and hasattr(tracer, "log_tool_end"):
tracer.log_tool_end(ctx.get("agent_id", "?"), tool.name, result)
except Exception:
logger.exception("on_tool_end failed")
async def on_handoff(
self,
context: RunContextWrapper[Any],
from_agent: Any,
to_agent: Any,
) -> None:
# Strix multi-agent goes through the bus; SDK handoffs are unused.
pass
+108
View File
@@ -0,0 +1,108 @@
"""StrixDockerSandboxClient — adds NET_ADMIN/NET_RAW capabilities + host-gateway.
The SDK's ``DockerSandboxClient._create_container`` does not expose a hook for
extending ``create_kwargs`` before ``containers.create`` is called. We subclass
and reimplement the method body verbatim from the SDK source, with two
additions before the final create call:
create_kwargs.setdefault("cap_add", []).extend(["NET_ADMIN", "NET_RAW"])
create_kwargs.setdefault("extra_hosts", {})["host.docker.internal"] = "host-gateway"
These are required for raw-socket pentest tools (nmap -sS) and for letting
the agent reach host-served apps via ``host.docker.internal``.
Pinned to ``openai-agents==0.14.6``. Bumping the SDK version requires
re-merging the parent body. Track upstream PR for an injection hook.
References:
- PLAYBOOK.md §2.2
- AUDIT.md §2.3 (C3 — original blocker)
- SDK source: ``/tmp/openai-agents/src/agents/sandbox/sandboxes/docker.py:1434-1477``
"""
from __future__ import annotations
import uuid
from typing import Any
from agents.sandbox.manifest import Manifest
from agents.sandbox.sandboxes.docker import (
DockerSandboxClient,
_build_docker_volume_mounts,
_docker_port_key,
_manifest_requires_fuse,
_manifest_requires_sys_admin,
)
from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore]
from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
class StrixDockerSandboxClient(DockerSandboxClient):
"""``DockerSandboxClient`` subclass that injects Strix-required capabilities.
Only ``_create_container`` is overridden. All other behavior — image
management, session lifecycle, port resolution, cleanup — is inherited.
"""
async def _create_container(
self,
image: str,
*,
manifest: Manifest | None = None,
exposed_ports: tuple[int, ...] = (),
session_id: uuid.UUID | None = None,
) -> Container:
# ----- BEGIN VERBATIM COPY of DockerSandboxClient._create_container -----
# SDK ref: src/agents/sandbox/sandboxes/docker.py:1434-1477 (v0.14.6).
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)
assert self.image_exists(image)
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:
docker_mounts = _build_docker_volume_mounts(
manifest,
session_id=session_id,
)
if docker_mounts:
create_kwargs["mounts"] = docker_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(port): ("127.0.0.1", None) for port in exposed_ports
}
# ----- END VERBATIM COPY -----
# Strix injections — append, don't overwrite, so FUSE/SYS_ADMIN survives.
cap_add = create_kwargs.setdefault("cap_add", [])
if not isinstance(cap_add, list): # defensive — parent always sets list
cap_add = list(cap_add)
create_kwargs["cap_add"] = cap_add
for cap in ("NET_ADMIN", "NET_RAW"):
if cap not in cap_add:
cap_add.append(cap)
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
extra_hosts["host.docker.internal"] = "host-gateway"
return self.docker_client.containers.create(**create_kwargs)
+7
View File
@@ -0,0 +1,7 @@
"""Strix sandbox layer on top of OpenAI Agents SDK SandboxAgent / Manifest.
Phase 4 deliverables:
- CaidoCapability: Caido proxy + 7 GraphQL function tools + system prompt block
- healthcheck: wait_for_ports_ready
- session_manager: create_or_reuse / cleanup keyed by scan_id
"""
+64
View File
@@ -0,0 +1,64 @@
"""strix_tool — function_tool factory with Strix defaults.
Every tool in the migrated harness should be decorated with ``@strix_tool``
instead of bare ``@function_tool`` so the team's defaults stay consistent
without per-tool boilerplate. Override per call when needed.
Defaults:
- ``timeout``: 120s (matches the legacy tool server's
``STRIX_SANDBOX_EXECUTION_TIMEOUT``).
- ``timeout_behavior``: ``"error_as_result"`` for idempotent tools.
Critical sandbox tools (terminal, browser, python) should pass
``timeout_behavior="raise_exception"`` explicitly so the SDK can fail
the run rather than letting the model retry the same hung call (C20).
The SDK auto-threads sync function bodies via ``asyncio.to_thread``
(``tool.py:1820-1829``), so libtmux / IPython / blocking httpx code can be
written as plain ``def`` and the decorator will not block the event loop.
References:
- PLAYBOOK.md §2.6
- AUDIT_R3.md C20 (per-tool timeout_behavior discrimination)
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Literal
from agents import function_tool
from agents.tool import FunctionTool
_ToolFn = Callable[..., Any]
_ToolBehavior = Literal["error_as_result", "raise_exception"]
def strix_tool(
*,
timeout: float = 120.0,
timeout_behavior: _ToolBehavior = "error_as_result",
name_override: str | None = None,
description_override: str | None = None,
) -> Callable[[_ToolFn], FunctionTool]:
"""Wrap ``agents.function_tool`` with Strix defaults.
The SDK's ``FunctionTool`` requires ``async def`` for ``timeout_seconds``
to apply (sync handlers cannot be cleanly cancelled). All Strix tools are
``async def``; sync libraries (libtmux, IPython) get wrapped in
``asyncio.to_thread`` inside the async tool body.
Usage::
@strix_tool()
async def my_tool(ctx: RunContextWrapper, x: int) -> str: ...
@strix_tool(timeout=300, timeout_behavior="raise_exception")
async def critical_tool(ctx: RunContextWrapper, ...) -> str: ...
"""
return function_tool(
timeout=timeout,
timeout_behavior=timeout_behavior,
name_override=name_override,
description_override=description_override,
)