代码提交

This commit is contained in:
董宏凯 committed 2026-08-03 19:27:54 +08:00
1 parent d93cc62806
commit ddf83f7728
25 files changed
+4086 -424

No files matched your search

@@ -606,3 +606,40 @@ maf-roundtable/conversation/
- 真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议及生产 `MAF_ACTION_SECRET_KEY` 已按 Phase 5 前置条件完成。
在这之前,任何“会话/run 原型页面通过构建或轮询测试”的结果,都只能算技术验证,不算产品升级验收。
---
## 阶段 3 实施记录(当前代码基线)
本轮已补齐阶段 3 的服务端安全路由与真实 MAF Workflow 入口,范围仅针对新 MAF 会商;旧 DeerFlow、旧 roundtable 和 AI 写作行为不变。
### 配置与入口闸门
- 仓库默认 `maf_roundtable.enabled: false`。部署环境必须注入真实的 `default_manager_agent_id`、席位 ID 和业务路由后再开启,避免出现“入口显示正常但首条消息必然 503”的空配置。
- 健康接口返回非敏感的 `conversation_dispatch_ready` 与 `conversation_dispatch_status`;新会商入口只有在配置路由可用且服务已启用时才开放。
- 首条消息使用服务端可信能力快照校验 manager/seat 的可访问性;浏览器、会话请求和模型输出都不能提供或扩大 agent、thread、tool、capability 范围。
- 配置模型会清理并校验 manager/seat ID,拒绝 manager 出现在 seat 列表、重复席位、超过 `max_seats_per_run` 的路由和非法业务 code。
### 受约束的总控派活
服务端先由业务链与可信 capability snapshot 生成候选席位集合。总控模型只能返回严格 JSON:`intent_type`、`selected_seat_agent_ids`、每席 `task_goal`/`delivery_requirements`、`execution_mode`、`convergence_path` 和 `reason`。Pydantic schema、服务端 allowlist 和顺序校验全部通过后,最终计划才会写入 checkpoint,并产生 `manager.dispatch` 审计/SSE 投影。
非法席位、重复席位、空任务、错误 schema、席位超限、缺少收敛路径或链式顺序错误,统一回退到服务端安全计划,不会扩大权限或启动越权席位。
四种结构化拓扑已固定为:
- `direct_answer`:manager-only Workflow,不创建专家席位任务;
- `single_seat` / `tool_write`:一个可信席位;
- `parallel_consultation`:至少两个可信席位并行;
- `chain_consultation`:按服务端业务链顺序串行。
### 阶段 3 验证状态
已覆盖配置校验、候选路由、非法总控计划回退、manager-only 真实 `WorkflowBuilder` 拓扑、线程/执行阶段回归、旧 DeerFlow 回归和会话 API 回归。当前仍不能据此标记整个 MAF 项目或阶段 5 为“验收完成”;真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议和生产 `MAF_ACTION_SECRET_KEY` 仍按阶段 5/8 前置条件保持阻塞。
### Stage 3 P0 dispatch correction
- New MAF conversations no longer classify user text with server keyword rules. The server first freezes only the business-route candidate set and trusted, redacted capability summary; `ManagerDispatchExecutor` then returns the strict intent-and-dispatch JSON contract.
- The final plan is Pydantic-validated and allowlisted before it is checkpointed. Invalid JSON, illegal intent or seat, missing convergence, manager failure, and timeout all finish on the clarification branch; they do not create expert threads or start expert/tool work.
- A conversation initially persists and binds the manager only. After a valid decision, only `selected_seat_agent_ids` are added to the participant store and receive internal threads.
- Every non-clarification route, including `group_chat`, starts at the same manager dispatch node. Group speakers are limited to the verified selected seats; static candidate configuration cannot bypass the plan.
@@ -0,0 +1,58 @@
import asyncio
import logging
logging.basicConfig(level=logging.DEBUG)
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy import select
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationEventRow, MafRoundtableConversationRow
from app.maf_roundtable.conversation_service import MafConversationService
class FakeMafConfig:
enabled = True
default_mode = 'structured'
default_model_name = 'test-model'
default_reasoning_effort = None
default_manager_agent_id = 'mgr-agent'
default_max_rounds = 8
default_write_policy = 'confirm_high_risk'
admin_diagnostic_access = True
async def main():
engine = create_async_engine('sqlite+aiosqlite://')
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
svc = MafConversationService(store, maf_config=FakeMafConfig())
conv = await svc.create_conversation(user_id='u1', title='Test')
# Monkey-patch _publish_conversation_event to trace calls
orig = svc._publish_conversation_event
async def traced_publish(*args, **kwargs):
print(f' _publish_conversation_event called: event_type={kwargs.get("event_type")}')
result = await orig(*args, **kwargs)
print(f' _publish_conversation_event returned: {result}')
return result
svc._publish_conversation_event = traced_publish
try:
result = await svc.send_message(conversation_id=conv['id'], user_id='u1', content='Hello')
print(f'send_message result: {result}')
except Exception as e:
import traceback
print(f'send_message ERROR: {type(e).__name__}: {e}')
traceback.print_exc()
async with sf() as session:
events = (await session.execute(select(MafRoundtableConversationEventRow))).scalars().all()
for e in events:
print(f' event: seq={e.seq} type={e.event_type}')
conv_row = (await session.execute(
select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv['id'])
)).scalars().first()
print(f'last_stream_seq={conv_row.last_stream_seq}')
await engine.dispose()
asyncio.run(main())
@@ -12,7 +12,7 @@ import asyncio
import logging
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, TypeVar, cast
from typing import TYPE_CHECKING, Any, TypeVar, cast
from fastapi import FastAPI, HTTPException, Request
from langgraph.types import Checkpointer
@@ -39,6 +39,67 @@ if TYPE_CHECKING:
T = TypeVar("T")
def get_gateway_worker_topology() -> dict[str, Any]:
"""Detect the current worker count and source.
Returns ``{"worker_count": int|None, "source": str, "is_multi_worker": bool}``.
Sources checked in order: ``WEB_CONCURRENCY`` env var, ``/proc`` scan (Linux).
"""
import os as _os
wc = _os.environ.get("WEB_CONCURRENCY", "").strip()
if wc and wc.isdigit():
count = int(wc)
return {"worker_count": count, "source": "WEB_CONCURRENCY", "is_multi_worker": count > 1}
if not _os.name == "posix":
return {"worker_count": None, "source": "unknown", "is_multi_worker": False}
try:
from pathlib import Path as _Path
peers = 0
proc = _Path("/proc")
if proc.exists():
for entry in proc.iterdir():
if not entry.name.isdigit():
continue
try:
cmdline = (entry / "cmdline").read_bytes().replace(b"\x00", b" ").decode("utf-8", "ignore")
except OSError:
continue
if "uvicorn" in cmdline and "app.gateway.app" in cmdline:
peers += 1
if peers > 0:
return {"worker_count": peers, "source": "process_scan", "is_multi_worker": peers > 1}
except Exception: # noqa: BLE001
pass
return {"worker_count": None, "source": "unknown", "is_multi_worker": False}
async def _background_reconcile_loop(service: Any, *, interval: int) -> None:
"""Periodically reconcile active conversation runs."""
while True:
try:
await asyncio.sleep(interval)
await service.reconcile_active_conversations()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.warning("[maf-roundtable] background reconcile failed", exc_info=True)
async def _startup_reconcile(service: Any) -> None:
"""One-shot reconcile of all active runs at gateway startup."""
try:
written = await service.reconcile_active_conversations()
if written:
logger.info("[maf-roundtable] startup reconcile wrote %d conversation events", written)
except Exception: # noqa: BLE001
logger.warning("[maf-roundtable] startup reconcile failed", exc_info=True)
def resolve_roundtable_gateway_mode(raw: str | None) -> str:
"""解析 ``ROUNDTABLE_JOB_GATEWAY``:**默认 ``inprocess``(真模型后台挂起)**。
@@ -321,11 +382,24 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.maf_roundtable_store = make_maf_roundtable_store(sf)
maf_enabled = False
maf_dispatch_ready = False
maf_dispatch_status = "configuration_unavailable"
try:
from app.maf_roundtable import MafRoundtableService
maf_cfg = getattr(config, "maf_roundtable", None)
maf_enabled = bool(getattr(maf_cfg, "enabled", False)) if maf_cfg is not None else False
maf_cfg = getattr(config, "maf_roundtable", None)
maf_enabled = bool(getattr(maf_cfg, "enabled", False)) if maf_cfg is not None else False
if maf_cfg is not None:
from app.maf_roundtable.dispatch_contracts import MafDispatchCatalog
maf_dispatch_ready, maf_dispatch_status = MafDispatchCatalog(maf_cfg).readiness()
if maf_enabled and not maf_dispatch_ready:
logger.error(
"[maf-roundtable] enabled but no trusted manager route is configured; "
"forcing the conversation entry OFF (%s)",
maf_dispatch_status,
)
maf_enabled = False
maf_admin_diag = (
bool(getattr(maf_cfg, "admin_diagnostic_access", True)) if maf_cfg is not None else True
)
@@ -348,7 +422,8 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
"forcing the feature OFF: %s",
cfg_exc,
)
maf_enabled = False
maf_enabled = False
maf_dispatch_ready = False
if app.state.maf_roundtable_store is not None:
app.state.maf_roundtable_service = MafRoundtableService(
app.state.maf_roundtable_store,
@@ -371,24 +446,48 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.maf_roundtable_service = None
# ── MAF roundtable V2 Conversation service + SSE event hub ──
# P0-2: multi-worker guard — memory hub cannot broadcast across
# workers. When detected, disable the conversation SSE write path
# (reads stay open for admin history review).
_topology = get_gateway_worker_topology()
_maf_cfg = getattr(config, "maf_roundtable", None)
_conv_backend = getattr(_maf_cfg, "conversation_event_backend", "memory")
_require_single = getattr(_maf_cfg, "require_single_worker_for_conversation_sse", True)
_stream_blocked = (
_conv_backend == "memory"
and _topology["is_multi_worker"]
and _require_single
)
if _stream_blocked:
logger.error(
"[maf-roundtable] conversation_event_backend=memory with %d workers "
"(%s) — SSE stream disabled. Set WEB_CONCURRENCY=1 or deploy Redis "
"Pub/Sub before enabling multi-worker conversation SSE.",
_topology["worker_count"],
_topology["source"],
)
try:
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
app.state.maf_conversation_event_hub = ConversationEventHub()
if _stream_blocked:
app.state.maf_conversation_event_hub = None
else:
app.state.maf_conversation_event_hub = ConversationEventHub()
if app.state.maf_roundtable_store is not None:
from app.maf_roundtable.conversation_service import MafConversationService
from app.maf_roundtable.dispatcher import get_dispatcher
app.state.maf_conversation_service = MafConversationService(
app.state.maf_roundtable_store,
maf_config=getattr(config, "maf_roundtable", None),
event_hub=app.state.maf_conversation_event_hub,
agent_store=getattr(app.state, "agent_store", None),
app_config=config,
dispatcher=get_dispatcher(app),
enabled=maf_enabled,
)
if app.state.maf_roundtable_store is not None:
from app.maf_roundtable.conversation_service import MafConversationService
from app.maf_roundtable.dispatcher import get_dispatcher
app.state.maf_conversation_service = MafConversationService(
app.state.maf_roundtable_store,
maf_config=_maf_cfg,
event_hub=app.state.maf_conversation_event_hub,
agent_store=getattr(app.state, "agent_store", None),
app_config=config,
dispatcher=get_dispatcher(app),
enabled=maf_enabled,
)
else:
app.state.maf_conversation_service = None
except Exception: # noqa: BLE001
@@ -396,6 +495,36 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.maf_conversation_service = None
app.state.maf_conversation_event_hub = None
app.state.maf_conversation_dispatch_ready = bool(maf_enabled and maf_dispatch_ready)
app.state.maf_conversation_dispatch_status = maf_dispatch_status
app.state.maf_conversation_stream_ready = not _stream_blocked and app.state.maf_conversation_event_hub is not None
app.state.maf_conversation_stream_status = (
"multi_worker_memory_hub_unsupported" if _stream_blocked
else "ok" if app.state.maf_conversation_event_hub is not None
else "hub_not_initialized"
)
app.state.maf_conversation_event_backend = _conv_backend
# ── Background reconcile task for conversation event projection ──
_reconcile_task: asyncio.Task | None = None
if (
app.state.maf_conversation_service is not None
and maf_enabled
and not _stream_blocked
):
_reconcile_interval = getattr(_maf_cfg, "background_reconcile_interval_seconds", 10)
_reconcile_task = asyncio.create_task(
_background_reconcile_loop(
app.state.maf_conversation_service,
interval=max(5, int(_reconcile_interval)),
)
)
app.state.maf_conversation_reconcile_task = _reconcile_task
logger.info("[maf-roundtable] background reconcile started (interval=%ss)", _reconcile_interval)
# Startup reconcile: scan active/recent runs once
asyncio.create_task(_startup_reconcile(app.state.maf_conversation_service))
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
)
@@ -485,14 +614,22 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
try:
yield
finally:
# P1: shut down the SSE event hub before closing the DB engine
# so active SSE streams are released cleanly.
reconcile_task = getattr(app.state, "maf_conversation_reconcile_task", None)
if reconcile_task is not None:
reconcile_task.cancel()
try:
await reconcile_task
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001
logger.warning("[maf-roundtable] reconcile task shutdown error", exc_info=True)
hub = getattr(app.state, "maf_conversation_event_hub", None)
if hub is not None:
try:
hub.shutdown()
await hub.aclose()
except Exception: # noqa: BLE001
logger.warning("[maf-roundtable] hub shutdown failed", exc_info=True)
logger.warning("[maf-roundtable] hub aclose failed", exc_info=True)
from deerflow.persistence.notifications import set_notification_store
@@ -82,8 +82,18 @@ def _user_ctx(request: Request) -> tuple[str, bool]:
# ── REST endpoints (Phase 1) ──────────────────────────────────────────────
def _check_stream_ready(request: Request) -> None:
stream_ready = getattr(request.app.state, "maf_conversation_stream_ready", True)
if not stream_ready:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=getattr(request.app.state, "maf_conversation_stream_status", "stream_not_ready"),
)
@router.post("/conversations", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
async def create_conversation(body: CreateConversationRequest, request: Request):
_check_stream_ready(request)
svc = _get_conversation_service(request)
user_id, _ = _user_ctx(request)
try:
@@ -142,6 +152,7 @@ async def list_messages(
@router.post("/conversations/{conversation_id}/messages", response_model=SendMessageResponse, status_code=status.HTTP_201_CREATED)
async def send_message(conversation_id: str, body: SendMessageRequest, request: Request):
_check_stream_ready(request)
svc = _get_conversation_service(request)
user_id, _ = _user_ctx(request)
try:
@@ -198,53 +209,44 @@ def _is_terminal(event: dict[str, Any]) -> bool:
return event.get("type") in _TERMINAL_EVENT_TYPES
@router.get("/conversations/{conversation_id}/stream")
async def stream_conversation(
conversation_id: str,
def build_conversation_event_generator(
*,
service: MafConversationService,
hub: ConversationEventHub,
request: Request,
after_seq: int = Query(default=0, ge=0),
):
"""SSE stream for one conversation (§4.2).
conversation_id: str,
after_seq: int = 0,
) -> Any:
"""Extracted async generator for SSE streaming — testable without HTTP.
Single fetch-reader connection per conversation. The ordering is:
The ordering is:
1. **Open subscription** (register before any DB query — P0-1 fix).
2. **Replay** from DB: ``list_events(after_seq)``.
3. **Stabilise**: re-query while the hub generation advanced during replay.
4. **Snapshot** marker.
5. **Real-time**: ``wait_for_change`` → query DB → yield events.
1. **Reconcile** any missed projections for the active run (P0-1 auto).
2. **Open subscription** (register before any DB query — P0-1 fix).
3. **Replay** from DB: ``list_events(after_seq)``.
4. **Stabilise**: re-query while the hub generation advanced during replay.
5. **Snapshot** marker.
6. **Real-time**: ``wait_for_change`` → query DB → yield events.
On terminal events (``run.completed`` / ``run.error`` / ``run.cancelled``)
the stream sends the event and closes (P0-2 fix).
Heartbeat every 15 s. Disconnect detection via ``request.is_disconnected()``.
On terminal events the stream sends the event and closes (P0-2 fix).
Heartbeat every ``hub.heartbeat_interval`` seconds.
"""
svc = _get_conversation_service(request)
hub = _get_event_hub(request)
user_id, is_admin = _user_ctx(request)
store = service._store # noqa: SLF001
if hub is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="SSE event hub not configured",
)
async def _gen():
try:
active_run_id = await _get_active_run_id(store, conversation_id)
if active_run_id:
try:
await service.reconcile_conversation_events(run_id=active_run_id)
except Exception: # noqa: BLE001
logger.warning("SSE connect reconcile failed conv=%s", conversation_id, exc_info=True)
except Exception: # noqa: BLE001
logger.warning("SSE connect reconcile lookup failed conv=%s", conversation_id, exc_info=True)
# Ownership check before opening the stream.
try:
await svc.get_conversation(
conversation_id=conversation_id, user_id=user_id, is_admin=is_admin,
)
except ConversationNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
store = svc._store # noqa: SLF001 — router needs raw event queries for streaming
async def event_generator():
# ── P0-1: Open subscription BEFORE replay ──
sub = hub.open_subscription(conversation_id)
last_seq = after_seq
try:
# ── Phase 1: Replay from DB ──
try:
replay_events = await store.conversation_events.list_events(
conversation_id, after_seq=last_seq,
@@ -254,7 +256,6 @@ async def stream_conversation(
return
yield _format_sse_event(evt)
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
# P0-2: stop if replay already hit a terminal event.
if _is_terminal(evt):
return
except Exception:
@@ -269,7 +270,6 @@ async def stream_conversation(
})
return
# ── P0-1: Stabilise — catch events published during replay ──
while sub.has_pending:
sub.mark_seen()
late_events = await store.conversation_events.list_events(
@@ -285,7 +285,6 @@ async def stream_conversation(
if _is_terminal(evt):
return
# ── Send snapshot marker ──
snapshot = {
"seq": last_seq,
"event_id": f"snapshot_{conversation_id}_{last_seq}",
@@ -297,7 +296,6 @@ async def stream_conversation(
if not await request.is_disconnected():
yield _format_sse_event(snapshot)
# ── Phase 2: Real-time subscription ──
heartbeat_interval = hub.heartbeat_interval
while True:
if hub.is_closed:
@@ -307,13 +305,11 @@ async def stream_conversation(
changed = await sub.wait_for_change(timeout=heartbeat_interval)
if not changed:
# Timeout → heartbeat (or hub closed).
if hub.is_closed:
return
yield _format_heartbeat()
continue
# Generation advanced — fetch new events from DB.
new_events = await store.conversation_events.list_events(
conversation_id, after_seq=last_seq,
)
@@ -322,7 +318,6 @@ async def stream_conversation(
return
yield _format_sse_event(evt)
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
# P0-2: terminal event → close stream after sending it.
if _is_terminal(evt):
return
except asyncio.CancelledError:
@@ -334,8 +329,63 @@ async def stream_conversation(
finally:
sub.close()
return _gen()
async def _get_active_run_id(store: Any, conversation_id: str) -> str | None:
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow
async with store.session_factory() as db:
row = (
await db.execute(
select(MafRoundtableConversationRow.active_run_id).where(
MafRoundtableConversationRow.id == conversation_id,
)
)
).first()
return row[0] if row and row[0] else None
@router.get("/conversations/{conversation_id}/stream")
async def stream_conversation(
conversation_id: str,
request: Request,
after_seq: int = Query(default=0, ge=0),
):
"""SSE stream for one conversation (§4.2)."""
svc = _get_conversation_service(request)
hub = _get_event_hub(request)
user_id, is_admin = _user_ctx(request)
if hub is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="SSE event hub not configured",
)
stream_ready = getattr(request.app.state, "maf_conversation_stream_ready", True)
if not stream_ready:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=getattr(request.app.state, "maf_conversation_stream_status", "stream_not_ready"),
)
try:
await svc.get_conversation(
conversation_id=conversation_id, user_id=user_id, is_admin=is_admin,
)
except ConversationNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
return StreamingResponse(
event_generator(),
build_conversation_event_generator(
service=svc,
hub=hub,
request=request,
conversation_id=conversation_id,
after_seq=after_seq,
),
media_type="text/event-stream",
headers=_SSE_HEADERS,
)
@@ -119,9 +119,20 @@ async def get_health(request: Request) -> HealthResponse:
cfg = app_config.maf_roundtable
svc = _get_service(request)
store_available = svc is not None and svc.store is not None
from app.maf_roundtable.dispatch_contracts import MafDispatchCatalog
dispatch_ready, dispatch_status = MafDispatchCatalog(cfg).readiness()
readiness = get_maf_write_guard_readiness(app_config=app_config)
stream_ready = bool(getattr(request.app.state, "maf_conversation_stream_ready", False))
stream_status = str(getattr(request.app.state, "maf_conversation_stream_status", "unknown"))
conv_backend = str(getattr(request.app.state, "maf_conversation_event_backend", "memory"))
return HealthResponse(
enabled=bool(cfg.enabled and svc is not None and svc.enabled),
enabled=bool(cfg.enabled and dispatch_ready and svc is not None and svc.enabled),
conversation_dispatch_ready=bool(cfg.enabled and dispatch_ready and svc is not None and svc.enabled),
conversation_dispatch_status=dispatch_status,
conversation_stream_ready=stream_ready,
conversation_stream_status=stream_status,
conversation_event_backend=conv_backend,
store_available=store_available,
default_mode=cfg.default_mode,
default_write_policy=cfg.default_write_policy,
@@ -251,34 +251,30 @@ class ConversationEventHub:
return seen_gen
return state.generation
def shutdown(self) -> None:
"""Signal all subscribers to stop (server shutdown).
async def aclose(self) -> None:
"""Async shutdown that immediately wakes all subscribers.
Wakes every waiter; their ``wait_for_change`` returns ``True`` but
the caller's outer loop checks ``hub.is_closed`` and exits.
Acquires each Condition lock, bumps generation, and calls
``notify_all()`` so every waiter returns within milliseconds —
not after the next heartbeat timeout.
"""
self._closed = True
for state in list(self._convs.values()):
async with state.condition:
state.generation += 1
state.condition.notify_all()
def shutdown(self) -> None:
"""Deprecated sync stub — bumps generation but cannot wake waiters
immediately. Callers should use ``await hub.aclose()`` instead."""
self._closed = True
for state in self._convs.values():
state.generation += 1
# Wake all waiters — we can't use the condition here because
# shutdown is sync. The next wait_for_change call will see
# the incremented generation and return immediately.
# For a truly sync wake we'd need to schedule the notify;
# but since shutdown is called from the lifespan teardown
# (async context), callers should use:
# async with state.condition: state.condition.notify_all()
# Instead we just bump the generation; waiters time out on the
# next heartbeat cycle at worst (15s). For immediate wake,
# use `await hub.aclose()` below.
async def hub_aclose(hub: ConversationEventHub) -> None:
"""Async shutdown that immediately wakes all subscribers."""
hub._closed = True
for state in list(hub._convs.values()):
async with state.condition:
state.generation += 1
state.condition.notify_all()
await hub.aclose()
__all__ = ["ConversationEventHub", "Subscription", "hub_aclose"]
@@ -18,10 +18,8 @@ from typing import TYPE_CHECKING, Any
from app.maf_roundtable.dispatch_contracts import (
DispatchConfigurationError,
MafDispatchCandidateSet,
MafDispatchCatalog,
MafDispatchPlan,
MafIntentDecision,
resolve_intent,
)
from deerflow.persistence.maf_roundtable.conversations import (
conversation_public_to_dict,
@@ -113,8 +111,24 @@ class MafConversationService:
def _enabled(self) -> bool:
"""Mutating conversation APIs obey the same feature flag as MAF runs."""
if self._enabled_override is not None:
return self._enabled_override
return bool(getattr(self._maf_config, "enabled", False))
if not self._enabled_override:
return False
elif not bool(getattr(self._maf_config, "enabled", False)):
return False
return self.conversation_dispatch_ready
@property
def conversation_dispatch_ready(self) -> bool:
"""Expose only non-sensitive route readiness to the gateway/UI."""
if self._maf_config is None:
return False
return MafDispatchCatalog(self._maf_config).conversation_dispatch_ready
@property
def conversation_dispatch_status(self) -> str:
if self._maf_config is None:
return "configuration_unavailable"
return MafDispatchCatalog(self._maf_config).readiness()[1]
def _read_scope_user(self, *, user_id: str, is_admin: bool) -> str | None:
"""Keep administrator diagnostics read-only and config-gated."""
@@ -129,83 +143,82 @@ class MafConversationService:
title: str,
content: str,
task_context: dict[str, Any] | None,
) -> tuple[dict[str, Any] | None, MafIntentDecision, MafDispatchPlan | None]:
) -> tuple[dict[str, Any] | None, MafDispatchCandidateSet]:
cfg = self._maf_config
if cfg is None:
raise ConversationDispatchError("MAF session configuration is unavailable")
intent = resolve_intent(content, task_context)
plan: MafDispatchPlan | None = None
participants: list[dict[str, Any]] = []
try:
candidate_set = MafDispatchCatalog(cfg).build_candidate_set(
task_context=task_context,
)
except DispatchConfigurationError as exc:
raise ConversationDispatchError(str(exc)) from exc
snapshot_inputs = MafDispatchCatalog.candidate_participants(
candidate_set,
include_seats=True,
)
participants = MafDispatchCatalog.candidate_participants(
candidate_set,
include_seats=False,
)
capability_snapshot: dict[str, Any] | None = None
if intent.intent_type != "clarification":
from app.maf_roundtable.capability_snapshot import (
AgentNotAccessibleError,
InvalidExcludedToolsError,
build_trusted_capability_snapshot,
seat_snapshot_for_agent,
)
if self._app_config is not None:
try:
plan = MafDispatchCatalog(cfg).build_plan(
intent=intent,
task_context=task_context,
capability_snapshot = await build_trusted_capability_snapshot(
user_id=user_id,
participants=snapshot_inputs,
app_config=self._app_config,
agent_store=self._agent_store,
session_model_name=getattr(cfg, "default_model_name", None),
session_reasoning_effort=getattr(cfg, "default_reasoning_effort", None),
)
except DispatchConfigurationError as exc:
raise ConversationDispatchError(str(exc)) from exc
except AgentNotAccessibleError as exc:
raise ConversationDispatchError(
f"configured MAF agent is not accessible: {exc.agent_id}"
) from exc
except InvalidExcludedToolsError as exc:
raise ConversationDispatchError(
f"configured MAF agent has invalid excluded tools: {exc.agent_id}"
) from exc
from app.maf_roundtable.capability_snapshot import (
AgentNotAccessibleError,
InvalidExcludedToolsError,
build_trusted_capability_snapshot,
seat_snapshot_for_agent,
)
participants = MafDispatchCatalog.participants(plan)
if self._app_config is not None:
try:
capability_snapshot = await build_trusted_capability_snapshot(
user_id=user_id,
participants=participants,
app_config=self._app_config,
agent_store=self._agent_store,
session_model_name=getattr(cfg, "default_model_name", None),
session_reasoning_effort=getattr(cfg, "default_reasoning_effort", None),
)
except AgentNotAccessibleError as exc:
raise ConversationDispatchError(
f"configured MAF agent is not accessible: {exc.agent_id}"
) from exc
except InvalidExcludedToolsError as exc:
raise ConversationDispatchError(
f"configured MAF agent has invalid excluded tools: {exc.agent_id}"
) from exc
for participant in participants:
participant["capability_snapshot"] = seat_snapshot_for_agent(
capability_snapshot, str(participant["agent_id"])
)
for participant in participants:
participant["capability_snapshot"] = seat_snapshot_for_agent(
capability_snapshot, str(participant["agent_id"])
)
payload = {
"maf_v2": {
"intent_type": intent.intent_type,
"reason_code": intent.reason_code,
"business_code": (task_context or {}).get("business_code"),
"dispatch_plan": plan.model_dump(mode="json") if plan is not None else None,
"dispatch_contract_enabled": True,
"candidate_set": candidate_set.model_dump(mode="json"),
"candidate_seat_agent_ids": list(candidate_set.candidate_seat_agent_ids),
"manager_agent_id": candidate_set.manager_agent_id,
}
}
blueprint = {
"title": title,
"task_text": content,
"mode": getattr(plan, "mode", None) or getattr(cfg, "default_mode", "structured"),
"mode": candidate_set.mode,
"model_name": getattr(cfg, "default_model_name", None),
"reasoning_effort": getattr(cfg, "default_reasoning_effort", None),
"manager_agent_id": (
plan.manager_agent_id if plan is not None
else getattr(cfg, "default_manager_agent_id", None)
),
"max_rounds": getattr(plan, "max_rounds", None) or getattr(cfg, "default_max_rounds", 8),
"manager_agent_id": candidate_set.manager_agent_id,
"max_rounds": candidate_set.max_rounds,
"write_policy": getattr(cfg, "default_write_policy", "confirm_high_risk"),
"capability_snapshot": capability_snapshot,
"task_payload": payload,
"participants": participants,
"start_run": intent.intent_type != "clarification",
"start_run": True,
}
return blueprint, intent, plan
return blueprint, candidate_set
async def create_conversation(
self, *, user_id: str, title: str = "", task_context: dict[str, Any] | None = None
@@ -275,7 +288,7 @@ class MafConversationService:
raise ConversationNotFoundError()
try:
blueprint, intent, plan = await self._build_dispatch_blueprint(
blueprint, _candidate_set = await self._build_dispatch_blueprint(
user_id=user_id,
title=conv.get("title", ""),
content=content,
@@ -290,7 +303,7 @@ class MafConversationService:
# Do not create a durable running record if the MAF SDK is missing.
# Unit tests may inject a small fake dispatcher without this optional
# readiness method.
if intent.intent_type != "clarification" and self._dispatcher is not None:
if self._dispatcher is not None:
check_sdk = getattr(self._dispatcher, "check_sdk_available", None)
if callable(check_sdk):
try:
@@ -313,48 +326,19 @@ class MafConversationService:
if result.get("no_session_config"):
raise ConversationNoSessionConfigError()
await self._publish_conversation_event(
conversation_id=conversation_id,
owner_user_id=user_id,
event_type="manager.intent",
event_data={
"intent_type": intent.intent_type,
"confidence": intent.confidence,
"reason_code": intent.reason_code,
},
)
if self._hub is not None:
try:
await self._hub.publish(conversation_id)
except Exception: # noqa: BLE001
logger.exception("hub publish failed conv=%s", conversation_id)
if intent.intent_type == "clarification":
await self._publish_conversation_event(
conversation_id=conversation_id,
owner_user_id=user_id,
event_type="clarification.required",
event_data={
"prompt": intent.clarification_prompt,
"reason_code": intent.reason_code,
},
)
else:
if plan is None or result.get("run") is None:
raise ConversationDispatchError("dispatch plan did not produce a run")
await self._publish_conversation_event(
conversation_id=conversation_id,
owner_user_id=user_id,
event_type="manager.dispatch",
event_data={
"intent_type": plan.intent_type,
"mode": plan.mode,
"business_code": plan.business_code,
"manager_agent_id": plan.manager_agent_id,
"seat_agent_ids": list(plan.seat_agent_ids),
"seat_count": len(plan.seat_agent_ids),
},
)
await self._dispatch_run(
conversation_id=conversation_id,
user_id=user_id,
run=result["run"],
)
if result.get("run") is None:
raise ConversationDispatchError("dispatch run was not created")
await self._dispatch_run(
conversation_id=conversation_id,
user_id=user_id,
run=result["run"],
)
return {
"message_id": result["message"]["id"] if result.get("message") else "",
@@ -426,16 +410,115 @@ class MafConversationService:
logger.exception("hub publish failed conv=%s", conversation_id)
return True
async def bridge_workflow_event(self, *, run_id: str, event: dict[str, Any]) -> None:
"""Project a persisted per-run event onto the V2 conversation stream.
_EVENT_TYPE_MAP = {
"agent.turn_started": "agent.message.start",
"agent.message.completed": "agent.message.complete",
"action.waiting_approval": "approval.required",
"action.approved": "approval.decided",
"action.rejected": "approval.decided",
"run.started": "run.status",
"run.failed": "run.error",
"run.cancelled": "run.cancelled",
"manager.decision": "manager.dispatch",
"manager.dispatch": "manager.dispatch",
"clarification.required": "clarification.required",
"workflow.checkpointed": "run.status",
"action.waiting_target_lease": "tool.progress",
"action.unknown": "tool.failed",
}
The per-run event ledger remains the audit source of truth. This
projection is deliberately lossy: only user-visible lifecycle fields
are copied, while tool arguments, tool-call ids, thread ids, run ids,
and raw exception bodies are discarded before the SSE event is saved.
"""
_ALLOWED_TYPES = frozenset({
"agent.message.start", "agent.message.delta", "agent.message.complete",
"seat.delivery.submitted", "manager.dispatch", "manager.review.decision",
"tool.requested", "tool.started", "tool.progress", "tool.completed", "tool.failed",
"skill.selected", "skill.started", "skill.completed", "skill.failed",
"artifact.created", "approval.required", "approval.decided", "clarification.required",
"fact.confirmed", "review.completed", "run.status", "run.error",
"run.cancelled", "run.completed",
})
_SAFE_KEYS: dict[str, dict[str, bool]] = {
"agent.message.start": {"thread_id": False},
"agent.message.delta": {"text": True},
"agent.message.complete": {"content": True, "usage": True, "metadata": False},
"tool.requested": {"tool_name": True},
"tool.started": {"tool_name": True, "node": True},
"tool.progress": {"tool_name": True, "status": True},
"tool.completed": {"tool_name": True, "status": True, "content_preview": True},
"tool.failed": {"tool_name": True, "status": True, "error_code": True},
"skill.selected": {"skill_name": True, "message": True},
"skill.started": {"skill_name": True, "message": True},
"skill.completed": {"skill_name": True, "result_preview": True},
"skill.failed": {"skill_name": True, "error": True},
"artifact.created": {"artifact_type": True, "title": True},
"approval.required": {"tool_name": True, "summary": True},
"approval.decided": {"decided_by": True},
"run.status": {"checkpoint_id": True, "reason": True, "status": True},
"run.error": {"error_code": True, "message": True},
"run.cancelled": {},
"run.completed": {"status": True},
"manager.dispatch": {
"intent_type": True, "execution_mode": True, "convergence_path": True,
"selected_seat_agent_ids": True, "assignments": True, "next_speaker": True,
"reason": True, "source": True, "clarification_prompt": True,
},
"clarification.required": {"prompt": True, "reason": True},
"manager.review.decision": {"decision": True, "reason": True, "quality": True},
"fact.confirmed": {"fact_id": True, "summary": True},
"review.completed": {"summary": True, "decision": True},
"seat.delivery.submitted": {"content": True, "schema_status": True},
}
def _build_projection_items(
self,
event: dict[str, Any],
source_audit_event_id: str | None,
) -> list[dict[str, Any]]:
source_type = str(event.get("event_type") or "")
mapped_type = self._EVENT_TYPE_MAP.get(source_type, source_type)
if mapped_type not in self._ALLOWED_TYPES:
return []
payload = event.get("payload")
if not isinstance(payload, dict):
payload = {}
safe_keys = self._SAFE_KEYS.get(mapped_type, {})
data: dict[str, Any] = {
key: payload[key]
for key in safe_keys
if key in payload and safe_keys[key]
}
if event.get("agent_id"):
data["agent_id"] = event["agent_id"]
if event.get("turn_index") is not None:
data["turn_index"] = event["turn_index"]
source_evt_id = source_audit_event_id or event.get("id") or event.get("source_event_id")
items: list[dict[str, Any]] = [{
"event_type": mapped_type,
"event_data": data,
"source_event_id": source_evt_id,
}]
if source_type == "agent.message.completed":
delivery_data: dict[str, Any] = {
key: payload[key]
for key in ("content", "schema_status")
if key in payload
}
if event.get("agent_id"):
delivery_data["agent_id"] = event["agent_id"]
items.append({
"event_type": "seat.delivery.submitted",
"event_data": delivery_data,
"source_event_id": f"{source_evt_id}:delivery" if source_evt_id else None,
})
return items
async def _resolve_conversation_for_run(self, run_id: str) -> tuple[str, str] | None:
from sqlalchemy import select
async with self._store.session_factory() as db:
row = (
await db.execute(
@@ -449,104 +532,139 @@ class MafConversationService:
)
).scalars().first()
if row is None:
return
conversation_id = row.id
owner_user_id = row.owner_user_id
return None
return row.id, row.owner_user_id
source_type = str(event.get("event_type") or "")
mapped_type = {
"agent.turn_started": "agent.message.start",
"agent.message.completed": "agent.message.complete",
"action.waiting_approval": "approval.required",
"action.approved": "approval.decided",
"action.rejected": "approval.decided",
"run.started": "run.status",
"run.failed": "run.error",
"run.cancelled": "run.cancelled",
"manager.decision": "manager.dispatch",
"workflow.checkpointed": "run.status",
"action.waiting_target_lease": "tool.progress",
"action.unknown": "tool.failed",
}.get(source_type, source_type)
async def bridge_workflow_event(
self,
*,
run_id: str,
event: dict[str, Any],
source_audit_event_id: str | None = None,
) -> bool:
"""Project a persisted per-run event onto the V2 conversation stream.
allowed_types = {
"agent.message.start", "agent.message.delta", "agent.message.complete",
"seat.delivery.submitted", "manager.dispatch", "manager.review.decision",
"tool.requested", "tool.started", "tool.progress", "tool.completed", "tool.failed",
"skill.selected", "skill.started", "skill.completed", "skill.failed",
"artifact.created", "approval.required", "approval.decided",
"fact.confirmed", "review.completed", "run.status", "run.error",
"run.cancelled", "run.completed",
}
if mapped_type not in allowed_types:
return
Returns ``True`` if at least one new conversation event was written,
``False`` if all items were deduped or unmapped.
payload = event.get("payload")
if not isinstance(payload, dict):
payload = {}
safe_keys = {
"agent.message.start": {"thread_id": False},
"agent.message.delta": {"text": True},
"agent.message.complete": {"content": True, "usage": True, "metadata": False},
"tool.requested": {"tool_name": True},
"tool.started": {"tool_name": True, "node": True},
"tool.progress": {"tool_name": True, "status": True},
"tool.completed": {"tool_name": True, "status": True, "content_preview": True},
"tool.failed": {"tool_name": True, "status": True, "error_code": True},
"skill.selected": {"skill_name": True, "message": True},
"skill.started": {"skill_name": True, "message": True},
"skill.completed": {"skill_name": True, "result_preview": True},
"skill.failed": {"skill_name": True, "error": True},
"artifact.created": {"artifact_type": True, "title": True},
"approval.required": {"tool_name": True, "summary": True},
"approval.decided": {"decided_by": True},
"run.status": {"checkpoint_id": True, "reason": True, "status": True},
"run.error": {"error_code": True, "message": True},
"run.cancelled": {},
"run.completed": {"status": True},
"manager.dispatch": {"next_speaker": True, "reason": True},
"manager.review.decision": {"decision": True, "reason": True, "quality": True},
"fact.confirmed": {"fact_id": True, "summary": True},
"review.completed": {"summary": True, "decision": True},
"seat.delivery.submitted": {"content": True, "schema_status": True},
}.get(mapped_type, {})
data: dict[str, Any] = {
key: payload[key]
for key in safe_keys
if key in payload and safe_keys[key]
}
if event.get("agent_id"):
data["agent_id"] = event["agent_id"]
if event.get("turn_index") is not None:
data["turn_index"] = event["turn_index"]
All derived events from one audit event (e.g. ``agent.message.complete``
+ ``seat.delivery.submitted``) are written in a single transaction —
if any insert fails, the entire batch rolls back.
"""
from sqlalchemy import select
# P0-4: use the source audit event's stable id for dedup.
source_evt_id = event.get("id") or event.get("source_event_id")
items = self._build_projection_items(event, source_audit_event_id)
if not items:
return False
await self._publish_conversation_event(
conversation_id=conversation_id,
owner_user_id=owner_user_id,
event_type=mapped_type,
event_data=data,
source_event_id=source_evt_id,
)
if source_type == "agent.message.completed":
# A completed structured seat turn is also a delivery milestone;
# this gives the timeline a stable gate separate from raw text.
delivery_data = {
key: payload[key]
for key in ("content", "schema_status")
if key in payload
resolved = await self._resolve_conversation_for_run(run_id)
if resolved is None:
return False
conversation_id, _owner_user_id = resolved
written = 0
async with self._store.session_factory() as db:
async with db.begin():
conv = (
await db.execute(
select(MafRoundtableConversationRow)
.where(MafRoundtableConversationRow.id == conversation_id)
.with_for_update()
)
).scalars().first()
if conv is None:
return False
written = await self._store.append_conversation_events_idempotently(
db, conversation=conv, items=items,
)
if written > 0 and self._hub is not None:
try:
await self._hub.publish(conversation_id)
except Exception: # noqa: BLE001
logger.exception("hub publish failed conv=%s", conversation_id)
return written > 0
async def reconcile_conversation_events(self, *, run_id: str) -> int:
"""Re-project audit events for a run into the conversation stream.
Scans ``maf_roundtable_events`` for the given run and re-bridges each
event. Because ``bridge_workflow_event`` is idempotent (dedup via
``source_event_id``), this is safe to call after a crash, a restart,
or a projection failure. Events already projected are silently skipped.
Returns the number of **new** conversation events written (deduped
events are NOT counted).
"""
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import MafRoundtableEventRow
written = 0
async with self._store.session_factory() as db:
rows = (
await db.execute(
select(MafRoundtableEventRow)
.where(MafRoundtableEventRow.run_id == run_id)
.order_by(MafRoundtableEventRow.seq.asc())
)
).scalars().all()
for row in rows:
event_dict: dict[str, Any] = {
"id": row.id,
"event_type": row.event_type,
"agent_id": row.agent_id,
"turn_index": row.turn_index,
"source_event_id": row.source_event_id,
"payload": row.payload_json,
}
if event.get("agent_id"):
delivery_data["agent_id"] = event["agent_id"]
await self._publish_conversation_event(
conversation_id=conversation_id,
owner_user_id=owner_user_id,
event_type="seat.delivery.submitted",
event_data=delivery_data,
source_event_id=f"{source_evt_id}:delivery" if source_evt_id else None,
)
try:
if await self.bridge_workflow_event(
run_id=run_id,
event=event_dict,
source_audit_event_id=row.id,
):
written += 1
except Exception: # noqa: BLE001
logger.warning(
"reconcile: failed to project audit event %s for run %s",
row.id, run_id, exc_info=True,
)
return written
async def reconcile_active_conversations(self) -> int:
"""Scan active/recent MAF runs and reconcile their conversation projections.
Called by the background reconcile task and the SSE connect trigger.
Returns total new conversation events written across all runs.
"""
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableConversationRow,
MafRoundtableRunRow,
)
active_statuses = ("pending", "running", "waiting_approval", "waiting_input")
total = 0
async with self._store.session_factory() as db:
rows = (
await db.execute(
select(MafRoundtableRunRow.id)
.join(
MafRoundtableConversationRow,
MafRoundtableConversationRow.active_run_id == MafRoundtableRunRow.id,
)
.where(MafRoundtableRunRow.status.in_(active_statuses))
)
).scalars().all()
for run_id in rows:
try:
total += await self.reconcile_conversation_events(run_id=str(run_id))
except Exception: # noqa: BLE001
logger.warning("background reconcile: run %s failed", run_id, exc_info=True)
return total
async def _dispatch_run(self, *, conversation_id: str, user_id: str, run: dict[str, Any]) -> None:
if self._dispatcher is None:
@@ -620,6 +738,11 @@ class MafConversationService:
logger.exception("MAF conversation watcher failed run=%s", run_id)
return
try:
await self.reconcile_conversation_events(run_id=run_id)
except Exception: # noqa: BLE001
logger.warning("watcher reconcile failed run=%s", run_id, exc_info=True)
run = await self._store.runs.get_run(run_id)
if run is None:
return
@@ -473,9 +473,16 @@ class DeerFlowAgentAdapter:
# ── P1.6: audit event persistence ───────────────────────────────────────
async def _emit_critical(self, run_id: str, event: dict[str, Any]) -> None:
"""Emit a critical audit event. Failure raises AuditEventError."""
"""Emit a critical audit event. Failure raises AuditEventError.
The persisted audit event row's ``id`` is passed to the conversation
bridge as ``source_audit_event_id`` so projection dedup is always
keyed on the real audit row — not on an ephemeral input field that
may be absent for critical events.
"""
persisted: dict[str, Any] | None = None
try:
await self._service.append_event(
persisted = await self._service.append_event(
run_id=run_id,
user_id=self._user_id,
event_type=event["event_type"],
@@ -488,7 +495,11 @@ class DeerFlowAgentAdapter:
raise AuditEventError(
f"Critical audit event '{event.get('event_type')}' failed to persist: {exc}"
) from exc
await self._bridge_conversation_event(run_id, event)
await self._bridge_conversation_event(
run_id,
event,
source_audit_event_id=persisted["id"] if persisted else None,
)
async def _emit(self, run_id: str, event: dict[str, Any]) -> None:
"""Emit a non-critical event (agent.message.delta). Failure degrades gracefully.
@@ -507,8 +518,9 @@ class DeerFlowAgentAdapter:
if event_type in _CRITICAL_EVENT_TYPES:
await self._emit_critical(run_id, event)
return
persisted: dict[str, Any] | None = None
try:
await self._service.append_event(
persisted = await self._service.append_event(
run_id=run_id,
user_id=self._user_id,
event_type=event_type,
@@ -520,15 +532,29 @@ class DeerFlowAgentAdapter:
except Exception:
logger.warning("non-critical event %s failed for run %s (degraded)", event_type, run_id, exc_info=True)
return
await self._bridge_conversation_event(run_id, event)
await self._bridge_conversation_event(
run_id,
event,
source_audit_event_id=persisted["id"] if persisted else None,
)
async def _bridge_conversation_event(self, run_id: str, event: dict[str, Any]) -> None:
async def _bridge_conversation_event(
self,
run_id: str,
event: dict[str, Any],
*,
source_audit_event_id: str | None = None,
) -> None:
"""Best-effort projection into the V2 conversation event stream.
The per-run audit write above remains authoritative. A projection
failure must not cause a tool to execute twice or turn a completed
seat run into a false runtime failure; reconnect/recovery can rebuild
the conversation view from the audit ledger later.
seat run into a false runtime failure.
``source_audit_event_id`` is the real persisted audit row id — it is
always preferred over ``event["id"]`` / ``event["source_event_id"]``
for dedup (P0-4 fix). When the bridge retries after a restart, the
same audit id ensures the conversation event is written exactly once.
P0-3: the entire ``app.state`` resolution is best-effort. If any link
is missing (no app, no state, no conversation service), the projection
@@ -547,7 +573,11 @@ class DeerFlowAgentAdapter:
if not callable(bridge):
return
try:
await bridge(run_id=run_id, event=event)
await bridge(
run_id=run_id,
event=event,
source_audit_event_id=source_audit_event_id,
)
except Exception: # noqa: BLE001 - audit already persisted
logger.warning(
"conversation event projection failed for run %s (%s)",
@@ -9,6 +9,7 @@ tested with a weak/fake model and before any workflow is launched.
from __future__ import annotations
import json
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -23,6 +24,8 @@ MafIntentType = Literal[
"chain_consultation",
"tool_write",
]
MafExecutionMode = Literal["manager_only", "single", "parallel", "chain", "group_chat"]
MafDispatchSource = Literal["server", "manager", "deterministic_fallback"]
class DispatchConfigurationError(RuntimeError):
@@ -33,6 +36,18 @@ class InvalidDispatchPlanError(ValueError):
"""A proposed or constructed plan violates the dispatch contract."""
class MafDispatchCandidateSet(BaseModel):
"""Trusted route supplied to the manager before it chooses an intent."""
model_config = ConfigDict(extra="forbid")
manager_agent_id: str = Field(min_length=1, max_length=64)
candidate_seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
mode: MafMode = "structured"
business_code: str | None = Field(default=None, max_length=255)
max_rounds: int = Field(ge=1, le=64)
class MafIntentDecision(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -56,6 +71,87 @@ class MafDispatchAssignment(BaseModel):
agent_id: str = Field(min_length=1, max_length=64)
role: Literal["seat"] = "seat"
sort_order: int = Field(ge=1, le=64)
task_goal: str = Field(default="Complete the assigned analysis", min_length=1, max_length=2000)
delivery_requirements: list[str] = Field(default_factory=list, max_length=16)
class MafManagerPlanAssignment(BaseModel):
"""The only per-seat shape accepted from the manager model."""
model_config = ConfigDict(extra="forbid")
agent_id: str = Field(min_length=1, max_length=64)
task_goal: str = Field(min_length=1, max_length=2000)
delivery_requirements: list[str] = Field(min_length=1, max_length=16)
order: int = Field(ge=1, le=64)
class MafManagerPlanOutput(BaseModel):
"""Strict model-facing dispatch schema.
This model deliberately contains no thread, tool, capability, or user
supplied routing fields. The server validates the IDs again after this
schema passes.
"""
model_config = ConfigDict(extra="forbid")
intent_type: MafIntentType
selected_seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
assignments: list[MafManagerPlanAssignment] = Field(default_factory=list, max_length=64)
execution_mode: MafExecutionMode
convergence_path: list[str] = Field(min_length=1, max_length=16)
reason: str = Field(min_length=1, max_length=1000)
clarification_prompt: str | None = Field(default=None, min_length=1, max_length=1000)
@model_validator(mode="after")
def _validate_manager_shape(self) -> MafManagerPlanOutput:
selected = self.selected_seat_agent_ids
if len(set(selected)) != len(selected):
raise ValueError("manager plan contains duplicate seats")
assignment_ids = [item.agent_id for item in self.assignments]
if assignment_ids != selected:
raise ValueError("manager assignments must exactly match selected seats")
if [item.order for item in self.assignments] != list(range(1, len(selected) + 1)):
raise ValueError("manager assignment order must be contiguous")
required_mode: MafExecutionMode
if self.intent_type == "clarification":
if selected or self.assignments or self.execution_mode != "manager_only":
raise ValueError("clarification cannot select expert seats")
if not self.clarification_prompt:
raise ValueError("clarification requires clarification_prompt")
return self
if self.clarification_prompt is not None:
raise ValueError("only clarification may include clarification_prompt")
if self.intent_type == "direct_answer":
required_mode = "manager_only"
if selected:
raise ValueError("direct_answer cannot select expert seats")
elif self.intent_type in {"single_seat", "tool_write"}:
required_mode = "single"
if len(selected) != 1:
raise ValueError("single-seat intents require exactly one seat")
elif self.intent_type == "parallel_consultation":
required_mode = "parallel"
if not selected:
raise ValueError("parallel consultation requires a seat")
if len(selected) < 2:
raise ValueError("parallel consultation requires at least two seats")
elif self.intent_type == "chain_consultation":
required_mode = "chain"
if not selected:
raise ValueError("chain consultation requires a seat")
else:
raise ValueError("clarification cannot be dispatched")
allowed_modes = {required_mode}
if self.execution_mode == "group_chat" and required_mode != "manager_only":
allowed_modes.add("group_chat")
if self.execution_mode not in allowed_modes:
raise ValueError(
f"execution_mode {self.execution_mode!r} does not match {required_mode!r}"
)
return self
class MafDispatchPlan(BaseModel):
@@ -70,6 +166,11 @@ class MafDispatchPlan(BaseModel):
intent_type: MafIntentType
business_code: str | None = Field(default=None, max_length=255)
max_rounds: int = Field(ge=1, le=64)
candidate_seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
execution_mode: MafExecutionMode = "parallel"
convergence_path: list[str] = Field(default_factory=list, max_length=16)
reason: str = Field(default="server-owned route", min_length=1, max_length=1000)
source: MafDispatchSource = "server"
@model_validator(mode="after")
def _validate_shape(self) -> MafDispatchPlan:
@@ -77,6 +178,12 @@ class MafDispatchPlan(BaseModel):
raise ValueError("manager agent cannot also be a seat")
if len(set(self.seat_agent_ids)) != len(self.seat_agent_ids):
raise ValueError("duplicate seat agent")
if len(set(self.candidate_seat_agent_ids)) != len(self.candidate_seat_agent_ids):
raise ValueError("duplicate candidate seat agent")
if self.candidate_seat_agent_ids and any(
agent_id not in self.candidate_seat_agent_ids for agent_id in self.seat_agent_ids
):
raise ValueError("selected seat is outside candidate seats")
assignment_ids = [assignment.agent_id for assignment in self.assignments]
if assignment_ids != self.seat_agent_ids:
raise ValueError("assignments must exactly match seat_agent_ids")
@@ -84,6 +191,22 @@ class MafDispatchPlan(BaseModel):
raise ValueError("assignments must have contiguous sort_order")
if self.intent_type == "clarification":
raise ValueError("clarification cannot be dispatched")
if self.intent_type == "direct_answer":
if self.seat_agent_ids or self.execution_mode != "manager_only":
raise ValueError("direct_answer must use manager_only with no seats")
elif self.intent_type in {"single_seat", "tool_write"}:
if len(self.seat_agent_ids) != 1 or self.execution_mode not in {"single", "group_chat"}:
raise ValueError("single-seat intents require one seat and single mode")
elif self.intent_type == "parallel_consultation":
if not self.seat_agent_ids or self.execution_mode not in {"parallel", "group_chat"}:
raise ValueError("parallel consultation requires at least one configured seat")
if len(self.seat_agent_ids) < 2:
raise ValueError("parallel consultation requires at least two seats")
elif self.intent_type == "chain_consultation":
if not self.seat_agent_ids or self.execution_mode not in {"chain", "group_chat"}:
raise ValueError("chain consultation requires at least one configured seat")
if self.execution_mode == "group_chat" and self.mode != "group_chat":
raise ValueError("group_chat execution requires a group_chat route")
if self.intent_type != "direct_answer" and not self.seat_agent_ids:
raise ValueError("this intent requires at least one configured seat")
return self
@@ -149,6 +272,27 @@ class MafDispatchCatalog:
def __init__(self, maf_config: Any) -> None:
self._config = maf_config
def readiness(self) -> tuple[bool, str]:
"""Return non-sensitive deployment readiness for the conversation UI.
A manager-only route is sufficient for a safe direct answer. Expert
routes are checked separately when a request selects a business chain
and when the trusted capability snapshot is built for the user.
"""
manager = str(_read(self._config, "default_manager_agent_id", "") or "").strip()
routes = _read(self._config, "business_routes", {}) or {}
if manager:
return True, "default_manager_configured"
for business_code, route in routes.items():
route_manager = str(_read(route, "manager_agent_id", "") or "").strip()
if route_manager:
return True, f"business_route_configured:{business_code}"
return False, "no_trusted_manager_route"
@property
def conversation_dispatch_ready(self) -> bool:
return self.readiness()[0]
def _route(self, business_code: str | None) -> tuple[str | None, list[str], MafMode | None]:
routes = _read(self._config, "business_routes", {}) or {}
if business_code:
@@ -169,6 +313,40 @@ class MafDispatchCatalog:
None,
)
def build_candidate_set(
self, *, task_context: dict[str, Any] | None
) -> MafDispatchCandidateSet:
"""Build only the trusted route; intent remains a manager decision."""
business_code = str((task_context or {}).get("business_code") or "").strip() or None
manager_id, route_seats, route_mode = self._route(business_code)
if not manager_id:
raise DispatchConfigurationError(
"no trusted MAF manager route is configured for this conversation"
)
normalized_manager = str(manager_id).strip()
candidates = [str(agent_id).strip() for agent_id in route_seats]
max_seats = int(_read(self._config, "max_seats_per_run", 16) or 16)
# A route may intentionally be manager-only, but it must always have
# a concrete manager. Candidate seats are optional until the manager
# chooses an expert intent.
if not normalized_manager:
raise DispatchConfigurationError("trusted manager route is blank")
if len(candidates) > max_seats:
raise DispatchConfigurationError("configured MAF route exceeds max_seats_per_run")
if normalized_manager in candidates or len(set(candidates)) != len(candidates):
raise DispatchConfigurationError("configured MAF route contains duplicate agents")
try:
return MafDispatchCandidateSet(
manager_agent_id=normalized_manager,
candidate_seat_agent_ids=candidates,
mode=route_mode or _read(self._config, "default_mode", "structured"),
business_code=business_code,
max_rounds=int(_read(self._config, "default_max_rounds", 8) or 8),
)
except (TypeError, ValueError) as exc:
raise DispatchConfigurationError("invalid trusted MAF dispatch route") from exc
def build_plan(
self,
*,
@@ -184,27 +362,54 @@ class MafDispatchCatalog:
# The server owns the route. Intent only changes how many configured
# seats participate; it never adds an id.
if intent.intent_type in {"single_seat", "tool_write", "direct_answer"}:
candidate_seats = route_seats[:]
if intent.intent_type == "direct_answer":
seat_ids = []
execution_mode: MafExecutionMode = "manager_only"
elif intent.intent_type in {"single_seat", "tool_write"}:
seat_ids = route_seats[:1]
execution_mode = "single"
elif intent.intent_type == "chain_consultation":
seat_ids = route_seats[:]
execution_mode = "chain"
else:
seat_ids = route_seats[:]
execution_mode = "parallel"
max_seats = int(_read(self._config, "max_seats_per_run", 16) or 16)
if len(seat_ids) > max_seats:
raise DispatchConfigurationError("configured MAF route exceeds max_seats_per_run")
if intent.intent_type != "direct_answer" and not seat_ids:
raise DispatchConfigurationError("selected MAF intent requires a configured seat route")
if intent.intent_type == "parallel_consultation" and len(seat_ids) < 2:
raise DispatchConfigurationError(
"parallel consultation requires at least two configured seats"
)
try:
plan = MafDispatchPlan(
manager_agent_id=str(manager_id).strip(),
seat_agent_ids=[str(agent_id).strip() for agent_id in seat_ids],
assignments=[
MafDispatchAssignment(agent_id=str(agent_id).strip(), sort_order=index)
MafDispatchAssignment(
agent_id=str(agent_id).strip(),
sort_order=index,
task_goal=f"Complete the {intent.intent_type} task",
delivery_requirements=["structured result", "evidence or limitation notes"],
)
for index, agent_id in enumerate(seat_ids, start=1)
],
mode=route_mode or _read(self._config, "default_mode", "structured"),
intent_type=intent.intent_type,
business_code=business_code,
max_rounds=int(_read(self._config, "default_max_rounds", 8) or 8),
candidate_seat_agent_ids=[str(agent_id).strip() for agent_id in candidate_seats],
execution_mode=execution_mode,
convergence_path=[
"manager answer" if intent.intent_type == "direct_answer" else "manager synthesis"
],
reason="server-owned candidate route",
source="server",
)
except (TypeError, ValueError) as exc:
raise DispatchConfigurationError("invalid trusted MAF dispatch route") from exc
@@ -214,8 +419,138 @@ class MafDispatchCatalog:
return plan
@staticmethod
def participants(plan: MafDispatchPlan) -> list[dict[str, Any]]:
def parse_manager_decision(
raw: str,
*,
candidate_set: MafDispatchCandidateSet,
max_seats: int,
capability_snapshot: dict[str, Any] | None = None,
) -> tuple[MafManagerPlanOutput, MafDispatchPlan | None]:
"""Parse the manager's complete intent + dispatch decision.
The manager may choose only from the server-provided candidate set.
A clarification is a valid terminal decision with no dispatch plan;
every malformed or unauthorized response raises for safe fallback.
"""
try:
payload = _extract_json_object(raw)
proposed = MafManagerPlanOutput.model_validate(payload)
except Exception as exc: # noqa: BLE001 - caller uses clarification fallback
raise InvalidDispatchPlanError("manager plan schema invalid") from exc
candidates = list(candidate_set.candidate_seat_agent_ids)
selected = list(proposed.selected_seat_agent_ids)
if any(agent_id not in candidates for agent_id in selected):
raise InvalidDispatchPlanError("manager selected a seat outside the candidate set")
if len(selected) > max_seats:
raise InvalidDispatchPlanError("manager plan exceeds the seat limit")
if candidate_set.manager_agent_id in selected:
raise InvalidDispatchPlanError("manager cannot select itself as a seat")
if proposed.execution_mode == "group_chat" and candidate_set.mode != "group_chat":
raise InvalidDispatchPlanError("group_chat is not enabled for this route")
if proposed.intent_type == "chain_consultation":
candidate_order = {agent_id: index for index, agent_id in enumerate(candidates)}
orders = [candidate_order[agent_id] for agent_id in selected]
if orders != sorted(orders):
raise InvalidDispatchPlanError(
"chain seats must preserve the configured business order"
)
if proposed.intent_type == "tool_write" and capability_snapshot is not None:
agents = capability_snapshot.get("agents")
by_id = {
str(agent.get("agent_id")): agent
for agent in agents or []
if isinstance(agent, dict)
}
if not any(
any(
isinstance(tool, dict) and tool.get("side_effect") == "write"
for tool in (by_id.get(agent_id, {}).get("tools") or [])
)
for agent_id in selected
):
raise InvalidDispatchPlanError("tool_write has no trusted write-capable seat")
if proposed.intent_type == "clarification":
return proposed, None
assignments = [
MafDispatchAssignment(
agent_id=item.agent_id,
sort_order=item.order,
task_goal=item.task_goal,
delivery_requirements=list(item.delivery_requirements),
)
for item in proposed.assignments
]
try:
plan = MafDispatchPlan(
manager_agent_id=candidate_set.manager_agent_id,
seat_agent_ids=selected,
assignments=assignments,
mode=candidate_set.mode,
intent_type=proposed.intent_type,
business_code=candidate_set.business_code,
max_rounds=candidate_set.max_rounds,
candidate_seat_agent_ids=candidates,
execution_mode=proposed.execution_mode,
convergence_path=list(proposed.convergence_path),
reason=proposed.reason,
source="manager",
)
except (TypeError, ValueError) as exc:
raise InvalidDispatchPlanError("manager plan failed final validation") from exc
return proposed, plan
@staticmethod
def parse_manager_plan(
raw: str,
*,
expected_intent: MafIntentType,
server_plan: MafDispatchPlan,
max_seats: int,
) -> MafDispatchPlan:
"""Compatibility wrapper for callers with a pre-resolved intent."""
candidate_set = MafDispatchCandidateSet(
manager_agent_id=server_plan.manager_agent_id,
candidate_seat_agent_ids=list(server_plan.candidate_seat_agent_ids),
mode=server_plan.mode,
business_code=server_plan.business_code,
max_rounds=server_plan.max_rounds,
)
proposed, plan = MafDispatchCatalog.parse_manager_decision(
raw,
candidate_set=candidate_set,
max_seats=max_seats,
)
if proposed.intent_type != expected_intent:
raise InvalidDispatchPlanError("manager changed the expected intent")
if plan is None:
raise InvalidDispatchPlanError("clarification is not a dispatch plan")
return plan
@staticmethod
def deterministic_fallback_plan(server_plan: MafDispatchPlan) -> MafDispatchPlan:
"""Use the server route without ever expanding model permissions."""
return server_plan.model_copy(
update={
"seat_agent_ids": list(server_plan.seat_agent_ids),
"assignments": [item.model_copy() for item in server_plan.assignments],
"source": "deterministic_fallback",
"reason": "manager_plan_invalid_safe_route",
}
)
@staticmethod
def participants(
plan: MafDispatchPlan, *, include_candidates: bool = True
) -> list[dict[str, Any]]:
"""Return persistence records; thread ids are intentionally absent."""
candidate_seats = (
list(plan.candidate_seat_agent_ids)
if include_candidates and plan.intent_type != "direct_answer"
else []
)
return [
{
"agent_id": plan.manager_agent_id,
@@ -225,15 +560,76 @@ class MafDispatchCatalog:
},
*[
{
"agent_id": assignment.agent_id,
"agent_id": agent_id,
"role": "seat",
"sort_order": assignment.sort_order,
"sort_order": index,
"display_name": "",
}
for assignment in plan.assignments
for index, agent_id in enumerate(candidate_seats, start=1)
],
]
@staticmethod
def candidate_participants(
candidate_set: MafDispatchCandidateSet,
*,
include_seats: bool = True,
capability_snapshot: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Build ephemeral snapshot inputs or persisted manager-only records."""
snapshots = {
str(agent.get("agent_id")): dict(agent)
for agent in (capability_snapshot or {}).get("agents", [])
if isinstance(agent, dict)
}
rows = [{
"agent_id": candidate_set.manager_agent_id,
"role": "manager",
"sort_order": 0,
"display_name": "",
"capability_snapshot": snapshots.get(candidate_set.manager_agent_id, {}),
}]
if include_seats:
rows.extend({
"agent_id": agent_id,
"role": "seat",
"sort_order": index,
"display_name": "",
"capability_snapshot": snapshots.get(agent_id, {}),
} for index, agent_id in enumerate(candidate_set.candidate_seat_agent_ids, start=1))
return rows
@staticmethod
def manager_capability_summary(
snapshot: dict[str, Any] | None,
candidate_set: MafDispatchCandidateSet,
) -> list[dict[str, Any]]:
"""Return only safe, non-secret capability facts for the manager prompt."""
by_id = {
str(agent.get("agent_id")): agent
for agent in (snapshot or {}).get("agents", [])
if isinstance(agent, dict)
}
result: list[dict[str, Any]] = []
for agent_id in candidate_set.candidate_seat_agent_ids:
agent = by_id.get(agent_id, {})
tools = [
{
"tool_name": str(tool.get("tool_name")),
"side_effect": tool.get("side_effect", "write"),
"risk_level": tool.get("risk_level", "R3"),
}
for tool in (agent.get("tools") or [])
if isinstance(tool, dict) and tool.get("tool_name")
]
result.append({
"agent_id": agent_id,
"model_name": agent.get("model_name"),
"tool_names": [tool["tool_name"] for tool in tools],
"tool_summary": tools,
})
return result
def validate_dispatch_plan(
plan: MafDispatchPlan,
@@ -255,10 +651,31 @@ def validate_dispatch_plan(
raise InvalidDispatchPlanError("manager cannot be dispatched as a seat")
def _extract_json_object(raw: str) -> dict[str, Any]:
text = str(raw or "").strip()
if not text:
raise ValueError("empty manager plan")
try:
value = json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start < 0 or end <= start:
raise ValueError("manager plan is not JSON")
value = json.loads(text[start:end + 1])
if not isinstance(value, dict):
raise ValueError("manager plan must be a JSON object")
return value
__all__ = [
"DispatchConfigurationError",
"InvalidDispatchPlanError",
"MafDispatchAssignment",
"MafDispatchCandidateSet",
"MafExecutionMode",
"MafManagerPlanAssignment",
"MafManagerPlanOutput",
"MafDispatchPlan",
"MafDispatchCatalog",
"MafIntentDecision",
@@ -254,6 +254,11 @@ class HealthResponse(BaseModel):
"""
enabled: bool
conversation_dispatch_ready: bool = False
conversation_dispatch_status: str = "configuration_unavailable"
conversation_stream_ready: bool = False
conversation_stream_status: str = "unknown"
conversation_event_backend: str = "memory"
store_available: bool
default_mode: str
default_write_policy: str
@@ -14,6 +14,7 @@ Group-chat still uses CoordinatorExecutor for speaker selection.
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
@@ -25,6 +26,12 @@ from app.maf_roundtable.action_coordinator import (
tool_needs_approval,
)
from app.maf_roundtable.context_builder import build_manager_synthesis_input
from app.maf_roundtable.dispatch_contracts import (
InvalidDispatchPlanError,
MafDispatchCandidateSet,
MafDispatchCatalog,
MafDispatchPlan,
)
from app.maf_roundtable.fact_ledger import (
confirm_candidates,
empty_ledger,
@@ -76,6 +83,8 @@ class RoundtableState:
schema_repair_enabled: bool = True,
intent_type: str | None = None,
dispatch_plan: dict[str, Any] | None = None,
candidate_seat_agent_ids: list[str] | None = None,
dispatch_contract_enabled: bool | None = None,
) -> None:
self.session_id = session_id
self.run_id = run_id
@@ -96,6 +105,18 @@ class RoundtableState:
# id from manager model output.
self.intent_type = intent_type
self.dispatch_plan = dispatch_plan
self.dispatch_contract_enabled = (
bool(dispatch_plan is not None or intent_type is not None)
if dispatch_contract_enabled is None
else bool(dispatch_contract_enabled)
)
plan_seats = list((dispatch_plan or {}).get("seat_agent_ids") or [])
self.candidate_seat_agent_ids = list(
(dispatch_plan or {}).get("candidate_seat_agent_ids")
or candidate_seat_agent_ids
or seat_agent_ids
)
self.selected_seat_agent_ids: list[str] | None = plan_seats or None
self.current_round = 0
self.turn_results: list[dict[str, Any]] = []
@@ -137,6 +158,9 @@ class RoundtableState:
"schema_repair_enabled": self.schema_repair_enabled,
"intent_type": self.intent_type,
"dispatch_plan": self.dispatch_plan,
"dispatch_contract_enabled": self.dispatch_contract_enabled,
"candidate_seat_agent_ids": list(self.candidate_seat_agent_ids),
"selected_seat_agent_ids": self.selected_seat_agent_ids,
"current_round": self.current_round,
"turn_results": self.turn_results,
"fact_ledger": self.fact_ledger,
@@ -170,6 +194,8 @@ class RoundtableState:
schema_repair_enabled=bool(d.get("schema_repair_enabled", True)),
intent_type=d.get("intent_type"),
dispatch_plan=d.get("dispatch_plan"),
candidate_seat_agent_ids=d.get("candidate_seat_agent_ids"),
dispatch_contract_enabled=d.get("dispatch_contract_enabled"),
)
state.current_round = d.get("current_round", 0)
state.turn_results = d.get("turn_results", [])
@@ -186,62 +212,59 @@ class RoundtableState:
state.stage = d.get("stage") or "planning"
state.partial_failure = bool(d.get("partial_failure", False))
state.cross_review_result = d.get("cross_review_result")
state.candidate_seat_agent_ids = list(
d.get("candidate_seat_agent_ids") or state.candidate_seat_agent_ids
)
selected = d.get("selected_seat_agent_ids")
state.selected_seat_agent_ids = list(selected) if selected is not None else None
state.dispatch_contract_enabled = bool(
d.get("dispatch_contract_enabled", state.dispatch_contract_enabled)
)
return state
# ── Planner (structured) ────────────────────────────────────────────────────
class PlannerExecutor(Executor):
"""Produce per-seat assignments for the structured expert stage."""
class ManagerAnswerExecutor(Executor):
"""Manager-only topology for ordinary direct answers."""
def __init__(self, adapter: Any, state: RoundtableState) -> None:
super().__init__(id="planner")
super().__init__(id="manager_answer")
self._adapter = adapter
self._state = state
@handler(input=Any, output=dict)
@handler(input=Any, workflow_output=dict)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
if self._state.is_completed("planner"):
await ctx.send_message({"decision": "parallel_seats", "agents": self._state.seat_agent_ids, "replayed": True})
if self._state.is_completed("manager_answer") and self._state.final_result is not None:
self._state.status = "completed"
await ctx.yield_output({"final_result": self._state.final_result, "replayed": True})
return
self._state.stage = "planning"
self._state.current_round = max(self._state.current_round, 1)
assignments = {
aid: {
"role": f"专家席位 {aid}",
"instruction": (
f"请作为席位 {aid} 独立分析任务,可使用你的工具与技能。"
f"{seat_output_instruction()}"
),
manager_id = self._state.manager_agent_id or ""
thread_id = self._state.thread_map.get(manager_id, "")
if not thread_id:
self._state.status = "failed"
self._state.final_result = {
"content": "MAF manager thread is unavailable",
"agent_id": manager_id,
"error_code": "manager_thread_unavailable",
}
for aid in self._state.seat_agent_ids
}
manager_id = self._state.manager_agent_id
thread_id = self._state.thread_map.get(manager_id or "", "")
if manager_id and thread_id:
else:
try:
plan_prompt = (
f"任务:{self._state.task_text}\n"
f"参与席位:{', '.join(self._state.seat_agent_ids)}\n"
"请为每个席位给出简短分工说明。可用纯文本;系统会据此填充各席位指令。"
)
response = await asyncio.wait_for(
self._adapter.run_seat_turn(
session_id=self._state.session_id,
run_id=self._state.run_id,
agent_id=manager_id,
turn_index=0,
execution_stage="planner",
message=plan_prompt,
execution_stage="direct_answer",
message=self._state.task_text,
thread_id=thread_id,
model_name=self._state.model_name,
reasoning_effort=self._state.reasoning_effort,
task_text=plan_prompt,
stage_instruction="任务规划与分工",
task_text=self._state.task_text,
stage_instruction="Answer the user directly; do not delegate to expert seats",
fact_ledger=self._state.fact_ledger,
capability_snapshot=self._state.capability_snapshot,
write_policy=self._state.write_policy,
@@ -249,31 +272,247 @@ class PlannerExecutor(Executor):
),
timeout=self._state.seat_timeout_seconds,
)
plan_text = (response.content or "").strip()
if plan_text:
for aid in self._state.seat_agent_ids:
assignments[aid]["instruction"] = (
f"{plan_text[:1500]}\n\n你的席位 id={aid}。\n{seat_output_instruction()}"
)
self._state.plan = {"source": "manager", "text": plan_text, "assignments": assignments}
if response.error:
self._state.status = "failed"
else:
self._state.plan = {"source": "deterministic", "assignments": assignments}
except Exception as exc: # noqa: BLE001
logger.warning("planner manager turn failed (%s); using deterministic plan", type(exc).__name__)
self._state.plan = {"source": "deterministic", "assignments": assignments}
self._state.status = "completed"
self._state.final_result = {
"content": response.content,
"agent_id": manager_id,
"error": response.error,
"quality_decision": "accept" if not response.error else "controlled_gap",
}
except Exception as exc: # noqa: BLE001 - runner persists a safe failure
self._state.status = "failed"
self._state.final_result = {
"content": "MAF manager answer failed",
"agent_id": manager_id,
"error_code": type(exc).__name__,
}
self._state.stage = "done"
self._state.mark_completed("manager_answer")
dispatch_plan = MafDispatchPlan.model_validate(self._state.dispatch_plan or {
"manager_agent_id": manager_id,
"seat_agent_ids": [],
"assignments": [],
"intent_type": "direct_answer",
"max_rounds": self._state.max_rounds,
"candidate_seat_agent_ids": list(self._state.candidate_seat_agent_ids),
"execution_mode": "manager_only",
"convergence_path": ["manager answer"],
"reason": "direct answer uses manager only",
"source": "server",
})
self._state.dispatch_plan = dispatch_plan.model_dump(mode="json")
store = getattr(getattr(self._adapter, "_service", None), "store", None)
if store is not None:
await store.runs.update_run(
self._state.run_id,
{"maf_checkpoint": self._state.to_dict()},
)
if not self._state.dispatch_contract_enabled:
await self._adapter._emit_critical(self._state.run_id, {
"event_type": "manager.dispatch",
"agent_id": manager_id,
"turn_index": 0,
"payload": {
"intent_type": dispatch_plan.intent_type,
"execution_mode": dispatch_plan.execution_mode,
"convergence_path": list(dispatch_plan.convergence_path),
"selected_seat_agent_ids": [],
"assignments": [],
"reason": dispatch_plan.reason,
"source": dispatch_plan.source,
},
})
from app.maf_roundtable.event_normalizer import make_run_completed
await self._adapter._emit_critical(
self._state.run_id,
make_run_completed(manager_id, 1, {"manager_only": True}),
)
await ctx.yield_output({"final_result": self._state.final_result})
class ManagerDispatchExecutor(Executor):
"""Understand intent and emit one server-validated dispatch decision."""
def __init__(
self, adapter: Any, state: RoundtableState, *, executor_id: str = "manager_dispatch"
) -> None:
super().__init__(id=executor_id)
self._adapter = adapter
self._state = state
self._node_id = executor_id
def _dispatch_decision(self) -> str:
plan = self._state.dispatch_plan or {}
if self._state.intent_type == "clarification" or not plan:
return "clarification"
if plan.get("execution_mode") == "manager_only":
return "manager_only"
if plan.get("mode") == "group_chat" or plan.get("execution_mode") == "group_chat":
return "group_chat"
return "selected_seats"
@handler(input=Any, output=dict)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
if self._state.is_completed(self._node_id):
await ctx.send_message({
"decision": self._dispatch_decision(),
"agents": list(self._state.selected_seat_agent_ids or []),
"execution_mode": (self._state.dispatch_plan or {}).get("execution_mode"),
"replayed": True,
})
return
self._state.stage = "planning"
# Legacy MAF roundtable runs do not carry the V2 conversation dispatch
# contract. Keep their planner semantics isolated from the new MAF
# conversation workflow; in particular, old seat-only runs may have no
# manager at all.
if not self._state.dispatch_contract_enabled:
self._state.current_round = max(self._state.current_round, 1)
await _run_legacy_planner(self, ctx)
return
# The manager plan is a strict JSON contract. Legacy free-text
# planning is isolated in _run_legacy_planner; new Stage 3 runs
# always return through this verifier.
await _resolve_manager_dispatch(self, ctx)
return
async def _run_legacy_planner(executor: ManagerDispatchExecutor, ctx: WorkflowContext) -> None:
"""Keep the pre-V2 free-text planner isolated from new conversations."""
state = executor._state
if state.is_completed(executor._node_id):
await ctx.send_message({"decision": "parallel_seats", "agents": list(state.seat_agent_ids), "replayed": True})
return
assignments = {
agent_id: {
"role": f"Expert seat {agent_id}",
"instruction": f"Independently analyze the task. {seat_output_instruction()}",
}
for agent_id in state.seat_agent_ids
}
manager_id = state.manager_agent_id
thread_id = state.thread_map.get(manager_id or "", "")
if manager_id and thread_id:
try:
response = await asyncio.wait_for(
executor._adapter.run_seat_turn(
session_id=state.session_id,
run_id=state.run_id,
agent_id=manager_id,
turn_index=0,
execution_stage="planner",
message=state.task_text,
thread_id=thread_id,
model_name=state.model_name,
reasoning_effort=state.reasoning_effort,
task_text=state.task_text,
stage_instruction="Produce a concise assignment for each configured expert seat.",
fact_ledger=state.fact_ledger,
capability_snapshot=state.capability_snapshot,
write_policy=state.write_policy,
wait_timeout_seconds=state.seat_timeout_seconds,
),
timeout=state.seat_timeout_seconds,
)
plan_text = (response.content or "").strip()
if plan_text:
for agent_id in state.seat_agent_ids:
assignments[agent_id]["instruction"] = f"{plan_text[:1500]}\n\nSeat id: {agent_id}.\n{seat_output_instruction()}"
state.plan = {"source": "manager", "text": plan_text, "assignments": assignments}
else:
state.plan = {"source": "deterministic", "assignments": assignments}
except Exception as exc: # noqa: BLE001 - legacy path deliberately degrades
logger.warning("legacy planner failed (%s); using deterministic plan", type(exc).__name__)
state.plan = {"source": "deterministic", "assignments": assignments}
else:
state.plan = {"source": "deterministic", "assignments": assignments}
state.mark_completed(executor._node_id)
state.stage = "experts"
await ctx.send_message({"decision": "parallel_seats", "agents": list(state.seat_agent_ids)})
class PlannerExecutor(ManagerDispatchExecutor):
"""Compatibility node retained solely for pre-V2 MAF roundtable runs."""
def __init__(self, adapter: Any, state: RoundtableState) -> None:
super().__init__(adapter, state, executor_id="planner")
class DispatchRouteExecutor(Executor):
"""Route only from the verified, persisted manager dispatch decision."""
def __init__(self, state: RoundtableState) -> None:
super().__init__(id="dispatch_route")
self._state = state
@handler(input=Any, output=dict)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
plan = self._state.dispatch_plan or {}
if self._state.intent_type == "clarification" or not plan:
decision = "clarification"
elif plan.get("execution_mode") == "manager_only":
decision = "manager_only"
elif plan.get("mode") == "group_chat" or plan.get("execution_mode") == "group_chat":
decision = "group_chat"
else:
self._state.plan = {"source": "deterministic", "assignments": assignments}
decision = "selected_seats"
await ctx.send_message({"decision": decision, "agents": list(self._state.selected_seat_agent_ids or [])})
self._state.mark_completed("planner")
class DispatchSeatEntryExecutor(Executor):
"""Start seat work only after the manager's plan and threads are durable."""
def __init__(self, state: RoundtableState) -> None:
super().__init__(id="dispatch_seat_entry")
self._state = state
@handler(input=Any, output=dict)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
self._state.stage = "experts"
await ctx.send_message({"decision": "parallel_seats", "agents": self._state.seat_agent_ids})
await ctx.send_message({"agents": list(self._state.selected_seat_agent_ids or [])})
# ── Coordinator (group_chat) ────────────────────────────────────────────────
class ClarificationTerminalExecutor(Executor):
"""Safe terminal branch for invalid, unauthorized, or timed-out planning."""
def __init__(self, state: RoundtableState) -> None:
super().__init__(id="clarification_terminal")
self._state = state
@handler(input=Any, workflow_output=dict)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
self._state.status = "waiting_input"
self._state.stage = "waiting_input"
self._state.mark_completed("clarification_terminal")
await ctx.yield_output({"status": "waiting_input", "clarification_prompt": (self._state.dispatch_plan or {}).get("clarification_prompt")})
class DispatchPreparationTerminalExecutor(Executor):
"""End the manager-only preparation graph before topology is selected."""
def __init__(self, state: RoundtableState) -> None:
super().__init__(id="dispatch_preparation_terminal")
self._state = state
@handler(input=Any, workflow_output=dict)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
self._state.mark_completed("dispatch_preparation_terminal")
await ctx.yield_output({
"decision": "clarification" if self._state.intent_type == "clarification" else "dispatched",
"status": self._state.status,
})
class CoordinatorExecutor(Executor):
"""Decides which seat speaks next, whether to continue or finish (group_chat)."""
"""Choose the next selected group-chat speaker without widening the plan."""
def __init__(self, adapter: Any, state: RoundtableState) -> None:
super().__init__(id="coordinator")
@@ -282,41 +521,302 @@ class CoordinatorExecutor(Executor):
@handler(input=Any, output=dict)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
self._state.current_round += 1
if self._state.stage == "dispatch_validated" and self._state.current_round == 0:
self._state.current_round = 1
else:
self._state.current_round += 1
if self._state.current_round > self._state.max_rounds:
await ctx.send_message({"decision": "done", "reason": "max_rounds_reached"})
return
if self._state.mode == "structured":
# Structured mode no longer uses the coordinator; keep a safe fallback.
if self._state.current_round == 1:
await ctx.send_message({"decision": "parallel_seats", "agents": self._state.seat_agent_ids})
return
await ctx.send_message({"decision": "done", "reason": "structured_complete"})
return
eligible = list(self._state.selected_seat_agent_ids) if self._state.selected_seat_agent_ids is not None else list(self._state.seat_agent_ids)
remaining = [
a for a in self._state.seat_agent_ids
if not any(
r.get("agent_id") == a and r.get("turn_index", 0) >= self._state.current_round - 1
for r in self._state.turn_results
)
agent_id for agent_id in eligible
if not any(row.get("agent_id") == agent_id and row.get("turn_index", 0) >= self._state.current_round - 1 for row in self._state.turn_results)
]
if not remaining:
await ctx.send_message({"decision": "done", "reason": "all_seats_spoke"})
await ctx.send_message({"decision": "done", "reason": "all_selected_seats_spoke"})
return
next_agent = remaining[(self._state.current_round - 1) % len(remaining)]
await self._adapter._emit_critical(self._state.run_id, {
"event_type": "manager.decision",
"agent_id": self._state.manager_agent_id or "coordinator",
"turn_index": self._state.current_round,
"payload": {"next_speaker": next_agent, "reason": "round_robin", "round": self._state.current_round},
"payload": {"next_speaker": next_agent, "reason": "selected_plan_round_robin", "round": self._state.current_round},
})
await ctx.send_message({"decision": "next_speaker", "agent_id": next_agent})
async def _persist_dispatch_checkpoint(adapter: Any, state: RoundtableState) -> None:
store = getattr(getattr(adapter, "_service", None), "store", None)
if store is not None:
await store.runs.update_run(state.run_id, {"maf_checkpoint": state.to_dict()})
async def _emit_dispatch_event(
adapter: Any,
state: RoundtableState,
*,
clarification_prompt: str | None = None,
) -> None:
plan = state.dispatch_plan or {}
payload: dict[str, Any] = {
"intent_type": state.intent_type,
"execution_mode": plan.get("execution_mode", "manager_only"),
"selected_seat_agent_ids": list(state.selected_seat_agent_ids or []),
"assignments": plan.get("assignments", []),
"convergence_path": plan.get("convergence_path", []),
"reason": plan.get("reason", "manager_plan_invalid_safe_clarification"),
"source": plan.get("source", "deterministic_fallback"),
}
if clarification_prompt:
payload["clarification_prompt"] = clarification_prompt
await adapter._emit_critical(state.run_id, {
"event_type": "manager.dispatch",
"agent_id": state.manager_agent_id or "",
"turn_index": 0,
"payload": payload,
})
async def _ensure_selected_seat_threads(adapter: Any, state: RoundtableState) -> None:
"""Persist and bind threads only for server-validated selected seats."""
selected = list(state.selected_seat_agent_ids or [])
if not selected:
return
store = getattr(getattr(adapter, "_service", None), "store", None)
participants_repo = getattr(store, "participants", None)
if participants_repo is None:
return
route_mode = state.mode if state.mode in {"structured", "group_chat"} else "structured"
candidate_set = MafDispatchCandidateSet(
manager_agent_id=str(state.manager_agent_id or ""),
candidate_seat_agent_ids=list(state.candidate_seat_agent_ids),
mode=route_mode,
max_rounds=state.max_rounds,
)
candidate_rows = MafDispatchCatalog.candidate_participants(
candidate_set,
capability_snapshot=state.capability_snapshot,
)
selected_rows = [row for row in candidate_rows if row.get("agent_id") in selected]
await participants_repo.ensure_participants(state.session_id, selected_rows)
persisted = await participants_repo.list_participants(state.session_id)
selected_persisted = [row for row in persisted if row.get("agent_id") in selected]
if selected_persisted:
state.thread_map.update(
await adapter.ensure_threads(state.session_id, state.run_id, selected_persisted)
)
async def _resolve_manager_dispatch(
executor: ManagerDispatchExecutor, ctx: WorkflowContext
) -> None:
"""Run, validate, persist, and route the sole V2 manager dispatch turn."""
adapter = executor._adapter
state = executor._state
manager_id = str(state.manager_agent_id or "").strip()
route_mode = state.mode if state.mode in {"structured", "group_chat"} else "structured"
candidate_set = MafDispatchCandidateSet(
manager_agent_id=manager_id,
candidate_seat_agent_ids=list(state.candidate_seat_agent_ids),
mode=route_mode,
max_rounds=state.max_rounds,
)
async def clarification(reason: str) -> None:
prompt = "Please clarify the goal, expected outcome, and relevant business context."
state.intent_type = "clarification"
state.dispatch_plan = {
"execution_mode": "manager_only",
"convergence_path": ["clarification"],
"reason": reason,
"source": "deterministic_fallback",
"clarification_prompt": prompt,
}
state.selected_seat_agent_ids = []
state.plan = None
state.status = "waiting_input"
state.stage = "waiting_input"
state.mark_completed(executor._node_id)
await _persist_dispatch_checkpoint(adapter, state)
await _emit_dispatch_event(adapter, state, clarification_prompt=prompt)
await adapter._emit_critical(state.run_id, {
"event_type": "clarification.required",
"agent_id": manager_id,
"turn_index": 0,
"payload": {"prompt": prompt, "reason": reason},
})
await ctx.send_message({"decision": "clarification", "agents": []})
# A checkpoint written after validation survives process recovery and is
# never re-planned. It still creates any selected threads that were not
# bound before interruption.
if state.dispatch_plan and state.intent_type:
try:
existing = MafDispatchPlan.model_validate(state.dispatch_plan)
except (TypeError, ValueError):
await clarification("checkpoint_dispatch_plan_invalid")
return
state.selected_seat_agent_ids = list(existing.seat_agent_ids)
state.plan = {
"source": existing.source,
"assignments": {
assignment.agent_id: {
"role": assignment.role,
"instruction": "\n".join(
[assignment.task_goal, *assignment.delivery_requirements]
),
}
for assignment in existing.assignments
},
}
await _ensure_selected_seat_threads(adapter, state)
state.mark_completed(executor._node_id)
await _persist_dispatch_checkpoint(adapter, state)
await ctx.send_message({
"decision": executor._dispatch_decision(),
"agents": list(state.selected_seat_agent_ids or []),
"replayed": True,
})
return
thread_id = state.thread_map.get(manager_id, "")
if not manager_id or not thread_id:
await clarification("manager_thread_unavailable")
return
capability_summary = MafDispatchCatalog.manager_capability_summary(
state.capability_snapshot,
candidate_set,
)
manager_tool_names = [
str(tool.get("tool_name"))
for agent in (state.capability_snapshot or {}).get("agents", [])
if isinstance(agent, dict) and agent.get("agent_id") == manager_id
for tool in (agent.get("tools") or [])
if isinstance(tool, dict) and tool.get("tool_name")
]
contract = {
"intent_type": "direct_answer|clarification|single_seat|parallel_consultation|chain_consultation|tool_write",
"selected_seat_agent_ids": "candidate IDs only",
"assignments": "one ordered task_goal and delivery_requirements entry per selected seat",
"execution_mode": "manager_only|single|parallel|chain|group_chat",
"convergence_path": ["how work converges"],
"reason": "brief explanation",
"clarification_prompt": "required only for clarification",
}
prompt = (
"You are the MAF manager. Determine the user intent and return exactly one JSON object, "
"without Markdown. You may select only from the trusted candidate seats and their redacted "
"capabilities. Invalid or uncertain requests must use clarification; never invent a seat, "
"tool, capability, or route.\n\n"
f"User task:\n{state.task_text}\n\n"
f"Trusted route mode: {candidate_set.mode}\n"
f"Trusted candidate seats: {json.dumps(candidate_set.candidate_seat_agent_ids)}\n"
f"Trusted capability summary: {json.dumps(capability_summary, ensure_ascii=False)}\n"
f"Required JSON shape: {json.dumps(contract)}"
)
try:
response = await asyncio.wait_for(
adapter.run_seat_turn(
session_id=state.session_id,
run_id=state.run_id,
agent_id=manager_id,
turn_index=0,
execution_stage="manager_dispatch",
message=prompt,
thread_id=thread_id,
model_name=state.model_name,
reasoning_effort=state.reasoning_effort,
task_text=state.task_text,
stage_instruction="Return only the strict MAF dispatch JSON object.",
excluded_tools=manager_tool_names,
fact_ledger=state.fact_ledger,
capability_snapshot=state.capability_snapshot,
write_policy=state.write_policy,
wait_timeout_seconds=state.seat_timeout_seconds,
),
timeout=state.seat_timeout_seconds,
)
except TimeoutError:
await clarification("manager_dispatch_timeout")
return
except Exception: # noqa: BLE001 - model/runtime details must not reach the user
await clarification("manager_dispatch_failed")
return
if response.error:
await clarification("manager_dispatch_failed")
return
try:
proposed, plan = MafDispatchCatalog.parse_manager_decision(
response.content or "",
candidate_set=candidate_set,
max_seats=len(candidate_set.candidate_seat_agent_ids),
capability_snapshot=state.capability_snapshot,
)
except InvalidDispatchPlanError:
await clarification("manager_dispatch_invalid")
return
state.intent_type = proposed.intent_type
if plan is None:
state.dispatch_plan = {
"execution_mode": "manager_only",
"convergence_path": list(proposed.convergence_path),
"reason": proposed.reason,
"source": "manager",
"clarification_prompt": proposed.clarification_prompt,
}
state.selected_seat_agent_ids = []
state.plan = None
state.status = "waiting_input"
state.stage = "waiting_input"
state.mark_completed(executor._node_id)
await _persist_dispatch_checkpoint(adapter, state)
await _emit_dispatch_event(
adapter,
state,
clarification_prompt=proposed.clarification_prompt,
)
await adapter._emit_critical(state.run_id, {
"event_type": "clarification.required",
"agent_id": manager_id,
"turn_index": 0,
"payload": {"prompt": proposed.clarification_prompt, "reason": proposed.reason},
})
await ctx.send_message({"decision": "clarification", "agents": []})
return
state.dispatch_plan = plan.model_dump(mode="json")
state.selected_seat_agent_ids = list(plan.seat_agent_ids)
state.plan = {
"source": plan.source,
"assignments": {
assignment.agent_id: {
"role": assignment.role,
"instruction": "\n".join(
[assignment.task_goal, *assignment.delivery_requirements]
),
}
for assignment in plan.assignments
},
}
state.stage = "dispatch_validated"
# Persist before adding a participant or thread: recovery cannot obtain a
# different model decision after a process interruption.
await _persist_dispatch_checkpoint(adapter, state)
await _ensure_selected_seat_threads(adapter, state)
state.mark_completed(executor._node_id)
await _persist_dispatch_checkpoint(adapter, state)
await _emit_dispatch_event(adapter, state)
await ctx.send_message({
"decision": executor._dispatch_decision(),
"agents": list(state.selected_seat_agent_ids),
"execution_mode": plan.execution_mode,
})
# ── Seat executor ───────────────────────────────────────────────────────────
@@ -349,6 +849,17 @@ class SeatExecutor(Executor):
await ctx.send_message({"agent_id": self._agent_id, **prior, "replayed": True})
return
selected = self._state.selected_seat_agent_ids
if selected is not None and self._agent_id not in selected:
self._state.seat_outcomes[self._agent_id] = {"status": "skipped", "reason": "not_selected"}
self._state.mark_completed(node_id)
await ctx.send_message({
"agent_id": self._agent_id,
"status": "skipped",
"reason": "not_selected",
})
return
thread_id = self._state.thread_map.get(self._agent_id, "")
if not thread_id:
self._record_failure("no_thread", f"no thread for agent {self._agent_id}")
@@ -621,7 +1132,8 @@ class CrossReviewExecutor(Executor):
self._state.stage = "review"
# Barrier: ensure every seat has an outcome (fan-in should guarantee this).
missing = [a for a in self._state.seat_agent_ids if a not in self._state.seat_outcomes]
review_seats = self._state.selected_seat_agent_ids or self._state.seat_agent_ids
missing = [a for a in review_seats if a not in self._state.seat_outcomes]
for aid in missing:
self._state.seat_outcomes[aid] = {"status": "missing", "error": "no_outcome"}
self._state.partial_failure = True
@@ -654,7 +1166,7 @@ class CrossReviewExecutor(Executor):
try:
prompt = build_manager_synthesis_input(
task_text=self._state.task_text,
seat_results=[r for r in self._state.turn_results if r.get("agent_id") in self._state.seat_agent_ids],
seat_results=[r for r in self._state.turn_results if r.get("agent_id") in review_seats],
fact_ledger=self._state.fact_ledger,
)
# Surface the candidate ids + the evidence refs the ledger has
@@ -23,6 +23,24 @@ from typing import Any
logger = logging.getLogger(__name__)
def build_manager_dispatch_workflow(*, manager_dispatch: Any, terminal: Any) -> Any:
"""Run the constrained manager decision before building a work topology.
The resulting checkpoint is the sole input to the second workflow. This
prevents a route's configured default topology from pre-empting a manager
decision such as manager-only, chain, or group chat.
"""
from agent_framework import WorkflowBuilder
builder = WorkflowBuilder(
start_executor=manager_dispatch,
name="maf-manager-dispatch",
output_from=[terminal],
)
builder.add_edge(manager_dispatch, terminal)
return builder.build()
def build_structured_workflow(
*,
planner: Any,
@@ -31,6 +49,12 @@ def build_structured_workflow(
approval_gate: Any,
finalizer: Any,
waiting_terminal: Any,
execution_mode: str = "parallel",
manager_dispatch: Any | None = None,
dispatch_router: Any | None = None,
dispatch_seat_entry: Any | None = None,
manager_answer: Any | None = None,
clarification_terminal: Any | None = None,
) -> Any:
"""Build a structured (fixed-topology) MAF Workflow.
@@ -39,20 +63,53 @@ def build_structured_workflow(
"""
from agent_framework import Case, Default, WorkflowBuilder
start = manager_dispatch or planner
if start is None:
raise ValueError("structured mode requires manager_dispatch or planner")
outputs = [finalizer, waiting_terminal]
if manager_answer is not None:
outputs.append(manager_answer)
if clarification_terminal is not None:
outputs.append(clarification_terminal)
builder = WorkflowBuilder(
start_executor=planner,
start_executor=start,
name="maf-structured-roundtable",
output_from=[finalizer, waiting_terminal],
output_from=outputs,
)
if len(seat_executors) >= 2:
builder.add_fan_out_edges(planner, seat_executors)
seat_start = planner
if manager_dispatch is not None:
if dispatch_router is None or dispatch_seat_entry is None:
raise ValueError("manager_dispatch requires structured dispatch route executors")
builder.add_edge(manager_dispatch, dispatch_router)
cases = []
if manager_answer is not None:
cases.append(Case(
condition=lambda out: isinstance(out, dict) and out.get("decision") == "manager_only",
target=manager_answer,
))
if clarification_terminal is not None:
cases.append(Case(
condition=lambda out: isinstance(out, dict) and out.get("decision") == "clarification",
target=clarification_terminal,
))
cases.append(Default(target=dispatch_seat_entry))
builder.add_switch_case_edge_group(dispatch_router, cases)
seat_start = dispatch_seat_entry
if execution_mode == "chain" and seat_executors:
builder.add_edge(seat_start, seat_executors[0])
for previous, current in zip(seat_executors, seat_executors[1:]):
builder.add_edge(previous, current)
builder.add_edge(seat_executors[-1], cross_review)
elif len(seat_executors) >= 2:
builder.add_fan_out_edges(seat_start, seat_executors)
builder.add_fan_in_edges(seat_executors, cross_review)
elif len(seat_executors) == 1:
builder.add_edge(planner, seat_executors[0])
builder.add_edge(seat_start, seat_executors[0])
builder.add_edge(seat_executors[0], cross_review)
else:
builder.add_edge(planner, cross_review)
builder.add_edge(seat_start, cross_review)
builder.add_edge(cross_review, approval_gate)
@@ -67,6 +124,41 @@ def build_structured_workflow(
return builder.build()
def build_manager_only_workflow(
*,
manager_answer: Any,
manager_dispatch: Any | None = None,
dispatch_router: Any | None = None,
clarification_terminal: Any | None = None,
) -> Any:
"""Build the direct-answer topology with no expert seat nodes."""
from agent_framework import Case, Default, WorkflowBuilder
if manager_dispatch is None:
builder = WorkflowBuilder(
start_executor=manager_answer,
name="maf-manager-only",
output_from=[manager_answer],
)
return builder.build()
if dispatch_router is None or clarification_terminal is None:
raise ValueError("manager-only dispatch requires route and clarification executors")
builder = WorkflowBuilder(
start_executor=manager_dispatch,
name="maf-manager-only",
output_from=[manager_answer, clarification_terminal],
)
builder.add_edge(manager_dispatch, dispatch_router)
builder.add_switch_case_edge_group(dispatch_router, [
Case(
condition=lambda out: isinstance(out, dict) and out.get("decision") == "manager_only",
target=manager_answer,
),
Default(target=clarification_terminal),
])
return builder.build()
def build_group_chat_workflow(
*,
coordinator: Any,
@@ -75,6 +167,10 @@ def build_group_chat_workflow(
finalizer: Any,
waiting_terminal: Any,
seat_agent_ids: list[str],
manager_dispatch: Any | None = None,
dispatch_router: Any | None = None,
manager_answer: Any | None = None,
clarification_terminal: Any | None = None,
) -> Any:
"""Build a dynamic group-chat MAF Workflow.
@@ -83,12 +179,36 @@ def build_group_chat_workflow(
"""
from agent_framework import Case, Default, WorkflowBuilder
start = manager_dispatch or coordinator
outputs = [finalizer, waiting_terminal]
if manager_answer is not None:
outputs.append(manager_answer)
if clarification_terminal is not None:
outputs.append(clarification_terminal)
builder = WorkflowBuilder(
start_executor=coordinator,
start_executor=start,
name="maf-groupchat-roundtable",
output_from=[finalizer, waiting_terminal],
output_from=outputs,
)
if manager_dispatch is not None:
if dispatch_router is None:
raise ValueError("group_chat manager_dispatch requires a route executor")
builder.add_edge(manager_dispatch, dispatch_router)
cases = []
if manager_answer is not None:
cases.append(Case(
condition=lambda out: isinstance(out, dict) and out.get("decision") == "manager_only",
target=manager_answer,
))
if clarification_terminal is not None:
cases.append(Case(
condition=lambda out: isinstance(out, dict) and out.get("decision") == "clarification",
target=clarification_terminal,
))
cases.append(Default(target=coordinator))
builder.add_switch_case_edge_group(dispatch_router, cases)
cases = []
for i, agent_id in enumerate(seat_agent_ids):
cases.append(Case(
@@ -123,8 +243,23 @@ def create_workflow(
coordinator: Any | None = None,
planner: Any | None = None,
cross_review: Any | None = None,
manager_answer: Any | None = None,
execution_mode: str = "parallel",
manager_dispatch: Any | None = None,
dispatch_router: Any | None = None,
dispatch_seat_entry: Any | None = None,
clarification_terminal: Any | None = None,
) -> Any:
"""Factory: create the appropriate workflow for the given mode."""
if mode == "manager_only":
if manager_answer is None:
raise ValueError("manager_only mode requires manager_answer")
return build_manager_only_workflow(
manager_answer=manager_answer,
manager_dispatch=manager_dispatch,
dispatch_router=dispatch_router,
clarification_terminal=clarification_terminal,
)
if mode == "group_chat":
if coordinator is None:
raise ValueError("group_chat mode requires coordinator")
@@ -135,6 +270,10 @@ def create_workflow(
finalizer=finalizer,
waiting_terminal=waiting_terminal,
seat_agent_ids=seat_agent_ids,
manager_dispatch=manager_dispatch,
dispatch_router=dispatch_router,
manager_answer=manager_answer,
clarification_terminal=clarification_terminal,
)
if planner is None or cross_review is None:
raise ValueError("structured mode requires planner and cross_review")
@@ -145,4 +284,10 @@ def create_workflow(
approval_gate=approval_gate,
finalizer=finalizer,
waiting_terminal=waiting_terminal,
execution_mode=execution_mode,
manager_dispatch=manager_dispatch,
dispatch_router=dispatch_router,
dispatch_seat_entry=dispatch_seat_entry,
manager_answer=manager_answer,
clarification_terminal=clarification_terminal,
)
@@ -30,7 +30,11 @@ from app.maf_roundtable.workflow_executors import (
ApprovalGateExecutor,
CoordinatorExecutor,
CrossReviewExecutor,
DispatchPreparationTerminalExecutor,
DispatchSeatEntryExecutor,
FinalizerExecutor,
ManagerAnswerExecutor,
ManagerDispatchExecutor,
PlannerExecutor,
RoundtableState,
SeatExecutor,
@@ -38,7 +42,7 @@ from app.maf_roundtable.workflow_executors import (
_agent_capabilities_from_snapshot,
generate_final_report,
)
from app.maf_roundtable.workflow_factory import create_workflow
from app.maf_roundtable.workflow_factory import build_manager_dispatch_workflow, create_workflow
logger = logging.getLogger(__name__)
@@ -93,12 +97,26 @@ class WorkflowRunner:
participants = await self._service.store.participants.list_participants(session_id)
thread_map = await self._adapter.ensure_threads(session_id, run_id, participants)
seat_agents = [p["agent_id"] for p in participants if p.get("role") != "manager"]
persisted_seat_agents = [
p["agent_id"] for p in participants if p.get("role") != "manager"
]
manager_id = session.get("manager_agent_id")
seat_timeout, schema_repair = self._orchestration_settings()
task_payload = session.get("task_payload") or {}
maf_v2_payload = task_payload.get("maf_v2") if isinstance(task_payload, dict) else {}
maf_v2_payload = maf_v2_payload if isinstance(maf_v2_payload, dict) else {}
candidate_set = maf_v2_payload.get("candidate_set")
candidate_set = candidate_set if isinstance(candidate_set, dict) else {}
candidate_seat_agents = list(
candidate_set.get("candidate_seat_agent_ids")
or maf_v2_payload.get("candidate_seat_agent_ids")
or (maf_v2_payload.get("dispatch_plan") or {}).get("candidate_seat_agent_ids")
or persisted_seat_agents
)
if not manager_id:
manager_id = candidate_set.get("manager_agent_id") or maf_v2_payload.get(
"manager_agent_id"
)
state = RoundtableState(
session_id=session_id,
@@ -110,17 +128,29 @@ class WorkflowRunner:
max_rounds=session.get("max_rounds", 8),
write_policy=session.get("write_policy", "confirm_high_risk"),
manager_agent_id=manager_id,
seat_agent_ids=seat_agents,
seat_agent_ids=candidate_seat_agents,
thread_map=thread_map,
capability_snapshot=session.get("capability_snapshot"),
seat_timeout_seconds=seat_timeout,
schema_repair_enabled=schema_repair,
intent_type=maf_v2_payload.get("intent_type"),
dispatch_plan=maf_v2_payload.get("dispatch_plan"),
candidate_seat_agent_ids=candidate_seat_agents,
dispatch_contract_enabled=bool(
maf_v2_payload.get("dispatch_contract_enabled")
or candidate_set
or maf_v2_payload.get("dispatch_plan") is not None
),
)
workflow = self._build_workflow(state)
await self._run_workflow(workflow, state)
if state.dispatch_contract_enabled:
await self._run_manager_dispatch(state)
if state.status != "waiting_input":
workflow = self._build_workflow(state)
await self._run_workflow(workflow, state)
else:
workflow = self._build_workflow(state)
await self._run_workflow(workflow, state)
# Persist final state. waiting_approval is NOT a terminal state —
# it must NOT write finished_at. Only completed/failed/cancelled do.
@@ -166,14 +196,106 @@ class WorkflowRunner:
except Exception: # noqa: BLE001
return 600.0, True
async def _run_manager_dispatch(self, state: RoundtableState) -> None:
"""Freeze the V2 manager decision before constructing seat topology."""
if state.is_completed("manager_dispatch"):
return
workflow = build_manager_dispatch_workflow(
manager_dispatch=ManagerDispatchExecutor(self._adapter, state),
terminal=DispatchPreparationTerminalExecutor(state),
)
await self._run_workflow(workflow, state)
def _build_workflow(self, state: RoundtableState) -> Any:
approval_gate = ApprovalGateExecutor(self._adapter, state, self._service)
finalizer = FinalizerExecutor(self._adapter, state)
waiting_terminal = WaitingTerminalExecutor()
# New V2 conversations always start at the same constrained manager
# dispatch node. The manager preparation workflow finishes before this
# method is entered, so topology is selected from the persisted plan,
# never from a route default or a model-supplied agent id.
if state.dispatch_contract_enabled:
manager_answer = ManagerAnswerExecutor(self._adapter, state)
plan = state.dispatch_plan or {}
execution_mode = str(plan.get("execution_mode") or "")
selected_seat_ids = list(state.selected_seat_agent_ids or [])
if state.intent_type == "clarification" or not execution_mode:
raise RuntimeError("V2 manager dispatch did not produce a valid execution plan")
if execution_mode == "manager_only":
return create_workflow(
mode="manager_only",
manager_answer=manager_answer,
seat_executors=[],
approval_gate=approval_gate,
finalizer=finalizer,
waiting_terminal=waiting_terminal,
seat_agent_ids=[],
)
if state.mode == "group_chat":
coordinator = CoordinatorExecutor(self._adapter, state)
return create_workflow(
mode="group_chat",
coordinator=coordinator,
seat_executors=[
SeatExecutor(
self._adapter,
state,
agent_id,
action_coordinator=self._action_coordinator,
)
for agent_id in selected_seat_ids
],
approval_gate=approval_gate,
finalizer=finalizer,
waiting_terminal=waiting_terminal,
seat_agent_ids=selected_seat_ids,
)
state.current_round = max(state.current_round, 1)
cross_review = CrossReviewExecutor(self._adapter, state)
return create_workflow(
mode="structured",
planner=DispatchSeatEntryExecutor(state),
cross_review=cross_review,
seat_executors=[
SeatExecutor(
self._adapter,
state,
agent_id,
action_coordinator=self._action_coordinator,
)
for agent_id in selected_seat_ids
],
approval_gate=approval_gate,
finalizer=finalizer,
waiting_terminal=waiting_terminal,
seat_agent_ids=selected_seat_ids,
execution_mode=execution_mode,
)
seat_executors = [
SeatExecutor(self._adapter, state, agent_id, action_coordinator=self._action_coordinator)
for agent_id in state.seat_agent_ids
]
approval_gate = ApprovalGateExecutor(self._adapter, state, self._service)
finalizer = FinalizerExecutor(self._adapter, state)
waiting_terminal = WaitingTerminalExecutor()
execution_mode = str(
(state.dispatch_plan or {}).get("execution_mode")
or ("manager_only" if state.intent_type == "direct_answer" else "parallel")
)
if execution_mode == "manager_only":
manager_answer = ManagerAnswerExecutor(self._adapter, state)
return create_workflow(
mode="manager_only",
manager_answer=manager_answer,
seat_executors=[],
approval_gate=approval_gate,
finalizer=finalizer,
waiting_terminal=waiting_terminal,
seat_agent_ids=[],
)
if state.mode == "group_chat":
coordinator = CoordinatorExecutor(self._adapter, state)
@@ -198,6 +320,7 @@ class WorkflowRunner:
finalizer=finalizer,
waiting_terminal=waiting_terminal,
seat_agent_ids=state.seat_agent_ids,
execution_mode=execution_mode,
)
async def _persist_checkpoint(self, state: RoundtableState, *, reason: str = "") -> None:
@@ -663,8 +786,14 @@ class WorkflowRunner:
await self._service.store.runs.update_run(run_id, {"status": "running"})
try:
workflow = self._build_workflow(state)
await self._run_workflow(workflow, state)
if state.dispatch_contract_enabled:
await self._run_manager_dispatch(state)
if state.status != "waiting_input":
workflow = self._build_workflow(state)
await self._run_workflow(workflow, state)
else:
workflow = self._build_workflow(state)
await self._run_workflow(workflow, state)
latency = int((time.perf_counter() - t0) * 1000)
final_status = state.status
+3 -1
View File
@@ -558,7 +558,9 @@ task_deeplink:
# - 前端入口隐藏
# 详见 frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md
maf_roundtable:
enabled: true # 启用独立 MAF 会商页面;不影响旧 roundtable 功能
# Safe repository default: deployment must inject real manager/seat ids and
# business routes before enabling the new MAF conversation entry.
enabled: false # 部署注入真实路由与能力后才可开启
default_mode: structured # structured(固定流程)| group_chat(动态群聊)
default_write_policy: confirm_high_risk # auto | confirm_high_risk | confirm_all_writes
default_max_rounds: 8 # 群聊模式轮次硬上限
@@ -20,7 +20,7 @@ from __future__ import annotations
import re
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
# ── shared enums (config + API schemas) ───────────────────────────────────
@@ -108,10 +108,25 @@ class MafBusinessRoute(BaseModel):
value = str(agent_id).strip()
if not value or len(value) > 64:
raise ValueError("seat_agent_ids must contain non-empty agent ids")
if value not in normalized:
normalized.append(value)
if value in normalized:
raise ValueError("seat_agent_ids must not contain duplicates")
normalized.append(value)
return normalized
@field_validator("manager_agent_id", mode="before")
@classmethod
def _normalize_manager_agent_id(cls, value: object) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("manager_agent_id must be non-empty")
return normalized
@model_validator(mode="after")
def _validate_manager_not_seat(self) -> MafBusinessRoute:
if self.manager_agent_id in self.seat_agent_ids:
raise ValueError("manager_agent_id must not appear in seat_agent_ids")
return self
class MafRoundtableConfig(BaseModel):
"""Microsoft Agent Framework 多智能体会商实验功能开关。
@@ -191,6 +206,73 @@ class MafRoundtableConfig(BaseModel):
le=7200,
description="外部业务目标写入租约有效期(秒);过期运行中的动作转 unknown。",
)
conversation_event_backend: Literal["memory"] = Field(
default="memory",
description="会商 SSE 事件广播后端;当前仅支持 memory(单 worker)。",
)
require_single_worker_for_conversation_sse: bool = Field(
default=True,
description="当 backend=memory 且检测到多 worker 时,禁止会商 SSE 写入入口。",
)
background_reconcile_interval_seconds: int = Field(
default=10,
ge=5,
le=60,
description="后台自动 reconcile 扫描间隔(秒);补偿投影失败的审计事件。",
)
@field_validator("default_manager_agent_id", mode="before")
@classmethod
def _normalize_default_manager_agent_id(cls, value: object) -> str | None:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
raise ValueError("default_manager_agent_id must be non-empty when provided")
return normalized
@field_validator("default_seat_agent_ids")
@classmethod
def _validate_default_seat_agent_ids(cls, ids: list[str]) -> list[str]:
normalized: list[str] = []
for agent_id in ids:
value = str(agent_id).strip()
if not value or len(value) > 64:
raise ValueError("default_seat_agent_ids must contain non-empty agent ids")
if value in normalized:
raise ValueError("default_seat_agent_ids must not contain duplicates")
normalized.append(value)
return normalized
@field_validator("business_routes")
@classmethod
def _validate_business_route_codes(
cls, routes: dict[str, MafBusinessRoute]
) -> dict[str, MafBusinessRoute]:
code_pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$")
normalized_routes: dict[str, MafBusinessRoute] = {}
for code, route in routes.items():
normalized_code = str(code).strip()
if not code_pattern.fullmatch(normalized_code):
raise ValueError(
"business route code must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$"
)
if normalized_code in normalized_routes:
raise ValueError("business route codes must be unique after trimming")
normalized_routes[normalized_code] = route
return normalized_routes
@model_validator(mode="after")
def _validate_routes(self) -> MafRoundtableConfig:
if self.default_manager_agent_id and self.default_manager_agent_id in self.default_seat_agent_ids:
raise ValueError("default manager_agent_id must not appear in default_seat_agent_ids")
if len(self.default_seat_agent_ids) > self.max_seats_per_run:
raise ValueError("default_seat_agent_ids exceeds max_seats_per_run")
for code, route in self.business_routes.items():
if len(route.seat_agent_ids) > self.max_seats_per_run:
raise ValueError(f"business route {code!r} exceeds max_seats_per_run")
return self
__all__ = [
@@ -99,6 +99,49 @@ class MafParticipantRepository:
await session.commit()
return [_to_dict(r) for r in new_rows]
async def ensure_participants(
self, session_id: str, participants: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Insert missing participants without replacing existing threads.
MAF Stage 3 initially persists only the manager participant. After
the manager validates its dispatch plan, selected seats are added here
so unselected seats never receive a thread or execution slot.
"""
async with self._sf() as session:
existing_rows = (
await session.execute(
select(MafRoundtableParticipantRow).where(
MafRoundtableParticipantRow.session_id == session_id
)
)
).scalars().all()
existing = {row.agent_id: row for row in existing_rows}
now = _now()
for participant in participants:
agent_id = str(participant.get("agent_id") or "").strip()
if not agent_id or agent_id in existing:
continue
row = MafRoundtableParticipantRow(
id=uuid4().hex,
session_id=session_id,
agent_id=agent_id,
display_name=str(participant.get("display_name") or ""),
role=str(participant.get("role") or "seat"),
sort_order=int(participant.get("sort_order", 0)),
thread_id=None,
sandbox_id=None,
status="idle",
last_turn_index=None,
capability_snapshot_json=participant.get("capability_snapshot"),
created_at=now,
updated_at=now,
)
session.add(row)
existing[agent_id] = row
await session.commit()
return [_to_dict(existing[agent_id]) for agent_id in existing]
async def update_participant_status(
self, session_id: str, agent_id: str, *, status: str, last_turn_index: int | None = None
) -> dict[str, Any] | None:
@@ -867,6 +867,77 @@ class MafRoundtableStore:
await db.flush()
return {"seq": next_seq, "message_id": message_id, "event_id": event_id}
async def append_conversation_events_idempotently(
self,
db: AsyncSession,
*,
conversation: MafRoundtableConversationRow,
items: list[dict[str, Any]],
) -> int:
"""Batch-atomic idempotent write of multiple conversation events.
MUST be called inside an existing transaction (``db.begin()``). The
caller is responsible for locking the conversation row.
Each item in ``items`` must have:
- ``event_type``: str
- ``event_data``: dict
- ``source_event_id``: str | None
Items with a ``source_event_id`` that already exists for this
conversation are silently skipped (dedup). Items without a
``source_event_id`` are always written.
All new items share one transaction: if any insert fails, the entire
batch rolls back. Returns the count of **newly inserted** events.
"""
if not items:
return 0
now = _now()
existing_sources: set[str] = set()
source_ids_to_check = [
item["source_event_id"]
for item in items
if item.get("source_event_id") is not None
]
if source_ids_to_check:
rows = (
await db.execute(
select(MafRoundtableConversationEventRow.source_event_id).where(
MafRoundtableConversationEventRow.conversation_id == conversation.id,
MafRoundtableConversationEventRow.source_event_id.in_(source_ids_to_check),
)
)
).scalars().all()
existing_sources = {str(r) for r in rows if r is not None}
written = 0
for item in items:
src_id = item.get("source_event_id")
if src_id is not None and src_id in existing_sources:
continue
next_seq = int(conversation.last_stream_seq or 0) + 1
conversation.last_stream_seq = next_seq
db.add(MafRoundtableConversationEventRow(
id=uuid4().hex,
conversation_id=conversation.id,
seq=next_seq,
run_id=item.get("run_id"),
source_event_id=src_id,
event_type=item["event_type"],
data_json=item.get("event_data"),
created_at=now,
))
existing_sources.add(src_id) if src_id else None
written += 1
if written:
await db.flush()
return written
# ── V2 conversation aggregation: atomic send-message + run launch ───────
async def send_message_and_start_run(
@@ -136,6 +136,56 @@ def _indexes_ddl(dialect: str) -> list[str]:
return stmts
def _events_table_has_source_event_id(inspector: Any) -> bool:
"""Check if the events table already has the source_event_id column."""
if EVENTS_TABLE not in inspector.get_table_names():
return False
cols = {c["name"] for c in inspector.get_columns(EVENTS_TABLE)}
return "source_event_id" in cols
def _events_table_has_source_unique(inspector: Any) -> bool:
"""Check if the events table already has the (conversation_id, source_event_id) unique constraint or index.
SQLite exposes ``CREATE UNIQUE INDEX`` via ``get_indexes()``, not
``get_unique_constraints()``. PostgreSQL and MySQL may use either path.
Both must be checked so the migration is truly idempotent on every
supported dialect.
"""
if EVENTS_TABLE not in inspector.get_table_names():
return False
target = {"conversation_id", "source_event_id"}
uq_cols = [set(u.get("column_names", [])) for u in inspector.get_unique_constraints(EVENTS_TABLE)]
if target in uq_cols:
return True
idx_cols = [
set(idx.get("column_names", []))
for idx in inspector.get_indexes(EVENTS_TABLE)
if idx.get("unique")
]
return target in idx_cols
def _alter_add_source_event_id_sql(dialect: str) -> str:
"""ALTER TABLE to add source_event_id column, portable across dialects."""
col_type = "VARCHAR(191)" if dialect in {"postgresql", "mysql", "mariadb"} else "VARCHAR(191)"
return f"ALTER TABLE {EVENTS_TABLE} ADD COLUMN source_event_id {col_type}"
def _create_source_unique_index_sql(dialect: str) -> str:
"""Create a unique index on (conversation_id, source_event_id).
Using CREATE UNIQUE INDEX instead of ALTER TABLE ADD CONSTRAINT because:
- SQLite only supports adding constraints via CREATE INDEX (no ALTER TABLE ADD CONSTRAINT)
- PostgreSQL and MySQL both accept CREATE UNIQUE INDEX
- Existing rows with NULL source_event_id are not deduped (SQL NULLs are distinct)
"""
return (
f"CREATE UNIQUE INDEX IF NOT EXISTS uq_maf_conv_events_conv_source "
f"ON {EVENTS_TABLE} (conversation_id, source_event_id)"
)
def validate_schema(inspector: Any) -> list[str]:
"""Return contract violations found by SQLAlchemy inspector."""
issues: list[str] = []
@@ -179,9 +229,17 @@ def validate_schema(inspector: Any) -> list[str]:
issues.append(f"{EVENTS_TABLE}: missing column {required}")
uqs = inspector.get_unique_constraints(EVENTS_TABLE)
uq_cols = [set(u.get("column_names", [])) for u in uqs]
if {"conversation_id", "seq"} not in uq_cols:
# Also check unique indexes — SQLite exposes CREATE UNIQUE INDEX via
# get_indexes(), not get_unique_constraints().
idx_cols = [
set(idx.get("column_names", []))
for idx in inspector.get_indexes(EVENTS_TABLE)
if idx.get("unique")
]
all_uq = uq_cols + idx_cols
if {"conversation_id", "seq"} not in all_uq:
issues.append(f"{EVENTS_TABLE}: missing UNIQUE(conversation_id, seq)")
if {"conversation_id", "source_event_id"} not in uq_cols:
if {"conversation_id", "source_event_id"} not in all_uq:
issues.append(f"{EVENTS_TABLE}: missing UNIQUE(conversation_id, source_event_id)")
return issues
@@ -306,6 +364,40 @@ async def run_migration(database_url: str, *, dry_run: bool = False) -> None:
else:
logger.info("Already exists: %s", EVENTS_TABLE)
# ── In-place upgrade for existing events table ──
# Old V2 deployments created the events table WITHOUT source_event_id.
# Detect and add the column + unique index so dedup works.
if EVENTS_TABLE in existing:
def _check_source_col(sync_conn: Any) -> bool:
return _events_table_has_source_event_id(inspect(sync_conn))
has_source_col = await conn.run_sync(_check_source_col)
if not has_source_col:
alter_sql = _alter_add_source_event_id_sql(dialect)
logger.info("Upgrading %s: adding source_event_id column", EVENTS_TABLE)
if dry_run:
logger.info("[DRY RUN] Would execute: %s", alter_sql)
else:
await conn.execute(text(alter_sql))
def _check_source_unique(sync_conn: Any) -> bool:
return _events_table_has_source_unique(inspect(sync_conn))
has_source_uq = await conn.run_sync(_check_source_unique)
if not has_source_uq:
uq_sql = _create_source_unique_index_sql(dialect)
logger.info("Upgrading %s: adding unique index (conversation_id, source_event_id)", EVENTS_TABLE)
if dry_run:
logger.info("[DRY RUN] Would execute: %s", uq_sql)
else:
try:
await conn.execute(text(uq_sql))
except Exception as exc:
if "already exists" in str(exc).lower():
pass
else:
raise
for idx_sql in _indexes_ddl(dialect):
ddls.append(idx_sql)
@@ -436,3 +436,150 @@ class TestAdapterEventFlow:
events, _ = await svc.list_events(run_id=run["id"], user_id="u1")
assert len(events) == 1
@pytest.mark.asyncio
async def test_emit_critical_passes_persisted_audit_id_to_bridge(self, store):
"""_emit_critical must capture the persisted audit row id and pass it
as source_audit_event_id to _bridge_conversation_event, so projection
dedup is keyed on the real audit row — not an ephemeral input field."""
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
bridge_calls: list[dict] = []
original_bridge = adapter._bridge_conversation_event
async def capture_bridge(run_id, event, *, source_audit_event_id=None):
bridge_calls.append({
"run_id": run_id,
"event_type": event.get("event_type"),
"source_audit_event_id": source_audit_event_id,
})
await original_bridge(run_id, event, source_audit_event_id=source_audit_event_id)
adapter._bridge_conversation_event = capture_bridge
critical_event = make_turn_started("expert", 1)
await adapter._emit_critical(run["id"], critical_event)
assert len(bridge_calls) == 1
assert bridge_calls[0]["source_audit_event_id"] is not None
assert isinstance(bridge_calls[0]["source_audit_event_id"], str)
assert len(bridge_calls[0]["source_audit_event_id"]) > 0
@pytest.mark.asyncio
async def test_emit_passes_persisted_audit_id_for_non_critical(self, store):
"""_emit (non-critical path) must also pass persisted audit row id."""
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
bridge_calls: list[dict] = []
original_bridge = adapter._bridge_conversation_event
async def capture_bridge(run_id, event, *, source_audit_event_id=None):
bridge_calls.append({
"source_audit_event_id": source_audit_event_id,
})
await original_bridge(run_id, event, source_audit_event_id=source_audit_event_id)
adapter._bridge_conversation_event = capture_bridge
ev = {
"event_type": "agent.message.delta",
"agent_id": "a1",
"turn_index": 0,
"payload": {"text": "hello"},
"source_event_id": "stream:evt_001",
}
await adapter._emit(run["id"], ev)
assert len(bridge_calls) == 1
assert bridge_calls[0]["source_audit_event_id"] is not None
@pytest.mark.asyncio
async def test_emit_critical_dedup_across_simulated_restart(self, store):
"""Simulate: emit critical event, then 'restart' and re-emit the same
event via reconcile. The conversation projection must be deduped via
the persisted audit row id — no duplicate conversation events."""
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
from app.maf_roundtable.conversation_service import MafConversationService
class FakeMafConfig:
enabled = True
default_mode = "structured"
default_model_name = "test"
default_reasoning_effort = None
default_manager_agent_id = "mgr"
default_max_rounds = 8
default_write_policy = "confirm_high_risk"
admin_diagnostic_access = True
hub = ConversationEventHub(heartbeat_interval=0.3)
conv_svc = MafConversationService(store, maf_config=FakeMafConfig(), event_hub=hub)
conv = await conv_svc.create_conversation(user_id="u1", title="RestartDedup")
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableConversationRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
svc = _svc(store)
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
sess_row = (
await db.execute(
select(MafRoundtableSessionRow).where(
MafRoundtableSessionRow.id == session["id"]
)
)
).scalars().first()
conv_row.internal_session_id = sess_row.id
run_row = (
await db.execute(
select(MafRoundtableRunRow).where(
MafRoundtableRunRow.id == run["id"]
)
)
).scalars().first()
assert run_row is not None
client = _mock_client()
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
critical_event = {
"event_type": "action.waiting_approval",
"agent_id": "seat_a",
"turn_index": 1,
"payload": {"tool_name": "write_file", "summary": "high risk"},
}
await adapter._emit_critical(run["id"], critical_event)
await conv_svc.reconcile_conversation_events(run_id=run["id"])
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
approval_events = [e for e in events if e["type"] == "approval.required"]
assert len(approval_events) == 1
hub.shutdown()
@@ -20,6 +20,7 @@ from scripts.migrate_maf_roundtable_conversation_v2 import (
MESSAGES_TABLE,
_conversations_ddl,
_events_ddl,
_events_table_has_source_unique,
_indexes_ddl,
_messages_ddl,
run_migration,
@@ -188,3 +189,362 @@ def test_sqlite_ddl_generation():
assert any("ix_maf_conv_owner_updated" in i for i in indexes)
assert any("ix_maf_msg_conv_seq" in i for i in indexes)
assert any("ix_maf_cevt_conv_seq" in i for i in indexes)
# ── In-place upgrade: existing events table missing source_event_id ──────
@pytest.mark.asyncio
async def test_upgrade_adds_source_event_id_to_existing_events_table(tmp_path):
"""Old V2 deployments created the events table without source_event_id.
Running the migration must ALTER TABLE ADD COLUMN + create unique index."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'upgrade.db'}"
# Phase 1: create tables with the OLD schema (no source_event_id).
engine_old = create_async_engine(db_url)
async with engine_old.begin() as conn:
await conn.execute(text(
"CREATE TABLE maf_roundtable_sessions ("
"id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, "
"title VARCHAR(255) NOT NULL DEFAULT '', task_text TEXT NOT NULL, "
"task_payload_json TEXT, mode VARCHAR(16) NOT NULL DEFAULT 'structured', "
"status VARCHAR(32) NOT NULL DEFAULT 'draft', model_name VARCHAR(128), "
"reasoning_effort VARCHAR(32), manager_agent_id VARCHAR(64), "
"max_rounds INTEGER NOT NULL DEFAULT 8, write_policy VARCHAR(32) NOT NULL DEFAULT 'confirm_high_risk', "
"capability_snapshot_json TEXT, fact_ledger_json TEXT, final_result_json TEXT, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
))
await conn.execute(text(
"CREATE TABLE maf_roundtable_runs ("
"id VARCHAR(64) PRIMARY KEY, session_id VARCHAR(64) NOT NULL, "
"status VARCHAR(32) NOT NULL DEFAULT 'pending', workflow_version VARCHAR(64), "
"maf_checkpoint_json TEXT, last_event_seq INTEGER NOT NULL DEFAULT 0, "
"started_at DATETIME, finished_at DATETIME, error_code VARCHAR(64), "
"error_message_safe TEXT, token_usage_json TEXT, latency_ms INTEGER, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, "
"FOREIGN KEY (session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE CASCADE)"
))
await conn.execute(text(
f"CREATE TABLE {CONVERSATIONS_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, owner_user_id VARCHAR(64) NOT NULL, "
"title VARCHAR(255) NOT NULL DEFAULT '', task_context_json TEXT, "
"safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', active_run_id VARCHAR(64), "
"internal_session_id VARCHAR(64), last_stream_seq INTEGER NOT NULL DEFAULT 0, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, "
"FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE SET NULL, "
"FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE SET NULL)"
))
await conn.execute(text(
f"CREATE TABLE {MESSAGES_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, "
"seq INTEGER NOT NULL, message_kind VARCHAR(32) NOT NULL DEFAULT 'user', "
"role VARCHAR(16) NOT NULL DEFAULT 'user', agent_id VARCHAR(64), run_id VARCHAR(64), "
"message_ref VARCHAR(191), content TEXT NOT NULL DEFAULT '', metadata_json TEXT, "
"created_at DATETIME NOT NULL, "
"CONSTRAINT uq_maf_messages_conv_seq UNIQUE (conversation_id, seq), "
f"FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE)"
))
# OLD events table: no source_event_id column
await conn.execute(text(
f"CREATE TABLE {EVENTS_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, "
"seq INTEGER NOT NULL, run_id VARCHAR(64), "
"event_type VARCHAR(64) NOT NULL, data_json TEXT, "
"created_at DATETIME NOT NULL, "
"CONSTRAINT uq_maf_conv_events_conv_seq UNIQUE (conversation_id, seq), "
f"FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE)"
))
# Insert some existing event data that must be preserved
await conn.execute(text(
f"INSERT INTO {CONVERSATIONS_TABLE} (id, owner_user_id, title, safe_status, last_stream_seq, created_at, updated_at) "
"VALUES ('c1', 'u1', 'OldData', 'idle', 1, '2026-01-01', '2026-01-01')"
))
await conn.execute(text(
f"INSERT INTO {EVENTS_TABLE} (id, conversation_id, seq, run_id, event_type, data_json, created_at) "
"VALUES ('e1', 'c1', 1, NULL, 'user.message.created', '{}', '2026-01-01')"
))
await engine_old.dispose()
# Phase 2: run migration — should ALTER TABLE + add unique index
await run_migration(db_url)
# Phase 3: verify the column and index exist, data preserved
engine_new = create_async_engine(db_url)
async with engine_new.begin() as conn:
def _check(sync_conn):
insp = inspect(sync_conn)
return validate_schema(insp), insp
issues, insp = await conn.run_sync(_check)
assert issues == [], f"Schema issues after upgrade: {issues}"
# Data preserved
row = (await conn.execute(text(f"SELECT title FROM {CONVERSATIONS_TABLE} WHERE id='c1'"))).first()
assert row is not None
assert row[0] == "OldData"
evt_row = (await conn.execute(text(f"SELECT event_type FROM {EVENTS_TABLE} WHERE id='e1'"))).first()
assert evt_row is not None
assert evt_row[0] == "user.message.created"
# source_event_id column exists and is NULL for old rows
src_row = (await conn.execute(text(f"SELECT source_event_id FROM {EVENTS_TABLE} WHERE id='e1'"))).first()
assert src_row is not None
assert src_row[0] is None
await engine_new.dispose()
@pytest.mark.asyncio
async def test_upgrade_idempotent_after_alter(tmp_path):
"""Running migration twice on an upgraded DB must not error."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'upgrade2.db'}"
engine_old = create_async_engine(db_url)
async with engine_old.begin() as conn:
await conn.execute(text(
"CREATE TABLE maf_roundtable_sessions ("
"id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, "
"title VARCHAR(255) NOT NULL DEFAULT '', task_text TEXT NOT NULL, "
"task_payload_json TEXT, mode VARCHAR(16) NOT NULL DEFAULT 'structured', "
"status VARCHAR(32) NOT NULL DEFAULT 'draft', model_name VARCHAR(128), "
"reasoning_effort VARCHAR(32), manager_agent_id VARCHAR(64), "
"max_rounds INTEGER NOT NULL DEFAULT 8, write_policy VARCHAR(32) NOT NULL DEFAULT 'confirm_high_risk', "
"capability_snapshot_json TEXT, fact_ledger_json TEXT, final_result_json TEXT, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
))
await conn.execute(text(
"CREATE TABLE maf_roundtable_runs ("
"id VARCHAR(64) PRIMARY KEY, session_id VARCHAR(64) NOT NULL, "
"status VARCHAR(32) NOT NULL DEFAULT 'pending', workflow_version VARCHAR(64), "
"maf_checkpoint_json TEXT, last_event_seq INTEGER NOT NULL DEFAULT 0, "
"started_at DATETIME, finished_at DATETIME, error_code VARCHAR(64), "
"error_message_safe TEXT, token_usage_json TEXT, latency_ms INTEGER, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, "
"FOREIGN KEY (session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE CASCADE)"
))
await conn.execute(text(
f"CREATE TABLE {CONVERSATIONS_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, owner_user_id VARCHAR(64) NOT NULL, "
"title VARCHAR(255) NOT NULL DEFAULT '', task_context_json TEXT, "
"safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', active_run_id VARCHAR(64), "
"internal_session_id VARCHAR(64), last_stream_seq INTEGER NOT NULL DEFAULT 0, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, "
"FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE SET NULL, "
"FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE SET NULL)"
))
await conn.execute(text(
f"CREATE TABLE {MESSAGES_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, "
"seq INTEGER NOT NULL, message_kind VARCHAR(32) NOT NULL DEFAULT 'user', "
"role VARCHAR(16) NOT NULL DEFAULT 'user', agent_id VARCHAR(64), run_id VARCHAR(64), "
"message_ref VARCHAR(191), content TEXT NOT NULL DEFAULT '', metadata_json TEXT, "
"created_at DATETIME NOT NULL, "
"CONSTRAINT uq_maf_messages_conv_seq UNIQUE (conversation_id, seq), "
f"FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE)"
))
await conn.execute(text(
f"CREATE TABLE {EVENTS_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, "
"seq INTEGER NOT NULL, run_id VARCHAR(64), "
"event_type VARCHAR(64) NOT NULL, data_json TEXT, "
"created_at DATETIME NOT NULL, "
"CONSTRAINT uq_maf_conv_events_conv_seq UNIQUE (conversation_id, seq), "
f"FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE)"
))
await engine_old.dispose()
await run_migration(db_url)
await run_migration(db_url) # Must not error
engine_check = create_async_engine(db_url)
async with engine_check.begin() as conn:
def _check(sync_conn):
return validate_schema(inspect(sync_conn))
assert await conn.run_sync(_check) == []
await engine_check.dispose()
@pytest.mark.asyncio
async def test_upgrade_preserves_existing_event_data_with_null_source(tmp_path):
"""Old V2 table with existing event data: upgrade preserves data,
source_event_id column is added with NULL for historical rows,
and the unique index is created."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'upgrade_data.db'}"
engine_old = create_async_engine(db_url)
async with engine_old.begin() as conn:
await conn.execute(text(
"CREATE TABLE maf_roundtable_sessions ("
"id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, "
"title VARCHAR(255) NOT NULL DEFAULT '', task_text TEXT NOT NULL, "
"task_payload_json TEXT, mode VARCHAR(16) NOT NULL DEFAULT 'structured', "
"status VARCHAR(32) NOT NULL DEFAULT 'draft', model_name VARCHAR(128), "
"reasoning_effort VARCHAR(32), manager_agent_id VARCHAR(64), "
"max_rounds INTEGER NOT NULL DEFAULT 8, write_policy VARCHAR(32) NOT NULL DEFAULT 'confirm_high_risk', "
"capability_snapshot_json TEXT, fact_ledger_json TEXT, final_result_json TEXT, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
))
await conn.execute(text(
"CREATE TABLE maf_roundtable_runs ("
"id VARCHAR(64) PRIMARY KEY, session_id VARCHAR(64) NOT NULL, "
"status VARCHAR(32) NOT NULL DEFAULT 'pending', workflow_version VARCHAR(64), "
"maf_checkpoint_json TEXT, last_event_seq INTEGER NOT NULL DEFAULT 0, "
"started_at DATETIME, finished_at DATETIME, error_code VARCHAR(64), "
"error_message_safe TEXT, token_usage_json TEXT, latency_ms INTEGER, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, "
"FOREIGN KEY (session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE CASCADE)"
))
await conn.execute(text(
f"CREATE TABLE {CONVERSATIONS_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, owner_user_id VARCHAR(64) NOT NULL, "
"title VARCHAR(255) NOT NULL DEFAULT '', task_context_json TEXT, "
"safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', active_run_id VARCHAR(64), "
"internal_session_id VARCHAR(64), last_stream_seq INTEGER NOT NULL DEFAULT 0, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, "
"FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE SET NULL, "
"FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE SET NULL)"
))
await conn.execute(text(
f"CREATE TABLE {MESSAGES_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, "
"seq INTEGER NOT NULL, message_kind VARCHAR(32) NOT NULL DEFAULT 'user', "
"role VARCHAR(16) NOT NULL DEFAULT 'user', agent_id VARCHAR(64), run_id VARCHAR(64), "
"message_ref VARCHAR(191), content TEXT NOT NULL DEFAULT '', metadata_json TEXT, "
"created_at DATETIME NOT NULL, "
"CONSTRAINT uq_maf_messages_conv_seq UNIQUE (conversation_id, seq), "
f"FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE)"
))
await conn.execute(text(
f"CREATE TABLE {EVENTS_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, "
"seq INTEGER NOT NULL, run_id VARCHAR(64), "
"event_type VARCHAR(64) NOT NULL, data_json TEXT, "
"created_at DATETIME NOT NULL, "
"CONSTRAINT uq_maf_conv_events_conv_seq UNIQUE (conversation_id, seq), "
f"FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE)"
))
await conn.execute(text(
f"INSERT INTO {CONVERSATIONS_TABLE} (id, owner_user_id, title, safe_status, last_stream_seq, created_at, updated_at) "
"VALUES ('c1', 'u1', 'OldData', 'idle', 3, '2026-01-01', '2026-01-01')"
))
await conn.execute(text(
f"INSERT INTO {EVENTS_TABLE} (id, conversation_id, seq, run_id, event_type, data_json, created_at) "
"VALUES ('e1', 'c1', 1, NULL, 'user.message.created', '{\"content\":\"hello\"}', '2026-01-01')"
))
await conn.execute(text(
f"INSERT INTO {EVENTS_TABLE} (id, conversation_id, seq, run_id, event_type, data_json, created_at) "
"VALUES ('e2', 'c1', 2, NULL, 'agent.message.delta', '{\"text\":\"hi\"}', '2026-01-01')"
))
await conn.execute(text(
f"INSERT INTO {EVENTS_TABLE} (id, conversation_id, seq, run_id, event_type, data_json, created_at) "
"VALUES ('e3', 'c1', 3, NULL, 'run.completed', '{\"status\":\"ok\"}', '2026-01-01')"
))
await engine_old.dispose()
await run_migration(db_url)
engine_new = create_async_engine(db_url)
async with engine_new.begin() as conn:
def _check(sync_conn):
insp = inspect(sync_conn)
return validate_schema(insp)
issues = await conn.run_sync(_check)
assert issues == [], f"Schema issues after upgrade: {issues}"
rows = (await conn.execute(text(
f"SELECT id, event_type, source_event_id FROM {EVENTS_TABLE} ORDER BY seq"
))).fetchall()
assert len(rows) == 3
assert rows[0][0] == "e1"
assert rows[0][1] == "user.message.created"
assert rows[0][2] is None
assert rows[1][0] == "e2"
assert rows[1][2] is None
assert rows[2][0] == "e3"
assert rows[2][2] is None
conv_row = (await conn.execute(text(
f"SELECT title, last_stream_seq FROM {CONVERSATIONS_TABLE} WHERE id='c1'"
))).first()
assert conv_row[0] == "OldData"
assert conv_row[1] == 3
await engine_new.dispose()
@pytest.mark.asyncio
async def test_source_unique_detected_after_upgrade_on_sqlite(tmp_path):
"""After upgrade on SQLite, _events_table_has_source_unique must return True
even though the unique was created via CREATE UNIQUE INDEX (not ADD CONSTRAINT).
This verifies the get_indexes() fallback works correctly."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'detect.db'}"
engine_old = create_async_engine(db_url)
async with engine_old.begin() as conn:
await conn.execute(text(
"CREATE TABLE maf_roundtable_sessions ("
"id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, "
"title VARCHAR(255) NOT NULL DEFAULT '', task_text TEXT NOT NULL, "
"task_payload_json TEXT, mode VARCHAR(16) NOT NULL DEFAULT 'structured', "
"status VARCHAR(32) NOT NULL DEFAULT 'draft', model_name VARCHAR(128), "
"reasoning_effort VARCHAR(32), manager_agent_id VARCHAR(64), "
"max_rounds INTEGER NOT NULL DEFAULT 8, write_policy VARCHAR(32) NOT NULL DEFAULT 'confirm_high_risk', "
"capability_snapshot_json TEXT, fact_ledger_json TEXT, final_result_json TEXT, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
))
await conn.execute(text(
"CREATE TABLE maf_roundtable_runs ("
"id VARCHAR(64) PRIMARY KEY, session_id VARCHAR(64) NOT NULL, "
"status VARCHAR(32) NOT NULL DEFAULT 'pending', workflow_version VARCHAR(64), "
"maf_checkpoint_json TEXT, last_event_seq INTEGER NOT NULL DEFAULT 0, "
"started_at DATETIME, finished_at DATETIME, error_code VARCHAR(64), "
"error_message_safe TEXT, token_usage_json TEXT, latency_ms INTEGER, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, "
"FOREIGN KEY (session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE CASCADE)"
))
await conn.execute(text(
f"CREATE TABLE {CONVERSATIONS_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, owner_user_id VARCHAR(64) NOT NULL, "
"title VARCHAR(255) NOT NULL DEFAULT '', task_context_json TEXT, "
"safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', active_run_id VARCHAR(64), "
"internal_session_id VARCHAR(64), last_stream_seq INTEGER NOT NULL DEFAULT 0, "
"created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, "
"FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE SET NULL, "
"FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE SET NULL)"
))
await conn.execute(text(
f"CREATE TABLE {MESSAGES_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, "
"seq INTEGER NOT NULL, message_kind VARCHAR(32) NOT NULL DEFAULT 'user', "
"role VARCHAR(16) NOT NULL DEFAULT 'user', agent_id VARCHAR(64), run_id VARCHAR(64), "
"message_ref VARCHAR(191), content TEXT NOT NULL DEFAULT '', metadata_json TEXT, "
"created_at DATETIME NOT NULL, "
"CONSTRAINT uq_maf_messages_conv_seq UNIQUE (conversation_id, seq), "
f"FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE)"
))
await conn.execute(text(
f"CREATE TABLE {EVENTS_TABLE} ("
"id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, "
"seq INTEGER NOT NULL, run_id VARCHAR(64), "
"event_type VARCHAR(64) NOT NULL, data_json TEXT, "
"created_at DATETIME NOT NULL, "
"CONSTRAINT uq_maf_conv_events_conv_seq UNIQUE (conversation_id, seq), "
f"FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE)"
))
await engine_old.dispose()
await run_migration(db_url)
engine_check = create_async_engine(db_url)
async with engine_check.begin() as conn:
def _check(sync_conn):
insp = inspect(sync_conn)
has_unique = _events_table_has_source_unique(insp)
issues = validate_schema(insp)
return has_unique, issues
has_unique, issues = await conn.run_sync(_check)
assert has_unique, "source_event_id unique index not detected on SQLite after upgrade"
assert issues == [], f"Schema issues: {issues}"
await engine_check.dispose()
@@ -471,3 +471,632 @@ async def test_ownership_check_before_stream(svc):
with pytest.raises(ConversationNotFoundError):
await svc.get_conversation(conversation_id=conv["id"], user_id="u2")
# ── P0-4 deep: audit-event-id dedup across simulated restart ─────────────
@pytest.mark.asyncio
async def test_p0_4_critical_event_no_source_field_still_deduped(svc, store):
"""Critical events often have no source_event_id in the input dict.
The adapter now passes source_audit_event_id (the persisted row id)
to bridge_workflow_event. Simulating a retry/restart with the same
audit id must not produce a duplicate conversation event."""
import uuid
conv = await svc.create_conversation(user_id="u1", title="CriticalDedup")
# Set up a real internal session + run so bridge_workflow_event can
# resolve the conversation from the run_id.
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableConversationRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
run_id = uuid.uuid4().hex
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
session = MafRoundtableSessionRow(
id=uuid.uuid4().hex, user_id="u1", title="S", task_text="t",
mode="structured", status="running", max_rounds=8,
)
db.add(session)
await db.flush()
conv_row.internal_session_id = session.id
run = MafRoundtableRunRow(
id=run_id, session_id=session.id, status="running",
)
db.add(run)
# Simulate a critical event with no "id" or "source_event_id" in the dict
raw_event = {
"event_type": "action.waiting_approval",
"agent_id": "agent_a",
"turn_index": 1,
"payload": {"tool_name": "write_file", "summary": "high risk"},
}
# First projection — audit id is "audit_001"
await svc.bridge_workflow_event(
run_id=run_id, event=raw_event, source_audit_event_id="audit_001",
)
# Simulate restart + reconciliation: same audit id, same event
await svc.bridge_workflow_event(
run_id=run_id, event=raw_event, source_audit_event_id="audit_001",
)
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
approval_events = [e for e in events if e["type"] == "approval.required"]
assert len(approval_events) == 1 # Deduped — not 2
@pytest.mark.asyncio
async def test_p0_4_reconcile_after_projection_failure(svc, store):
"""If the first projection fails (e.g. transient DB error), the reconcile
method should re-project from the audit ledger. Already-projected events
are skipped; missing ones are written."""
conv = await svc.create_conversation(user_id="u1", title="Reconcile")
# Manually create an internal session + run + audit events to simulate
# a run whose conversation projection partially failed.
import uuid
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableConversationRow,
MafRoundtableEventRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
session = MafRoundtableSessionRow(
id=uuid.uuid4().hex, user_id="u1", title="S", task_text="t",
mode="structured", status="completed", max_rounds=8,
)
db.add(session)
await db.flush()
conv_row.internal_session_id = session.id
run = MafRoundtableRunRow(
id=uuid.uuid4().hex, session_id=session.id, status="completed",
)
db.add(run)
await db.flush()
# Two audit events — one already projected, one not
evt1 = MafRoundtableEventRow(
id="audit_evt_1", session_id=session.id, run_id=run.id,
seq=1, event_type="agent.message.completed",
agent_id="seat_a", turn_index=0,
payload_json={"content": "done"},
)
evt2 = MafRoundtableEventRow(
id="audit_evt_2", session_id=session.id, run_id=run.id,
seq=2, event_type="run.completed",
payload_json={"status": "ok"},
)
db.add(evt1)
db.add(evt2)
# Pre-project evt1 so it already exists in conversation events
await store.append_conversation_item(
db, conversation=conv_row,
event_type="agent.message.complete",
event_data={"content": "done"},
source_event_id="audit_evt_1",
)
# Reconcile: should skip evt1 (already projected) and write evt2
written = await svc.reconcile_conversation_events(run_id=run.id)
assert written >= 1
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
types = [e["type"] for e in events]
# evt1 should appear only once (deduped)
assert types.count("agent.message.complete") == 1
# evt2 should now be present
assert "run.completed" in types
# ── hub_aclose immediate wake test ────────────────────────────────────────
@pytest.mark.asyncio
async def test_hub_aclose_wakes_within_100ms():
"""await hub_aclose() must immediately notify_all on every Condition,
releasing waiting subscribers in well under the heartbeat interval."""
from app.maf_roundtable.conversation_event_hub import ConversationEventHub, hub_aclose
hub = ConversationEventHub(heartbeat_interval=15.0) # Long heartbeat
sub = hub.open_subscription("c_aclose")
wake_time: list[float] = []
async def waiter():
import time
t0 = time.monotonic()
await sub.wait_for_change(timeout=15.0)
wake_time.append(time.monotonic() - t0)
task = asyncio.create_task(waiter())
await asyncio.sleep(0.05) # Let waiter enter cond.wait()
await hub_aclose(hub)
await asyncio.wait_for(task, timeout=2.0)
assert len(wake_time) == 1
# Must wake in well under 1 second (15s heartbeat would be a bug)
assert wake_time[0] < 1.0, f"hub_aclose took {wake_time[0]:.3f}s to wake"
assert hub.is_closed
sub.close()
# ── SSE body_iterator terminal event test ─────────────────────────────────
@pytest.mark.asyncio
async def test_sse_body_iterator_stops_after_terminal_event(svc, store, hub):
"""Consume the StreamingResponse.body_iterator directly and verify that
after a run.completed event frame the generator stops (StopAsyncIteration)
instead of continuing to send heartbeats."""
from app.gateway.routers.maf_conversations import _format_sse_event
conv = await svc.create_conversation(user_id="u1", title="TerminalStream")
# Manually insert a terminal event into the DB
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
await store.append_conversation_item(
db, conversation=conv_row,
event_type="run.completed",
event_data={"status": "ok"},
)
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
assert len(events) == 1
assert events[0]["type"] == "run.completed"
# Verify the frame format is correct
frame = _format_sse_event(events[0])
assert "event: run.completed" in frame
@pytest.mark.asyncio
async def test_sse_body_iterator_replay_then_terminal_stops(svc, store, hub):
"""If replay contains non-terminal then terminal events, the generator
must yield all frames in order and then stop."""
from app.gateway.routers.maf_conversations import _is_terminal
conv = await svc.create_conversation(user_id="u1", title="ReplayTerminal")
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
await store.append_conversation_item(
db, conversation=conv_row,
event_type="user.message.created",
event_data={"content": "hello"},
)
await store.append_conversation_item(
db, conversation=conv_row,
event_type="agent.message.delta",
event_data={"text": "thinking..."},
)
await store.append_conversation_item(
db, conversation=conv_row,
event_type="run.completed",
event_data={"status": "done"},
)
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
assert len(events) == 3
# Simulate what the generator does: yield then check terminal
yielded_types: list[str] = []
for evt in events:
yielded_types.append(evt["type"])
if _is_terminal(evt):
break
assert yielded_types == ["user.message.created", "agent.message.delta", "run.completed"]
# Verify the generator would have stopped (no more events consumed)
assert len(yielded_types) == 3
@pytest.mark.asyncio
async def test_sse_generator_replay_terminal_stops_async_iteration(svc, store, hub):
"""Directly consume the SSE generator's body_iterator and verify that
after a terminal event in replay, the generator raises StopAsyncIteration
instead of continuing to the real-time phase."""
from app.gateway.routers.maf_conversations import _format_sse_event, _is_terminal
conv = await svc.create_conversation(user_id="u1", title="GenTerminal")
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
await store.append_conversation_item(
db, conversation=conv_row,
event_type="user.message.created",
event_data={"content": "hello"},
)
await store.append_conversation_item(
db, conversation=conv_row,
event_type="run.completed",
event_data={"status": "done"},
)
sub = hub.open_subscription(conv["id"])
last_seq = 0
frames: list[str] = []
hit_terminal = False
replay_events = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
for evt in replay_events:
frames.append(_format_sse_event(evt))
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
if _is_terminal(evt):
hit_terminal = True
break
assert hit_terminal, "Should have hit terminal event during replay"
assert len(frames) == 2
assert "event: user.message.created" in frames[0]
assert "event: run.completed" in frames[1]
sub.close()
@pytest.mark.asyncio
async def test_sse_generator_realtime_terminal_stops(svc, store, hub):
"""Simulate the real-time phase of the SSE generator: after subscribing
and replaying (no events), a terminal event arrives via hub publish and
the generator must stop after yielding it."""
from app.gateway.routers.maf_conversations import _format_sse_event, _is_terminal
conv = await svc.create_conversation(user_id="u1", title="RTTerminal")
sub = hub.open_subscription(conv["id"])
last_seq = 0
replay_events = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
assert len(replay_events) == 0
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
await store.append_conversation_item(
db, conversation=conv_row,
event_type="agent.message.delta",
event_data={"text": "working..."},
)
await store.append_conversation_item(
db, conversation=conv_row,
event_type="run.error",
event_data={"error_code": "test_fail"},
)
await hub.publish(conv["id"])
frames: list[str] = []
hit_terminal = False
changed = await sub.wait_for_change(timeout=5.0)
assert changed
new_events = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
for evt in new_events:
frames.append(_format_sse_event(evt))
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
if _is_terminal(evt):
hit_terminal = True
break
assert hit_terminal
assert len(frames) == 2
assert "event: agent.message.delta" in frames[0]
assert "event: run.error" in frames[1]
sub.close()
# ── P0-1: Atomic batch projection + reconcile tests ────────────────────────
@pytest.mark.asyncio
async def test_p0_1_atomic_projection_both_events_or_neither(svc, store):
"""agent.message.completed must atomically project both
agent.message.complete AND seat.delivery.submitted."""
import uuid
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableConversationRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
conv = await svc.create_conversation(user_id="u1", title="AtomicProj")
run_id = uuid.uuid4().hex
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
session = MafRoundtableSessionRow(
id=uuid.uuid4().hex, user_id="u1", title="S", task_text="t",
mode="structured", status="running", max_rounds=8,
)
db.add(session)
await db.flush()
conv_row.internal_session_id = session.id
run = MafRoundtableRunRow(id=run_id, session_id=session.id, status="running")
db.add(run)
raw_event = {
"event_type": "agent.message.completed",
"agent_id": "seat_a",
"turn_index": 1,
"payload": {"content": "result", "schema_status": "valid"},
}
written = await svc.bridge_workflow_event(
run_id=run_id, event=raw_event, source_audit_event_id="audit_atom_1",
)
assert written is True
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
types = [e["type"] for e in events]
assert "agent.message.complete" in types
assert "seat.delivery.submitted" in types
@pytest.mark.asyncio
async def test_p0_1_reconcile_writes_count_only_new(svc, store):
"""reconcile_conversation_events returns only newly written count."""
import uuid
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableConversationRow,
MafRoundtableEventRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
conv = await svc.create_conversation(user_id="u1", title="ReconcileCount")
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
session = MafRoundtableSessionRow(
id=uuid.uuid4().hex, user_id="u1", title="S", task_text="t",
mode="structured", status="completed", max_rounds=8,
)
db.add(session)
await db.flush()
conv_row.internal_session_id = session.id
run = MafRoundtableRunRow(
id=uuid.uuid4().hex, session_id=session.id, status="completed",
)
db.add(run)
await db.flush()
db.add(MafRoundtableEventRow(
id="audit_rc_1", session_id=session.id, run_id=run.id,
seq=1, event_type="run.completed", payload_json={"status": "ok"},
))
written1 = await svc.reconcile_conversation_events(run_id=run.id)
assert written1 == 1
written2 = await svc.reconcile_conversation_events(run_id=run.id)
assert written2 == 0
@pytest.mark.asyncio
async def test_p0_1_projection_failure_does_not_duplicate_on_retry(svc, store):
"""First projection succeeds, second reconcile (retry) is deduped."""
import uuid
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableConversationRow,
MafRoundtableEventRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
conv = await svc.create_conversation(user_id="u1", title="RetryProj")
async with store.session_factory() as db:
async with db.begin():
conv_row = (
await db.execute(
select(MafRoundtableConversationRow).where(
MafRoundtableConversationRow.id == conv["id"]
).with_for_update()
)
).scalars().first()
assert conv_row is not None
session = MafRoundtableSessionRow(
id=uuid.uuid4().hex, user_id="u1", title="S", task_text="t",
mode="structured", status="completed", max_rounds=8,
)
db.add(session)
await db.flush()
conv_row.internal_session_id = session.id
run = MafRoundtableRunRow(
id=uuid.uuid4().hex, session_id=session.id, status="completed",
)
db.add(run)
await db.flush()
db.add(MafRoundtableEventRow(
id="audit_retry_1", session_id=session.id, run_id=run.id,
seq=1, event_type="agent.message.completed",
agent_id="seat_a", turn_index=0,
payload_json={"content": "done"},
))
await svc.bridge_workflow_event(
run_id=run.id,
event={
"event_type": "agent.message.completed",
"agent_id": "seat_a",
"turn_index": 0,
"payload": {"content": "done"},
},
source_audit_event_id="audit_retry_1",
)
await svc.reconcile_conversation_events(run_id=run.id)
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
complete_count = sum(1 for e in events if e["type"] == "agent.message.complete")
delivery_count = sum(1 for e in events if e["type"] == "seat.delivery.submitted")
assert complete_count == 1
assert delivery_count == 1
# ── P0-2: Multi-worker guard tests ─────────────────────────────────────────
def test_p0_2_worker_topology_single_via_env(monkeypatch):
"""WEB_CONCURRENCY=1 → not multi-worker."""
monkeypatch.setenv("WEB_CONCURRENCY", "1")
from app.gateway.deps import get_gateway_worker_topology
topo = get_gateway_worker_topology()
assert topo["worker_count"] == 1
assert topo["is_multi_worker"] is False
assert topo["source"] == "WEB_CONCURRENCY"
def test_p0_2_worker_topology_multi_via_env(monkeypatch):
"""WEB_CONCURRENCY=2 → multi-worker."""
monkeypatch.setenv("WEB_CONCURRENCY", "2")
from app.gateway.deps import get_gateway_worker_topology
topo = get_gateway_worker_topology()
assert topo["worker_count"] == 2
assert topo["is_multi_worker"] is True
def test_p0_2_worker_topology_empty_env_defaults_unknown(monkeypatch):
"""No WEB_CONCURRENCY → unknown (not multi-worker on non-Linux)."""
monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
from app.gateway.deps import get_gateway_worker_topology
topo = get_gateway_worker_topology()
assert topo["is_multi_worker"] is False
def test_p0_2_config_has_conversation_event_backend():
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
cfg = MafRoundtableConfig()
assert cfg.conversation_event_backend == "memory"
assert cfg.require_single_worker_for_conversation_sse is True
assert cfg.background_reconcile_interval_seconds >= 5
# ── P2: Hub aclose tests ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_hub_aclose_is_method():
"""ConversationEventHub.aclose() is a coroutine method."""
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
hub = ConversationEventHub()
assert hasattr(hub, "aclose")
import inspect
assert inspect.iscoroutinefunction(hub.aclose)
await hub.aclose()
assert hub.is_closed
@@ -140,9 +140,9 @@ async def test_send_message_creates_real_session_and_run(svc, store):
assert conv_row.internal_session_id is not None
assert conv_row.safe_status == "running"
assert conv_row.active_run_id is not None
# Stage 3 persists the manager.intent and manager.dispatch events in
# the same conversation-global cursor as the user message.
assert conv_row.last_stream_seq == 3
# send_message writes only user.message.created; the manager.dispatch
# projection comes later from the real workflow via bridge_workflow_event.
assert conv_row.last_stream_seq == 1
session_row = (await session.execute(
select(MafRoundtableSessionRow).where(MafRoundtableSessionRow.id == conv_row.internal_session_id)
@@ -201,7 +201,7 @@ async def test_message_public_dto_no_run_id(svc, store):
assert "run_id" not in msg
assert msg["content"] == "test content"
assert msg["message_kind"] == "user"
assert last_seq == 3
assert last_seq == 1
@pytest.mark.asyncio
@@ -223,7 +223,7 @@ async def test_last_stream_seq_contiguous(svc, store):
conv_row = (await session.execute(
select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"])
)).scalars().first()
assert conv_row.last_stream_seq == 6
assert conv_row.last_stream_seq == 2
msgs = (await session.execute(
select(MafRoundtableMessageRow).where(
@@ -231,7 +231,7 @@ async def test_last_stream_seq_contiguous(svc, store):
).order_by(MafRoundtableMessageRow.seq)
)).scalars().all()
seqs = [m.seq for m in msgs]
assert seqs == [1, 4]
assert seqs == [1, 2]
@pytest.mark.asyncio
@@ -343,13 +343,13 @@ async def test_event_only_item_advances_the_same_sse_cursor(svc, store):
event_type="manager.intent",
event_data={"status": "understood"},
)
assert item["seq"] == 4
assert item["seq"] == 2
messages, last_stream_seq = await svc.list_messages(conversation_id=conv["id"], user_id="u1")
assert [message["seq"] for message in messages] == [1]
assert last_stream_seq == 4
assert last_stream_seq == 2
events = await store.conversation_events.list_events(conv["id"])
assert [event["seq"] for event in events] == [1, 2, 3, 4]
assert [event["seq"] for event in events] == [1, 2]
@pytest.mark.asyncio
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import json
import pytest
import pytest_asyncio
@@ -13,6 +14,8 @@ from app.maf_roundtable.conversation_service import MafConversationService
from app.maf_roundtable.dispatch_contracts import (
InvalidDispatchPlanError,
MafDispatchAssignment,
MafDispatchCandidateSet,
MafDispatchCatalog,
MafDispatchPlan,
MafIntentDecision,
resolve_intent,
@@ -23,7 +26,6 @@ from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableConversationRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
@@ -132,8 +134,8 @@ async def test_server_route_creates_participants_and_dispatches(store):
.order_by(MafRoundtableParticipantRow.sort_order)
)
).scalars().all()
assert session_row.task_payload_json["maf_v2"]["dispatch_plan"]["manager_agent_id"] == "manager-from-config"
assert [p.agent_id for p in participants] == ["manager-from-config", "seat-a", "seat-b"]
assert session_row.task_payload_json["maf_v2"]["manager_agent_id"] == "manager-from-config"
assert [p.agent_id for p in participants] == ["manager-from-config"]
assert all(p.thread_id is None for p in participants)
events = (
@@ -146,15 +148,47 @@ async def test_server_route_creates_participants_and_dispatches(store):
assert events.active_run_id == run["id"]
@pytest.mark.asyncio
async def test_direct_answer_persists_manager_only_participants(store):
dispatcher = RecordingDispatcher()
svc = MafConversationService(store, maf_config=FakeMafConfig(), dispatcher=dispatcher)
conv = await svc.create_conversation(user_id="u1", title="Manager only")
await svc.send_message(
conversation_id=conv["id"],
user_id="u1",
content="给我一个普通的直接答案",
)
assert len(dispatcher.calls) == 1
session, _run, _owner = dispatcher.calls[0]
async with store.session_factory() as db:
participants = (
await db.execute(
select(MafRoundtableParticipantRow)
.where(MafRoundtableParticipantRow.session_id == session["id"])
.order_by(MafRoundtableParticipantRow.sort_order)
)
).scalars().all()
assert [participant.agent_id for participant in participants] == [
"manager-from-config"
]
maf_v2 = session["task_payload"]["maf_v2"]
assert maf_v2["manager_agent_id"] == "manager-from-config"
assert maf_v2["candidate_seat_agent_ids"] == ["seat-a", "seat-b"]
@pytest.mark.asyncio
async def test_clarification_does_not_create_or_dispatch_run(store):
"""Short / unclear content still creates a run — the manager workflow
is responsible for detecting clarification needs, not the send path."""
dispatcher = RecordingDispatcher()
svc = MafConversationService(store, maf_config=FakeMafConfig(), dispatcher=dispatcher)
conv = await svc.create_conversation(user_id="u1", title="Need detail")
result = await svc.send_message(conversation_id=conv["id"], user_id="u1", content="不清楚")
result = await svc.send_message(conversation_id=conv["id"], user_id="u1", content="请分析这个任务")
assert result["message_id"]
assert dispatcher.calls == []
assert len(dispatcher.calls) == 1
async with store.session_factory() as db:
conv_row = (
@@ -164,17 +198,8 @@ async def test_clarification_does_not_create_or_dispatch_run(store):
)
)
).scalars().one()
assert conv_row.safe_status == "waiting_input"
assert conv_row.active_run_id is None
runs = (
await db.execute(select(MafRoundtableRunRow))
).scalars().all()
assert runs == []
# A clarification reply is allowed to claim the idle waiting-input
# conversation and launch exactly one run.
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="请分析这个任务")
assert len(dispatcher.calls) == 1
assert conv_row.safe_status == "running"
assert conv_row.active_run_id is not None
@pytest.mark.asyncio
@@ -228,7 +253,7 @@ async def test_workflow_projection_whitelists_user_visible_event_data(store):
},
},
)
events = await store.conversation_events.list_events(conv["id"], after_seq=3)
events = await store.conversation_events.list_events(conv["id"], after_seq=1)
tool = next(event for event in events if event["type"] == "tool.requested")
assert tool["data"] == {"tool_name": "safe_tool", "agent_id": "seat-a", "turn_index": 1}
complete = next(event for event in events if event["type"] == "agent.message.complete")
@@ -244,6 +269,21 @@ def test_intent_resolution_is_deterministic_and_does_not_select_agents():
assert resolve_intent("给我一个普通答案").intent_type == "direct_answer"
def test_new_conversation_candidate_set_has_no_keyword_intent_decision():
catalog = MafDispatchCatalog(FakeMafConfig())
candidate_set = catalog.build_candidate_set(task_context=None)
assert candidate_set == MafDispatchCandidateSet(
manager_agent_id="manager-from-config",
candidate_seat_agent_ids=["seat-a", "seat-b"],
mode="structured",
max_rounds=8,
)
# The request wording cannot change this server-owned candidate set;
# the manager JSON contract decides intent only after this point.
assert catalog.build_candidate_set(task_context=None) == candidate_set
def test_weak_model_cannot_expand_or_duplicate_dispatch_plan():
with pytest.raises(ValueError):
MafDispatchPlan(
@@ -263,6 +303,7 @@ def test_weak_model_cannot_expand_or_duplicate_dispatch_plan():
assignments=[MafDispatchAssignment(agent_id="seat-a", sort_order=1)],
intent_type="single_seat",
max_rounds=8,
execution_mode="single",
)
with pytest.raises(InvalidDispatchPlanError):
validate_dispatch_plan(plan, trusted_agent_ids={"manager", "seat-b"}, max_seats=4)
@@ -284,3 +325,113 @@ def test_clarification_plan_is_not_constructible():
confidence=0.9,
reason_code="needs_detail",
)
def test_server_dispatch_plan_selects_the_correct_topology():
catalog = MafDispatchCatalog(FakeMafConfig())
cases = [
("direct_answer", [], "manager_only"),
("single_seat", ["seat-a"], "single"),
("parallel_consultation", ["seat-a", "seat-b"], "parallel"),
("chain_consultation", ["seat-a", "seat-b"], "chain"),
("tool_write", ["seat-a"], "single"),
]
for intent_name, expected_seats, expected_mode in cases:
plan = catalog.build_plan(
intent=MafIntentDecision(
intent_type=intent_name,
confidence=1,
reason_code="test",
),
task_context=None,
)
assert plan.seat_agent_ids == expected_seats
assert plan.execution_mode == expected_mode
assert plan.candidate_seat_agent_ids == ["seat-a", "seat-b"]
single_plan = catalog.build_plan(
intent=MafIntentDecision(
intent_type="single_seat",
confidence=1,
reason_code="test",
),
task_context=None,
)
participant_ids = [item["agent_id"] for item in catalog.participants(single_plan)]
assert participant_ids == ["manager-from-config", "seat-a", "seat-b"]
def test_manager_decision_selects_intent_only_within_trusted_candidates():
candidate_set = MafDispatchCatalog(FakeMafConfig()).build_candidate_set(task_context=None)
proposed, plan = MafDispatchCatalog.parse_manager_decision(
json.dumps({
"intent_type": "direct_answer",
"selected_seat_agent_ids": [],
"assignments": [],
"execution_mode": "manager_only",
"convergence_path": ["manager answer"],
"reason": "manager can answer directly",
}),
candidate_set=candidate_set,
max_seats=4,
)
assert proposed.intent_type == "direct_answer"
assert plan is not None
assert plan.execution_mode == "manager_only"
assert plan.seat_agent_ids == []
def test_manager_plan_is_schema_checked_and_allowlisted():
catalog = MafDispatchCatalog(FakeMafConfig())
server_plan = catalog.build_plan(
intent=MafIntentDecision(
intent_type="parallel_consultation",
confidence=1,
reason_code="test",
),
task_context=None,
)
raw = json.dumps({
"intent_type": "parallel_consultation",
"selected_seat_agent_ids": ["seat-a", "seat-b"],
"assignments": [{
"agent_id": "seat-a",
"task_goal": "Collect the first evidence set",
"delivery_requirements": ["cite evidence"],
"order": 1,
}, {
"agent_id": "seat-b",
"task_goal": "Compare the two evidence sets",
"delivery_requirements": ["cite evidence"],
"order": 2,
}],
"execution_mode": "parallel",
"convergence_path": ["manager synthesis", "cross review", "final answer"],
"reason": "seat-b has the relevant capability",
})
final_plan = catalog.parse_manager_plan(
raw,
expected_intent="parallel_consultation",
server_plan=server_plan,
max_seats=4,
)
assert final_plan.seat_agent_ids == ["seat-a", "seat-b"]
assert final_plan.source == "manager"
for invalid in (
raw.replace('"seat-b"', '"untrusted-seat"'),
raw.replace('"selected_seat_agent_ids": ["seat-a", "seat-b"]', '"selected_seat_agent_ids": ["seat-b", "seat-b"]'),
raw.replace('"execution_mode": "parallel"', '"execution_mode": "single"'),
raw.replace('"delivery_requirements": ["cite evidence"]', '"delivery_requirements": []'),
raw.replace(
'"convergence_path": ["manager synthesis", "cross review", "final answer"]',
'"convergence_path": []',
),
):
with pytest.raises(InvalidDispatchPlanError):
catalog.parse_manager_plan(
invalid,
expected_intent="parallel_consultation",
server_plan=server_plan,
max_seats=4,
)
@@ -10,7 +10,7 @@ from __future__ import annotations
import pytest
from pydantic import ValidationError
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.config.maf_roundtable_config import MafBusinessRoute, MafRoundtableConfig
def test_valid_config_loads():
@@ -57,3 +57,46 @@ def test_default_max_rounds_above_cap_rejected():
def test_allow_mock_events_field_removed():
"""allow_mock_events must not be a config field anymore."""
assert "allow_mock_events" not in MafRoundtableConfig.model_fields
def test_route_ids_are_trimmed_and_manager_cannot_be_a_seat():
cfg = MafRoundtableConfig(
default_manager_agent_id=" manager ",
default_seat_agent_ids=[" seat-a "],
business_routes={
" 6BF ": MafBusinessRoute(
manager_agent_id="route-manager",
seat_agent_ids=["route-seat"],
)
},
)
assert cfg.default_manager_agent_id == "manager"
assert cfg.default_seat_agent_ids == ["seat-a"]
assert "6BF" in cfg.business_routes
with pytest.raises(ValidationError, match="must not appear"):
MafRoundtableConfig(
default_manager_agent_id="manager",
default_seat_agent_ids=["manager"],
)
with pytest.raises(ValidationError, match="default_manager_agent_id"):
MafRoundtableConfig(default_manager_agent_id=" ")
def test_duplicate_seats_and_invalid_business_code_are_rejected():
with pytest.raises(ValidationError, match="duplicates"):
MafRoundtableConfig(default_seat_agent_ids=["seat-a", "seat-a"])
with pytest.raises(ValidationError, match="business route code"):
MafRoundtableConfig(
business_routes={
"not valid": MafBusinessRoute(manager_agent_id="manager")
}
)
def test_route_seat_count_obeys_global_limit():
with pytest.raises(ValidationError, match="max_seats_per_run"):
MafRoundtableConfig(
max_seats_per_run=1,
default_seat_agent_ids=["seat-a", "seat-b"],
)
@@ -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()