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