代码提交
This commit is contained in:
1 parent
d93cc62806
commit
ddf83f7728
25 files changed
+4086
-424
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -12,12 +13,19 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.fact_ledger import executed_action_ids, proposed_action_ids
|
||||
from app.maf_roundtable.workflow_executors import (
|
||||
ApprovalGateExecutor,
|
||||
CoordinatorExecutor,
|
||||
CrossReviewExecutor,
|
||||
DispatchPreparationTerminalExecutor,
|
||||
FinalizerExecutor,
|
||||
ManagerAnswerExecutor,
|
||||
ManagerDispatchExecutor,
|
||||
PlannerExecutor,
|
||||
RoundtableState,
|
||||
SeatExecutor,
|
||||
WaitingTerminalExecutor,
|
||||
)
|
||||
from app.maf_roundtable.workflow_factory import create_workflow
|
||||
from app.maf_roundtable.workflow_factory import build_manager_dispatch_workflow, create_workflow
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
|
||||
@@ -53,7 +61,7 @@ def _state(seat_ids: list[str] | None = None) -> RoundtableState:
|
||||
reasoning_effort=None,
|
||||
max_rounds=8,
|
||||
write_policy="confirm_high_risk",
|
||||
manager_agent_id=None,
|
||||
manager_agent_id="manager",
|
||||
seat_agent_ids=seats,
|
||||
thread_map={a: f"t-{a}" for a in seats},
|
||||
capability_snapshot=None,
|
||||
@@ -62,6 +70,390 @@ def _state(seat_ids: list[str] | None = None) -> RoundtableState:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_answer_is_manager_only():
|
||||
state = _state([])
|
||||
state.manager_agent_id = "manager"
|
||||
state.thread_map = {"manager": "manager-thread"}
|
||||
adapter = SimpleNamespace(
|
||||
run_seat_turn=AsyncMock(return_value=_FakeSeatResponse(content="direct answer")),
|
||||
_emit_critical=AsyncMock(),
|
||||
)
|
||||
|
||||
await ManagerAnswerExecutor(adapter, state).handle("task", _FakeCtx())
|
||||
|
||||
adapter.run_seat_turn.assert_awaited_once()
|
||||
assert adapter.run_seat_turn.await_args.kwargs["agent_id"] == "manager"
|
||||
assert adapter.run_seat_turn.await_args.kwargs["execution_stage"] == "direct_answer"
|
||||
assert state.status == "completed"
|
||||
dispatch = next(
|
||||
call.args[1]
|
||||
for call in adapter._emit_critical.await_args_list
|
||||
if call.args[1].get("event_type") == "manager.dispatch"
|
||||
)
|
||||
assert dispatch["payload"]["execution_mode"] == "manager_only"
|
||||
assert dispatch["payload"]["selected_seat_agent_ids"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_only_topology_runs_real_maf_workflow_without_seats():
|
||||
pytest.importorskip("agent_framework")
|
||||
state = _state([])
|
||||
state.manager_agent_id = "manager"
|
||||
state.thread_map = {"manager": "manager-thread"}
|
||||
state.intent_type = "direct_answer"
|
||||
state.dispatch_plan = {
|
||||
"manager_agent_id": "manager",
|
||||
"seat_agent_ids": [],
|
||||
"assignments": [],
|
||||
"intent_type": "direct_answer",
|
||||
"max_rounds": 8,
|
||||
"candidate_seat_agent_ids": ["seat-a", "seat-b", "seat-c"],
|
||||
"execution_mode": "manager_only",
|
||||
"convergence_path": ["manager answer"],
|
||||
}
|
||||
adapter = SimpleNamespace(
|
||||
run_seat_turn=AsyncMock(return_value=_FakeSeatResponse(content="manager answer")),
|
||||
_emit_critical=AsyncMock(),
|
||||
)
|
||||
manager = ManagerAnswerExecutor(adapter, state)
|
||||
workflow = create_workflow(
|
||||
mode="manager_only",
|
||||
manager_answer=manager,
|
||||
seat_executors=[object(), object()],
|
||||
approval_gate=object(),
|
||||
finalizer=object(),
|
||||
waiting_terminal=object(),
|
||||
seat_agent_ids=["seat-a", "seat-b"],
|
||||
)
|
||||
|
||||
async for _ in workflow.run(state.task_text, stream=True, include_status_events=True):
|
||||
pass
|
||||
|
||||
adapter.run_seat_turn.assert_awaited_once()
|
||||
assert adapter.run_seat_turn.await_args.kwargs["agent_id"] == "manager"
|
||||
assert state.status == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_direct_answer_runs_manager_dispatch_then_manager_only_workflow():
|
||||
pytest.importorskip("agent_framework")
|
||||
state = _state(["seat-a", "seat-b"])
|
||||
state.thread_map = {"manager": "manager-thread"}
|
||||
state.manager_agent_id = "manager"
|
||||
state.candidate_seat_agent_ids = ["seat-a", "seat-b"]
|
||||
state.dispatch_contract_enabled = True
|
||||
decision = {
|
||||
"intent_type": "direct_answer",
|
||||
"selected_seat_agent_ids": [],
|
||||
"assignments": [],
|
||||
"execution_mode": "manager_only",
|
||||
"convergence_path": ["manager answer"],
|
||||
"reason": "manager can answer directly",
|
||||
}
|
||||
|
||||
async def run_turn(**kwargs):
|
||||
if kwargs["execution_stage"] == "manager_dispatch":
|
||||
return _FakeSeatResponse(content=json.dumps(decision))
|
||||
return _FakeSeatResponse(content="direct answer")
|
||||
|
||||
adapter = SimpleNamespace(
|
||||
run_seat_turn=AsyncMock(side_effect=run_turn),
|
||||
_emit_critical=AsyncMock(),
|
||||
)
|
||||
preparation = build_manager_dispatch_workflow(
|
||||
manager_dispatch=ManagerDispatchExecutor(adapter, state),
|
||||
terminal=DispatchPreparationTerminalExecutor(state),
|
||||
)
|
||||
async for _ in preparation.run(state.task_text, stream=True, include_status_events=True):
|
||||
pass
|
||||
workflow = create_workflow(
|
||||
mode="manager_only",
|
||||
manager_answer=ManagerAnswerExecutor(adapter, state),
|
||||
seat_executors=[],
|
||||
approval_gate=object(),
|
||||
finalizer=object(),
|
||||
waiting_terminal=object(),
|
||||
seat_agent_ids=[],
|
||||
)
|
||||
async for _ in workflow.run(state.task_text, stream=True, include_status_events=True):
|
||||
pass
|
||||
|
||||
stages = [call.kwargs["execution_stage"] for call in adapter.run_seat_turn.await_args_list]
|
||||
assert stages == ["manager_dispatch", "direct_answer"]
|
||||
assert state.selected_seat_agent_ids == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_manager_plan_falls_back_to_clarification_without_starting_seats():
|
||||
state = _state()
|
||||
state.manager_agent_id = "manager"
|
||||
state.thread_map["manager"] = "manager-thread"
|
||||
state.candidate_seat_agent_ids = ["seat-a", "seat-b", "seat-c"]
|
||||
state.dispatch_contract_enabled = True
|
||||
adapter = SimpleNamespace(
|
||||
run_seat_turn=AsyncMock(return_value=_FakeSeatResponse(content="not a dispatch plan")),
|
||||
_emit_critical=AsyncMock(),
|
||||
)
|
||||
ctx = _FakeCtx()
|
||||
await ManagerDispatchExecutor(adapter, state).handle("task", ctx)
|
||||
|
||||
assert state.status == "waiting_input"
|
||||
assert state.intent_type == "clarification"
|
||||
assert state.selected_seat_agent_ids == []
|
||||
assert ctx.messages[-1]["decision"] == "clarification"
|
||||
assert adapter.run_seat_turn.await_args.kwargs["execution_stage"] == "manager_dispatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_dispatch_uses_capability_summary_and_selects_only_a_subset():
|
||||
state = _state()
|
||||
state.manager_agent_id = "manager"
|
||||
state.thread_map["manager"] = "manager-thread"
|
||||
state.candidate_seat_agent_ids = ["seat-a", "seat-b", "seat-c"]
|
||||
state.dispatch_contract_enabled = True
|
||||
state.capability_snapshot = {
|
||||
"agents": [
|
||||
{"agent_id": "manager", "tools": [{"tool_name": "manager_write", "side_effect": "write", "risk_level": "R3"}]},
|
||||
{"agent_id": "seat-a", "model_name": "m-a", "tools": [{"tool_name": "read_a", "side_effect": "read", "risk_level": "R1"}]},
|
||||
{"agent_id": "seat-b", "model_name": "m-b", "tools": [{"tool_name": "read_b", "side_effect": "read", "risk_level": "R1"}]},
|
||||
{"agent_id": "seat-c", "model_name": "m-c", "tools": [{"tool_name": "write_c", "side_effect": "write", "risk_level": "R3"}], "secret": "never-prompted"},
|
||||
]
|
||||
}
|
||||
decision = {
|
||||
"intent_type": "parallel_consultation",
|
||||
"selected_seat_agent_ids": ["seat-a", "seat-b"],
|
||||
"assignments": [
|
||||
{"agent_id": "seat-a", "task_goal": "collect evidence", "delivery_requirements": ["sources"], "order": 1},
|
||||
{"agent_id": "seat-b", "task_goal": "compare evidence", "delivery_requirements": ["gaps"], "order": 2},
|
||||
],
|
||||
"execution_mode": "parallel",
|
||||
"convergence_path": ["manager synthesis"],
|
||||
"reason": "two independent perspectives are needed",
|
||||
}
|
||||
adapter = SimpleNamespace(
|
||||
run_seat_turn=AsyncMock(return_value=_FakeSeatResponse(content=json.dumps(decision))),
|
||||
_emit_critical=AsyncMock(),
|
||||
)
|
||||
|
||||
await ManagerDispatchExecutor(adapter, state).handle("task", _FakeCtx())
|
||||
|
||||
prompt = adapter.run_seat_turn.await_args.kwargs["message"]
|
||||
assert "trusted capability summary" in prompt.lower()
|
||||
assert "never-prompted" not in prompt
|
||||
assert adapter.run_seat_turn.await_args.kwargs["excluded_tools"] == ["manager_write"]
|
||||
assert state.selected_seat_agent_ids == ["seat-a", "seat-b"]
|
||||
assert state.plan["assignments"].keys() == {"seat-a", "seat-b"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("manager_result", [
|
||||
"{\"intent_type\": \"unknown\"}",
|
||||
json.dumps({
|
||||
"intent_type": "single_seat",
|
||||
"selected_seat_agent_ids": ["untrusted-seat"],
|
||||
"assignments": [{"agent_id": "untrusted-seat", "task_goal": "x", "delivery_requirements": ["y"], "order": 1}],
|
||||
"execution_mode": "single",
|
||||
"convergence_path": ["manager synthesis"],
|
||||
"reason": "invalid seat",
|
||||
}),
|
||||
])
|
||||
async def test_invalid_intent_or_seat_falls_back_to_clarification(manager_result):
|
||||
state = _state()
|
||||
state.manager_agent_id = "manager"
|
||||
state.thread_map["manager"] = "manager-thread"
|
||||
state.candidate_seat_agent_ids = ["seat-a", "seat-b", "seat-c"]
|
||||
state.dispatch_contract_enabled = True
|
||||
adapter = SimpleNamespace(
|
||||
run_seat_turn=AsyncMock(return_value=_FakeSeatResponse(content=manager_result)),
|
||||
_emit_critical=AsyncMock(),
|
||||
)
|
||||
|
||||
await ManagerDispatchExecutor(adapter, state).handle("task", _FakeCtx())
|
||||
|
||||
assert state.status == "waiting_input"
|
||||
assert state.selected_seat_agent_ids == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_dispatch_timeout_falls_back_to_clarification():
|
||||
state = _state()
|
||||
state.manager_agent_id = "manager"
|
||||
state.thread_map["manager"] = "manager-thread"
|
||||
state.candidate_seat_agent_ids = ["seat-a", "seat-b", "seat-c"]
|
||||
state.dispatch_contract_enabled = True
|
||||
adapter = SimpleNamespace(
|
||||
run_seat_turn=AsyncMock(side_effect=TimeoutError()),
|
||||
_emit_critical=AsyncMock(),
|
||||
)
|
||||
|
||||
await ManagerDispatchExecutor(adapter, state).handle("task", _FakeCtx())
|
||||
|
||||
assert state.status == "waiting_input"
|
||||
assert state.dispatch_plan["reason"] == "manager_dispatch_timeout"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_dispatch_persists_and_binds_only_selected_seat_threads(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'dispatch-subset.db'}")
|
||||
async with engine.begin() as conn:
|
||||
import deerflow.persistence.maf_roundtable.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
store = make_maf_roundtable_store(async_sessionmaker(engine, expire_on_commit=False))
|
||||
session = await store.sessions.create_session({
|
||||
"user_id": "u1",
|
||||
"task_text": "analyze",
|
||||
"mode": "structured",
|
||||
"manager_agent_id": "manager",
|
||||
"max_rounds": 8,
|
||||
})
|
||||
await store.participants.replace_participants(session["id"], [{
|
||||
"agent_id": "manager",
|
||||
"role": "manager",
|
||||
"sort_order": 0,
|
||||
}])
|
||||
run = await store.runs.create_run(session["id"])
|
||||
state = _state()
|
||||
state.session_id = session["id"]
|
||||
state.run_id = run["id"]
|
||||
state.thread_map = {"manager": "manager-thread"}
|
||||
state.candidate_seat_agent_ids = ["seat-a", "seat-b", "seat-c"]
|
||||
state.dispatch_contract_enabled = True
|
||||
decision = {
|
||||
"intent_type": "parallel_consultation",
|
||||
"selected_seat_agent_ids": ["seat-a", "seat-b"],
|
||||
"assignments": [
|
||||
{"agent_id": "seat-a", "task_goal": "evidence", "delivery_requirements": ["sources"], "order": 1},
|
||||
{"agent_id": "seat-b", "task_goal": "review", "delivery_requirements": ["gaps"], "order": 2},
|
||||
],
|
||||
"execution_mode": "parallel",
|
||||
"convergence_path": ["manager synthesis"],
|
||||
"reason": "selected subset",
|
||||
}
|
||||
adapter = SimpleNamespace(
|
||||
_service=SimpleNamespace(store=store),
|
||||
run_seat_turn=AsyncMock(return_value=_FakeSeatResponse(content=json.dumps(decision))),
|
||||
ensure_threads=AsyncMock(return_value={"seat-a": "thread-a", "seat-b": "thread-b"}),
|
||||
_emit_critical=AsyncMock(),
|
||||
)
|
||||
|
||||
try:
|
||||
await ManagerDispatchExecutor(adapter, state).handle("task", _FakeCtx())
|
||||
participants = await store.participants.list_participants(session["id"])
|
||||
assert [row["agent_id"] for row in participants] == ["manager", "seat-a", "seat-b"]
|
||||
adapter.ensure_threads.assert_awaited_once()
|
||||
bound_agents = [row["agent_id"] for row in adapter.ensure_threads.await_args.args[2]]
|
||||
assert bound_agents == ["seat-a", "seat-b"]
|
||||
assert "seat-c" not in state.thread_map
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_chat_workflow_executes_verified_manager_subset_only():
|
||||
pytest.importorskip("agent_framework")
|
||||
state = _state(["seat-a", "seat-b", "seat-c"])
|
||||
state.mode = "group_chat"
|
||||
state.thread_map = {"manager": "manager-thread"}
|
||||
state.manager_agent_id = "manager"
|
||||
state.candidate_seat_agent_ids = ["seat-a", "seat-b", "seat-c"]
|
||||
state.dispatch_contract_enabled = True
|
||||
participant_rows = [{"agent_id": "manager", "role": "manager", "thread_id": "manager-thread"}]
|
||||
|
||||
async def ensure_participants(_session_id, rows):
|
||||
participant_rows.extend(row for row in rows if row["agent_id"] not in {p["agent_id"] for p in participant_rows})
|
||||
|
||||
async def list_participants(_session_id):
|
||||
return list(participant_rows)
|
||||
|
||||
dispatch = {
|
||||
"intent_type": "parallel_consultation",
|
||||
"selected_seat_agent_ids": ["seat-a", "seat-b"],
|
||||
"assignments": [
|
||||
{"agent_id": "seat-a", "task_goal": "evidence", "delivery_requirements": ["sources"], "order": 1},
|
||||
{"agent_id": "seat-b", "task_goal": "review", "delivery_requirements": ["gaps"], "order": 2},
|
||||
],
|
||||
"execution_mode": "group_chat",
|
||||
"convergence_path": ["manager synthesis"],
|
||||
"reason": "discussion requires two seats",
|
||||
}
|
||||
|
||||
async def run_turn(**kwargs):
|
||||
if kwargs["execution_stage"] == "manager_dispatch":
|
||||
return _FakeSeatResponse(content=json.dumps(dispatch))
|
||||
if kwargs["execution_stage"] == "finalizer":
|
||||
return _FakeSeatResponse(content="final")
|
||||
return _FakeSeatResponse(content=f"{kwargs['agent_id']} result")
|
||||
|
||||
adapter = SimpleNamespace(
|
||||
_service=SimpleNamespace(
|
||||
store=SimpleNamespace(
|
||||
participants=SimpleNamespace(
|
||||
ensure_participants=ensure_participants,
|
||||
list_participants=list_participants,
|
||||
),
|
||||
runs=SimpleNamespace(update_run=AsyncMock()),
|
||||
actions=SimpleNamespace(list_actions=AsyncMock(return_value=[])),
|
||||
)
|
||||
),
|
||||
run_seat_turn=AsyncMock(side_effect=run_turn),
|
||||
ensure_threads=AsyncMock(return_value={"seat-a": "thread-a", "seat-b": "thread-b"}),
|
||||
_emit_critical=AsyncMock(),
|
||||
)
|
||||
preparation = build_manager_dispatch_workflow(
|
||||
manager_dispatch=ManagerDispatchExecutor(adapter, state),
|
||||
terminal=DispatchPreparationTerminalExecutor(state),
|
||||
)
|
||||
async for _ in preparation.run(state.task_text, stream=True, include_status_events=True):
|
||||
pass
|
||||
|
||||
workflow = create_workflow(
|
||||
mode="group_chat",
|
||||
coordinator=CoordinatorExecutor(adapter, state),
|
||||
seat_executors=[
|
||||
SeatExecutor(adapter, state, agent_id)
|
||||
for agent_id in state.selected_seat_agent_ids or []
|
||||
],
|
||||
approval_gate=ApprovalGateExecutor(adapter, state, SimpleNamespace()),
|
||||
finalizer=FinalizerExecutor(adapter, state),
|
||||
waiting_terminal=WaitingTerminalExecutor(),
|
||||
seat_agent_ids=state.selected_seat_agent_ids or [],
|
||||
)
|
||||
|
||||
async for _ in workflow.run(state.task_text, stream=True, include_status_events=True):
|
||||
pass
|
||||
|
||||
stages = [call.kwargs["execution_stage"] for call in adapter.run_seat_turn.await_args_list]
|
||||
assert stages.count("manager_dispatch") == 1
|
||||
assert stages.count("group_chat") == 2
|
||||
assert "seat-c" not in [call.kwargs["agent_id"] for call in adapter.run_seat_turn.await_args_list]
|
||||
assert [row["agent_id"] for row in participant_rows] == ["manager", "seat-a", "seat-b"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("execution_mode", "seat_count"),
|
||||
[("single", 1), ("parallel", 2), ("chain", 2)],
|
||||
)
|
||||
def test_structured_factory_builds_each_trusted_topology(execution_mode, seat_count):
|
||||
pytest.importorskip("agent_framework")
|
||||
state = _state(["seat-a", "seat-b"][:seat_count])
|
||||
adapter = SimpleNamespace(_emit_critical=AsyncMock())
|
||||
seat_executors = [SeatExecutor(adapter, state, agent_id) for agent_id in state.seat_agent_ids]
|
||||
workflow = create_workflow(
|
||||
mode="structured",
|
||||
planner=PlannerExecutor(adapter, state),
|
||||
cross_review=CrossReviewExecutor(adapter, state),
|
||||
seat_executors=seat_executors,
|
||||
approval_gate=ApprovalGateExecutor(adapter, state, SimpleNamespace()),
|
||||
finalizer=FinalizerExecutor(adapter, state),
|
||||
waiting_terminal=WaitingTerminalExecutor(),
|
||||
seat_agent_ids=state.seat_agent_ids,
|
||||
execution_mode=execution_mode,
|
||||
)
|
||||
assert workflow is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_planner_deterministic_assignments():
|
||||
state = _state()
|
||||
|
||||
Reference in new issue
Block a user