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:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
"""
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user