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,104 @@
|
||||
"""Phase 0 smoke tests for AnthropicCachingLitellmModel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel
|
||||
|
||||
|
||||
def _make(model: str, **kwargs: object) -> AnthropicCachingLitellmModel:
|
||||
# ``LitellmModel.__init__`` only validates that model is a string; we
|
||||
# don't need a real API key for in-memory ``_patch`` testing.
|
||||
return AnthropicCachingLitellmModel(model=model, api_key="test-key", **kwargs)
|
||||
|
||||
|
||||
def test_is_anthropic_detects_anthropic_prefix() -> None:
|
||||
m = _make("anthropic/claude-3-5-sonnet")
|
||||
assert m._is_anthropic() is True
|
||||
|
||||
|
||||
def test_is_anthropic_detects_claude_substring() -> None:
|
||||
m = _make("openrouter/anthropic-claude-haiku")
|
||||
assert m._is_anthropic() is True
|
||||
|
||||
|
||||
def test_is_anthropic_false_for_openai() -> None:
|
||||
m = _make("openai/gpt-4o")
|
||||
assert m._is_anthropic() is False
|
||||
|
||||
|
||||
def test_is_anthropic_false_for_gemini() -> None:
|
||||
m = _make("gemini/gemini-1.5-pro")
|
||||
assert m._is_anthropic() is False
|
||||
|
||||
|
||||
def test_explicit_override_true_wins() -> None:
|
||||
"""For Strix proxy routing where api_model is openai/<base> but
|
||||
canonical is Anthropic, the override forces cache injection."""
|
||||
m = _make("openai/claude-sonnet-4.6", is_anthropic_override=True)
|
||||
assert m._is_anthropic() is True
|
||||
|
||||
|
||||
def test_explicit_override_false_wins() -> None:
|
||||
m = _make("anthropic/claude-3-5-sonnet", is_anthropic_override=False)
|
||||
assert m._is_anthropic() is False
|
||||
|
||||
|
||||
def test_patch_anthropic_adds_cache_control_to_system() -> None:
|
||||
m = _make("anthropic/claude-3-5-sonnet")
|
||||
items: list = [
|
||||
{"role": "system", "content": "You are a helpful agent."},
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
out = m._patch(items)
|
||||
assert out[0]["role"] == "system"
|
||||
content = out[0]["content"]
|
||||
assert isinstance(content, list)
|
||||
assert content[0]["type"] == "text"
|
||||
assert content[0]["text"] == "You are a helpful agent."
|
||||
assert content[0]["cache_control"] == {"type": "ephemeral"}
|
||||
# Second item passes through unchanged.
|
||||
assert out[1] == {"role": "user", "content": "hi"}
|
||||
|
||||
|
||||
def test_patch_non_anthropic_passes_through() -> None:
|
||||
m = _make("openai/gpt-4o")
|
||||
items: list = [
|
||||
{"role": "system", "content": "You are a helpful agent."},
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
assert m._patch(items) is items # exact same list reference, no copy
|
||||
|
||||
|
||||
def test_patch_skips_non_string_system_content() -> None:
|
||||
"""If system content is already structured (e.g., previously patched),
|
||||
don't re-wrap — pass through unchanged."""
|
||||
m = _make("anthropic/claude-3-5-sonnet")
|
||||
items: list = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "x"}]},
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
out = m._patch(items)
|
||||
assert out[0]["content"] == [{"type": "text", "text": "x"}]
|
||||
|
||||
|
||||
def test_patch_handles_empty_list() -> None:
|
||||
m = _make("anthropic/claude-3-5-sonnet")
|
||||
assert m._patch([]) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"openai/claude-sonnet-4.6", # Strix proxy with Anthropic underneath
|
||||
"openai/gpt-5.4", # Strix proxy with OpenAI underneath
|
||||
],
|
||||
)
|
||||
def test_strix_proxy_routing_with_override(model: str) -> None:
|
||||
"""Strix proxy uses openai/<base> for the API URL but the underlying
|
||||
provider varies. The override flag is the source of truth."""
|
||||
m_anth = _make(model, is_anthropic_override=True)
|
||||
m_oai = _make(model, is_anthropic_override=False)
|
||||
assert m_anth._is_anthropic() is True
|
||||
assert m_oai._is_anthropic() is False
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Phase 0 smoke tests for multi_provider_setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from agents.exceptions import UserError
|
||||
from agents.extensions.models.litellm_model import LitellmModel
|
||||
|
||||
from strix.config.config import STRIX_API_BASE
|
||||
from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel
|
||||
from strix.llm.multi_provider_setup import (
|
||||
LitellmAnthropicProvider,
|
||||
StrixModelProvider,
|
||||
build_multi_provider,
|
||||
)
|
||||
|
||||
|
||||
def test_strix_provider_resolves_anthropic_alias_with_override() -> None:
|
||||
provider = StrixModelProvider()
|
||||
model = provider.get_model("claude-sonnet-4.6")
|
||||
assert isinstance(model, AnthropicCachingLitellmModel)
|
||||
# Goes via Strix proxy as openai/<base>, but is_anthropic still True.
|
||||
assert model.model == "openai/claude-sonnet-4.6"
|
||||
assert str(model.base_url) == STRIX_API_BASE
|
||||
assert model._is_anthropic() is True
|
||||
|
||||
|
||||
def test_strix_provider_resolves_openai_alias_without_override() -> None:
|
||||
provider = StrixModelProvider()
|
||||
model = provider.get_model("gpt-5.4")
|
||||
# Plain LitellmModel, NOT the caching subclass.
|
||||
assert isinstance(model, LitellmModel)
|
||||
assert not isinstance(model, AnthropicCachingLitellmModel)
|
||||
assert model.model == "openai/gpt-5.4"
|
||||
|
||||
|
||||
def test_strix_provider_unknown_alias_raises_user_error() -> None:
|
||||
"""C17 (AUDIT_R3): unknown alias must surface a clear error with valid options."""
|
||||
provider = StrixModelProvider()
|
||||
with pytest.raises(UserError, match="Unknown Strix model alias"):
|
||||
provider.get_model("typo-model-name")
|
||||
|
||||
|
||||
def test_strix_provider_empty_name_raises() -> None:
|
||||
provider = StrixModelProvider()
|
||||
with pytest.raises(UserError, match="non-empty"):
|
||||
provider.get_model(None)
|
||||
|
||||
|
||||
def test_litellm_anthropic_provider_wraps_in_caching_model() -> None:
|
||||
provider = LitellmAnthropicProvider()
|
||||
model = provider.get_model("claude-3-5-sonnet-20241022")
|
||||
assert isinstance(model, AnthropicCachingLitellmModel)
|
||||
assert model.model == "anthropic/claude-3-5-sonnet-20241022"
|
||||
assert model._is_anthropic() is True
|
||||
|
||||
|
||||
def test_build_multi_provider_registers_strix_prefix() -> None:
|
||||
mp = build_multi_provider()
|
||||
# The MultiProvider stores the map; the easiest check is that resolving
|
||||
# "strix/claude-sonnet-4.6" goes through StrixModelProvider.
|
||||
model = mp.get_model("strix/claude-sonnet-4.6")
|
||||
assert isinstance(model, AnthropicCachingLitellmModel)
|
||||
assert model.model == "openai/claude-sonnet-4.6"
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Phase 0 smoke tests for AgentMessageBus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.orchestration.bus import AgentMessageBus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus() -> AgentMessageBus:
|
||||
return AgentMessageBus()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_records_agent(bus: AgentMessageBus) -> None:
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
assert bus.statuses["a1"] == "running"
|
||||
assert bus.parent_of["a1"] is None
|
||||
assert bus.names["a1"] == "alpha"
|
||||
assert bus.inboxes["a1"] == []
|
||||
assert bus.stats_live["a1"]["calls"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_and_drain_fifo(bus: AgentMessageBus) -> None:
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
await bus.send("a1", {"from": "b", "content": "first"})
|
||||
await bus.send("a1", {"from": "c", "content": "second"})
|
||||
drained = await bus.drain("a1")
|
||||
assert [m["content"] for m in drained] == ["first", "second"]
|
||||
assert await bus.drain("a1") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_unknown_agent_is_dropped(bus: AgentMessageBus) -> None:
|
||||
await bus.send("ghost", {"from": "user", "content": "x"})
|
||||
assert "ghost" not in bus.inboxes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_clears_inbox_parent_name(bus: AgentMessageBus) -> None:
|
||||
"""C13 (AUDIT_R3): finalize cleans up routing state to avoid orphan messages."""
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
await bus.register("a2", "beta", parent_id="a1")
|
||||
await bus.send("a1", {"from": "a2", "content": "hi"})
|
||||
await bus.finalize("a1", "completed")
|
||||
# Inbox / parent / name removed so siblings can't accidentally re-fill.
|
||||
assert "a1" not in bus.inboxes
|
||||
assert "a1" not in bus.parent_of
|
||||
assert "a1" not in bus.names
|
||||
# Status remains for diagnostics.
|
||||
assert bus.statuses["a1"] == "completed"
|
||||
# Messages sent to a finalized agent are dropped silently.
|
||||
await bus.send("a1", {"from": "a2", "content": "ignored"})
|
||||
assert "a1" not in bus.inboxes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_usage_aggregates(bus: AgentMessageBus) -> None:
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
|
||||
class _Details:
|
||||
cached_tokens = 10
|
||||
|
||||
class _Usage:
|
||||
input_tokens = 100
|
||||
output_tokens = 50
|
||||
input_tokens_details = _Details()
|
||||
|
||||
await bus.record_usage("a1", _Usage())
|
||||
await bus.record_usage("a1", _Usage())
|
||||
stats = bus.stats_live["a1"]
|
||||
assert stats["in"] == 200
|
||||
assert stats["out"] == 100
|
||||
assert stats["cached"] == 20
|
||||
assert stats["calls"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_usage_handles_none(bus: AgentMessageBus) -> None:
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
await bus.record_usage("a1", None)
|
||||
assert bus.stats_live["a1"]["calls"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_stats_snapshot(bus: AgentMessageBus) -> None:
|
||||
"""C12 (AUDIT_R2): total_stats acquires the lock for a consistent snapshot."""
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
await bus.register("a2", "beta", parent_id="a1")
|
||||
|
||||
class _Details:
|
||||
cached_tokens = 5
|
||||
|
||||
class _Usage:
|
||||
input_tokens = 10
|
||||
output_tokens = 20
|
||||
input_tokens_details = _Details()
|
||||
|
||||
await bus.record_usage("a1", _Usage())
|
||||
await bus.record_usage("a2", _Usage())
|
||||
await bus.finalize("a2", "completed")
|
||||
|
||||
totals = await bus.total_stats()
|
||||
assert totals["in"] == 20
|
||||
assert totals["out"] == 40
|
||||
assert totals["cached"] == 10
|
||||
assert totals["calls"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_send_drain_no_lost_messages() -> None:
|
||||
bus = AgentMessageBus()
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
|
||||
async def producer(start: int, count: int) -> None:
|
||||
for i in range(count):
|
||||
await bus.send("a1", {"from": "p", "content": str(start + i)})
|
||||
|
||||
# 50 producers x 20 messages = 1000 messages; drain in 1 reader.
|
||||
producers = [asyncio.create_task(producer(i * 20, 20)) for i in range(50)]
|
||||
await asyncio.gather(*producers)
|
||||
drained = await bus.drain("a1")
|
||||
assert len(drained) == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_descendants_cancels_whole_tree() -> None:
|
||||
"""C9 (AUDIT_R2): cancel_descendants cancels every transitive child."""
|
||||
bus = AgentMessageBus()
|
||||
await bus.register("root", "root", parent_id=None)
|
||||
await bus.register("child1", "c1", parent_id="root")
|
||||
await bus.register("grandchild1", "g1", parent_id="child1")
|
||||
await bus.register("child2", "c2", parent_id="root")
|
||||
|
||||
pending = asyncio.get_event_loop().create_future()
|
||||
|
||||
async def fake_run() -> None:
|
||||
await pending # block until cancelled
|
||||
|
||||
for aid in ("root", "child1", "grandchild1", "child2"):
|
||||
bus.tasks[aid] = asyncio.create_task(fake_run())
|
||||
|
||||
await bus.cancel_descendants("root")
|
||||
|
||||
for aid in ("root", "child1", "grandchild1", "child2"):
|
||||
assert bus.tasks[aid].cancelled() or bus.tasks[aid].done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_descendants_triggers_leaves_before_root() -> None:
|
||||
"""C9: explicit ordering check — leaves' .cancel() called before root's."""
|
||||
bus = AgentMessageBus()
|
||||
await bus.register("root", "root", parent_id=None)
|
||||
await bus.register("child1", "c1", parent_id="root")
|
||||
await bus.register("grandchild1", "g1", parent_id="child1")
|
||||
|
||||
cancel_call_order: list[str] = []
|
||||
pending = asyncio.get_event_loop().create_future()
|
||||
|
||||
class _RecordingTask:
|
||||
"""Wrap a real Task; record the moment .cancel() is invoked."""
|
||||
|
||||
def __init__(self, name: str, task: asyncio.Task) -> None:
|
||||
self._name = name
|
||||
self._task = task
|
||||
|
||||
def done(self) -> bool:
|
||||
return self._task.done()
|
||||
|
||||
def cancelled(self) -> bool:
|
||||
return self._task.cancelled()
|
||||
|
||||
def cancel(self, *args: object, **kwargs: object) -> bool:
|
||||
cancel_call_order.append(self._name)
|
||||
return self._task.cancel()
|
||||
|
||||
def __await__(self):
|
||||
return self._task.__await__()
|
||||
|
||||
async def fake_run() -> None:
|
||||
await pending
|
||||
|
||||
for aid in ("root", "child1", "grandchild1"):
|
||||
real = asyncio.create_task(fake_run())
|
||||
bus.tasks[aid] = _RecordingTask(aid, real) # type: ignore[assignment]
|
||||
|
||||
await bus.cancel_descendants("root")
|
||||
|
||||
# grandchild and child must have .cancel() called before root.
|
||||
assert cancel_call_order.index("grandchild1") < cancel_call_order.index("root")
|
||||
assert cancel_call_order.index("child1") < cancel_call_order.index("root")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Phase 0 smoke tests for inject_messages_filter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agents.run_config import CallModelData, ModelInputData
|
||||
|
||||
from strix.orchestration.bus import AgentMessageBus
|
||||
from strix.orchestration.filter import inject_messages_filter
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAgent:
|
||||
name: str = "agent"
|
||||
|
||||
|
||||
def _call_data(
|
||||
context: Any,
|
||||
items: list[Any],
|
||||
instructions: str | None = "system",
|
||||
) -> CallModelData[Any]:
|
||||
return CallModelData(
|
||||
model_data=ModelInputData(input=items, instructions=instructions),
|
||||
agent=_FakeAgent(),
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_inbox_passes_through() -> None:
|
||||
bus = AgentMessageBus()
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
data = _call_data({"bus": bus, "agent_id": "a1"}, [{"role": "user", "content": "x"}])
|
||||
|
||||
out = await inject_messages_filter(data)
|
||||
|
||||
assert out.input == [{"role": "user", "content": "x"}]
|
||||
assert out.instructions == "system"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_messages_appended_in_order() -> None:
|
||||
bus = AgentMessageBus()
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
await bus.send("a1", {"from": "b", "content": "hello", "type": "info", "priority": "normal"})
|
||||
await bus.send("a1", {"from": "c", "content": "second", "type": "info", "priority": "high"})
|
||||
data = _call_data({"bus": bus, "agent_id": "a1"}, [{"role": "user", "content": "task"}])
|
||||
|
||||
out = await inject_messages_filter(data)
|
||||
|
||||
assert len(out.input) == 3
|
||||
assert out.input[0] == {"role": "user", "content": "task"}
|
||||
assert "<inter_agent_message from='b'" in out.input[1]["content"]
|
||||
assert "hello" in out.input[1]["content"]
|
||||
assert "second" in out.input[2]["content"]
|
||||
assert "priority='high'" in out.input[2]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_sender_skips_xml_wrap() -> None:
|
||||
bus = AgentMessageBus()
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
await bus.send("a1", {"from": "user", "content": "follow-up question"})
|
||||
data = _call_data({"bus": bus, "agent_id": "a1"}, [])
|
||||
|
||||
out = await inject_messages_filter(data)
|
||||
|
||||
assert out.input == [{"role": "user", "content": "follow-up question"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_bus_in_context_passes_through() -> None:
|
||||
data = _call_data({"agent_id": "a1"}, [{"role": "user", "content": "x"}])
|
||||
out = await inject_messages_filter(data)
|
||||
assert out.input == [{"role": "user", "content": "x"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_exception_returns_unmodified() -> None:
|
||||
"""C14 (AUDIT_R3): filter exception is caught; original data returned."""
|
||||
|
||||
class _BrokenBus:
|
||||
async def drain(self, _: str) -> list[dict[str, Any]]:
|
||||
raise RuntimeError("simulated bug")
|
||||
|
||||
data = _call_data(
|
||||
{"bus": _BrokenBus(), "agent_id": "a1"},
|
||||
[{"role": "user", "content": "still works"}],
|
||||
)
|
||||
|
||||
out = await inject_messages_filter(data)
|
||||
assert out.input == [{"role": "user", "content": "still works"}]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Phase 0 smoke tests for StrixOrchestrationHooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.orchestration.bus import AgentMessageBus
|
||||
from strix.orchestration.hooks import StrixOrchestrationHooks
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Ctx:
|
||||
"""Minimal stand-in for RunContextWrapper / AgentHookContext.
|
||||
|
||||
Only ``.context`` is exercised by the hooks under test; SDK's real wrappers
|
||||
expose much more, but the hooks treat ``.context`` as the dict we put in.
|
||||
"""
|
||||
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Tool:
|
||||
name: str
|
||||
|
||||
|
||||
class _FakeTracer:
|
||||
def __init__(self) -> None:
|
||||
self.starts: list[tuple[str, str]] = []
|
||||
self.ends: list[tuple[str, str, Any]] = []
|
||||
|
||||
def log_tool_start(self, agent_id: str, tool_name: str) -> None:
|
||||
self.starts.append((agent_id, tool_name))
|
||||
|
||||
def log_tool_end(self, agent_id: str, tool_name: str, result: Any) -> None:
|
||||
self.ends.append((agent_id, tool_name, result))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_llm_start_injects_85_percent_warning() -> None:
|
||||
hooks = StrixOrchestrationHooks()
|
||||
items: list[Any] = []
|
||||
ctx = _Ctx(context={"max_turns": 100, "turn_count": 85})
|
||||
await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items)
|
||||
assert len(items) == 1
|
||||
assert "85%" in items[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_llm_start_injects_n_minus_3_warning() -> None:
|
||||
hooks = StrixOrchestrationHooks()
|
||||
items: list[Any] = []
|
||||
ctx = _Ctx(context={"max_turns": 100, "turn_count": 97})
|
||||
await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items)
|
||||
assert len(items) == 1
|
||||
assert "3 iterations left" in items[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_llm_start_no_warning_at_other_turns() -> None:
|
||||
hooks = StrixOrchestrationHooks()
|
||||
items: list[Any] = []
|
||||
ctx = _Ctx(context={"max_turns": 100, "turn_count": 50})
|
||||
await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items)
|
||||
assert items == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_llm_end_records_usage_and_increments_turn() -> None:
|
||||
hooks = StrixOrchestrationHooks()
|
||||
bus = AgentMessageBus()
|
||||
await bus.register("a1", "alpha", parent_id=None)
|
||||
ctx = _Ctx(context={"bus": bus, "agent_id": "a1", "turn_count": 0})
|
||||
|
||||
class _Details:
|
||||
cached_tokens = 5
|
||||
|
||||
class _Usage:
|
||||
input_tokens = 10
|
||||
output_tokens = 20
|
||||
input_tokens_details = _Details()
|
||||
|
||||
class _Resp:
|
||||
usage = _Usage()
|
||||
|
||||
await hooks.on_llm_end(ctx, agent=None, response=_Resp())
|
||||
assert ctx.context["turn_count"] == 1
|
||||
assert bus.stats_live["a1"]["in"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_agent_end_detects_crash() -> None:
|
||||
"""C8 (AUDIT_R2): on_agent_end without agent_finish_called posts crash to parent."""
|
||||
hooks = StrixOrchestrationHooks()
|
||||
bus = AgentMessageBus()
|
||||
await bus.register("root", "root", parent_id=None)
|
||||
await bus.register("child", "specialist", parent_id="root")
|
||||
ctx = _Ctx(context={"bus": bus, "agent_id": "child"})
|
||||
|
||||
await hooks.on_agent_end(ctx, agent=None, output=None) # crashed (output=None)
|
||||
|
||||
drained = await bus.drain("root")
|
||||
assert len(drained) == 1
|
||||
assert "<agent_crash" in drained[0]["content"]
|
||||
assert "agent_id='child'" in drained[0]["content"]
|
||||
assert bus.statuses["child"] == "crashed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_agent_end_no_crash_when_finish_called() -> None:
|
||||
hooks = StrixOrchestrationHooks()
|
||||
bus = AgentMessageBus()
|
||||
await bus.register("root", "root", parent_id=None)
|
||||
await bus.register("child", "specialist", parent_id="root")
|
||||
ctx = _Ctx(
|
||||
context={
|
||||
"bus": bus,
|
||||
"agent_id": "child",
|
||||
"agent_finish_called": True,
|
||||
}
|
||||
)
|
||||
|
||||
await hooks.on_agent_end(ctx, agent=None, output="done")
|
||||
|
||||
assert await bus.drain("root") == []
|
||||
assert bus.statuses["child"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_tool_end_marks_finish_called() -> None:
|
||||
"""When agent_finish or finish_scan returns, mark context flag for crash detection."""
|
||||
hooks = StrixOrchestrationHooks()
|
||||
ctx = _Ctx(context={"agent_id": "a1"})
|
||||
await hooks.on_tool_end(ctx, agent=None, tool=_Tool("agent_finish"), result="ok")
|
||||
assert ctx.context["agent_finish_called"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_tool_end_other_tool_does_not_set_flag() -> None:
|
||||
hooks = StrixOrchestrationHooks()
|
||||
ctx = _Ctx(context={"agent_id": "a1"})
|
||||
await hooks.on_tool_end(ctx, agent=None, tool=_Tool("terminal_execute"), result="x")
|
||||
assert ctx.context.get("agent_finish_called") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_tool_start_logs_to_tracer() -> None:
|
||||
hooks = StrixOrchestrationHooks()
|
||||
tracer = _FakeTracer()
|
||||
ctx = _Ctx(context={"tracer": tracer, "agent_id": "a1"})
|
||||
await hooks.on_tool_start(ctx, agent=None, tool=_Tool("browser_action"))
|
||||
assert tracer.starts == [("a1", "browser_action")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_exception_does_not_propagate() -> None:
|
||||
"""C15 (AUDIT_R3): a bug in the hook body must never tear down the run."""
|
||||
hooks = StrixOrchestrationHooks()
|
||||
|
||||
class _BrokenBus:
|
||||
async def record_usage(self, *_: Any, **__: Any) -> None:
|
||||
raise RuntimeError("simulated")
|
||||
|
||||
ctx = _Ctx(context={"bus": _BrokenBus(), "agent_id": "a1"})
|
||||
|
||||
class _Resp:
|
||||
usage = None
|
||||
|
||||
# Should not raise.
|
||||
await hooks.on_llm_end(ctx, agent=None, response=_Resp())
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Phase 0 smoke tests for StrixDockerSandboxClient.
|
||||
|
||||
These tests do NOT require Docker. They mock the Docker SDK client (passed
|
||||
to the constructor) and verify our subclass injects the right kwargs into
|
||||
``containers.create``. Live container tests are part of Phase 0 manual
|
||||
smoke (TESTING_STRATEGY.md §9).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import docker.errors # type: ignore[import-untyped,unused-ignore]
|
||||
import pytest
|
||||
|
||||
from strix.runtime.strix_docker_client import StrixDockerSandboxClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_docker() -> MagicMock:
|
||||
"""Stand-in for the Docker SDK client passed to the subclass."""
|
||||
fake = MagicMock()
|
||||
fake.images.get.return_value = MagicMock() # image_exists -> True
|
||||
fake.images.pull = MagicMock()
|
||||
fake.containers.create.return_value = MagicMock(name="container")
|
||||
return fake
|
||||
|
||||
|
||||
def _create_kwargs(fake: MagicMock) -> dict[str, Any]:
|
||||
result: dict[str, Any] = fake.containers.create.call_args.kwargs
|
||||
return result
|
||||
|
||||
|
||||
def test_subclass_injects_net_admin_and_net_raw(fake_docker: MagicMock) -> None:
|
||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
||||
asyncio.run(
|
||||
client._create_container("strix-image:latest", manifest=None, exposed_ports=()),
|
||||
)
|
||||
kwargs = _create_kwargs(fake_docker)
|
||||
assert "NET_ADMIN" in kwargs["cap_add"]
|
||||
assert "NET_RAW" in kwargs["cap_add"]
|
||||
|
||||
|
||||
def test_subclass_injects_host_gateway(fake_docker: MagicMock) -> None:
|
||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
||||
asyncio.run(
|
||||
client._create_container("strix-image:latest", manifest=None, exposed_ports=()),
|
||||
)
|
||||
kwargs = _create_kwargs(fake_docker)
|
||||
assert kwargs["extra_hosts"]["host.docker.internal"] == "host-gateway"
|
||||
|
||||
|
||||
def test_subclass_preserves_image_and_command(fake_docker: MagicMock) -> None:
|
||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
||||
asyncio.run(
|
||||
client._create_container("custom:tag", manifest=None, exposed_ports=()),
|
||||
)
|
||||
kwargs = _create_kwargs(fake_docker)
|
||||
assert kwargs["image"] == "custom:tag"
|
||||
assert kwargs["entrypoint"] == ["tail"]
|
||||
assert kwargs["command"] == ["-f", "/dev/null"]
|
||||
assert kwargs["detach"] is True
|
||||
|
||||
|
||||
def test_subclass_emits_ports_dict_for_exposed_ports(fake_docker: MagicMock) -> None:
|
||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
||||
asyncio.run(
|
||||
client._create_container(
|
||||
"strix-image:latest",
|
||||
manifest=None,
|
||||
exposed_ports=(48081, 48080),
|
||||
),
|
||||
)
|
||||
kwargs = _create_kwargs(fake_docker)
|
||||
assert "48081/tcp" in kwargs["ports"]
|
||||
assert "48080/tcp" in kwargs["ports"]
|
||||
|
||||
|
||||
def test_caps_appended_not_duplicated(fake_docker: MagicMock) -> None:
|
||||
"""Idempotent injection: calling twice doesn't add duplicate caps."""
|
||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
||||
asyncio.run(
|
||||
client._create_container("img:latest", manifest=None, exposed_ports=()),
|
||||
)
|
||||
kwargs = _create_kwargs(fake_docker)
|
||||
assert kwargs["cap_add"].count("NET_ADMIN") == 1
|
||||
assert kwargs["cap_add"].count("NET_RAW") == 1
|
||||
|
||||
|
||||
def test_pulls_image_when_missing(fake_docker: MagicMock) -> None:
|
||||
"""If image_exists returns False on first check, pull is invoked."""
|
||||
# First call raises ImageNotFound, second succeeds.
|
||||
fake_docker.images.get.side_effect = [
|
||||
docker.errors.ImageNotFound("not found"),
|
||||
MagicMock(),
|
||||
]
|
||||
client = StrixDockerSandboxClient(docker_client=fake_docker)
|
||||
asyncio.run(
|
||||
client._create_container("registry.io/strix:tag", manifest=None, exposed_ports=()),
|
||||
)
|
||||
fake_docker.images.pull.assert_called_once()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Phase 0 smoke tests for the strix_tool decorator factory.
|
||||
|
||||
The SDK's ``FunctionTool`` only honors ``timeout_seconds`` for ``async``
|
||||
handlers (verified at ``agents.tool._validate_function_tool_timeout_config``):
|
||||
sync function bodies cannot be cleanly cancelled, so the SDK refuses to
|
||||
attach a timeout to them. Every Strix tool is therefore an ``async def``;
|
||||
sync libraries (libtmux, IPython) get wrapped in ``asyncio.to_thread``
|
||||
inside the async tool body.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from agents.tool import FunctionTool
|
||||
|
||||
from strix.tools._decorator import strix_tool
|
||||
|
||||
|
||||
def test_strix_tool_returns_function_tool() -> None:
|
||||
@strix_tool()
|
||||
async def my_tool(x: int) -> str:
|
||||
"""Do a thing."""
|
||||
return str(x)
|
||||
|
||||
assert isinstance(my_tool, FunctionTool)
|
||||
|
||||
|
||||
def test_strix_tool_default_timeout_is_120s() -> None:
|
||||
@strix_tool()
|
||||
async def my_tool(x: int) -> str:
|
||||
"""Do a thing."""
|
||||
return str(x)
|
||||
|
||||
assert my_tool.timeout_seconds == 120.0
|
||||
|
||||
|
||||
def test_strix_tool_default_timeout_behavior_is_error_as_result() -> None:
|
||||
@strix_tool()
|
||||
async def my_tool(x: int) -> str:
|
||||
"""Do a thing."""
|
||||
return str(x)
|
||||
|
||||
assert my_tool.timeout_behavior == "error_as_result"
|
||||
|
||||
|
||||
def test_strix_tool_timeout_override() -> None:
|
||||
@strix_tool(timeout=300)
|
||||
async def my_tool(x: int) -> str:
|
||||
"""Do a thing."""
|
||||
return str(x)
|
||||
|
||||
assert my_tool.timeout_seconds == 300.0
|
||||
|
||||
|
||||
def test_strix_tool_critical_tool_can_raise() -> None:
|
||||
"""C20 (AUDIT_R3): critical tools opt into raise_exception."""
|
||||
|
||||
@strix_tool(timeout=30, timeout_behavior="raise_exception")
|
||||
async def critical_tool(x: int) -> str:
|
||||
"""Do a critical thing."""
|
||||
return str(x)
|
||||
|
||||
assert critical_tool.timeout_behavior == "raise_exception"
|
||||
|
||||
|
||||
def test_strix_tool_sync_handlers_rejected_by_sdk() -> None:
|
||||
"""SDK explicitly rejects timeout on sync handlers; documenting the constraint."""
|
||||
with pytest.raises(ValueError, match="async @function_tool"):
|
||||
|
||||
@strix_tool()
|
||||
def my_sync_tool(x: int) -> str:
|
||||
"""Sync tools can't have a timeout in the SDK."""
|
||||
return str(x)
|
||||
Reference in New Issue
Block a user