Files
2026-07-13 13:12:33 +08:00

596 lines
20 KiB
Python

"""DAG-parallelism contract tests for MetaOrchestrator (M7).
Three contracts:
1. Two steps with disjoint depends_on run concurrently — their
ToolUseStartEvents both arrive before either's ToolResultEvent.
2. When one step in a parallel batch fails, the sibling task is
cancelled — its ToolResultEvent never arrives.
3. A purely-linear DAG keeps the same event order as the old
linear-topo scheduler (backwards compat).
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any
import pytest
from opensquilla.engine.types import (
AgentEvent,
DoneEvent,
TextDeltaEvent,
ToolResultEvent,
ToolUseStartEvent,
)
from opensquilla.skills.meta.orchestrator import MetaOrchestrator
from opensquilla.skills.meta.parser import parse_meta_plan
from opensquilla.skills.meta.types import MetaMatch, MetaResult
from opensquilla.skills.types import SkillLayer, SkillSpec
def _meta_spec(steps: list[dict[str, Any]]) -> SkillSpec:
return SkillSpec(
name="meta-test",
description="t",
layer=SkillLayer.BUNDLED,
always=False,
triggers=["t"],
content="fallback",
kind="meta",
composition_raw={"steps": steps},
)
def _skill(name: str) -> SkillSpec:
return SkillSpec(
name=name,
description=f"{name} d",
layer=SkillLayer.BUNDLED,
always=False,
triggers=[],
content=name.upper(),
kind="skill",
)
class _FakeLoader:
def __init__(self, specs: list[SkillSpec]) -> None:
self._by_name = {s.name: s for s in specs}
def get_by_name(self, name: str) -> SkillSpec | None:
return self._by_name.get(name)
@pytest.mark.asyncio
async def test_independent_steps_run_concurrently() -> None:
"""Two steps with no deps both start before either finishes."""
spec = _meta_spec(
[
{"id": "a", "skill": "skill_a", "with": {}},
{"id": "b", "skill": "skill_b", "with": {}}, # no depends_on
],
)
plan = parse_meta_plan(spec)
assert plan is not None
started = asyncio.Event()
second_started_first = asyncio.Event()
async def runner(system_prompt: str, _u: str) -> AsyncIterator[AgentEvent]:
# First runner blocks until the second has also started — proves
# they overlap. With a serial scheduler this hangs forever.
if "SKILL_A" in system_prompt:
started.set()
await asyncio.wait_for(second_started_first.wait(), timeout=2.0)
yield TextDeltaEvent(text="A done")
else:
await asyncio.wait_for(started.wait(), timeout=2.0)
second_started_first.set()
yield TextDeltaEvent(text="B done")
yield DoneEvent(text="")
orch = MetaOrchestrator(
agent_runner=runner,
skill_loader=_FakeLoader([_skill("skill_a"), _skill("skill_b")]),
)
starts: list[str] = []
results: list[str] = []
final: MetaResult | None = None
async for ev in orch.iter_events(MetaMatch(plan=plan, inputs={})):
if isinstance(ev, MetaResult):
final = ev
elif isinstance(ev, ToolUseStartEvent) and ev.tool_name.startswith("meta-step:"):
starts.append(ev.tool_name)
elif isinstance(ev, ToolResultEvent) and ev.tool_name.startswith("meta-step:"):
results.append(ev.tool_name)
assert final is not None and final.ok, final.error if final else "no final"
# Both starts must arrive before either result.
assert set(starts) == {"meta-step:a", "meta-step:b"}
assert set(results) == {"meta-step:a", "meta-step:b"}
# In the linear scheduler, starts[0] would always produce results[0]
# before starts[1]. The parallel scheduler interleaves them, which we
# already proved by the asyncio.Event coupling above — if we got here
# without TimeoutError, the steps ran concurrently.
@pytest.mark.asyncio
async def test_failing_step_cancels_sibling() -> None:
"""When one parallel step raises, the sibling task is cancelled."""
spec = _meta_spec(
[
{"id": "a", "skill": "skill_a", "with": {}},
{"id": "b", "skill": "skill_b", "with": {}},
],
)
plan = parse_meta_plan(spec)
assert plan is not None
sibling_completed = False
async def runner(system_prompt: str, _u: str) -> AsyncIterator[AgentEvent]:
nonlocal sibling_completed
if "SKILL_A" in system_prompt:
# Fail fast.
raise RuntimeError("a failed")
# b is slow — must be cancelled before it finishes.
try:
await asyncio.sleep(5.0)
except asyncio.CancelledError:
raise
sibling_completed = True
yield TextDeltaEvent(text="should not arrive")
yield DoneEvent(text="")
orch = MetaOrchestrator(
agent_runner=runner,
skill_loader=_FakeLoader([_skill("skill_a"), _skill("skill_b")]),
)
final: MetaResult | None = None
error_results: list[ToolResultEvent] = []
async for ev in orch.iter_events(MetaMatch(plan=plan, inputs={})):
if isinstance(ev, MetaResult):
final = ev
elif isinstance(ev, ToolResultEvent) and ev.is_error:
error_results.append(ev)
assert final is not None and final.ok is False
assert sibling_completed is False
assert len(error_results) >= 1
@pytest.mark.asyncio
async def test_failure_closes_sibling_brackets() -> None:
"""Every yielded ToolUseStart for a meta-step has a matching ToolResult.
Concretely: when two siblings A/B both emit ToolUseStartEvents and A
fails while B is still sleeping, the orchestrator must emit a
ToolResultEvent for B carrying ``is_error=True`` and a "cancelled"
message so the WebUI does not leak an open in-progress card.
"""
spec = _meta_spec(
[
{"id": "a", "skill": "skill_a", "with": {}},
{"id": "b", "skill": "skill_b", "with": {}},
],
)
plan = parse_meta_plan(spec)
assert plan is not None
b_running = asyncio.Event()
async def runner(system_prompt: str, _u: str) -> AsyncIterator[AgentEvent]:
if "SKILL_A" in system_prompt:
# Make sure B has emitted its ToolUseStart before A fails — we
# want to exercise the path where the outer loop yielded both
# starts already.
await asyncio.wait_for(b_running.wait(), timeout=2.0)
raise RuntimeError("a blew up mid-flight")
# B emits a TextDelta (forces _run_one to enter the streaming
# body so its ToolUseStart has been pushed) then signals A and
# sleeps. The sleep is interruptible so cancellation lands.
yield TextDeltaEvent(text="b: starting")
b_running.set()
try:
await asyncio.sleep(5.0)
except asyncio.CancelledError:
raise
yield TextDeltaEvent(text="b: should not arrive")
yield DoneEvent(text="")
orch = MetaOrchestrator(
agent_runner=runner,
skill_loader=_FakeLoader([_skill("skill_a"), _skill("skill_b")]),
)
starts: dict[str, ToolUseStartEvent] = {}
results: dict[str, ToolResultEvent] = {}
final: MetaResult | None = None
async for ev in orch.iter_events(MetaMatch(plan=plan, inputs={})):
if isinstance(ev, MetaResult):
final = ev
elif isinstance(ev, ToolUseStartEvent) and ev.tool_name.startswith(
"meta-step:",
):
starts[ev.tool_use_id] = ev
elif isinstance(ev, ToolResultEvent) and ev.tool_name.startswith(
"meta-step:",
):
# Keep the FIRST result we see per id — a real ToolResult
# from the failing task should not be overwritten by a
# synthetic one (and the orchestrator should not emit two
# results for the same start anyway).
results.setdefault(ev.tool_use_id, ev)
assert final is not None and final.ok is False
# Close-bracket invariant: every start has a matching result with
# the same tool_use_id.
assert set(starts.keys()) == set(results.keys()), (
f"unbalanced brackets: starts={set(starts.keys())} "
f"results={set(results.keys())}"
)
# Both meta-step:a and meta-step:b must be present.
assert "meta_step_a" in starts
assert "meta_step_b" in starts
# Sibling B (cancelled) must carry is_error=True and a "cancelled" hint.
b_result = results["meta_step_b"]
assert b_result.is_error is True
assert "cancel" in str(b_result.result).lower()
@pytest.mark.asyncio
async def test_linear_dag_event_order_preserved() -> None:
"""A→B→C chain keeps deterministic event ordering."""
spec = _meta_spec(
[
{"id": "a", "skill": "skill_a", "with": {}},
{"id": "b", "skill": "skill_b", "depends_on": ["a"], "with": {}},
{"id": "c", "skill": "skill_c", "depends_on": ["b"], "with": {}},
],
)
plan = parse_meta_plan(spec)
assert plan is not None
async def runner(system_prompt: str, _u: str) -> AsyncIterator[AgentEvent]:
for letter in ("A", "B", "C"):
if f"SKILL_{letter}" in system_prompt:
yield TextDeltaEvent(text=f"{letter}-done")
break
yield DoneEvent(text="")
orch = MetaOrchestrator(
agent_runner=runner,
skill_loader=_FakeLoader(
[_skill("skill_a"), _skill("skill_b"), _skill("skill_c")],
),
)
ordering: list[tuple[str, str]] = []
final: MetaResult | None = None
async for ev in orch.iter_events(MetaMatch(plan=plan, inputs={})):
if isinstance(ev, MetaResult):
final = ev
elif isinstance(ev, ToolUseStartEvent) and ev.tool_name.startswith("meta-step:"):
ordering.append(("start", ev.tool_name))
elif isinstance(ev, ToolResultEvent) and ev.tool_name.startswith("meta-step:"):
ordering.append(("end", ev.tool_name))
assert final is not None and final.ok
# Strict interleaving: start(a) → end(a) → start(b) → end(b) → start(c) → end(c)
assert ordering == [
("start", "meta-step:a"),
("end", "meta-step:a"),
("start", "meta-step:b"),
("end", "meta-step:b"),
("start", "meta-step:c"),
("end", "meta-step:c"),
]
@pytest.mark.asyncio
async def test_max_parallelism_caps_concurrent_steps() -> None:
"""With max_parallelism=2 and 4 independent steps, no more than 2 are
in-flight at any moment."""
spec = _meta_spec(
[
{"id": f"s{i}", "skill": f"skill_{i}", "with": {}}
for i in range(4)
],
)
plan = parse_meta_plan(spec)
assert plan is not None
in_flight = 0
high_water_mark = 0
lock = asyncio.Lock()
async def runner(_s: str, _u: str) -> AsyncIterator[AgentEvent]:
nonlocal in_flight, high_water_mark
async with lock:
in_flight += 1
high_water_mark = max(high_water_mark, in_flight)
try:
await asyncio.sleep(0.05)
yield TextDeltaEvent(text="done")
yield DoneEvent(text="")
finally:
async with lock:
in_flight -= 1
loader = _FakeLoader([_skill(f"skill_{i}") for i in range(4)])
orch = MetaOrchestrator(
agent_runner=runner,
skill_loader=loader,
max_parallelism=2,
)
async for _ in orch.iter_events(MetaMatch(plan=plan, inputs={})):
pass
assert high_water_mark <= 2, f"high_water_mark={high_water_mark}"
@pytest.mark.asyncio
async def test_max_parallelism_none_unbounded() -> None:
"""max_parallelism=None preserves the current 5-way fan-out."""
spec = _meta_spec(
[
{"id": f"s{i}", "skill": f"skill_{i}", "with": {}}
for i in range(5)
],
)
plan = parse_meta_plan(spec)
assert plan is not None
started_count = 0
target_lock = asyncio.Lock()
all_started = asyncio.Event()
async def runner(_s: str, _u: str) -> AsyncIterator[AgentEvent]:
nonlocal started_count
async with target_lock:
started_count += 1
if started_count == 5:
all_started.set()
await asyncio.wait_for(all_started.wait(), timeout=1.0)
yield TextDeltaEvent(text="done")
yield DoneEvent(text="")
loader = _FakeLoader([_skill(f"skill_{i}") for i in range(5)])
orch = MetaOrchestrator(
agent_runner=runner,
skill_loader=loader,
max_parallelism=None,
)
final: MetaResult | None = None
async for ev in orch.iter_events(MetaMatch(plan=plan, inputs={})):
if isinstance(ev, MetaResult):
final = ev
assert final is not None and final.ok
@pytest.mark.asyncio
async def test_orchestrator_default_parallelism_caps_at_four() -> None:
spec = _meta_spec(
[
{"id": f"s{i}", "skill": f"skill_{i}", "with": {}}
for i in range(5)
],
)
plan = parse_meta_plan(spec)
assert plan is not None
in_flight = 0
high_water_mark = 0
lock = asyncio.Lock()
async def runner(_s: str, _u: str) -> AsyncIterator[AgentEvent]:
nonlocal in_flight, high_water_mark
async with lock:
in_flight += 1
high_water_mark = max(high_water_mark, in_flight)
try:
await asyncio.sleep(0.05)
yield TextDeltaEvent(text="done")
yield DoneEvent(text="")
finally:
async with lock:
in_flight -= 1
orch = MetaOrchestrator(
agent_runner=runner,
skill_loader=_FakeLoader([_skill(f"skill_{i}") for i in range(5)]),
)
async for _ in orch.iter_events(MetaMatch(plan=plan, inputs={})):
pass
assert high_water_mark <= 4, f"high_water_mark={high_water_mark}"
@pytest.mark.asyncio
async def test_run_dag_emits_three_callback_types() -> None:
"""C3: scheduler must externalise step begin / done / failover events."""
from opensquilla.skills.meta.scheduler import run_dag
from opensquilla.skills.meta.types import MetaMatch, MetaPlan, MetaStep
plan = MetaPlan(
name="cb-test",
triggers=("x",),
priority=10,
steps=(
MetaStep(id="a", skill="alpha", kind="agent", on_failure="b"),
MetaStep(id="b", skill="beta", kind="agent"),
),
)
match = MetaMatch(plan=plan, inputs={})
begin_calls: list[tuple[str, str]] = []
finish_calls: list[tuple[str, str]] = []
failover_calls: list[tuple[str, str, str]] = []
async def begin_cb(step_id: str, effective_skill: str, rendered_inputs: dict) -> None:
begin_calls.append((step_id, effective_skill))
async def finish_cb(
step_id: str,
status: str,
output_text: str | None,
error: str | None,
) -> None:
finish_calls.append((step_id, status))
async def failover_cb(failed_step_id: str, substitute_step_id: str, error: str) -> None:
failover_calls.append((failed_step_id, substitute_step_id, error))
async def dispatch_stub(step, effective_skill, inputs, outputs):
if step.id == "a":
raise RuntimeError("alpha exploded")
from opensquilla.skills.meta.events import _StepDone
yield _StepDone(text="beta-output")
async def preface_stub(step_id: str, effective_skill: str):
if False:
yield None
return
async for _ in run_dag(
match,
dispatch_step_stream=dispatch_stub,
yield_skill_view_preface=preface_stub,
max_parallelism=4,
on_step_begin=begin_cb,
on_step_finish=finish_cb,
on_step_failover=failover_cb,
):
pass
assert ("a", "alpha") in begin_calls
assert ("b", "beta") in begin_calls
assert ("b", "ok") in finish_calls
assert len(failover_calls) == 1
assert failover_calls[0][:2] == ("a", "b")
@pytest.mark.asyncio
async def test_run_dag_on_step_begin_gets_rendered_inputs() -> None:
from opensquilla.skills.meta.scheduler import run_dag
from opensquilla.skills.meta.types import MetaMatch, MetaPlan, MetaStep
plan = MetaPlan(
name="rendered-inputs-test",
triggers=("x",),
priority=10,
steps=(
MetaStep(
id="a",
skill="alpha",
kind="agent",
with_args={"q": "hello {{ inputs.user_message }}"},
),
),
)
match = MetaMatch(plan=plan, inputs={"user_message": "world"})
begin_inputs: list[dict[str, Any]] = []
async def begin_cb(
step_id: str,
effective_skill: str,
rendered_inputs: dict[str, Any],
) -> None:
begin_inputs.append(rendered_inputs)
async def dispatch_stub(step, effective_skill, inputs, outputs):
from opensquilla.skills.meta.events import _StepDone
yield _StepDone(text="ok")
async def preface_stub(step_id: str, effective_skill: str):
if False:
yield None
return
async for _ in run_dag(
match,
dispatch_step_stream=dispatch_stub,
yield_skill_view_preface=preface_stub,
on_step_begin=begin_cb,
):
pass
assert begin_inputs == [{"q": "hello world"}]
@pytest.mark.asyncio
async def test_run_dag_tool_result_includes_scoped_step_usage() -> None:
from opensquilla.engine.types import ToolResultEvent
from opensquilla.engine.usage import UsageTracker
from opensquilla.skills.meta.events import _StepDone
from opensquilla.skills.meta.scheduler import run_dag
from opensquilla.skills.meta.types import MetaMatch, MetaPlan, MetaStep
plan = MetaPlan(
name="step-usage-test",
triggers=("x",),
priority=10,
steps=(
MetaStep(id="a", skill="alpha", kind="llm_chat"),
MetaStep(id="b", skill="beta", kind="llm_chat"),
),
)
match = MetaMatch(plan=plan, inputs={})
tracker = UsageTracker()
results: dict[str, ToolResultEvent] = {}
async def dispatch_stub(step, effective_skill, inputs, outputs):
tracker.add(
"session-a",
input_tokens=10 if step.id == "a" else 20,
output_tokens=1 if step.id == "a" else 2,
model_id=f"model-{step.id}",
billed_cost=0.123 if step.id == "a" else 0.0,
)
yield _StepDone(text=f"{step.id}-done")
async def preface_stub(step_id: str, effective_skill: str):
if False:
yield None
return
async for event in run_dag(
match,
dispatch_step_stream=dispatch_stub,
yield_skill_view_preface=preface_stub,
max_parallelism=2,
usage_tracker=tracker,
session_key="session-a",
usage_scope_prefix="run-1",
):
if isinstance(event, ToolResultEvent):
results[event.tool_name] = event
usage_a = (results["meta-step:a"].arguments or {})["usage"]
usage_b = (results["meta-step:b"].arguments or {})["usage"]
assert usage_a["input_tokens"] == 10
assert usage_a["output_tokens"] == 1
assert usage_a["total_tokens"] == 11
assert usage_a["model"] == "model-a"
assert usage_a["cost_usd"] == pytest.approx(0.123)
assert usage_a["billed_cost"] == pytest.approx(0.123)
assert usage_a["billed_cost_usd"] == pytest.approx(0.123)
assert usage_a["cost_source"] == "provider_billed"
assert usage_a["is_provider_billed"] is True
assert usage_b["input_tokens"] == 20
assert usage_b["output_tokens"] == 2
assert usage_b["total_tokens"] == 22
assert usage_b["model"] == "model-b"
assert usage_b["billed_cost"] == 0.0
assert usage_b["is_provider_billed"] is False