diff --git a/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md b/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md index b6be5e1e..4cf5681e 100644 --- a/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md +++ b/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md @@ -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. diff --git a/offline-backend-20260512/backend/_debug_test.py b/offline-backend-20260512/backend/_debug_test.py new file mode 100644 index 00000000..fa1a4ad5 --- /dev/null +++ b/offline-backend-20260512/backend/_debug_test.py @@ -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()) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 390c4b65..5208fdd7 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -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 diff --git a/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py b/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py index 6a753674..232318d8 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py +++ b/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py @@ -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, ) diff --git a/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py index 4abe9d9f..7fbcef72 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py @@ -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, diff --git a/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py b/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py index ff1d0e08..544f3b42 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py @@ -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"] diff --git a/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py b/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py index eb379dc1..e5102d12 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py b/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py index b5c8c748..40dd8260 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py @@ -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)", diff --git a/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py b/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py index b9caaed5..b38bad07 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py @@ -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", diff --git a/offline-backend-20260512/backend/app/maf_roundtable/schemas.py b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py index d51f9d35..8da2f65f 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/schemas.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py index b1fd6df9..7b13a364 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_factory.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_factory.py index b6c7c42d..6a9f2e6b 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/workflow_factory.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_factory.py @@ -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, ) diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py index 7a394833..7f6cd438 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py @@ -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 diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 1af343cc..dd75ae59 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -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 # 群聊模式轮次硬上限 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py index 00530708..dad6a619 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py @@ -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__ = [ diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py index bfd86c0c..569fa0d8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py @@ -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: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py index 842f1346..973ff3ab 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py @@ -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( diff --git a/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py b/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py index e2576a16..c9cfad5f 100644 --- a/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py +++ b/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py @@ -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) diff --git a/offline-backend-20260512/backend/tests/test_maf_agent_adapter.py b/offline-backend-20260512/backend/tests/test_maf_agent_adapter.py index 31a37d43..af25551c 100644 --- a/offline-backend-20260512/backend/tests/test_maf_agent_adapter.py +++ b/offline-backend-20260512/backend/tests/test_maf_agent_adapter.py @@ -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() diff --git a/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py b/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py index 9085f4d3..22ffa40e 100644 --- a/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py +++ b/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py @@ -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() diff --git a/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py b/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py index 8ae7c9e8..81142707 100644 --- a/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py +++ b/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_maf_conversations.py b/offline-backend-20260512/backend/tests/test_maf_conversations.py index 2f752f87..34ae4e81 100644 --- a/offline-backend-20260512/backend/tests/test_maf_conversations.py +++ b/offline-backend-20260512/backend/tests/test_maf_conversations.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py b/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py index 5e828a4b..40c88a30 100644 --- a/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py +++ b/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py @@ -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, + ) diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py index f03fd743..179eec09 100644 --- a/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py @@ -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"], + ) diff --git a/offline-backend-20260512/backend/tests/test_maf_structured_workflow.py b/offline-backend-20260512/backend/tests/test_maf_structured_workflow.py index ee58f0f8..e2a2749d 100644 --- a/offline-backend-20260512/backend/tests/test_maf_structured_workflow.py +++ b/offline-backend-20260512/backend/tests/test_maf_structured_workflow.py @@ -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()