439 lines
13 KiB
Python
439 lines
13 KiB
Python
"""scheduler 在 step 开始/成功时分别发出 meta_step_state(running/succeeded)。"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from opensquilla.engine.types import (
|
|
MetaPreflightEvent,
|
|
MetaRunAnnouncedEvent,
|
|
MetaRunCompletedEvent,
|
|
MetaStepStateEvent,
|
|
ToolResultEvent,
|
|
)
|
|
from opensquilla.skills.meta.events import _StepDone
|
|
from opensquilla.skills.meta.scheduler import _failure_hint, _failure_rescue_payload
|
|
from opensquilla.skills.meta.types import (
|
|
ClarifyField,
|
|
ClarifyStepConfig,
|
|
MetaMatch,
|
|
MetaPaused,
|
|
MetaPlan,
|
|
MetaPreflightRequired,
|
|
MetaResult,
|
|
MetaStep,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def make_two_step_match():
|
|
plan = MetaPlan(
|
|
name="meta-fake",
|
|
triggers=("fake",),
|
|
priority=0,
|
|
steps=(
|
|
MetaStep(id="intake", skill="intake", kind="llm_chat", label="意图提取"),
|
|
MetaStep(
|
|
id="summary", skill="summary", kind="llm_chat",
|
|
label="总结", depends_on=("intake",),
|
|
),
|
|
),
|
|
final_text_mode="raw",
|
|
)
|
|
return MetaMatch(plan=plan, inputs={"user_message": "hi"})
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_dispatch_stream():
|
|
async def _dispatch(step, effective_skill, inputs, outputs):
|
|
yield _StepDone(text=f"out:{step.id}")
|
|
|
|
return _dispatch
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_preface():
|
|
async def _preface(step_id, effective_skill):
|
|
return
|
|
yield # never reached; keeps it an async generator
|
|
|
|
return _preface
|
|
|
|
|
|
async def _collect_all_events(match, dispatch, preface):
|
|
from opensquilla.skills.meta.scheduler import run_dag
|
|
|
|
events = []
|
|
async for ev in run_dag(
|
|
match,
|
|
dispatch_step_stream=dispatch,
|
|
yield_skill_view_preface=preface,
|
|
):
|
|
events.append(ev)
|
|
return events
|
|
|
|
|
|
def test_running_emitted_at_step_start(
|
|
make_two_step_match, fake_dispatch_stream, fake_preface,
|
|
):
|
|
events = asyncio.run(_collect_all_events(
|
|
make_two_step_match, fake_dispatch_stream, fake_preface,
|
|
))
|
|
|
|
step_states = [
|
|
(ev.step_id, ev.state)
|
|
for ev in events
|
|
if isinstance(ev, MetaStepStateEvent)
|
|
]
|
|
assert ("intake", "running") in step_states
|
|
assert ("intake", "succeeded") in step_states
|
|
|
|
|
|
def test_required_preflight_pauses_before_announcing_run(fake_dispatch_stream, fake_preface):
|
|
plan = MetaPlan(
|
|
name="meta-preflight-required",
|
|
triggers=("fake",),
|
|
priority=0,
|
|
steps=(MetaStep(id="draft", skill="draft", kind="llm_chat", label="Draft"),),
|
|
request_template={
|
|
"mode": "confirm",
|
|
"fields": [
|
|
{"name": "audience", "required": True},
|
|
{"name": "deadline", "required": False},
|
|
],
|
|
"assumptions": ["Use default depth unless the user says otherwise."],
|
|
},
|
|
final_text_mode="raw",
|
|
)
|
|
|
|
events = asyncio.run(_collect_all_events(
|
|
MetaMatch(plan=plan, inputs={"user_message": "write a brief"}),
|
|
fake_dispatch_stream,
|
|
fake_preface,
|
|
))
|
|
|
|
preflight = next(e for e in events if isinstance(e, MetaPreflightEvent))
|
|
result = next(e for e in events if isinstance(e, MetaResult))
|
|
assert preflight.requires_confirmation is True
|
|
assert preflight.can_skip is False
|
|
assert preflight.missing_fields == ["audience"]
|
|
assert not any(isinstance(e, MetaRunAnnouncedEvent) for e in events)
|
|
assert not any(isinstance(e, MetaStepStateEvent) for e in events)
|
|
assert result.paused is True
|
|
assert isinstance(result.paused_payload, MetaPreflightRequired)
|
|
assert result.paused_payload.missing_fields == ["audience"]
|
|
|
|
|
|
def test_preview_preflight_event_does_not_pause_without_explicit_gate(
|
|
fake_dispatch_stream,
|
|
fake_preface,
|
|
):
|
|
plan = MetaPlan(
|
|
name="meta-preflight-preview",
|
|
triggers=("fake",),
|
|
priority=0,
|
|
steps=(MetaStep(id="draft", skill="draft", kind="llm_chat", label="Draft"),),
|
|
request_template={
|
|
"fields": [{"name": "audience", "required": True}],
|
|
"assumptions": ["Use default depth unless the user says otherwise."],
|
|
},
|
|
final_text_mode="raw",
|
|
)
|
|
|
|
events = asyncio.run(_collect_all_events(
|
|
MetaMatch(plan=plan, inputs={"user_message": "write a brief"}),
|
|
fake_dispatch_stream,
|
|
fake_preface,
|
|
))
|
|
|
|
preflight = next(e for e in events if isinstance(e, MetaPreflightEvent))
|
|
result = next(e for e in events if isinstance(e, MetaResult))
|
|
assert preflight.requires_confirmation is False
|
|
assert preflight.can_skip is True
|
|
assert preflight.missing_fields == ["audience"]
|
|
assert any(isinstance(e, MetaRunAnnouncedEvent) for e in events)
|
|
assert any(isinstance(e, MetaStepStateEvent) for e in events)
|
|
assert result.paused is False
|
|
assert result.ok is True
|
|
|
|
|
|
def test_confirmed_preflight_still_pauses_when_required_fields_missing(
|
|
fake_dispatch_stream,
|
|
fake_preface,
|
|
):
|
|
plan = MetaPlan(
|
|
name="meta-preflight-required",
|
|
triggers=("fake",),
|
|
priority=0,
|
|
steps=(MetaStep(id="draft", skill="draft", kind="llm_chat", label="Draft"),),
|
|
request_template={
|
|
"mode": "confirm",
|
|
"fields": [{"name": "audience", "required": True}],
|
|
"assumptions": ["Use default depth unless the user says otherwise."],
|
|
},
|
|
final_text_mode="raw",
|
|
)
|
|
|
|
events = asyncio.run(_collect_all_events(
|
|
MetaMatch(
|
|
plan=plan,
|
|
inputs={
|
|
"user_message": "write a brief",
|
|
"meta_preflight_confirmed": True,
|
|
},
|
|
),
|
|
fake_dispatch_stream,
|
|
fake_preface,
|
|
))
|
|
|
|
result = next(e for e in events if isinstance(e, MetaResult))
|
|
assert not any(isinstance(e, MetaRunAnnouncedEvent) for e in events)
|
|
assert result.paused is True
|
|
assert isinstance(result.paused_payload, MetaPreflightRequired)
|
|
assert result.paused_payload.missing_fields == ["audience"]
|
|
|
|
|
|
def test_running_precedes_succeeded(
|
|
make_two_step_match, fake_dispatch_stream, fake_preface,
|
|
):
|
|
events = asyncio.run(_collect_all_events(
|
|
make_two_step_match, fake_dispatch_stream, fake_preface,
|
|
))
|
|
|
|
seq = [
|
|
ev.state
|
|
for ev in events
|
|
if isinstance(ev, MetaStepStateEvent) and ev.step_id == "intake"
|
|
]
|
|
assert seq.index("running") < seq.index("succeeded")
|
|
|
|
|
|
@pytest.fixture
|
|
def make_skipped_match():
|
|
plan = MetaPlan(
|
|
name="meta-skip-fake",
|
|
triggers=("fake",),
|
|
priority=0,
|
|
steps=(
|
|
MetaStep(id="intake", skill="intake", kind="llm_chat", label="意图提取"),
|
|
MetaStep(
|
|
id="optional", skill="optional", kind="llm_chat",
|
|
label="可选", depends_on=("intake",), when="False",
|
|
),
|
|
),
|
|
final_text_mode="raw",
|
|
)
|
|
return MetaMatch(plan=plan, inputs={"user_message": "hi"})
|
|
|
|
|
|
def test_skipped_emitted_on_when_false(
|
|
make_skipped_match, fake_dispatch_stream, fake_preface,
|
|
):
|
|
events = asyncio.run(_collect_all_events(
|
|
make_skipped_match, fake_dispatch_stream, fake_preface,
|
|
))
|
|
|
|
states = [
|
|
(ev.step_id, ev.state)
|
|
for ev in events
|
|
if isinstance(ev, MetaStepStateEvent)
|
|
]
|
|
assert ("optional", "skipped") in states
|
|
|
|
|
|
@pytest.fixture
|
|
def failing_dispatch():
|
|
async def _dispatch(step, effective_skill, inputs, outputs):
|
|
if step.id == "search":
|
|
raise RuntimeError("simulated step failure")
|
|
yield _StepDone(text=f"out:{step.id}")
|
|
|
|
return _dispatch
|
|
|
|
|
|
@pytest.fixture
|
|
def make_failover_match():
|
|
plan = MetaPlan(
|
|
name="meta-fail-fake",
|
|
triggers=("fake",),
|
|
priority=0,
|
|
steps=(
|
|
MetaStep(
|
|
id="search", skill="search", kind="agent", label="检索",
|
|
on_failure="search_fallback",
|
|
),
|
|
MetaStep(
|
|
id="search_fallback", skill="search_fallback",
|
|
kind="llm_chat", label="替代检索",
|
|
),
|
|
),
|
|
final_text_mode="raw",
|
|
)
|
|
return MetaMatch(plan=plan, inputs={"user_message": "hi"})
|
|
|
|
|
|
def test_failed_then_substituted(
|
|
make_failover_match, failing_dispatch, fake_preface,
|
|
):
|
|
events = asyncio.run(_collect_all_events(
|
|
make_failover_match, failing_dispatch, fake_preface,
|
|
))
|
|
|
|
states = [
|
|
(ev.step_id, ev.state, ev.substitute_for)
|
|
for ev in events
|
|
if isinstance(ev, MetaStepStateEvent)
|
|
]
|
|
assert ("search", "failed", None) in states
|
|
assert ("search_fallback", "substituted", "search") in states
|
|
|
|
|
|
def test_successful_substitute_completes_run_ok(
|
|
make_failover_match, failing_dispatch, fake_preface,
|
|
):
|
|
events = asyncio.run(_collect_all_events(
|
|
make_failover_match, failing_dispatch, fake_preface,
|
|
))
|
|
completed = next(
|
|
(e for e in events if isinstance(e, MetaRunCompletedEvent)), None,
|
|
)
|
|
|
|
assert completed is not None
|
|
assert completed.outcome == "ok"
|
|
assert completed.failed_steps == []
|
|
assert completed.recovered_steps == ["search"]
|
|
|
|
|
|
def test_hard_failure_tool_result_includes_rescue_hints(fake_preface):
|
|
plan = MetaPlan(
|
|
name="meta-hard-fail",
|
|
triggers=("fake",),
|
|
priority=0,
|
|
steps=(
|
|
MetaStep(id="intake", skill="intake", kind="llm_chat", label="Intake"),
|
|
MetaStep(
|
|
id="render",
|
|
skill="render",
|
|
kind="skill_exec",
|
|
label="Render PDF",
|
|
depends_on=("intake",),
|
|
),
|
|
),
|
|
final_text_mode="raw",
|
|
)
|
|
|
|
async def dispatch(step, effective_skill, inputs, outputs):
|
|
if step.id == "render":
|
|
raise RuntimeError("wkhtmltopdf not found")
|
|
yield _StepDone(text=f"out:{step.id}")
|
|
|
|
events = asyncio.run(_collect_all_events(
|
|
MetaMatch(plan=plan, inputs={"user_message": "hi"}),
|
|
dispatch,
|
|
fake_preface,
|
|
))
|
|
failure = next(
|
|
(
|
|
e for e in events
|
|
if isinstance(e, ToolResultEvent) and e.tool_name == "meta-step:render"
|
|
),
|
|
None,
|
|
)
|
|
|
|
assert failure is not None
|
|
rescue = (failure.arguments or {}).get("rescue")
|
|
assert rescue["failed_step_id"] == "render"
|
|
assert rescue["partial_output_step_ids"] == ["intake"]
|
|
action_ids = [item["id"] for item in rescue["actions"]]
|
|
assert "retry-run" in action_ids
|
|
assert "retry-with-partial-context" in action_ids
|
|
assert rescue["hint"]["category"] == "missing_dependency"
|
|
failed_state = next(
|
|
(
|
|
e for e in events
|
|
if isinstance(e, MetaStepStateEvent)
|
|
and e.step_id == "render"
|
|
and e.state == "failed"
|
|
),
|
|
None,
|
|
)
|
|
assert failed_state is not None
|
|
assert failed_state.rescue["hint"]["category"] == "missing_dependency"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "category"),
|
|
[
|
|
("401 unauthorized: token expired", "auth_or_permission"),
|
|
("request timed out after 30s", "timeout"),
|
|
("unexpected parser crash", "runtime_error"),
|
|
],
|
|
)
|
|
def test_failure_hint_classifies_recovery_category(error: str, category: str) -> None:
|
|
assert _failure_hint(error)["category"] == category
|
|
|
|
|
|
def test_failure_rescue_payload_includes_actionable_recovery_context() -> None:
|
|
payload = _failure_rescue_payload(
|
|
plan_name="meta-fake",
|
|
step=MetaStep(id="render_artifact", skill="render", kind="skill_exec"),
|
|
error="ffmpeg command not found",
|
|
outputs={"draft": "x" * 900},
|
|
has_substitute=False,
|
|
)
|
|
|
|
action_ids = [action["id"] for action in payload["actions"]]
|
|
assert "retry-step" in action_ids
|
|
assert "install-dependency" in action_ids
|
|
assert "continue-text-only" in action_ids
|
|
assert payload["prior_outputs"][0]["step_id"] == "draft"
|
|
assert len(payload["prior_outputs"][0]["excerpt"]) < 700
|
|
assert payload["prior_outputs"][0]["truncated"] is True
|
|
|
|
|
|
def test_run_completed_emitted_at_end(
|
|
make_two_step_match, fake_dispatch_stream, fake_preface,
|
|
):
|
|
events = asyncio.run(_collect_all_events(
|
|
make_two_step_match, fake_dispatch_stream, fake_preface,
|
|
))
|
|
completed = next(
|
|
(e for e in events if isinstance(e, MetaRunCompletedEvent)), None,
|
|
)
|
|
|
|
assert completed is not None
|
|
assert completed.outcome == "ok"
|
|
assert sorted(completed.completed_steps) == ["intake", "summary"]
|
|
|
|
|
|
def test_paused_step_completes_run_cancelled(fake_preface):
|
|
cfg = ClarifyStepConfig(
|
|
mode="form",
|
|
fields=(ClarifyField(name="topic", type="string", required=True),),
|
|
)
|
|
plan = MetaPlan(
|
|
name="meta-pause-fake",
|
|
triggers=("fake",),
|
|
priority=0,
|
|
steps=(
|
|
MetaStep(id="collect", skill="collect", kind="user_input", label="澄清"),
|
|
),
|
|
final_text_mode="raw",
|
|
)
|
|
|
|
async def paused_dispatch(step, effective_skill, inputs, outputs):
|
|
raise MetaPaused(run_id="r-paused", step_id=step.id, schema=cfg)
|
|
yield # never reached; keeps it an async generator
|
|
|
|
events = asyncio.run(_collect_all_events(
|
|
MetaMatch(plan=plan, inputs={"user_message": "hi"}),
|
|
paused_dispatch,
|
|
fake_preface,
|
|
))
|
|
completed = next(
|
|
(e for e in events if isinstance(e, MetaRunCompletedEvent)), None,
|
|
)
|
|
|
|
assert completed is not None
|
|
assert completed.outcome == "cancelled"
|