Files
董宏凯andCursor 99642fca6c feat(maf): conversation-driven MAF roundtable (R1/R2) + server-owned stage-3 dispatch
Backend:
- Conversation API at /api/maf-roundtable/conversations: create/list/detail,
  paginated messages, atomic send+run start, owner-only isolation,
  admin read-only diagnostic access
- SSE real-time event stream (GET /conversations/{id}/stream) backed by
  ConversationEventHub + conversation_events store; multi-worker deployment doc
- Deterministic server-owned stage-3 dispatch contracts (dispatch_contracts.py):
  routing resolved from trusted MAF deployment config instead of manager-chosen
  agent ids; config.yaml keeps stage-3 routing deployment-owned
  (default_manager_agent_id / seat ids / business_routes / max_seats_per_run)
- Conversation persistence (conversations/messages stores) + v2 migration script
- Tests: conversations, conversation stream, migration, phase3 conversation dispatch

Frontend:
- Vitest test infra for MAF (vitest.maf.config.ts + test:maf script) with
  component tests for ApprovalCard/RunHeader/SessionsPage and use-maf-events
- Session form lib, flow-graph dialog / run-detail / sessions page updates,
  routes and nav tweaks
- Docs: R0 legacy interaction baseline mapping, updated implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 12:09:47 +08:00

177 lines
6.9 KiB
Python

"""Message repository for the MAF roundtable V2 (stable conversation history).
Every user-visible bubble (user / manager / seat / system / clarification /
approval / result / tool_progress) has exactly one row here. ``seq`` is a
conversation-global monotonic counter (unique per conversation) allocated via
``SELECT MAX(seq) + 1`` with ``IntegrityError`` retry — the same portable
approach as ``maf_roundtable_events`` (works on sqlite / postgres / mysql).
The message history is the durable record the frontend rebuilds the timeline
from on refresh; the SSE conversation-event stream (§4.3) is the real-time
channel. Both share the conversation-global seq space so a refresh can resume
the SSE cursor from the last persisted message/event seq.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.model import MafRoundtableMessageRow
def _now() -> datetime:
return datetime.now(UTC)
def message_to_dict(row: MafRoundtableMessageRow) -> dict[str, Any]:
"""Internal serialisation — includes all columns. Never expose to the browser."""
return {
"id": row.id,
"conversation_id": row.conversation_id,
"seq": row.seq,
"message_kind": row.message_kind,
"role": row.role,
"agent_id": row.agent_id,
"run_id": row.run_id,
"message_ref": row.message_ref,
"content": row.content,
"metadata": row.metadata_json,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
}
_METADATA_WHITELIST_BY_KIND: dict[str, frozenset[str]] = {
"user": frozenset(),
"manager": frozenset({"intent_type", "dispatch_plan_summary"}),
"seat": frozenset({"seat_name", "delivery_status", "attempt_number"}),
"system": frozenset({"reason"}),
"clarification": frozenset({"clarification_id", "options", "allow_multiple", "status"}),
"approval": frozenset({"approval_id", "tool_name", "risk_level", "summary", "status"}),
"result": frozenset({"result_type", "has_controlled_gap", "gap_summary"}),
"tool_progress": frozenset({"tool_name", "progress_pct", "status"}),
}
def message_public_to_dict(row: MafRoundtableMessageRow) -> dict[str, Any]:
"""Public DTO — only display-safe fields. No run_id, no unfiltered metadata."""
raw_meta = row.metadata_json
safe_meta: dict[str, Any] | None = None
if isinstance(raw_meta, dict):
allowed = _METADATA_WHITELIST_BY_KIND.get(row.message_kind, frozenset())
safe_meta = {k: v for k, v in raw_meta.items() if k in allowed} or None
return {
"id": row.id,
"conversation_id": row.conversation_id,
"seq": row.seq,
"message_kind": row.message_kind,
"role": row.role,
"agent_id": row.agent_id,
"message_ref": row.message_ref,
"content": row.content,
"metadata": safe_meta,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
}
class MafMessageRepository:
"""Conversation-scoped message history with a monotonic per-conversation seq."""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def append_message(
self,
*,
conversation_id: str,
message_kind: str,
role: str,
content: str = "",
agent_id: str | None = None,
run_id: str | None = None,
message_ref: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Reject an unsafe standalone write.
Message and event writes must use
:meth:`MafRoundtableStore.append_conversation_item` inside one
transaction. Separate ``MAX(seq) + 1`` allocators would violate the
conversation's single SSE cursor.
"""
del conversation_id, message_kind, role, content, agent_id, run_id, message_ref, metadata
raise RuntimeError(
"Conversation message writes must use "
"MafRoundtableStore.append_conversation_item"
)
async def list_messages(
self,
conversation_id: str,
*,
after_seq: int = 0,
before_seq: int | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
"""Paginated message history in ascending seq order.
``after_seq`` / ``before_seq`` implement cursor paging: the frontend
loads the latest page on entry (``after_seq=0`` + reverse via
:meth:`list_messages_desc`) and older pages on scroll-up.
"""
stmt = (
select(MafRoundtableMessageRow)
.where(
MafRoundtableMessageRow.conversation_id == conversation_id,
MafRoundtableMessageRow.seq > after_seq,
)
)
if before_seq is not None:
stmt = stmt.where(MafRoundtableMessageRow.seq < before_seq)
stmt = stmt.order_by(MafRoundtableMessageRow.seq.asc()).limit(limit)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [message_to_dict(r) for r in rows]
async def list_messages_desc(
self, conversation_id: str, *, limit: int = 50, before_seq: int | None = None
) -> list[dict[str, Any]]:
"""Latest ``limit`` messages, newest-first (for initial page load).
Returned newest-first; callers that want chronological order reverse
the list. ``before_seq`` loads the next older page.
"""
stmt = select(MafRoundtableMessageRow).where(
MafRoundtableMessageRow.conversation_id == conversation_id
)
if before_seq is not None:
stmt = stmt.where(MafRoundtableMessageRow.seq < before_seq)
stmt = stmt.order_by(MafRoundtableMessageRow.seq.desc()).limit(limit)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [message_to_dict(r) for r in rows]
async def latest_seq(self, conversation_id: str) -> int:
async with self._sf() as session:
max_seq = (
await session.execute(
select(func.max(MafRoundtableMessageRow.seq)).where(
MafRoundtableMessageRow.conversation_id == conversation_id
)
)
).scalar_one()
return int(max_seq or 0)
async def count_messages(self, conversation_id: str) -> int:
async with self._sf() as session:
return int((
await session.execute(
select(func.count()).select_from(MafRoundtableMessageRow).where(
MafRoundtableMessageRow.conversation_id == conversation_id
)
)
).scalar_one())