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>
This commit is contained in:
1 parent
df408831e5
commit
99642fca6c
46 files changed
+6358
-1306
No files matched your search
@@ -50,6 +50,7 @@ from app.gateway.routers import (
|
||||
knowledge_ingest,
|
||||
light_apps,
|
||||
llm_metrics,
|
||||
maf_conversations,
|
||||
maf_roundtable,
|
||||
mcp,
|
||||
memory,
|
||||
@@ -1612,6 +1613,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
|
||||
app.include_router(maf_roundtable.router)
|
||||
|
||||
# MAF roundtable V2 Conversation API (user-visible layer) at /api/maf-roundtable/conversations
|
||||
app.include_router(maf_conversations.router)
|
||||
|
||||
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
|
||||
app.include_router(taskcop_tasks.router)
|
||||
|
||||
|
||||
@@ -319,8 +319,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# abort Gateway startup (the feature is experimental / opt-in).
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
|
||||
app.state.maf_roundtable_store = make_maf_roundtable_store(sf)
|
||||
try:
|
||||
app.state.maf_roundtable_store = make_maf_roundtable_store(sf)
|
||||
maf_enabled = False
|
||||
try:
|
||||
from app.maf_roundtable import MafRoundtableService
|
||||
|
||||
maf_cfg = getattr(config, "maf_roundtable", None)
|
||||
@@ -369,6 +370,32 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.exception("[maf-roundtable] service initialization failed; feature stays off")
|
||||
app.state.maf_roundtable_service = None
|
||||
|
||||
# ── MAF roundtable V2 Conversation service + SSE event hub ──
|
||||
try:
|
||||
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
|
||||
|
||||
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,
|
||||
)
|
||||
else:
|
||||
app.state.maf_conversation_service = None
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[maf-roundtable] conversation service init failed")
|
||||
app.state.maf_conversation_service = None
|
||||
app.state.maf_conversation_event_hub = None
|
||||
|
||||
from deerflow.persistence.roundtable_jobs import (
|
||||
make_roundtable_job_store,
|
||||
)
|
||||
@@ -458,6 +485,15 @@ 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.
|
||||
hub = getattr(app.state, "maf_conversation_event_hub", None)
|
||||
if hub is not None:
|
||||
try:
|
||||
hub.shutdown()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[maf-roundtable] hub shutdown failed", exc_info=True)
|
||||
|
||||
from deerflow.persistence.notifications import set_notification_store
|
||||
|
||||
set_notification_store(None)
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""HTTP router for MAF roundtable Conversation API at ``/api/maf-roundtable/conversations``.
|
||||
|
||||
Phase 1 (R1) scope:
|
||||
- POST /conversations — create blank user conversation
|
||||
- GET /conversations — list current user's history
|
||||
- GET /conversations/{id} — public conversation DTO
|
||||
- GET /conversations/{id}/messages — paginated message history
|
||||
- POST /conversations/{id}/messages — atomic send + run start
|
||||
|
||||
Phase 2 (R2) adds:
|
||||
- GET /conversations/{id}/stream — SSE real-time event stream
|
||||
|
||||
User isolation: owner-only reads/writes. Admin diagnostic access is read-only.
|
||||
Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids)
|
||||
are never returned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
|
||||
from app.maf_roundtable.conversation_schemas import (
|
||||
ConversationListResponse,
|
||||
ConversationResponse,
|
||||
CreateConversationRequest,
|
||||
MessageListResponse,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
)
|
||||
from app.maf_roundtable.conversation_service import (
|
||||
ConversationConflictError,
|
||||
ConversationDisabledError,
|
||||
ConversationNoSessionConfigError,
|
||||
ConversationNotFoundError,
|
||||
ConversationServiceError,
|
||||
MafConversationService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/maf-roundtable", tags=["maf-roundtable-conversations"])
|
||||
|
||||
_SSE_HEADERS = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
|
||||
|
||||
def _get_conversation_service(request: Request) -> MafConversationService:
|
||||
svc = getattr(request.app.state, "maf_conversation_service", None)
|
||||
if svc is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF conversation service not available")
|
||||
return svc
|
||||
|
||||
|
||||
def _get_event_hub(request: Request) -> ConversationEventHub | None:
|
||||
return getattr(request.app.state, "maf_conversation_event_hub", None)
|
||||
|
||||
|
||||
def _user_ctx(request: Request) -> tuple[str, bool]:
|
||||
"""Match the main MAF router's authenticated-user convention.
|
||||
|
||||
``AuthMiddleware`` stamps the resolved account on ``request.state.user``;
|
||||
it does not create ``user_id`` / ``is_admin`` attributes. Reading those
|
||||
nonexistent attributes collapses every authenticated caller into the
|
||||
fallback account and breaks owner isolation.
|
||||
"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is None:
|
||||
return "default", False
|
||||
return str(user.id), getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
# ── REST endpoints (Phase 1) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/conversations", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_conversation(body: CreateConversationRequest, request: Request):
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, _ = _user_ctx(request)
|
||||
try:
|
||||
return await svc.create_conversation(
|
||||
user_id=user_id,
|
||||
title=body.title.strip(),
|
||||
task_context=body.task_context,
|
||||
)
|
||||
except ConversationDisabledError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="MAF roundtable feature is disabled",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=ConversationListResponse)
|
||||
async def list_conversations(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, _ = _user_ctx(request)
|
||||
conversations, total = await svc.list_conversations(user_id=user_id, limit=limit, offset=offset)
|
||||
return {"conversations": conversations, "total": total}
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}", response_model=ConversationResponse)
|
||||
async def get_conversation(conversation_id: str, request: Request):
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
return 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")
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}/messages", response_model=MessageListResponse)
|
||||
async def list_messages(
|
||||
conversation_id: str,
|
||||
request: Request,
|
||||
before_seq: int | None = Query(default=None, ge=1),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
):
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
messages, last_stream_seq = await svc.list_messages(
|
||||
conversation_id=conversation_id, user_id=user_id, is_admin=is_admin,
|
||||
before_seq=before_seq, limit=limit,
|
||||
)
|
||||
return {"messages": messages, "last_stream_seq": last_stream_seq}
|
||||
except ConversationNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
|
||||
|
||||
@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):
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, _ = _user_ctx(request)
|
||||
try:
|
||||
return await svc.send_message(conversation_id=conversation_id, user_id=user_id, content=body.content)
|
||||
except ConversationNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
except ConversationConflictError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A run is already active")
|
||||
except ConversationNoSessionConfigError:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="No trusted session configuration")
|
||||
except ConversationDisabledError:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF roundtable feature is disabled")
|
||||
except ConversationServiceError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message)
|
||||
|
||||
|
||||
# ── SSE endpoint (Phase 2 / R2) ───────────────────────────────────────────
|
||||
|
||||
|
||||
# Event types that mark the end of a run's lifecycle. When the SSE stream
|
||||
# encounters any of these in order, it sends the event and then closes the
|
||||
# connection so the client doesn't sit idle receiving heartbeats.
|
||||
_TERMINAL_EVENT_TYPES = frozenset({
|
||||
"run.completed",
|
||||
"run.error",
|
||||
"run.cancelled",
|
||||
})
|
||||
|
||||
|
||||
def _format_sse_event(event: dict[str, Any]) -> str:
|
||||
"""Format one conversation event as an SSE frame per §4.3 contract.
|
||||
|
||||
``event:`` field carries the event type; ``data:`` carries the full
|
||||
envelope; ``id:`` carries the event_id for Last-Event-ID reconnection.
|
||||
"""
|
||||
event_type = event.get("type", "message")
|
||||
event_id = event.get("event_id")
|
||||
payload = json.dumps(event, default=str, ensure_ascii=False)
|
||||
parts = [f"event: {event_type}", f"data: {payload}"]
|
||||
if event_id:
|
||||
parts.append(f"id: {event_id}")
|
||||
parts.append("")
|
||||
parts.append("")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _format_heartbeat() -> str:
|
||||
"""SSE comment frame — keeps the connection alive without producing a
|
||||
client-visible event."""
|
||||
return ": heartbeat\n\n"
|
||||
|
||||
|
||||
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,
|
||||
request: Request,
|
||||
after_seq: int = Query(default=0, ge=0),
|
||||
):
|
||||
"""SSE stream for one conversation (§4.2).
|
||||
|
||||
Single fetch-reader connection per conversation. 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.
|
||||
|
||||
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()``.
|
||||
"""
|
||||
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",
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
for evt in replay_events:
|
||||
if await request.is_disconnected():
|
||||
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:
|
||||
logger.exception("SSE replay error conv=%s", conversation_id)
|
||||
yield _format_sse_event({
|
||||
"seq": last_seq,
|
||||
"event_id": f"replay_error_{last_seq}",
|
||||
"conversation_id": conversation_id,
|
||||
"type": "run.error",
|
||||
"occurred_at": datetime.now(UTC).isoformat(),
|
||||
"data": {"error": "replay_failed"},
|
||||
})
|
||||
return
|
||||
|
||||
# ── P0-1: Stabilise — catch events published during replay ──
|
||||
while sub.has_pending:
|
||||
sub.mark_seen()
|
||||
late_events = await store.conversation_events.list_events(
|
||||
conversation_id, after_seq=last_seq,
|
||||
)
|
||||
if not late_events:
|
||||
break
|
||||
for evt in late_events:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
yield _format_sse_event(evt)
|
||||
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
|
||||
if _is_terminal(evt):
|
||||
return
|
||||
|
||||
# ── Send snapshot marker ──
|
||||
snapshot = {
|
||||
"seq": last_seq,
|
||||
"event_id": f"snapshot_{conversation_id}_{last_seq}",
|
||||
"conversation_id": conversation_id,
|
||||
"type": "conversation.snapshot",
|
||||
"occurred_at": datetime.now(UTC).isoformat(),
|
||||
"data": {"last_stream_seq": last_seq},
|
||||
}
|
||||
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:
|
||||
return
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
|
||||
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,
|
||||
)
|
||||
for evt in new_events:
|
||||
if await request.is_disconnected():
|
||||
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:
|
||||
logger.debug("SSE stream cancelled conv=%s", conversation_id)
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("SSE stream error conv=%s", conversation_id)
|
||||
return
|
||||
finally:
|
||||
sub.close()
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers=_SSE_HEADERS,
|
||||
)
|
||||
@@ -131,6 +131,9 @@ async def get_health(request: Request) -> HealthResponse:
|
||||
write_guard_ready=readiness["ready"],
|
||||
action_secret_configured=readiness["secret_configured"],
|
||||
write_guard_status=readiness["status"],
|
||||
sse_hub_type="in_memory",
|
||||
sse_multi_worker=False,
|
||||
sse_warning="Single-worker only. Multi-worker requires Redis Pub/Sub.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Conversation-scoped in-process event hub for MAF roundtable SSE streaming.
|
||||
|
||||
This hub mediates between the writers (store/service/workflow executor that
|
||||
persist conversation events to the DB) and the SSE subscribers (the HTTP
|
||||
stream endpoint). It is deliberately **notification-only**: the database is
|
||||
the authoritative event store, and this hub merely signals "new events may be
|
||||
available" so subscribers can re-query. This avoids duplicating the event log
|
||||
in memory and guarantees that a reconnecting client can always replay from the
|
||||
DB cursor (``after_seq``).
|
||||
|
||||
Writer contract (§4.2 "先持久化、后通知"):
|
||||
1. Persist events to ``maf_roundtable_conversation_events`` (via
|
||||
``MafRoundtableStore.append_conversation_item``) inside a transaction.
|
||||
2. **After commit**, call ``await hub.publish(conversation_id)``.
|
||||
|
||||
Subscriber contract (P0-1 race-free):
|
||||
1. ``sub = hub.open_subscription(conversation_id)`` — register **before**
|
||||
any DB query so publishes during replay are captured.
|
||||
2. Replay from DB: ``list_events(after_seq=last_seq)``.
|
||||
3. **Stabilise**: if ``sub.current_generation > sub.seen_generation``,
|
||||
re-query DB and repeat until stable (events arrived during replay).
|
||||
4. Send snapshot.
|
||||
5. ``await sub.wait_for_change(timeout=heartbeat)`` → on True, query DB
|
||||
for new events; on False, send heartbeat.
|
||||
|
||||
This eliminates the ``signal.clear()`` race: generation is monotonic and
|
||||
never cleared, so a publish that happens between the DB read and the
|
||||
``wait()`` is always detected on the next stabilisation check.
|
||||
|
||||
Multi-worker limitation:
|
||||
Single-process, in-memory. Multi-worker requires Redis Pub/Sub.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_HEARTBEAT_INTERVAL = 15.0
|
||||
_MAX_SUBSCRIBERS_PER_CONVERSATION = 64
|
||||
|
||||
|
||||
class _ConvState:
|
||||
"""Per-conversation notification state."""
|
||||
|
||||
__slots__ = ("generation", "condition", "subscriber_count")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.generation: int = 0
|
||||
self.condition: asyncio.Condition = asyncio.Condition()
|
||||
self.subscriber_count: int = 0
|
||||
|
||||
|
||||
class Subscription:
|
||||
"""Explicit subscription handle — race-free notification reader.
|
||||
|
||||
Created via :meth:`ConversationEventHub.open_subscription`. The caller
|
||||
owns its lifecycle: call :meth:`close` when the SSE connection ends.
|
||||
|
||||
Key invariant: ``seen_generation`` is monotonic — it is never reset.
|
||||
A publish that races ahead of ``wait_for_change`` simply increments
|
||||
``current_generation``, and the next ``wait_for_change`` returns
|
||||
immediately (no lost wakeup).
|
||||
"""
|
||||
|
||||
__slots__ = ("_hub", "_conv_id", "_seen_gen", "_closed")
|
||||
|
||||
def __init__(self, hub: ConversationEventHub, conversation_id: str, gen_at_open: int) -> None:
|
||||
self._hub = hub
|
||||
self._conv_id = conversation_id
|
||||
self._seen_gen = gen_at_open
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def conversation_id(self) -> str:
|
||||
return self._conv_id
|
||||
|
||||
@property
|
||||
def current_generation(self) -> int:
|
||||
"""Latest generation known to the hub for this conversation."""
|
||||
return self._hub._get_generation(self._conv_id)
|
||||
|
||||
@property
|
||||
def seen_generation(self) -> int:
|
||||
"""Generation as of the last successful stabilisation / wait."""
|
||||
return self._seen_gen
|
||||
|
||||
@property
|
||||
def has_pending(self) -> bool:
|
||||
"""True when the hub generation is ahead of what we've seen."""
|
||||
return self.current_generation > self._seen_gen
|
||||
|
||||
def mark_seen(self) -> None:
|
||||
"""Advance ``seen_generation`` to the hub's current generation."""
|
||||
self._seen_gen = self.current_generation
|
||||
|
||||
def mark_seen_if_higher(self, gen: int) -> None:
|
||||
if gen > self._seen_gen:
|
||||
self._seen_gen = gen
|
||||
|
||||
async def wait_for_change(self, *, timeout: float) -> bool:
|
||||
"""Block until ``current_generation > seen_generation`` or timeout.
|
||||
|
||||
Returns ``True`` if the generation advanced (new events *might* be
|
||||
available — caller must query DB to confirm). Returns ``False`` on
|
||||
timeout (caller should send a heartbeat).
|
||||
"""
|
||||
state = self._hub._convs.get(self._conv_id)
|
||||
if state is None:
|
||||
return False
|
||||
async with state.condition:
|
||||
if state.generation > self._seen_gen:
|
||||
self._seen_gen = state.generation
|
||||
return True
|
||||
try:
|
||||
await asyncio.wait_for(state.condition.wait(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
return False
|
||||
if state.generation > self._seen_gen:
|
||||
self._seen_gen = state.generation
|
||||
return True
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the subscription. Safe to call multiple times."""
|
||||
if not self._closed:
|
||||
self._closed = True
|
||||
self._hub._release_subscription(self._conv_id)
|
||||
|
||||
def __enter__(self) -> Subscription:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class ConversationEventHub:
|
||||
"""In-process notification hub keyed by ``conversation_id``.
|
||||
|
||||
Not thread-safe; intended for a single asyncio event loop (one worker).
|
||||
"""
|
||||
|
||||
def __init__(self, *, heartbeat_interval: float = _DEFAULT_HEARTBEAT_INTERVAL) -> None:
|
||||
self._heartbeat_interval = heartbeat_interval
|
||||
self._convs: dict[str, _ConvState] = {}
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def heartbeat_interval(self) -> float:
|
||||
return self._heartbeat_interval
|
||||
|
||||
@property
|
||||
def total_subscribers(self) -> int:
|
||||
return sum(s.subscriber_count for s in self._convs.values())
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
def conversation_subscriber_count(self, conversation_id: str) -> int:
|
||||
state = self._convs.get(conversation_id)
|
||||
return state.subscriber_count if state else 0
|
||||
|
||||
def _get_generation(self, conversation_id: str) -> int:
|
||||
state = self._convs.get(conversation_id)
|
||||
return state.generation if state else 0
|
||||
|
||||
def open_subscription(self, conversation_id: str) -> Subscription:
|
||||
"""Register a new subscriber **before** any DB replay.
|
||||
|
||||
Returns a :class:`Subscription` whose ``seen_generation`` is the hub's
|
||||
current generation at registration time. Any ``publish()`` after this
|
||||
call will be detectable via ``sub.has_pending``.
|
||||
"""
|
||||
if self._closed:
|
||||
raise RuntimeError("ConversationEventHub is shut down")
|
||||
state = self._convs.get(conversation_id)
|
||||
if state is None:
|
||||
state = _ConvState()
|
||||
self._convs[conversation_id] = state
|
||||
if state.subscriber_count >= _MAX_SUBSCRIBERS_PER_CONVERSATION:
|
||||
raise RuntimeError(
|
||||
f"Too many SSE subscribers for conversation {conversation_id} "
|
||||
f"(max {_MAX_SUBSCRIBERS_PER_CONVERSATION})"
|
||||
)
|
||||
state.subscriber_count += 1
|
||||
logger.debug(
|
||||
"SSE subscribe conv=%s gen=%d (subs=%d)",
|
||||
conversation_id, state.generation, state.subscriber_count,
|
||||
)
|
||||
return Subscription(self, conversation_id, state.generation)
|
||||
|
||||
def _release_subscription(self, conversation_id: str) -> None:
|
||||
state = self._convs.get(conversation_id)
|
||||
if state is None:
|
||||
return
|
||||
state.subscriber_count -= 1
|
||||
logger.debug(
|
||||
"SSE unsubscribe conv=%s (subs=%d)",
|
||||
conversation_id, max(0, state.subscriber_count),
|
||||
)
|
||||
if state.subscriber_count <= 0:
|
||||
self._convs.pop(conversation_id, None)
|
||||
|
||||
async def publish(self, conversation_id: str) -> None:
|
||||
"""Signal that new events have been persisted for a conversation.
|
||||
|
||||
Must be called **after** the events are committed to the DB, never
|
||||
before. Atomically increments the per-conversation generation counter
|
||||
and wakes all waiting subscribers. Safe to call when no subscribers
|
||||
are active (the next subscriber's stabilisation check will see the
|
||||
incremented generation).
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
state = self._convs.get(conversation_id)
|
||||
if state is None:
|
||||
# No active subscribers — nothing to wake. The generation is not
|
||||
# tracked for conversations with zero subscribers; a future
|
||||
# open_subscription starts fresh at 0, which is correct because
|
||||
# there was nobody to notify.
|
||||
return
|
||||
async with state.condition:
|
||||
state.generation += 1
|
||||
state.condition.notify_all()
|
||||
|
||||
async def wait_for_change(
|
||||
self, conversation_id: str, seen_gen: int, *, timeout: float
|
||||
) -> int:
|
||||
"""Convenience: wait until generation > ``seen_gen``.
|
||||
|
||||
Returns the new generation (> ``seen_gen``) on change, or ``seen_gen``
|
||||
unchanged on timeout. Prefer using a :class:`Subscription` for the
|
||||
full lifecycle.
|
||||
"""
|
||||
state = self._convs.get(conversation_id)
|
||||
if state is None:
|
||||
return seen_gen
|
||||
async with state.condition:
|
||||
if state.generation > seen_gen:
|
||||
return state.generation
|
||||
try:
|
||||
await asyncio.wait_for(state.condition.wait(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
return seen_gen
|
||||
return state.generation
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Signal all subscribers to stop (server shutdown).
|
||||
|
||||
Wakes every waiter; their ``wait_for_change`` returns ``True`` but
|
||||
the caller's outer loop checks ``hub.is_closed`` and exits.
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
__all__ = ["ConversationEventHub", "Subscription", "hub_aclose"]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Pydantic schemas for the MAF roundtable Conversation API (V2).
|
||||
|
||||
These are the public wire contracts for ``/api/maf-roundtable/conversations``.
|
||||
Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids,
|
||||
capability snapshots, idempotency keys) are NEVER exposed here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
_TASK_CONTEXT_SAFE_KEYS = frozenset({"business_code", "task_id", "task_name", "chain_id", "chain_name"})
|
||||
|
||||
|
||||
class _StrictRequestModel(BaseModel):
|
||||
"""Reject unknown request members instead of silently ignoring spoofed input."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CreateConversationRequest(_StrictRequestModel):
|
||||
title: str = Field(default="", max_length=255)
|
||||
task_context: dict[str, Any] | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _reject_internal_fields(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id",
|
||||
"thread_id", "capability_snapshot", "id"}
|
||||
found = forbidden & set(values.keys())
|
||||
if found:
|
||||
raise ValueError(f"Forbidden fields: {sorted(found)}")
|
||||
return values
|
||||
|
||||
@field_validator("task_context")
|
||||
@classmethod
|
||||
def _validate_task_context(cls, value: dict[str, Any] | None) -> dict[str, str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
unexpected = set(value) - _TASK_CONTEXT_SAFE_KEYS
|
||||
if unexpected:
|
||||
raise ValueError(f"Unsupported task_context fields: {sorted(unexpected)}")
|
||||
cleaned: dict[str, str] = {}
|
||||
for key, raw in value.items():
|
||||
if not isinstance(raw, (str, int)) or isinstance(raw, bool):
|
||||
raise ValueError(f"task_context.{key} must be a string or integer")
|
||||
rendered = str(raw).strip()
|
||||
if not rendered:
|
||||
raise ValueError(f"task_context.{key} cannot be blank")
|
||||
if len(rendered) > 255:
|
||||
raise ValueError(f"task_context.{key} exceeds 255 characters")
|
||||
cleaned[key] = rendered
|
||||
return cleaned
|
||||
|
||||
|
||||
class SendMessageRequest(_StrictRequestModel):
|
||||
content: str = Field(min_length=1, max_length=20000)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _reject_internal_fields(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id",
|
||||
"thread_id", "capability_snapshot", "idempotency_key"}
|
||||
found = forbidden & set(values.keys())
|
||||
if found:
|
||||
raise ValueError(f"Forbidden fields: {sorted(found)}")
|
||||
return values
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _strip_content(self) -> SendMessageRequest:
|
||||
stripped = self.content.strip()
|
||||
if not stripped:
|
||||
raise ValueError("content cannot be empty or whitespace-only")
|
||||
self.content = stripped
|
||||
return self
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
task_context: dict[str, Any] | None = None
|
||||
safe_status: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
last_stream_seq: int = 0
|
||||
|
||||
|
||||
class ConversationListResponse(BaseModel):
|
||||
conversations: list[ConversationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: str
|
||||
conversation_id: str
|
||||
seq: int
|
||||
message_kind: str
|
||||
role: str
|
||||
agent_id: str | None = None
|
||||
message_ref: str | None = None
|
||||
content: str
|
||||
metadata: dict[str, Any] | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class MessageListResponse(BaseModel):
|
||||
messages: list[MessageResponse]
|
||||
last_stream_seq: int
|
||||
|
||||
|
||||
class SendMessageResponse(BaseModel):
|
||||
message_id: str
|
||||
conversation_id: str
|
||||
seq: int
|
||||
@@ -0,0 +1,670 @@
|
||||
"""Conversation service for the MAF roundtable V2 (App layer).
|
||||
|
||||
Orchestrates the harness-layer store with business rules that belong to the
|
||||
application layer: session blueprint construction, ownership enforcement,
|
||||
public DTO projection, and fail-closed behaviour when configuration is missing.
|
||||
|
||||
Blueprint generation lives here (not in the harness) because it depends on
|
||||
deployment config (``MafRoundtableConfig``) which is an app-layer concern.
|
||||
The harness ``MafRoundtableStore`` never imports ``app.*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.maf_roundtable.dispatch_contracts import (
|
||||
DispatchConfigurationError,
|
||||
MafDispatchCatalog,
|
||||
MafDispatchPlan,
|
||||
MafIntentDecision,
|
||||
resolve_intent,
|
||||
)
|
||||
from deerflow.persistence.maf_roundtable.conversations import (
|
||||
conversation_public_to_dict,
|
||||
)
|
||||
from deerflow.persistence.maf_roundtable.messages import message_public_to_dict
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableMessageRow,
|
||||
MafRoundtableRunRow,
|
||||
)
|
||||
from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationServiceError(Exception):
|
||||
def __init__(self, code: str, message: str, status_code: int = 400):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ConversationNotFoundError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("not_found", "Conversation not found", 404)
|
||||
|
||||
|
||||
class ConversationConflictError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("conflict", "A run is already active for this conversation", 409)
|
||||
|
||||
|
||||
class ConversationNoSessionConfigError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"no_session_config",
|
||||
"Cannot start a run: no trusted session configuration available",
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
class ConversationDisabledError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("disabled", "MAF roundtable feature is disabled", 503)
|
||||
|
||||
|
||||
class ConversationDispatchError(ConversationServiceError):
|
||||
def __init__(self, message: str = "MAF dispatch is not configured") -> None:
|
||||
super().__init__("dispatch_unavailable", message, 503)
|
||||
|
||||
|
||||
class ConversationRuntimeUnavailableError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"runtime_unavailable",
|
||||
"MAF workflow runtime is not available",
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
class MafConversationService:
|
||||
"""App-layer service for user-visible MAF conversations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: MafRoundtableStore,
|
||||
*,
|
||||
maf_config: Any = None,
|
||||
event_hub: ConversationEventHub | None = None,
|
||||
agent_store: Any | None = None,
|
||||
app_config: Any | None = None,
|
||||
dispatcher: Any | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._maf_config = maf_config
|
||||
self._hub = event_hub
|
||||
self._agent_store = agent_store
|
||||
self._app_config = app_config
|
||||
self._dispatcher = dispatcher
|
||||
self._enabled_override = enabled
|
||||
self._watchers: dict[str, asyncio.Task[None]] = {}
|
||||
|
||||
@property
|
||||
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))
|
||||
|
||||
def _read_scope_user(self, *, user_id: str, is_admin: bool) -> str | None:
|
||||
"""Keep administrator diagnostics read-only and config-gated."""
|
||||
if is_admin and bool(getattr(self._maf_config, "admin_diagnostic_access", False)):
|
||||
return None
|
||||
return user_id
|
||||
|
||||
async def _build_dispatch_blueprint(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
title: str,
|
||||
content: str,
|
||||
task_context: dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any] | None, MafIntentDecision, MafDispatchPlan | None]:
|
||||
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]] = []
|
||||
capability_snapshot: dict[str, Any] | None = None
|
||||
|
||||
if intent.intent_type != "clarification":
|
||||
try:
|
||||
plan = MafDispatchCatalog(cfg).build_plan(
|
||||
intent=intent,
|
||||
task_context=task_context,
|
||||
)
|
||||
except DispatchConfigurationError as exc:
|
||||
raise ConversationDispatchError(str(exc)) 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"])
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
blueprint = {
|
||||
"title": title,
|
||||
"task_text": content,
|
||||
"mode": getattr(plan, "mode", None) or getattr(cfg, "default_mode", "structured"),
|
||||
"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),
|
||||
"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",
|
||||
}
|
||||
return blueprint, intent, plan
|
||||
|
||||
async def create_conversation(
|
||||
self, *, user_id: str, title: str = "", task_context: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
if not self._enabled:
|
||||
raise ConversationDisabledError()
|
||||
row_data = await self._store.conversations.create_conversation({
|
||||
"owner_user_id": user_id,
|
||||
"title": title,
|
||||
"task_context": task_context,
|
||||
})
|
||||
conv_row = await self._get_conv_row(row_data["id"])
|
||||
if conv_row is None:
|
||||
raise ConversationNotFoundError()
|
||||
return conversation_public_to_dict(conv_row)
|
||||
|
||||
async def list_conversations(
|
||||
self, *, user_id: str, limit: int = 50, offset: int = 0
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
rows = await self._store.conversations.list_conversations(
|
||||
user_id=user_id, limit=limit, offset=offset
|
||||
)
|
||||
total = await self._store.conversations.count_conversations(user_id=user_id)
|
||||
public = []
|
||||
for r in rows:
|
||||
conv_row = await self._get_conv_row(r["id"])
|
||||
if conv_row is not None:
|
||||
public.append(conversation_public_to_dict(conv_row))
|
||||
return public, total
|
||||
|
||||
async def get_conversation(self, *, conversation_id: str, user_id: str, is_admin: bool = False) -> dict[str, Any]:
|
||||
effective_user = self._read_scope_user(user_id=user_id, is_admin=is_admin)
|
||||
row = await self._store.conversations.get_conversation(conversation_id, user_id=effective_user)
|
||||
if row is None:
|
||||
raise ConversationNotFoundError()
|
||||
conv_row = await self._get_conv_row(conversation_id)
|
||||
if conv_row is None:
|
||||
raise ConversationNotFoundError()
|
||||
return conversation_public_to_dict(conv_row)
|
||||
|
||||
async def list_messages(
|
||||
self, *, conversation_id: str, user_id: str, is_admin: bool = False,
|
||||
before_seq: int | None = None, limit: int = 50,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
effective_user = self._read_scope_user(user_id=user_id, is_admin=is_admin)
|
||||
conv = await self._store.conversations.get_conversation(conversation_id, user_id=effective_user)
|
||||
if conv is None:
|
||||
raise ConversationNotFoundError()
|
||||
|
||||
rows = await self._store.messages.list_messages_desc(
|
||||
conversation_id, limit=limit, before_seq=before_seq
|
||||
)
|
||||
public = []
|
||||
for r in rows:
|
||||
msg_row = await self._get_msg_row(r["id"])
|
||||
if msg_row is not None:
|
||||
public.append(message_public_to_dict(msg_row))
|
||||
return public, int(conv.get("last_stream_seq", 0))
|
||||
|
||||
async def send_message(
|
||||
self, *, conversation_id: str, user_id: str, content: str
|
||||
) -> dict[str, Any]:
|
||||
if not self._enabled:
|
||||
raise ConversationDisabledError()
|
||||
conv = await self._store.conversations.get_conversation(conversation_id, user_id=user_id)
|
||||
if conv is None:
|
||||
raise ConversationNotFoundError()
|
||||
|
||||
try:
|
||||
blueprint, intent, plan = await self._build_dispatch_blueprint(
|
||||
user_id=user_id,
|
||||
title=conv.get("title", ""),
|
||||
content=content,
|
||||
task_context=conv.get("task_context"),
|
||||
)
|
||||
except ConversationServiceError:
|
||||
raise
|
||||
|
||||
if blueprint is None:
|
||||
raise ConversationNoSessionConfigError()
|
||||
|
||||
# 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:
|
||||
check_sdk = getattr(self._dispatcher, "check_sdk_available", None)
|
||||
if callable(check_sdk):
|
||||
try:
|
||||
check_sdk()
|
||||
except Exception as exc: # noqa: BLE001 - safe public error
|
||||
logger.warning("MAF runtime unavailable before dispatch: %s", type(exc).__name__)
|
||||
raise ConversationRuntimeUnavailableError() from exc
|
||||
|
||||
result = await self._store.send_message_and_start_run(
|
||||
conversation_id=conversation_id,
|
||||
owner_user_id=user_id,
|
||||
content=content,
|
||||
session_blueprint=blueprint,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise ConversationNotFoundError()
|
||||
if result.get("conflict"):
|
||||
raise ConversationConflictError()
|
||||
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 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"],
|
||||
)
|
||||
|
||||
return {
|
||||
"message_id": result["message"]["id"] if result.get("message") else "",
|
||||
"conversation_id": conversation_id,
|
||||
"seq": result.get("seq", 0),
|
||||
}
|
||||
|
||||
async def _publish_conversation_event(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
owner_user_id: str,
|
||||
event_type: str,
|
||||
event_data: dict[str, Any],
|
||||
source_event_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Persist first, then notify; no internal run/thread ids are used.
|
||||
|
||||
P0-4: when ``source_event_id`` is provided, dedup is enforced — if an
|
||||
event with the same ``(conversation_id, source_event_id)`` already
|
||||
exists, the method returns ``False`` (no duplicate written). This
|
||||
makes projection idempotent across retries and restarts.
|
||||
|
||||
Returns ``True`` if a new event was written, ``False`` if deduped.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
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 or conv.owner_user_id != owner_user_id:
|
||||
return False
|
||||
|
||||
# P0-4: dedup check — skip if already projected.
|
||||
if source_event_id is not None:
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableConversationEventRow,
|
||||
)
|
||||
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(MafRoundtableConversationEventRow.id).where(
|
||||
MafRoundtableConversationEventRow.conversation_id == conversation_id,
|
||||
MafRoundtableConversationEventRow.source_event_id == source_event_id,
|
||||
).limit(1)
|
||||
)
|
||||
).first()
|
||||
if existing is not None:
|
||||
return False
|
||||
|
||||
await self._store.append_conversation_item(
|
||||
db,
|
||||
conversation=conv,
|
||||
event_type=event_type,
|
||||
event_data=event_data,
|
||||
run_id=None,
|
||||
source_event_id=source_event_id,
|
||||
)
|
||||
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)
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
async with self._store.session_factory() as db:
|
||||
row = (
|
||||
await db.execute(
|
||||
select(MafRoundtableConversationRow)
|
||||
.join(
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableConversationRow.internal_session_id
|
||||
== MafRoundtableRunRow.session_id,
|
||||
)
|
||||
.where(MafRoundtableRunRow.id == run_id)
|
||||
)
|
||||
).scalars().first()
|
||||
if row is None:
|
||||
return
|
||||
conversation_id = row.id
|
||||
owner_user_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)
|
||||
|
||||
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
|
||||
|
||||
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"]
|
||||
|
||||
# P0-4: use the source audit event's stable id for dedup.
|
||||
source_evt_id = event.get("id") or event.get("source_event_id")
|
||||
|
||||
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
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
async def _dispatch_run(self, *, conversation_id: str, user_id: str, run: dict[str, Any]) -> None:
|
||||
if self._dispatcher is None:
|
||||
# R1 unit callers can still exercise persistence without starting
|
||||
# the optional SDK runtime. The production dependency wiring always
|
||||
# supplies a dispatcher.
|
||||
return
|
||||
session = await self._store.sessions.get_session(run["session_id"], user_id=user_id)
|
||||
if session is None:
|
||||
raise ConversationDispatchError("internal MAF session could not be loaded")
|
||||
try:
|
||||
await self._dispatcher.dispatch(session, run, user_id=user_id)
|
||||
except Exception as exc: # noqa: BLE001 - reconcile durable state
|
||||
logger.exception("MAF dispatch failed run=%s", run["id"])
|
||||
await self._mark_dispatch_failed(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
run_id=run["id"],
|
||||
error_code=type(exc).__name__[:64],
|
||||
)
|
||||
raise ConversationRuntimeUnavailableError() from exc
|
||||
|
||||
wait_for_run = getattr(self._dispatcher, "wait_for_run", None)
|
||||
if callable(wait_for_run):
|
||||
watcher = asyncio.create_task(
|
||||
self._watch_run(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
run_id=run["id"],
|
||||
wait_for_run=wait_for_run,
|
||||
)
|
||||
)
|
||||
self._watchers[run["id"]] = watcher
|
||||
watcher.add_done_callback(lambda _task, rid=run["id"]: self._watchers.pop(rid, None))
|
||||
|
||||
async def _mark_dispatch_failed(
|
||||
self, *, conversation_id: str, user_id: str, run_id: str, error_code: str
|
||||
) -> None:
|
||||
await self._store.runs.update_run(run_id, {
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(UTC),
|
||||
"error_code": error_code,
|
||||
"error_message_safe": "MAF workflow dispatch failed",
|
||||
})
|
||||
run = await self._store.runs.get_run(run_id)
|
||||
if run is not None:
|
||||
await self._store.sessions.update_session(
|
||||
run["session_id"], user_id=user_id, fields={"status": "failed"}
|
||||
)
|
||||
await self._store.conversations.set_active_run(conversation_id, None, "failed")
|
||||
await self._publish_conversation_event(
|
||||
conversation_id=conversation_id,
|
||||
owner_user_id=user_id,
|
||||
event_type="run.error",
|
||||
event_data={"status": "failed", "error_code": error_code},
|
||||
)
|
||||
|
||||
async def _watch_run(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
run_id: str,
|
||||
wait_for_run: Any,
|
||||
) -> None:
|
||||
try:
|
||||
await wait_for_run(run_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("MAF conversation watcher failed run=%s", run_id)
|
||||
return
|
||||
|
||||
run = await self._store.runs.get_run(run_id)
|
||||
if run is None:
|
||||
return
|
||||
status_value = str(run.get("status") or "failed")
|
||||
terminal = status_value in {"completed", "failed", "cancelled"}
|
||||
await self._store.conversations.set_active_run(
|
||||
conversation_id,
|
||||
None if terminal else run_id,
|
||||
status_value,
|
||||
)
|
||||
event_type = {
|
||||
"completed": "run.completed",
|
||||
"failed": "run.error",
|
||||
"cancelled": "run.cancelled",
|
||||
}.get(status_value, "run.status")
|
||||
data: dict[str, Any] = {"status": status_value}
|
||||
if run.get("error_code"):
|
||||
data["error_code"] = run["error_code"]
|
||||
await self._publish_conversation_event(
|
||||
conversation_id=conversation_id,
|
||||
owner_user_id=user_id,
|
||||
event_type=event_type,
|
||||
event_data=data,
|
||||
)
|
||||
|
||||
async def _get_conv_row(self, conversation_id: str) -> MafRoundtableConversationRow | None:
|
||||
from sqlalchemy import select
|
||||
async with self._store.session_factory() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(MafRoundtableConversationRow).where(
|
||||
MafRoundtableConversationRow.id == conversation_id
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
return row
|
||||
|
||||
async def _get_msg_row(self, message_id: str) -> MafRoundtableMessageRow | None:
|
||||
from sqlalchemy import select
|
||||
async with self._store.session_factory() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(MafRoundtableMessageRow).where(
|
||||
MafRoundtableMessageRow.id == message_id
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
return row
|
||||
@@ -488,6 +488,7 @@ 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)
|
||||
|
||||
async def _emit(self, run_id: str, event: dict[str, Any]) -> None:
|
||||
"""Emit a non-critical event (agent.message.delta). Failure degrades gracefully.
|
||||
@@ -518,6 +519,42 @@ 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)
|
||||
|
||||
async def _bridge_conversation_event(self, run_id: str, event: dict[str, Any]) -> 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.
|
||||
|
||||
P0-3: the entire ``app.state`` resolution is best-effort. If any link
|
||||
is missing (no app, no state, no conversation service), the projection
|
||||
is silently skipped — audit, recovery, cancel, lease, and tool
|
||||
execution safety are never affected.
|
||||
"""
|
||||
try:
|
||||
app = getattr(self._client, "app", None)
|
||||
state = getattr(app, "state", None)
|
||||
conversation_service = getattr(state, "maf_conversation_service", None)
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
if conversation_service is None:
|
||||
return
|
||||
bridge = getattr(conversation_service, "bridge_workflow_event", None)
|
||||
if not callable(bridge):
|
||||
return
|
||||
try:
|
||||
await bridge(run_id=run_id, event=event)
|
||||
except Exception: # noqa: BLE001 - audit already persisted
|
||||
logger.warning(
|
||||
"conversation event projection failed for run %s (%s)",
|
||||
run_id,
|
||||
event.get("event_type"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# ── internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Deterministic, server-owned contracts for MAF Stage 3 dispatch.
|
||||
|
||||
The manager may describe work, but it does not get to choose arbitrary agent
|
||||
ids, threads, tools, or capabilities. Routing is resolved from the trusted
|
||||
MAF deployment configuration and the small, validated task-context allowlist.
|
||||
This module is intentionally independent of the MAF SDK so the policy can be
|
||||
tested with a weak/fake model and before any workflow is launched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from deerflow.config.maf_roundtable_config import MafMode
|
||||
|
||||
MafIntentType = Literal[
|
||||
"direct_answer",
|
||||
"clarification",
|
||||
"single_seat",
|
||||
"parallel_consultation",
|
||||
"chain_consultation",
|
||||
"tool_write",
|
||||
]
|
||||
|
||||
|
||||
class DispatchConfigurationError(RuntimeError):
|
||||
"""The deployment has not supplied a complete trusted route."""
|
||||
|
||||
|
||||
class InvalidDispatchPlanError(ValueError):
|
||||
"""A proposed or constructed plan violates the dispatch contract."""
|
||||
|
||||
|
||||
class MafIntentDecision(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
intent_type: MafIntentType
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
reason_code: str = Field(min_length=1, max_length=64)
|
||||
clarification_prompt: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _clarification_shape(self) -> MafIntentDecision:
|
||||
if self.intent_type == "clarification" and not self.clarification_prompt:
|
||||
raise ValueError("clarification intent requires clarification_prompt")
|
||||
if self.intent_type != "clarification" and self.clarification_prompt is not None:
|
||||
raise ValueError("only clarification intent may include clarification_prompt")
|
||||
return self
|
||||
|
||||
|
||||
class MafDispatchAssignment(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
agent_id: str = Field(min_length=1, max_length=64)
|
||||
role: Literal["seat"] = "seat"
|
||||
sort_order: int = Field(ge=1, le=64)
|
||||
|
||||
|
||||
class MafDispatchPlan(BaseModel):
|
||||
"""Validated plan containing only server-selected participants."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
manager_agent_id: str = Field(min_length=1, max_length=64)
|
||||
seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
|
||||
assignments: list[MafDispatchAssignment] = Field(default_factory=list, max_length=64)
|
||||
mode: MafMode = "structured"
|
||||
intent_type: MafIntentType
|
||||
business_code: str | None = Field(default=None, max_length=255)
|
||||
max_rounds: int = Field(ge=1, le=64)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_shape(self) -> MafDispatchPlan:
|
||||
if self.manager_agent_id in self.seat_agent_ids:
|
||||
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")
|
||||
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")
|
||||
if [a.sort_order for a in self.assignments] != list(range(1, len(self.assignments) + 1)):
|
||||
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" and not self.seat_agent_ids:
|
||||
raise ValueError("this intent requires at least one configured seat")
|
||||
return self
|
||||
|
||||
|
||||
def resolve_intent(content: str, task_context: dict[str, Any] | None = None) -> MafIntentDecision:
|
||||
"""Resolve the common Stage 3 intents deterministically.
|
||||
|
||||
A production model can later supply a *description* for the intent, but
|
||||
this policy remains the final validator. The fallback deliberately uses
|
||||
conservative keywords and never extracts agent/tool ids from user text.
|
||||
"""
|
||||
|
||||
text = " ".join(str(content or "").strip().split())
|
||||
lowered = text.casefold()
|
||||
if len(text) < 4 or lowered in {"?", "?", "不清楚", "不明确", "再说清楚"}:
|
||||
return MafIntentDecision(
|
||||
intent_type="clarification",
|
||||
confidence=0.99,
|
||||
reason_code="insufficient_task_detail",
|
||||
clarification_prompt="请补充希望解决的具体问题、目标和必要的业务背景。",
|
||||
)
|
||||
|
||||
write_markers = (
|
||||
"写入", "发布", "创建工单", "更新任务", "提交", "删除", "write", "publish", "create ticket",
|
||||
)
|
||||
chain_markers = ("业务链", "链式", "逐阶段", "先…再", "先...再", "chain")
|
||||
parallel_markers = ("多专家", "多角度", "并行", "同时分析", "比较", "parallel", "multi-agent")
|
||||
single_markers = ("请某个专家", "单个席位", "单专家", "single seat", "指定专家")
|
||||
|
||||
if any(marker in lowered for marker in write_markers):
|
||||
intent_type: MafIntentType = "tool_write"
|
||||
reason_code = "write_request"
|
||||
elif any(marker in lowered for marker in chain_markers):
|
||||
intent_type = "chain_consultation"
|
||||
reason_code = "chain_request"
|
||||
elif any(marker in lowered for marker in parallel_markers):
|
||||
intent_type = "parallel_consultation"
|
||||
reason_code = "parallel_request"
|
||||
elif any(marker in lowered for marker in single_markers):
|
||||
intent_type = "single_seat"
|
||||
reason_code = "single_seat_request"
|
||||
else:
|
||||
intent_type = "direct_answer"
|
||||
reason_code = "default_direct_answer"
|
||||
|
||||
return MafIntentDecision(
|
||||
intent_type=intent_type,
|
||||
confidence=0.72 if task_context else 0.68,
|
||||
reason_code=reason_code,
|
||||
)
|
||||
|
||||
|
||||
def _read(value: Any, name: str, default: Any = None) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return value.get(name, default)
|
||||
return getattr(value, name, default)
|
||||
|
||||
|
||||
class MafDispatchCatalog:
|
||||
"""Resolve routes from deployment config, never from a request body."""
|
||||
|
||||
def __init__(self, maf_config: Any) -> None:
|
||||
self._config = maf_config
|
||||
|
||||
def _route(self, business_code: str | None) -> tuple[str | None, list[str], MafMode | None]:
|
||||
routes = _read(self._config, "business_routes", {}) or {}
|
||||
if business_code:
|
||||
route = routes.get(business_code)
|
||||
if route is None:
|
||||
# A supplied business code must match an explicitly configured
|
||||
# route. Falling back here would silently cross business
|
||||
# boundaries.
|
||||
return None, [], None
|
||||
return (
|
||||
_read(route, "manager_agent_id"),
|
||||
list(_read(route, "seat_agent_ids", []) or []),
|
||||
_read(route, "mode"),
|
||||
)
|
||||
return (
|
||||
_read(self._config, "default_manager_agent_id"),
|
||||
list(_read(self._config, "default_seat_agent_ids", []) or []),
|
||||
None,
|
||||
)
|
||||
|
||||
def build_plan(
|
||||
self,
|
||||
*,
|
||||
intent: MafIntentDecision,
|
||||
task_context: dict[str, Any] | None,
|
||||
) -> MafDispatchPlan:
|
||||
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"
|
||||
)
|
||||
|
||||
# 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"}:
|
||||
seat_ids = route_seats[:1]
|
||||
else:
|
||||
seat_ids = route_seats[:]
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
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),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise DispatchConfigurationError("invalid trusted MAF dispatch route") from exc
|
||||
|
||||
trusted_ids = {str(manager_id), *(str(agent_id) for agent_id in route_seats)}
|
||||
validate_dispatch_plan(plan, trusted_agent_ids=trusted_ids, max_seats=max_seats)
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def participants(plan: MafDispatchPlan) -> list[dict[str, Any]]:
|
||||
"""Return persistence records; thread ids are intentionally absent."""
|
||||
return [
|
||||
{
|
||||
"agent_id": plan.manager_agent_id,
|
||||
"role": "manager",
|
||||
"sort_order": 0,
|
||||
"display_name": "",
|
||||
},
|
||||
*[
|
||||
{
|
||||
"agent_id": assignment.agent_id,
|
||||
"role": "seat",
|
||||
"sort_order": assignment.sort_order,
|
||||
"display_name": "",
|
||||
}
|
||||
for assignment in plan.assignments
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def validate_dispatch_plan(
|
||||
plan: MafDispatchPlan,
|
||||
*,
|
||||
trusted_agent_ids: set[str],
|
||||
max_seats: int,
|
||||
) -> None:
|
||||
"""Final allowlist check used before persistence and workflow launch."""
|
||||
|
||||
if plan.manager_agent_id not in trusted_agent_ids:
|
||||
raise InvalidDispatchPlanError("manager agent is outside the trusted route")
|
||||
if len(plan.seat_agent_ids) > max_seats:
|
||||
raise InvalidDispatchPlanError("dispatch plan exceeds the seat limit")
|
||||
if any(agent_id not in trusted_agent_ids for agent_id in plan.seat_agent_ids):
|
||||
raise InvalidDispatchPlanError("seat agent is outside the trusted route")
|
||||
if len(set(plan.seat_agent_ids)) != len(plan.seat_agent_ids):
|
||||
raise InvalidDispatchPlanError("dispatch plan contains duplicate seats")
|
||||
if plan.manager_agent_id in plan.seat_agent_ids:
|
||||
raise InvalidDispatchPlanError("manager cannot be dispatched as a seat")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DispatchConfigurationError",
|
||||
"InvalidDispatchPlanError",
|
||||
"MafDispatchAssignment",
|
||||
"MafDispatchPlan",
|
||||
"MafDispatchCatalog",
|
||||
"MafIntentDecision",
|
||||
"MafIntentType",
|
||||
"resolve_intent",
|
||||
"validate_dispatch_plan",
|
||||
]
|
||||
@@ -71,6 +71,23 @@ class MafRunDispatcher:
|
||||
self._tasks[run_id] = task
|
||||
task.add_done_callback(lambda t, rid=run_id: self._tasks.pop(rid, None))
|
||||
|
||||
async def wait_for_run(self, run_id: str) -> None:
|
||||
"""Wait for an in-process run without exposing its task to callers.
|
||||
|
||||
Conversation V2 uses this only for a server-side status bridge. The
|
||||
persisted run remains authoritative, so a missing task (for example
|
||||
after a worker restart) simply returns and lets recovery own the run.
|
||||
"""
|
||||
task = self._tasks.get(run_id)
|
||||
if task is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - runner already persisted failure
|
||||
return
|
||||
|
||||
async def _execute(self, runner: Any, session: dict[str, Any], run: dict[str, Any], run_id: str) -> None:
|
||||
"""Execute the workflow, catching all exceptions (they're already persisted by the runner)."""
|
||||
try:
|
||||
|
||||
@@ -245,6 +245,12 @@ class HealthResponse(BaseModel):
|
||||
"""Health / feature-flag probe. The frontend reads ``enabled`` to decide
|
||||
whether to show the MAF entry. ``store_available`` is false when no DB is
|
||||
configured (memory mode) — the router then returns 503 on writes.
|
||||
|
||||
SSE fields (P1): ``sse_hub_type`` reports the hub implementation
|
||||
(``in_memory`` or future ``redis``); ``sse_multi_worker`` is ``False``
|
||||
until Redis Pub/Sub is wired; ``sse_warning`` carries a human-readable
|
||||
advisory. When multi-worker is unsupported, production deployments must
|
||||
use a single worker or fail-closed.
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
@@ -257,3 +263,6 @@ class HealthResponse(BaseModel):
|
||||
write_guard_ready: bool
|
||||
action_secret_configured: bool
|
||||
write_guard_status: str
|
||||
sse_hub_type: str = "in_memory"
|
||||
sse_multi_worker: bool = False
|
||||
sse_warning: str = "Single-worker only. Multi-worker requires Redis Pub/Sub."
|
||||
@@ -74,6 +74,8 @@ class RoundtableState:
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
seat_timeout_seconds: float = 600.0,
|
||||
schema_repair_enabled: bool = True,
|
||||
intent_type: str | None = None,
|
||||
dispatch_plan: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.session_id = session_id
|
||||
self.run_id = run_id
|
||||
@@ -89,6 +91,11 @@ class RoundtableState:
|
||||
self.capability_snapshot = capability_snapshot
|
||||
self.seat_timeout_seconds = seat_timeout_seconds
|
||||
self.schema_repair_enabled = schema_repair_enabled
|
||||
# Stage 3 server-validated routing metadata. The workflow may use
|
||||
# this for diagnostics/checkpoints, but it never accepts a new agent
|
||||
# id from manager model output.
|
||||
self.intent_type = intent_type
|
||||
self.dispatch_plan = dispatch_plan
|
||||
|
||||
self.current_round = 0
|
||||
self.turn_results: list[dict[str, Any]] = []
|
||||
@@ -128,6 +135,8 @@ class RoundtableState:
|
||||
"capability_snapshot": self.capability_snapshot,
|
||||
"seat_timeout_seconds": self.seat_timeout_seconds,
|
||||
"schema_repair_enabled": self.schema_repair_enabled,
|
||||
"intent_type": self.intent_type,
|
||||
"dispatch_plan": self.dispatch_plan,
|
||||
"current_round": self.current_round,
|
||||
"turn_results": self.turn_results,
|
||||
"fact_ledger": self.fact_ledger,
|
||||
@@ -159,6 +168,8 @@ class RoundtableState:
|
||||
capability_snapshot=d.get("capability_snapshot"),
|
||||
seat_timeout_seconds=float(d.get("seat_timeout_seconds") or 600.0),
|
||||
schema_repair_enabled=bool(d.get("schema_repair_enabled", True)),
|
||||
intent_type=d.get("intent_type"),
|
||||
dispatch_plan=d.get("dispatch_plan"),
|
||||
)
|
||||
state.current_round = d.get("current_round", 0)
|
||||
state.turn_results = d.get("turn_results", [])
|
||||
@@ -531,12 +542,20 @@ class SeatExecutor(Executor):
|
||||
a for a in turn_actions
|
||||
if a.get("tool_name") == tc_name and not a.get("logical_action_id")
|
||||
]
|
||||
if len(hits) != 1:
|
||||
if len(hits) > 1:
|
||||
raise RuntimeError(
|
||||
f"seat {self._agent_id} turn {self._state.current_round}: "
|
||||
f"pending tool call '{tc_name}' (id={tc_id or '<none>'}) matched "
|
||||
f"{len(hits)} waiting actions (expected exactly 1) — refusing to guess"
|
||||
)
|
||||
if len(hits) == 0:
|
||||
logger.warning(
|
||||
"seat %s turn %s: pending tool call '%s' (id=%s) classified as "
|
||||
"needing approval but matched 0 waiting actions — tool may be "
|
||||
"unclassified or executed as read by WriteGuard; skipping",
|
||||
self._agent_id, self._state.current_round, tc_name, tc_id or "<none>",
|
||||
)
|
||||
continue
|
||||
matched.append(hits[0])
|
||||
|
||||
# Merge into pending_approvals (multi-seat).
|
||||
@@ -686,12 +705,45 @@ class CrossReviewExecutor(Executor):
|
||||
if failed_seats:
|
||||
review["gaps"] = [f"席位失败: {', '.join(failed_seats)}"]
|
||||
|
||||
# The quality gate is server-owned. A manager may recommend acceptance,
|
||||
# but gaps/failures/schema degradation always become a controlled gap
|
||||
# and are carried into the final result instead of being labelled as a
|
||||
# verified fact.
|
||||
has_quality_gap = bool(
|
||||
review.get("partial_failure")
|
||||
or review.get("failed_seats")
|
||||
or review.get("gaps")
|
||||
or review.get("conflicts")
|
||||
or review.get("tool_failures")
|
||||
or review.get("schema_status") == "schema_invalid"
|
||||
)
|
||||
review["decision"] = "controlled_gap" if has_quality_gap else "accept"
|
||||
review["reason"] = (
|
||||
"required evidence or seat output is incomplete"
|
||||
if has_quality_gap
|
||||
else "required seat deliveries passed deterministic checks"
|
||||
)
|
||||
self._state.cross_review_result = review
|
||||
# Surface unresolved conflicts from review notes.
|
||||
for c in review.get("conflicts") or []:
|
||||
if c and c not in self._state.fact_ledger["conflicts"]:
|
||||
self._state.fact_ledger["conflicts"].append(c)
|
||||
|
||||
await self._adapter._emit_critical(self._state.run_id, {
|
||||
"event_type": "manager.review.decision",
|
||||
"agent_id": self._state.manager_agent_id or "",
|
||||
"turn_index": self._state.current_round,
|
||||
"payload": {
|
||||
"decision": review["decision"],
|
||||
"reason": review["reason"],
|
||||
"quality": {
|
||||
"failed_seat_count": len(review.get("failed_seats") or []),
|
||||
"gap_count": len(review.get("gaps") or []),
|
||||
"conflict_count": len(review.get("conflicts") or []),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
self._state.mark_completed("cross_review")
|
||||
self._state.stage = "approval"
|
||||
await ctx.send_message({"decision": "reviewed", "review": review})
|
||||
@@ -937,16 +989,13 @@ class ApprovalGateExecutor(Executor):
|
||||
def _check_needs_approval(self) -> bool:
|
||||
if self._state.write_policy == "auto":
|
||||
return False
|
||||
if self._state.pending_approvals:
|
||||
return True
|
||||
for result in self._state.turn_results:
|
||||
for tc in result.get("tool_calls") or []:
|
||||
name = tc.get("name", "")
|
||||
if not name:
|
||||
continue
|
||||
if tool_needs_approval(name, self._state.write_policy, self._state.capability_snapshot):
|
||||
return True
|
||||
return False
|
||||
# pending_approvals is the authoritative source: it is populated by
|
||||
# SeatExecutor._capture_pending_approvals which only adds tool calls
|
||||
# that WriteGuard actually intercepted (i.e. have a database action).
|
||||
# Tools that were classified as needing approval but had 0 matching
|
||||
# actions (unclassified/hallucinated/read tools) are intentionally
|
||||
# skipped and must NOT re-trigger the gate here.
|
||||
return bool(self._state.pending_approvals)
|
||||
|
||||
|
||||
# ── Finalizer ───────────────────────────────────────────────────────────────
|
||||
@@ -1006,6 +1055,10 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
||||
"agent_id": manager_id,
|
||||
"partial_failure": state.partial_failure,
|
||||
"degraded": state.partial_failure,
|
||||
"quality_decision": (
|
||||
(state.cross_review_result or {}).get("decision")
|
||||
or ("controlled_gap" if state.partial_failure else "accept")
|
||||
),
|
||||
}
|
||||
else:
|
||||
state.final_result = {
|
||||
@@ -1014,6 +1067,10 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
||||
"note": "no_manager_thread",
|
||||
"partial_failure": state.partial_failure,
|
||||
"degraded": state.partial_failure,
|
||||
"quality_decision": (
|
||||
(state.cross_review_result or {}).get("decision")
|
||||
or ("controlled_gap" if state.partial_failure else "accept")
|
||||
),
|
||||
}
|
||||
|
||||
await adapter._emit_critical(state.run_id, make_run_completed(manager_id, state.current_round + 1, {
|
||||
|
||||
@@ -96,6 +96,9 @@ class WorkflowRunner:
|
||||
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 {}
|
||||
|
||||
state = RoundtableState(
|
||||
session_id=session_id,
|
||||
@@ -112,6 +115,8 @@ class WorkflowRunner:
|
||||
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"),
|
||||
)
|
||||
|
||||
workflow = self._build_workflow(state)
|
||||
|
||||
@@ -562,6 +562,12 @@ maf_roundtable:
|
||||
default_mode: structured # structured(固定流程)| group_chat(动态群聊)
|
||||
default_write_policy: confirm_high_risk # auto | confirm_high_risk | confirm_all_writes
|
||||
default_max_rounds: 8 # 群聊模式轮次硬上限
|
||||
# Stage 3 routing is deployment-owned. Keep these empty in the repository;
|
||||
# production must inject exact agent ids from its catalog.
|
||||
default_manager_agent_id: null
|
||||
default_seat_agent_ids: []
|
||||
business_routes: {}
|
||||
max_seats_per_run: 16
|
||||
admin_diagnostic_access: true # 管理员是否可跨用户读取所有 MAF 会商用于诊断(仅读,不含写)
|
||||
seat_timeout_seconds: 600 # 结构化流程单席位超时(秒)
|
||||
approval_scan_interval_seconds: 30 # 审批主动过期扫描间隔(秒)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# MAF 会商 SSE 多 Worker 部署说明
|
||||
|
||||
> 状态:Phase 2 (R2) — 单 Worker 已就绪,多 Worker 需补 Redis Pub/Sub
|
||||
> 更新时间:2026-08-03
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前架构(单 Worker)
|
||||
|
||||
`ConversationEventHub`(`app/maf_roundtable/conversation_event_hub.py`)是一个
|
||||
**进程内** 的 `asyncio.Event` 通知器:
|
||||
|
||||
- 每个 worker 进程有自己独立的 hub 实例(`app.state.maf_conversation_event_hub`)
|
||||
- 事件写入者(`ConversationService.send_message`)在事务提交后调用 `hub.publish()`
|
||||
- SSE 订阅者通过 `hub.subscribe()` 注册一个 `asyncio.Event`,被 `publish()` 唤醒后
|
||||
从数据库查询最新事件(通知-only,不缓存事件内容)
|
||||
- 心跳:15 秒超时 → `: heartbeat\n\n` SSE 注释帧
|
||||
|
||||
**适用场景:** `uvicorn --workers 1` / `gunicorn -w 1`,开发环境、小规模灰度。
|
||||
|
||||
---
|
||||
|
||||
## 2. 多 Worker 限制
|
||||
|
||||
当使用 `--workers 2` 或更多 worker 时:
|
||||
|
||||
| 问题 | 原因 | 影响 |
|
||||
|------|------|------|
|
||||
| **跨 Worker 不通知** | Worker A 写入事件后 `hub.publish()` 只通知 A 自己的订阅者;Worker B 的 SSE 客户端收不到实时推送 | SSE 流"卡住"直到客户端超时重连,重连后从 DB 重放才能补到事件 |
|
||||
| **无跨进程共享状态** | `asyncio.Event` 是进程内对象,不能跨进程通信 | 需要 Redis Pub/Sub 或等价中间件 |
|
||||
|
||||
**在接入 Redis Pub/Sub 之前,禁止多 Worker 灰度上线。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 健康检查建议
|
||||
|
||||
建议在 `GET /api/maf-roundtable/health` 中增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"sse_hub_type": "in_memory",
|
||||
"sse_multi_worker": false,
|
||||
"sse_warning": "Single-worker only. Multi-worker requires Redis Pub/Sub."
|
||||
}
|
||||
```
|
||||
|
||||
当前版本已通过 `sse_multi_worker: false` 明确标注不支持多 Worker。
|
||||
部署脚本应检查此项,在多 Worker 模式下发出告警。
|
||||
|
||||
---
|
||||
|
||||
## 4. Redis Pub/Sub 接入方案(后续阶段)
|
||||
|
||||
当需要多 Worker 时,替换 `ConversationEventHub` 为 Redis-backed 实现:
|
||||
|
||||
### 4.1 接口不变
|
||||
|
||||
```python
|
||||
class ConversationEventHub(abc.ABC):
|
||||
async def publish(self, conversation_id: str) -> None: ...
|
||||
async def subscribe(self, conversation_id: str, ...) -> AsyncIterator[bool]: ...
|
||||
```
|
||||
|
||||
### 4.2 Redis 实现
|
||||
|
||||
```python
|
||||
class RedisConversationEventHub(ConversationEventHub):
|
||||
def __init__(self, redis: Redis, *, heartbeat_interval: float = 15.0):
|
||||
self._redis = redis
|
||||
self._heartbeat_interval = heartbeat_interval
|
||||
|
||||
async def publish(self, conversation_id: str) -> None:
|
||||
await self._redis.publish(f"maf:conv:{conversation_id}", "1")
|
||||
|
||||
async def subscribe(self, conversation_id: str, ...):
|
||||
pubsub = self._redis.pubsub()
|
||||
await pubsub.subscribe(f"maf:conv:{conversation_id}")
|
||||
try:
|
||||
while True:
|
||||
msg = await asyncio.wait_for(
|
||||
pubsub.get_message(ignore_subscribe_messages=True, timeout=...),
|
||||
timeout=self._heartbeat_interval,
|
||||
)
|
||||
if msg is None:
|
||||
yield False # heartbeat
|
||||
else:
|
||||
yield True # new events might be available
|
||||
finally:
|
||||
await pubsub.unsubscribe(f"maf:conv:{conversation_id}")
|
||||
```
|
||||
|
||||
### 4.3 选择逻辑
|
||||
|
||||
```python
|
||||
# deps.py
|
||||
if config.database and config.database.redis_url:
|
||||
hub = RedisConversationEventHub(create_redis(config.database.redis_url))
|
||||
else:
|
||||
hub = ConversationEventHub() # in-memory, single-worker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 部署检查清单
|
||||
|
||||
- [ ] 确认 `uvicorn --workers 1`(或等价单进程配置)
|
||||
- [ ] 确认 SSE 端点 `GET /api/maf-roundtable/conversations/{id}/stream` 可访问
|
||||
- [ ] 确认 nginx / 反向代理未缓冲 SSE(`X-Accel-Buffering: no` 生效)
|
||||
- [ ] 确认心跳间隔 15s 足够长不会误触发代理超时(proxy_read_timeout 应 ≥ 60s)
|
||||
- [ ] 确认 `after_seq` 重连不产生重复气泡(前端 reducer 按 seq 去重)
|
||||
- [ ] 多 Worker 上线前:接入 Redis Pub/Sub 并更新 `sse_multi_worker: true`
|
||||
+34
@@ -87,6 +87,32 @@ class MafToolMetadata(BaseModel):
|
||||
return namespace
|
||||
|
||||
|
||||
class MafBusinessRoute(BaseModel):
|
||||
"""Server-owned route for one MAF business chain.
|
||||
|
||||
The browser and the model never submit these agent ids. Operators must
|
||||
configure the exact ids discovered in the deployment's agent catalog.
|
||||
Keeping the route in the MAF config also makes a session reproducible: the
|
||||
selected route is frozen into its trusted capability snapshot at launch.
|
||||
"""
|
||||
|
||||
manager_agent_id: str = Field(min_length=1, max_length=64)
|
||||
seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
|
||||
mode: MafMode | None = None
|
||||
|
||||
@field_validator("seat_agent_ids")
|
||||
@classmethod
|
||||
def _validate_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("seat_agent_ids must contain non-empty agent ids")
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
|
||||
|
||||
class MafRoundtableConfig(BaseModel):
|
||||
"""Microsoft Agent Framework 多智能体会商实验功能开关。
|
||||
|
||||
@@ -116,6 +142,13 @@ class MafRoundtableConfig(BaseModel):
|
||||
le=64,
|
||||
description="默认最大轮次上限(群聊模式硬上限,避免弱模型无限讨论)",
|
||||
)
|
||||
# Stage 3: server-owned routing. These remain empty in the repository;
|
||||
# production deployments must inject exact agent ids in their deployment
|
||||
# configuration instead of guessing from names or client input.
|
||||
default_manager_agent_id: str | None = Field(default=None, max_length=64)
|
||||
default_seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
|
||||
business_routes: dict[str, MafBusinessRoute] = Field(default_factory=dict)
|
||||
max_seats_per_run: int = Field(default=16, ge=1, le=64)
|
||||
# 管理员诊断是否可见所有用户的会商(true=管理员跨用户读,false=管理员也只能看自己的)。
|
||||
admin_diagnostic_access: bool = Field(
|
||||
default=True,
|
||||
@@ -162,6 +195,7 @@ class MafRoundtableConfig(BaseModel):
|
||||
|
||||
__all__ = [
|
||||
"MafMode",
|
||||
"MafBusinessRoute",
|
||||
"MafParticipantRole",
|
||||
"MafRiskLevel",
|
||||
"MafRoundtableConfig",
|
||||
|
||||
+12
@@ -31,13 +31,19 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from deerflow.persistence.maf_roundtable.action_secrets import MafActionSecretRepository
|
||||
from deerflow.persistence.maf_roundtable.actions import MafActionRepository
|
||||
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
|
||||
from deerflow.persistence.maf_roundtable.conversation_events import MafConversationEventRepository
|
||||
from deerflow.persistence.maf_roundtable.conversations import MafConversationRepository
|
||||
from deerflow.persistence.maf_roundtable.events import MafEventRepository
|
||||
from deerflow.persistence.maf_roundtable.messages import MafMessageRepository
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableActionLeaseRow,
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
MafRoundtableConversationEventRow,
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableEventRow,
|
||||
MafRoundtableMessageRow,
|
||||
MafRoundtableParticipantRow,
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableSeatRunRow,
|
||||
@@ -53,13 +59,19 @@ __all__ = [
|
||||
"MafActionRepository",
|
||||
"MafActionSecretRepository",
|
||||
"MafApprovalRepository",
|
||||
"MafConversationEventRepository",
|
||||
"MafConversationRepository",
|
||||
"MafEventRepository",
|
||||
"MafMessageRepository",
|
||||
"MafParticipantRepository",
|
||||
"MafRoundtableActionRow",
|
||||
"MafRoundtableActionLeaseRow",
|
||||
"MafRoundtableActionSecretRow",
|
||||
"MafRoundtableApprovalRow",
|
||||
"MafRoundtableConversationEventRow",
|
||||
"MafRoundtableConversationRow",
|
||||
"MafRoundtableEventRow",
|
||||
"MafRoundtableMessageRow",
|
||||
"MafRoundtableParticipantRow",
|
||||
"MafRoundtableRunRow",
|
||||
"MafRoundtableSeatRunRow",
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""Conversation-event repository for the MAF roundtable V2 (SSE stream).
|
||||
|
||||
Unlike ``maf_roundtable_events`` (per-run seq), this table uses a
|
||||
**conversation-global** monotonic ``seq`` so the frontend needs only one cursor
|
||||
(``after_seq``) regardless of how many internal runs a conversation spawns.
|
||||
The SSE hub (§4.2) replays from this table on connect/reconnect, then
|
||||
subscribes to real-time notifications.
|
||||
|
||||
Seq allocation mirrors ``maf_roundtable_events``: ``SELECT MAX(seq) + 1`` with
|
||||
``IntegrityError`` retry, portable across sqlite / postgres / mysql.
|
||||
|
||||
The writer order is "persist first, then notify" — the hub never broadcasts an
|
||||
event that is not already durable here, so a reconnecting client can always
|
||||
replay anything it missed.
|
||||
"""
|
||||
|
||||
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 MafRoundtableConversationEventRow
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def conversation_event_to_dict(row: MafRoundtableConversationEventRow) -> dict[str, Any]:
|
||||
"""Serialise a conversation-event ORM row to the §4.3 SSE envelope dict."""
|
||||
return {
|
||||
"seq": row.seq,
|
||||
"event_id": row.id,
|
||||
"conversation_id": row.conversation_id,
|
||||
"run_id": row.run_id,
|
||||
"source_event_id": getattr(row, "source_event_id", None),
|
||||
"type": row.event_type,
|
||||
"occurred_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"data": row.data_json or {},
|
||||
}
|
||||
|
||||
|
||||
class MafConversationEventRepository:
|
||||
"""Conversation-global monotonic event stream backing the SSE channel."""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def append_event(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
event_type: str,
|
||||
run_id: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist one event with a monotonic conversation-global seq.
|
||||
|
||||
Concurrent appends are safe: each attempts ``MAX(seq)+1`` and retries
|
||||
on the ``(conversation_id, seq)`` unique violation. Returns the §4.3
|
||||
envelope dict.
|
||||
"""
|
||||
del conversation_id, event_type, run_id, data
|
||||
raise RuntimeError(
|
||||
"Conversation event writes must use "
|
||||
"MafRoundtableStore.append_conversation_item"
|
||||
)
|
||||
|
||||
async def list_events(
|
||||
self, conversation_id: str, *, after_seq: int = 0, limit: int = 1000
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Replay: events with ``seq > after_seq`` in ascending order.
|
||||
|
||||
Used by the SSE hub on connect/reconnect to catch the client up before
|
||||
switching to the real-time subscription.
|
||||
"""
|
||||
stmt = (
|
||||
select(MafRoundtableConversationEventRow)
|
||||
.where(
|
||||
MafRoundtableConversationEventRow.conversation_id == conversation_id,
|
||||
MafRoundtableConversationEventRow.seq > after_seq,
|
||||
)
|
||||
.order_by(MafRoundtableConversationEventRow.seq.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return [conversation_event_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(MafRoundtableConversationEventRow.seq)).where(
|
||||
MafRoundtableConversationEventRow.conversation_id == conversation_id
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
return int(max_seq or 0)
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
"""Conversation repository for the MAF roundtable V2 (user-visible aggregation).
|
||||
|
||||
A conversation is the **only** entity the frontend knows about. Owner-scoped
|
||||
by ``owner_user_id``: every read / update / delete checks ownership. Admins
|
||||
bypass the owner filter when ``admin_diagnostic_access`` is true (handled in
|
||||
the service / router layer, not here).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def conversation_internal_to_dict(row: MafRoundtableConversationRow) -> dict[str, Any]:
|
||||
"""Internal serialisation — includes all columns. Never expose to the browser."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"owner_user_id": row.owner_user_id,
|
||||
"title": row.title,
|
||||
"task_context": row.task_context_json,
|
||||
"safe_status": row.safe_status,
|
||||
"active_run_id": row.active_run_id,
|
||||
"internal_session_id": row.internal_session_id,
|
||||
"last_stream_seq": row.last_stream_seq,
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
|
||||
_TASK_CONTEXT_SAFE_KEYS = frozenset({"business_code", "task_id", "task_name", "chain_id", "chain_name"})
|
||||
|
||||
|
||||
def conversation_public_to_dict(row: MafRoundtableConversationRow) -> dict[str, Any]:
|
||||
"""Public DTO — only fields safe for the browser. No owner, session, run, or thread ids."""
|
||||
raw_ctx = row.task_context_json
|
||||
safe_ctx: dict[str, Any] | None = None
|
||||
if isinstance(raw_ctx, dict):
|
||||
safe_ctx = {k: v for k, v in raw_ctx.items() if k in _TASK_CONTEXT_SAFE_KEYS}
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"task_context": safe_ctx,
|
||||
"safe_status": row.safe_status,
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
"last_stream_seq": row.last_stream_seq,
|
||||
}
|
||||
|
||||
|
||||
class MafConversationRepository:
|
||||
"""Owner-scoped CRUD for MAF roundtable conversations."""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def create_conversation(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a new conversation. ``data`` must include ``owner_user_id``."""
|
||||
now = _now()
|
||||
row = MafRoundtableConversationRow(
|
||||
id=data.get("id") or uuid4().hex,
|
||||
owner_user_id=str(data["owner_user_id"]),
|
||||
title=str(data.get("title") or ""),
|
||||
task_context_json=data.get("task_context"),
|
||||
safe_status="idle",
|
||||
active_run_id=None,
|
||||
internal_session_id=None,
|
||||
last_stream_seq=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return conversation_internal_to_dict(row)
|
||||
|
||||
async def get_conversation(
|
||||
self, conversation_id: str, *, user_id: str | None = "REQUIRED"
|
||||
) -> dict[str, Any] | None:
|
||||
"""Read one conversation.
|
||||
|
||||
``user_id`` scoping:
|
||||
- ``"REQUIRED"`` (default): enforce owner scope.
|
||||
- ``None``: bypass owner scope (admin / internal path).
|
||||
"""
|
||||
async with self._sf() as session:
|
||||
stmt = select(MafRoundtableConversationRow).where(
|
||||
MafRoundtableConversationRow.id == conversation_id
|
||||
).limit(1)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
if user_id is not None and row.owner_user_id != user_id:
|
||||
return None
|
||||
return conversation_internal_to_dict(row)
|
||||
|
||||
async def list_conversations(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
safe_status: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List conversations, newest-first.
|
||||
|
||||
``user_id=None`` lists across all users (admin / diagnostic).
|
||||
"""
|
||||
stmt = select(MafRoundtableConversationRow)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(MafRoundtableConversationRow.owner_user_id == user_id)
|
||||
if safe_status is not None:
|
||||
stmt = stmt.where(MafRoundtableConversationRow.safe_status == safe_status)
|
||||
stmt = stmt.order_by(MafRoundtableConversationRow.updated_at.desc()).limit(limit).offset(offset)
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return [conversation_internal_to_dict(r) for r in rows]
|
||||
|
||||
async def count_conversations(
|
||||
self, *, user_id: str | None = None, safe_status: str | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(MafRoundtableConversationRow)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(MafRoundtableConversationRow.owner_user_id == user_id)
|
||||
if safe_status is not None:
|
||||
stmt = stmt.where(MafRoundtableConversationRow.safe_status == safe_status)
|
||||
async with self._sf() as session:
|
||||
return int((await session.execute(stmt)).scalar_one())
|
||||
|
||||
async def update_conversation(
|
||||
self, conversation_id: str, *, user_id: str | None, fields: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Partial update. Returns ``None`` if missing or wrong owner."""
|
||||
async with self._sf() as session:
|
||||
stmt = select(MafRoundtableConversationRow).where(
|
||||
MafRoundtableConversationRow.id == conversation_id
|
||||
).limit(1)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
if user_id is not None and row.owner_user_id != user_id:
|
||||
return None
|
||||
col_map = {"task_context": "task_context_json"}
|
||||
for key in ("title", "task_context", "safe_status", "active_run_id",
|
||||
"internal_session_id", "last_stream_seq"):
|
||||
if key in fields:
|
||||
col = col_map.get(key, key)
|
||||
setattr(row, col, fields[key])
|
||||
row.updated_at = _now()
|
||||
await session.commit()
|
||||
return conversation_internal_to_dict(row)
|
||||
|
||||
async def delete_conversation(self, conversation_id: str, *, user_id: str | None) -> bool:
|
||||
"""Delete a conversation (children via FK CASCADE)."""
|
||||
async with self._sf() as session:
|
||||
stmt = select(MafRoundtableConversationRow).where(
|
||||
MafRoundtableConversationRow.id == conversation_id
|
||||
).limit(1)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return False
|
||||
if user_id is not None and row.owner_user_id != user_id:
|
||||
return False
|
||||
await session.execute(
|
||||
delete(MafRoundtableConversationRow).where(
|
||||
MafRoundtableConversationRow.id == conversation_id
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def set_status(self, conversation_id: str, safe_status: str) -> None:
|
||||
"""Internal: update only the safe_status column."""
|
||||
async with self._sf() as session:
|
||||
await session.execute(
|
||||
update(MafRoundtableConversationRow)
|
||||
.where(MafRoundtableConversationRow.id == conversation_id)
|
||||
.values(safe_status=safe_status, updated_at=_now())
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def set_active_run(self, conversation_id: str, run_id: str | None, safe_status: str) -> None:
|
||||
"""Internal: update active_run_id + safe_status together."""
|
||||
async with self._sf() as session:
|
||||
await session.execute(
|
||||
update(MafRoundtableConversationRow)
|
||||
.where(MafRoundtableConversationRow.id == conversation_id)
|
||||
.values(active_run_id=run_id, safe_status=safe_status, updated_at=_now())
|
||||
)
|
||||
await session.commit()
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
"""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())
|
||||
+130
@@ -437,12 +437,142 @@ class MafRoundtableActionSecretRow(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
|
||||
|
||||
|
||||
# ── 10. conversations (V2: user-visible conversation aggregation) ─────────
|
||||
|
||||
|
||||
class MafRoundtableConversationRow(Base):
|
||||
"""One user-visible MAF roundtable conversation.
|
||||
|
||||
This is the **only** entity the frontend knows about. A conversation can
|
||||
span multiple user messages, each of which may trigger an internal
|
||||
``maf_roundtable_runs`` execution. The user never sees session/run/thread
|
||||
ids — only ``conversation_id``.
|
||||
|
||||
``active_run_id`` points to the currently executing internal run (nullable
|
||||
when idle). ``safe_status`` is a coarse user-facing status derived from
|
||||
the active run's state (idle / running / waiting_input / waiting_approval
|
||||
/ completed / failed / cancelled).
|
||||
"""
|
||||
|
||||
__tablename__ = "maf_roundtable_conversations"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), default="")
|
||||
task_context_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
|
||||
# idle | running | waiting_input | waiting_approval | completed | failed | cancelled
|
||||
safe_status: Mapped[str] = mapped_column(String(32), default="idle", index=True)
|
||||
active_run_id: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("maf_roundtable_runs.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
# Internal session this conversation is linked to (lazy-created on first message).
|
||||
internal_session_id: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("maf_roundtable_sessions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
last_stream_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
|
||||
# ── 11. conversation messages ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class MafRoundtableMessageRow(Base):
|
||||
"""Stable message history for a conversation.
|
||||
|
||||
Every user-visible bubble (user message, manager message, seat message,
|
||||
clarification card, approval card, result card) has exactly one row here.
|
||||
``seq`` is a conversation-global monotonic counter (unique per conversation)
|
||||
used for SSE cursor and ordering. ``message_kind`` classifies the bubble
|
||||
type for the frontend reducer.
|
||||
|
||||
``run_id`` links the message to the internal run that produced it (nullable
|
||||
for user messages that haven't started a run yet). ``agent_id`` identifies
|
||||
the speaking agent (null for user messages).
|
||||
"""
|
||||
|
||||
__tablename__ = "maf_roundtable_messages"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("conversation_id", "seq", name="uq_maf_messages_conv_seq"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("maf_roundtable_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# user | manager | seat | system | clarification | approval | result | tool_progress
|
||||
message_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="user")
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
|
||||
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
# Stable message identity for SSE delta merging (e.g. LangGraph message id).
|
||||
message_ref: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True)
|
||||
content: Mapped[str] = mapped_column(PortableLongText, default="")
|
||||
metadata_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True)
|
||||
|
||||
|
||||
# ── 12. conversation events (SSE stream, conversation-global seq) ─────────
|
||||
|
||||
|
||||
class MafRoundtableConversationEventRow(Base):
|
||||
"""Conversation-dimension event stream for SSE.
|
||||
|
||||
Unlike ``maf_roundtable_events`` (per-run seq), this table uses a
|
||||
**conversation-global** monotonic ``seq`` so the frontend only needs one
|
||||
cursor (``after_seq``) regardless of how many internal runs happen.
|
||||
The SSE hub replays from this table on connect/reconnect, then subscribes
|
||||
to real-time notifications.
|
||||
|
||||
``event_type`` follows the §4.3 contract (conversation.snapshot,
|
||||
user.message.created, manager.intent, clarification.required, etc.).
|
||||
``data_json`` is the already-sanitized event payload safe for the browser.
|
||||
"""
|
||||
|
||||
__tablename__ = "maf_roundtable_conversation_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("conversation_id", "seq", name="uq_maf_conv_events_conv_seq"),
|
||||
# P0-4: dedup key for projection idempotency. The source audit event's
|
||||
# stable id (e.g. LangGraph event id / tool_call id) prevents duplicate
|
||||
# conversation events when the bridge retries or the adapter re-projects
|
||||
# after a restart. NULL source_event_ids are not deduped (SQL treats
|
||||
# NULLs as distinct in UNIQUE constraints).
|
||||
UniqueConstraint("conversation_id", "source_event_id", name="uq_maf_conv_events_conv_source"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("maf_roundtable_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Stable upstream id from the source audit event (maf_roundtable_events.id
|
||||
# or the LangGraph source_event_id). Used for projection dedup via the
|
||||
# (conversation_id, source_event_id) unique constraint.
|
||||
source_event_id: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
data_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MafRoundtableActionRow",
|
||||
"MafRoundtableActionLeaseRow",
|
||||
"MafRoundtableActionSecretRow",
|
||||
"MafRoundtableApprovalRow",
|
||||
"MafRoundtableConversationEventRow",
|
||||
"MafRoundtableConversationRow",
|
||||
"MafRoundtableEventRow",
|
||||
"MafRoundtableMessageRow",
|
||||
"MafRoundtableParticipantRow",
|
||||
"MafRoundtableRunRow",
|
||||
"MafRoundtableSeatRunRow",
|
||||
|
||||
+276
@@ -29,13 +29,20 @@ from deerflow.persistence.maf_roundtable.actions import (
|
||||
action_to_dict,
|
||||
)
|
||||
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
|
||||
from deerflow.persistence.maf_roundtable.conversation_events import MafConversationEventRepository
|
||||
from deerflow.persistence.maf_roundtable.conversations import MafConversationRepository
|
||||
from deerflow.persistence.maf_roundtable.events import MafEventRepository
|
||||
from deerflow.persistence.maf_roundtable.messages import MafMessageRepository
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableActionLeaseRow,
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
MafRoundtableConversationEventRow,
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableEventRow,
|
||||
MafRoundtableMessageRow,
|
||||
MafRoundtableParticipantRow,
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableSessionRow,
|
||||
)
|
||||
@@ -74,6 +81,10 @@ class MafRoundtableStore:
|
||||
self.approvals = MafApprovalRepository(session_factory)
|
||||
self.seat_runs = MafSeatRunRepository(session_factory)
|
||||
self.action_secrets = MafActionSecretRepository(session_factory)
|
||||
# V2 conversation aggregation (user-visible layer).
|
||||
self.conversations = MafConversationRepository(session_factory)
|
||||
self.messages = MafMessageRepository(session_factory)
|
||||
self.conversation_events = MafConversationEventRepository(session_factory)
|
||||
|
||||
@property
|
||||
def session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||
@@ -791,12 +802,277 @@ class MafRoundtableStore:
|
||||
await db.flush()
|
||||
return recovered
|
||||
|
||||
# ── V2 conversation aggregation: unified seq allocation ─────────────────
|
||||
|
||||
async def append_conversation_item(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
conversation: MafRoundtableConversationRow,
|
||||
event_type: str,
|
||||
event_data: dict[str, Any],
|
||||
message: dict[str, Any] | None = None,
|
||||
run_id: str | None = None,
|
||||
source_event_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Allocate the next conversation-global seq and persist message + event atomically.
|
||||
|
||||
MUST be called inside an existing transaction (``db.begin()``). The caller
|
||||
is responsible for locking the conversation row (``with_for_update``).
|
||||
|
||||
1. ``conversation.last_stream_seq += 1`` (CAS via the row lock).
|
||||
2. If ``message`` is provided, write it at the same seq.
|
||||
3. Write the conversation event at the same seq.
|
||||
4. Flush (no commit — caller controls the transaction boundary).
|
||||
|
||||
``source_event_id`` (P0-4): stable upstream id for projection dedup.
|
||||
If the same source_event_id was already projected for this conversation,
|
||||
the caller should check and skip before calling this method.
|
||||
|
||||
Returns ``{"seq": int, "message_id": str | None, "event_id": str}``.
|
||||
"""
|
||||
now = _now()
|
||||
next_seq = int(conversation.last_stream_seq or 0) + 1
|
||||
conversation.last_stream_seq = next_seq
|
||||
|
||||
message_id: str | None = None
|
||||
if message is not None:
|
||||
message_id = message.get("id") or uuid4().hex
|
||||
db.add(MafRoundtableMessageRow(
|
||||
id=message_id,
|
||||
conversation_id=conversation.id,
|
||||
seq=next_seq,
|
||||
message_kind=message.get("message_kind", "user"),
|
||||
role=message.get("role", "user"),
|
||||
agent_id=message.get("agent_id"),
|
||||
run_id=message.get("run_id") or run_id,
|
||||
message_ref=message.get("message_ref"),
|
||||
content=message.get("content", ""),
|
||||
metadata_json=message.get("metadata"),
|
||||
created_at=now,
|
||||
))
|
||||
|
||||
event_id = uuid4().hex
|
||||
db.add(MafRoundtableConversationEventRow(
|
||||
id=event_id,
|
||||
conversation_id=conversation.id,
|
||||
seq=next_seq,
|
||||
run_id=run_id,
|
||||
source_event_id=source_event_id,
|
||||
event_type=event_type,
|
||||
data_json=event_data,
|
||||
created_at=now,
|
||||
))
|
||||
|
||||
await db.flush()
|
||||
return {"seq": next_seq, "message_id": message_id, "event_id": event_id}
|
||||
|
||||
# ── V2 conversation aggregation: atomic send-message + run launch ───────
|
||||
|
||||
async def send_message_and_start_run(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
owner_user_id: str,
|
||||
content: str,
|
||||
message_metadata: dict[str, Any] | None = None,
|
||||
workflow_version: str | None = None,
|
||||
session_blueprint: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Atomically persist a user message, ensure internal session, and start a run.
|
||||
|
||||
Single ``AsyncSession`` + ``begin()`` so all writes share one transaction
|
||||
— a mid-way failure rolls everything back (MAF-R1 requirement 1):
|
||||
|
||||
1. Load the conversation with ``FOR UPDATE``; verify ownership.
|
||||
2. Reject when an active run is already in flight → ``{"conflict": True}``.
|
||||
3. If no internal session exists, create one from ``session_blueprint``.
|
||||
4. Create a ``pending`` run linked to the real internal session.
|
||||
5. Allocate the next conversation-global seq via ``append_conversation_item``
|
||||
(message + event share the same seq).
|
||||
6. Flip conversation to ``safe_status='running'`` + ``active_run_id``.
|
||||
|
||||
``session_blueprint`` is provided by the App layer (ConversationService).
|
||||
If the conversation has no internal session and no blueprint is given,
|
||||
the method fails closed with ``{"no_session_config": True}``.
|
||||
|
||||
Returns ``{"message": {...}, "run": {...}, "conversation_id": ...}`` on
|
||||
success, ``{"conflict": True}`` when a run is already active,
|
||||
``{"no_session_config": True}`` when session cannot be created, or
|
||||
``None`` when the conversation is missing / not owned.
|
||||
"""
|
||||
now = _now()
|
||||
try:
|
||||
async with self._sf() 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 or conv.owner_user_id != owner_user_id:
|
||||
return None
|
||||
|
||||
# ``waiting_input`` with no active run is a clarification
|
||||
# turn. It is intentionally writable so the user can
|
||||
# answer the clarification; approval/workflow waits keep
|
||||
# their active run and remain protected by the CAS.
|
||||
if conv.safe_status in _ACTIVE_SESSION_STATUSES and not (
|
||||
conv.safe_status == "waiting_input" and conv.active_run_id is None
|
||||
):
|
||||
return {"conflict": True}
|
||||
|
||||
# 3. Ensure internal session exists.
|
||||
start_run = True if session_blueprint is None else bool(
|
||||
session_blueprint.get("start_run", True)
|
||||
)
|
||||
participant_blueprint = list(
|
||||
(session_blueprint or {}).get("participants") or []
|
||||
)
|
||||
if conv.internal_session_id is None:
|
||||
if session_blueprint is None:
|
||||
return {"no_session_config": True}
|
||||
session_row = MafRoundtableSessionRow(
|
||||
id=uuid4().hex,
|
||||
user_id=owner_user_id,
|
||||
title=session_blueprint.get("title") or conv.title or "",
|
||||
task_text=session_blueprint.get("task_text") or content,
|
||||
task_payload_json=session_blueprint.get("task_payload"),
|
||||
mode=session_blueprint.get("mode", "structured"),
|
||||
status="running" if start_run else "waiting_input",
|
||||
model_name=session_blueprint.get("model_name"),
|
||||
reasoning_effort=session_blueprint.get("reasoning_effort"),
|
||||
manager_agent_id=session_blueprint.get("manager_agent_id"),
|
||||
max_rounds=session_blueprint.get("max_rounds", 8),
|
||||
write_policy=session_blueprint.get("write_policy", "confirm_high_risk"),
|
||||
capability_snapshot_json=session_blueprint.get("capability_snapshot"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(session_row)
|
||||
await db.flush()
|
||||
conv.internal_session_id = session_row.id
|
||||
else:
|
||||
session_row = (
|
||||
await db.execute(
|
||||
select(MafRoundtableSessionRow).where(
|
||||
MafRoundtableSessionRow.id == conv.internal_session_id
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if session_row is None or session_row.user_id != owner_user_id:
|
||||
# Never repair a corrupted private link by guessing
|
||||
# an id or attaching another user's session.
|
||||
return {"no_session_config": True}
|
||||
session_row.status = "running" if start_run else "waiting_input"
|
||||
session_row.updated_at = now
|
||||
|
||||
# Participants are selected by the App-layer trusted
|
||||
# dispatch catalog and inserted in this same transaction.
|
||||
# A clarification may create the session before a route is
|
||||
# selected; the first real dispatch fills that empty set.
|
||||
existing_participants = (
|
||||
await db.execute(
|
||||
select(MafRoundtableParticipantRow.id).where(
|
||||
MafRoundtableParticipantRow.session_id == session_row.id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
if not existing_participants and participant_blueprint:
|
||||
seen_agents: set[str] = set()
|
||||
for participant in participant_blueprint:
|
||||
agent_id = str(participant.get("agent_id") or "").strip()
|
||||
if not agent_id or agent_id in seen_agents:
|
||||
continue
|
||||
seen_agents.add(agent_id)
|
||||
db.add(MafRoundtableParticipantRow(
|
||||
id=uuid4().hex,
|
||||
session_id=session_row.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,
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
# 4. Create pending run linked to the real session.
|
||||
run_row: MafRoundtableRunRow | None = None
|
||||
if start_run:
|
||||
run_row = MafRoundtableRunRow(
|
||||
id=uuid4().hex,
|
||||
session_id=conv.internal_session_id,
|
||||
status="pending",
|
||||
workflow_version=workflow_version,
|
||||
started_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(run_row)
|
||||
await db.flush()
|
||||
|
||||
# 5. Allocate unified seq: message + event share the same seq.
|
||||
item_result = await self.append_conversation_item(
|
||||
db,
|
||||
conversation=conv,
|
||||
event_type="user.message.created",
|
||||
event_data={"content": content},
|
||||
message={
|
||||
"message_kind": "user",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
"metadata": message_metadata,
|
||||
# Conversation messages are public and must not
|
||||
# carry the internal run id.
|
||||
"run_id": None,
|
||||
},
|
||||
run_id=None,
|
||||
)
|
||||
|
||||
# 6. Claim the conversation.
|
||||
conv.safe_status = "running" if start_run else "waiting_input"
|
||||
conv.active_run_id = run_row.id if run_row is not None else None
|
||||
conv.updated_at = now
|
||||
await db.flush()
|
||||
|
||||
from deerflow.persistence.maf_roundtable.conversations import conversation_internal_to_dict
|
||||
from deerflow.persistence.maf_roundtable.messages import message_to_dict
|
||||
|
||||
msg_row = (
|
||||
await db.execute(
|
||||
select(MafRoundtableMessageRow).where(
|
||||
MafRoundtableMessageRow.id == item_result["message_id"]
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
return {
|
||||
"message": message_to_dict(msg_row) if msg_row else None,
|
||||
"run": run_to_dict(run_row) if run_row is not None else None,
|
||||
"conversation": conversation_internal_to_dict(conv),
|
||||
"conversation_id": conversation_id,
|
||||
"seq": item_result["seq"],
|
||||
}
|
||||
except IntegrityError:
|
||||
return {"conflict": True}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MafActionRepository",
|
||||
"MafActionSecretRepository",
|
||||
"MafApprovalRepository",
|
||||
"MafConversationEventRepository",
|
||||
"MafConversationRepository",
|
||||
"MafEventRepository",
|
||||
"MafMessageRepository",
|
||||
"MafParticipantRepository",
|
||||
"MafRoundtableStore",
|
||||
"MafRunRepository",
|
||||
|
||||
@@ -30,6 +30,7 @@ _BUILTIN_METADATA: dict[str, tuple[str, str]] = {
|
||||
"grep": ("read", "R0"),
|
||||
"glob": ("read", "R0"),
|
||||
"ls": ("read", "R0"),
|
||||
"read_peer_delivery": ("read", "R0"),
|
||||
"invoke_skill": ("skill", "R1"),
|
||||
"skill_search": ("skill", "R1"),
|
||||
"task": ("read", "R2"),
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""V2 migration for MAF roundtable conversation tables.
|
||||
|
||||
Creates the three user-visible conversation tables:
|
||||
- maf_roundtable_conversations
|
||||
- maf_roundtable_messages
|
||||
- maf_roundtable_conversation_events
|
||||
|
||||
Idempotent: safe to run multiple times on SQLite / PostgreSQL / MySQL.
|
||||
Does NOT modify or migrate existing MAF session/run/event data.
|
||||
|
||||
Usage:
|
||||
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_conversation_v2.py --dry-run
|
||||
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_conversation_v2.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger("migrate_maf_roundtable_conversation_v2")
|
||||
|
||||
CONVERSATIONS_TABLE = "maf_roundtable_conversations"
|
||||
MESSAGES_TABLE = "maf_roundtable_messages"
|
||||
EVENTS_TABLE = "maf_roundtable_conversation_events"
|
||||
|
||||
|
||||
def _dialect_name(conn_or_dialect: Any) -> str:
|
||||
return str(getattr(getattr(conn_or_dialect, "dialect", conn_or_dialect), "name", "sqlite")).lower()
|
||||
|
||||
|
||||
def _timestamp_sql(dialect: str) -> str:
|
||||
if dialect == "postgresql":
|
||||
return "TIMESTAMP WITH TIME ZONE"
|
||||
if dialect in {"mysql", "mariadb"}:
|
||||
return "DATETIME(6)"
|
||||
return "DATETIME"
|
||||
|
||||
|
||||
def _json_sql(dialect: str) -> str:
|
||||
if dialect == "postgresql":
|
||||
return "JSONB"
|
||||
if dialect in {"mysql", "mariadb"}:
|
||||
return "JSON"
|
||||
return "TEXT"
|
||||
|
||||
|
||||
def _longtext_sql(dialect: str) -> str:
|
||||
if dialect == "postgresql":
|
||||
return "TEXT"
|
||||
if dialect in {"mysql", "mariadb"}:
|
||||
return "LONGTEXT"
|
||||
return "TEXT"
|
||||
|
||||
|
||||
def _conversations_ddl(dialect: str) -> str:
|
||||
ts = _timestamp_sql(dialect)
|
||||
js = _json_sql(dialect)
|
||||
return f"""
|
||||
CREATE TABLE {CONVERSATIONS_TABLE} (
|
||||
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
owner_user_id VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL DEFAULT '',
|
||||
task_context_json {js},
|
||||
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 {ts} NOT NULL,
|
||||
updated_at {ts} 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
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _messages_ddl(dialect: str) -> str:
|
||||
ts = _timestamp_sql(dialect)
|
||||
js = _json_sql(dialect)
|
||||
lt = _longtext_sql(dialect)
|
||||
return f"""
|
||||
CREATE TABLE {MESSAGES_TABLE} (
|
||||
id VARCHAR(64) NOT NULL 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 {lt} NOT NULL DEFAULT '',
|
||||
metadata_json {js},
|
||||
created_at {ts} NOT NULL,
|
||||
CONSTRAINT uq_maf_messages_conv_seq UNIQUE (conversation_id, seq),
|
||||
FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _events_ddl(dialect: str) -> str:
|
||||
ts = _timestamp_sql(dialect)
|
||||
js = _json_sql(dialect)
|
||||
return f"""
|
||||
CREATE TABLE {EVENTS_TABLE} (
|
||||
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
conversation_id VARCHAR(64) NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
run_id VARCHAR(64),
|
||||
source_event_id VARCHAR(191),
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
data_json {js},
|
||||
created_at {ts} NOT NULL,
|
||||
CONSTRAINT uq_maf_conv_events_conv_seq UNIQUE (conversation_id, seq),
|
||||
CONSTRAINT uq_maf_conv_events_conv_source UNIQUE (conversation_id, source_event_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _indexes_ddl(dialect: str) -> list[str]:
|
||||
stmts: list[str] = []
|
||||
stmts.append(f"CREATE INDEX ix_maf_conv_owner_updated ON {CONVERSATIONS_TABLE} (owner_user_id, updated_at)")
|
||||
stmts.append(f"CREATE INDEX ix_maf_conv_safe_status ON {CONVERSATIONS_TABLE} (safe_status)")
|
||||
stmts.append(f"CREATE INDEX ix_maf_msg_conv_seq ON {MESSAGES_TABLE} (conversation_id, seq)")
|
||||
stmts.append(f"CREATE INDEX ix_maf_msg_created ON {MESSAGES_TABLE} (created_at)")
|
||||
stmts.append(f"CREATE INDEX ix_maf_msg_run ON {MESSAGES_TABLE} (run_id)")
|
||||
stmts.append(f"CREATE INDEX ix_maf_msg_ref ON {MESSAGES_TABLE} (message_ref)")
|
||||
stmts.append(f"CREATE INDEX ix_maf_cevt_conv_seq ON {EVENTS_TABLE} (conversation_id, seq)")
|
||||
stmts.append(f"CREATE INDEX ix_maf_cevt_type ON {EVENTS_TABLE} (event_type)")
|
||||
stmts.append(f"CREATE INDEX ix_maf_cevt_created ON {EVENTS_TABLE} (created_at)")
|
||||
stmts.append(f"CREATE INDEX ix_maf_cevt_source ON {EVENTS_TABLE} (source_event_id)")
|
||||
return stmts
|
||||
|
||||
|
||||
def validate_schema(inspector: Any) -> list[str]:
|
||||
"""Return contract violations found by SQLAlchemy inspector."""
|
||||
issues: list[str] = []
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
for tbl in (CONVERSATIONS_TABLE, MESSAGES_TABLE, EVENTS_TABLE):
|
||||
if tbl not in tables:
|
||||
issues.append(f"missing table: {tbl}")
|
||||
|
||||
if CONVERSATIONS_TABLE in tables:
|
||||
cols = {c["name"] for c in inspector.get_columns(CONVERSATIONS_TABLE)}
|
||||
for required in ("id", "owner_user_id", "title", "safe_status", "last_stream_seq", "created_at", "updated_at"):
|
||||
if required not in cols:
|
||||
issues.append(f"{CONVERSATIONS_TABLE}: missing column {required}")
|
||||
if "last_message_seq" in cols:
|
||||
issues.append(f"{CONVERSATIONS_TABLE}: deprecated column last_message_seq still present")
|
||||
fks = inspector.get_foreign_keys(CONVERSATIONS_TABLE)
|
||||
fk_columns = {
|
||||
(tuple(fk.get("constrained_columns") or ()), fk.get("referred_table"))
|
||||
for fk in fks
|
||||
}
|
||||
if (("active_run_id",), "maf_roundtable_runs") not in fk_columns:
|
||||
issues.append(f"{CONVERSATIONS_TABLE}: missing FK(active_run_id -> maf_roundtable_runs)")
|
||||
if (("internal_session_id",), "maf_roundtable_sessions") not in fk_columns:
|
||||
issues.append(f"{CONVERSATIONS_TABLE}: missing FK(internal_session_id -> maf_roundtable_sessions)")
|
||||
|
||||
if MESSAGES_TABLE in tables:
|
||||
cols = {c["name"] for c in inspector.get_columns(MESSAGES_TABLE)}
|
||||
for required in ("id", "conversation_id", "seq", "message_kind", "role", "content", "created_at"):
|
||||
if required not in cols:
|
||||
issues.append(f"{MESSAGES_TABLE}: missing column {required}")
|
||||
uqs = inspector.get_unique_constraints(MESSAGES_TABLE)
|
||||
uq_cols = [set(u.get("column_names", [])) for u in uqs]
|
||||
if {"conversation_id", "seq"} not in uq_cols:
|
||||
issues.append(f"{MESSAGES_TABLE}: missing UNIQUE(conversation_id, seq)")
|
||||
|
||||
if EVENTS_TABLE in tables:
|
||||
cols = {c["name"] for c in inspector.get_columns(EVENTS_TABLE)}
|
||||
for required in ("id", "conversation_id", "seq", "event_type", "created_at", "source_event_id"):
|
||||
if required not in cols:
|
||||
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:
|
||||
issues.append(f"{EVENTS_TABLE}: missing UNIQUE(conversation_id, seq)")
|
||||
if {"conversation_id", "source_event_id"} not in uq_cols:
|
||||
issues.append(f"{EVENTS_TABLE}: missing UNIQUE(conversation_id, source_event_id)")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _missing_conversation_link_foreign_keys(inspector: Any) -> list[str]:
|
||||
"""Return missing private-link FKs on an already-created conversation table."""
|
||||
if CONVERSATIONS_TABLE not in inspector.get_table_names():
|
||||
return []
|
||||
foreign_keys = {
|
||||
(tuple(fk.get("constrained_columns") or ()), fk.get("referred_table"))
|
||||
for fk in inspector.get_foreign_keys(CONVERSATIONS_TABLE)
|
||||
}
|
||||
missing: list[str] = []
|
||||
if (("active_run_id",), "maf_roundtable_runs") not in foreign_keys:
|
||||
missing.append("active_run_id -> maf_roundtable_runs")
|
||||
if (("internal_session_id",), "maf_roundtable_sessions") not in foreign_keys:
|
||||
missing.append("internal_session_id -> maf_roundtable_sessions")
|
||||
return missing
|
||||
|
||||
|
||||
async def run_migration(database_url: str, *, dry_run: bool = False) -> None:
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
# A migration is a one-shot operation. NullPool makes every early
|
||||
# failure close its SQLite connection as the transaction context exits,
|
||||
# rather than retaining it until the process exits.
|
||||
engine = create_async_engine(database_url, poolclass=NullPool)
|
||||
async with engine.begin() as conn:
|
||||
dialect = _dialect_name(conn)
|
||||
logger.info("Dialect: %s", dialect)
|
||||
|
||||
def _get_table_names(sync_conn: Any) -> list[str]:
|
||||
return inspect(sync_conn).get_table_names()
|
||||
|
||||
existing = set(await conn.run_sync(_get_table_names))
|
||||
|
||||
def _missing_links(sync_conn: Any) -> list[str]:
|
||||
return _missing_conversation_link_foreign_keys(inspect(sync_conn))
|
||||
|
||||
missing_links = await conn.run_sync(_missing_links)
|
||||
if missing_links:
|
||||
if dialect in {"sqlite"}:
|
||||
# SQLite cannot add a foreign-key constraint in place. These
|
||||
# V2 tables have not been released as a data source yet, so an
|
||||
# empty partial install can be rebuilt safely. Never delete
|
||||
# a non-empty conversation history merely to improve a schema.
|
||||
tables_to_rebuild = [EVENTS_TABLE, MESSAGES_TABLE, CONVERSATIONS_TABLE]
|
||||
non_empty: list[str] = []
|
||||
for table in tables_to_rebuild:
|
||||
if table not in existing:
|
||||
continue
|
||||
count = int((await conn.execute(text(f"SELECT COUNT(*) FROM {table}"))).scalar_one())
|
||||
if count:
|
||||
non_empty.append(table)
|
||||
if non_empty:
|
||||
# ``run_migration`` normally disposes below the transaction
|
||||
# block. This explicit failure is intentionally raised
|
||||
# earlier, so release the aiosqlite pool before bubbling it
|
||||
# to callers/tests.
|
||||
await engine.dispose()
|
||||
raise RuntimeError(
|
||||
"MAF Conversation V2 schema is missing foreign keys "
|
||||
f"({', '.join(missing_links)}) but has data in "
|
||||
f"{', '.join(non_empty)}. Back up and migrate it explicitly; "
|
||||
"this script refuses to delete conversation history."
|
||||
)
|
||||
logger.warning(
|
||||
"Rebuilding empty partial V2 tables to add missing foreign keys: %s",
|
||||
", ".join(missing_links),
|
||||
)
|
||||
if not dry_run:
|
||||
for table in tables_to_rebuild:
|
||||
if table in existing:
|
||||
await conn.execute(text(f"DROP TABLE {table}"))
|
||||
existing.difference_update(tables_to_rebuild)
|
||||
else:
|
||||
# PostgreSQL and MySQL/MariaDB can add the missing constraints
|
||||
# in place. The database validates existing rows and fails
|
||||
# closed if a dangling private link exists.
|
||||
if "active_run_id -> maf_roundtable_runs" in missing_links:
|
||||
sql = (
|
||||
f"ALTER TABLE {CONVERSATIONS_TABLE} "
|
||||
"ADD CONSTRAINT fk_maf_conv_active_run "
|
||||
"FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs (id) "
|
||||
"ON DELETE SET NULL"
|
||||
)
|
||||
if dry_run:
|
||||
logger.info("[DRY RUN] Would execute: %s", sql)
|
||||
else:
|
||||
await conn.execute(text(sql))
|
||||
if "internal_session_id -> maf_roundtable_sessions" in missing_links:
|
||||
sql = (
|
||||
f"ALTER TABLE {CONVERSATIONS_TABLE} "
|
||||
"ADD CONSTRAINT fk_maf_conv_internal_session "
|
||||
"FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions (id) "
|
||||
"ON DELETE SET NULL"
|
||||
)
|
||||
if dry_run:
|
||||
logger.info("[DRY RUN] Would execute: %s", sql)
|
||||
else:
|
||||
await conn.execute(text(sql))
|
||||
|
||||
ddls: list[str] = []
|
||||
if CONVERSATIONS_TABLE not in existing:
|
||||
ddls.append(_conversations_ddl(dialect))
|
||||
logger.info("Will create: %s", CONVERSATIONS_TABLE)
|
||||
else:
|
||||
logger.info("Already exists: %s", CONVERSATIONS_TABLE)
|
||||
|
||||
if MESSAGES_TABLE not in existing:
|
||||
ddls.append(_messages_ddl(dialect))
|
||||
logger.info("Will create: %s", MESSAGES_TABLE)
|
||||
else:
|
||||
logger.info("Already exists: %s", MESSAGES_TABLE)
|
||||
|
||||
if EVENTS_TABLE not in existing:
|
||||
ddls.append(_events_ddl(dialect))
|
||||
logger.info("Will create: %s", EVENTS_TABLE)
|
||||
else:
|
||||
logger.info("Already exists: %s", EVENTS_TABLE)
|
||||
|
||||
for idx_sql in _indexes_ddl(dialect):
|
||||
ddls.append(idx_sql)
|
||||
|
||||
if dry_run:
|
||||
logger.info("[DRY RUN] Would execute %d statements:", len(ddls))
|
||||
for ddl in ddls:
|
||||
logger.info(" %s", " ".join(ddl.split())[:120])
|
||||
else:
|
||||
for ddl in ddls:
|
||||
try:
|
||||
await conn.execute(text(ddl))
|
||||
except Exception as exc:
|
||||
if "already exists" in str(exc).lower() or "duplicate" in str(exc).lower():
|
||||
continue
|
||||
raise
|
||||
logger.info("Executed %d statements successfully.", len(ddls))
|
||||
|
||||
def _validate(sync_conn: Any) -> list[str]:
|
||||
return validate_schema(inspect(sync_conn))
|
||||
|
||||
issues = await conn.run_sync(_validate)
|
||||
if issues:
|
||||
logger.error("Schema validation FAILED:")
|
||||
for issue in issues:
|
||||
logger.error(" - %s", issue)
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.info("Schema validation passed.")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="MAF roundtable conversation V2 migration")
|
||||
parser.add_argument("--database-url", default=None, help="Async database URL (default: from config)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print DDL without executing")
|
||||
args = parser.parse_args()
|
||||
|
||||
database_url = args.database_url
|
||||
if database_url is None:
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
config_path = Path(__file__).resolve().parent.parent / "config.yaml"
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
db_cfg = cfg.get("database", {})
|
||||
database_url = db_cfg.get("url", "sqlite+aiosqlite:///./data/deerflow.db")
|
||||
else:
|
||||
database_url = "sqlite+aiosqlite:///./data/deerflow.db"
|
||||
|
||||
logger.info("Database: %s", database_url.split("?")[0])
|
||||
asyncio.run(run_migration(database_url, dry_run=args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Tests for the MAF roundtable conversation V2 migration script.
|
||||
|
||||
Covers:
|
||||
- SQLite real migration idempotency (run twice, no error).
|
||||
- Schema validation passes after migration.
|
||||
- Deprecated last_message_seq column is not present.
|
||||
- PostgreSQL / MySQL DDL assertions (dialect-specific SQL generation).
|
||||
- Existing tables are not destroyed by re-running.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from scripts.migrate_maf_roundtable_conversation_v2 import (
|
||||
CONVERSATIONS_TABLE,
|
||||
EVENTS_TABLE,
|
||||
MESSAGES_TABLE,
|
||||
_conversations_ddl,
|
||||
_events_ddl,
|
||||
_indexes_ddl,
|
||||
_messages_ddl,
|
||||
run_migration,
|
||||
validate_schema,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sqlite_migration_creates_tables(tmp_path):
|
||||
db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig.db'}"
|
||||
await run_migration(db_url)
|
||||
|
||||
engine = create_async_engine(db_url)
|
||||
async with engine.begin() as conn:
|
||||
def _check(sync_conn):
|
||||
insp = inspect(sync_conn)
|
||||
tables = insp.get_table_names()
|
||||
issues = validate_schema(insp)
|
||||
return tables, issues
|
||||
|
||||
tables, issues = await conn.run_sync(_check)
|
||||
assert CONVERSATIONS_TABLE in tables
|
||||
assert MESSAGES_TABLE in tables
|
||||
assert EVENTS_TABLE in tables
|
||||
assert issues == [], f"Schema issues: {issues}"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sqlite_migration_idempotent(tmp_path):
|
||||
db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig2.db'}"
|
||||
await run_migration(db_url)
|
||||
await run_migration(db_url)
|
||||
|
||||
engine = create_async_engine(db_url)
|
||||
async with engine.begin() as conn:
|
||||
def _check(sync_conn):
|
||||
return validate_schema(inspect(sync_conn))
|
||||
|
||||
issues = await conn.run_sync(_check)
|
||||
assert issues == [], f"Schema issues after 2nd run: {issues}"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_deprecated_last_message_seq(tmp_path):
|
||||
db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig3.db'}"
|
||||
await run_migration(db_url)
|
||||
|
||||
engine = create_async_engine(db_url)
|
||||
async with engine.begin() as conn:
|
||||
def _check(sync_conn):
|
||||
insp = inspect(sync_conn)
|
||||
return {c["name"] for c in insp.get_columns(CONVERSATIONS_TABLE)}
|
||||
|
||||
cols = await conn.run_sync(_check)
|
||||
assert "last_stream_seq" in cols
|
||||
assert "last_message_seq" not in cols
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_data_preserved(tmp_path):
|
||||
db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig4.db'}"
|
||||
await run_migration(db_url)
|
||||
|
||||
engine = create_async_engine(db_url)
|
||||
async with engine.begin() as conn:
|
||||
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', 'keep', 'idle', 0, '2026-01-01', '2026-01-01')"
|
||||
))
|
||||
|
||||
await run_migration(db_url)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
row = (await conn.execute(text(f"SELECT id, title FROM {CONVERSATIONS_TABLE} WHERE id='c1'"))).first()
|
||||
assert row is not None
|
||||
assert row[1] == "keep"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_partial_conversation_table_is_rebuilt_with_foreign_keys(tmp_path):
|
||||
db_url = f"sqlite+aiosqlite:///{tmp_path / 'partial.db'}"
|
||||
engine = create_async_engine(db_url)
|
||||
async with engine.begin() as conn:
|
||||
# Represents the first uncommitted V2 draft: the table exists but its
|
||||
# private run/session links were not constrained.
|
||||
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)"
|
||||
))
|
||||
await engine.dispose()
|
||||
|
||||
await run_migration(db_url)
|
||||
|
||||
engine = create_async_engine(db_url)
|
||||
async with engine.begin() as conn:
|
||||
def _check(sync_conn):
|
||||
return validate_schema(inspect(sync_conn))
|
||||
|
||||
assert await conn.run_sync(_check) == []
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonempty_partial_conversation_table_fails_closed(tmp_path):
|
||||
db_url = f"sqlite+aiosqlite:///{tmp_path / 'partial-data.db'}"
|
||||
engine = create_async_engine(db_url)
|
||||
async with engine.begin() as conn:
|
||||
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 '', safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', "
|
||||
"last_stream_seq INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
|
||||
))
|
||||
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', 'keep', 'idle', 0, '2026-01-01', '2026-01-01')"
|
||||
))
|
||||
await engine.dispose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="refuses to delete conversation history"):
|
||||
await run_migration(db_url)
|
||||
|
||||
|
||||
def test_postgresql_ddl_generation():
|
||||
ddl = _conversations_ddl("postgresql")
|
||||
assert "TIMESTAMP WITH TIME ZONE" in ddl
|
||||
assert "JSONB" in ddl
|
||||
assert CONVERSATIONS_TABLE in ddl
|
||||
assert "FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs" in ddl
|
||||
assert "FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions" in ddl
|
||||
|
||||
msg_ddl = _messages_ddl("postgresql")
|
||||
assert "ON DELETE CASCADE" in msg_ddl
|
||||
assert "UNIQUE (conversation_id, seq)" in msg_ddl
|
||||
|
||||
evt_ddl = _events_ddl("postgresql")
|
||||
assert "UNIQUE (conversation_id, seq)" in evt_ddl
|
||||
|
||||
|
||||
def test_mysql_ddl_generation():
|
||||
ddl = _conversations_ddl("mysql")
|
||||
assert "DATETIME(6)" in ddl
|
||||
assert "JSON" in ddl
|
||||
assert "FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs" in ddl
|
||||
|
||||
msg_ddl = _messages_ddl("mysql")
|
||||
assert "LONGTEXT" in msg_ddl
|
||||
assert "ON DELETE CASCADE" in msg_ddl
|
||||
|
||||
|
||||
def test_sqlite_ddl_generation():
|
||||
ddl = _conversations_ddl("sqlite")
|
||||
assert "DATETIME" in ddl
|
||||
assert "TEXT" in ddl
|
||||
|
||||
indexes = _indexes_ddl("sqlite")
|
||||
assert len(indexes) >= 8
|
||||
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)
|
||||
@@ -0,0 +1,473 @@
|
||||
"""Tests for MAF roundtable V2 Conversation SSE streaming (Phase 2 / R2 + P0 fixes).
|
||||
|
||||
Covers:
|
||||
- ConversationEventHub: generation counter, publish/subscribe, multi-subscriber.
|
||||
- P0-1: race-free replay — events published during replay are not lost.
|
||||
- P0-2: terminal events auto-close the stream.
|
||||
- P0-4: projection dedup via source_event_id.
|
||||
- SSE replay: events persisted before connect are delivered.
|
||||
- after_seq cursor: reconnect resumes without duplicates.
|
||||
- Heartbeat: idle connection produces ``: heartbeat`` frames.
|
||||
- Concurrent subscribers: fan-out to multiple listeners.
|
||||
- Ownership: non-owner gets 404 on the stream endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
|
||||
from app.maf_roundtable.conversation_service import (
|
||||
ConversationNotFoundError,
|
||||
MafConversationService,
|
||||
)
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableConversationRow,
|
||||
)
|
||||
from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'stream.db'}")
|
||||
async with engine.begin() as conn:
|
||||
import deerflow.persistence.maf_roundtable.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
yield make_maf_roundtable_store(sf)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def hub():
|
||||
h = ConversationEventHub(heartbeat_interval=0.3)
|
||||
yield h
|
||||
h.shutdown()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def svc(store, hub):
|
||||
return MafConversationService(store, maf_config=FakeMafConfig(), event_hub=hub)
|
||||
|
||||
|
||||
# ── ConversationEventHub unit tests ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hub_publish_notifies_subscriber(hub):
|
||||
sub = hub.open_subscription("c1")
|
||||
assert not sub.has_pending
|
||||
|
||||
await hub.publish("c1")
|
||||
assert sub.has_pending
|
||||
|
||||
changed = await sub.wait_for_change(timeout=2.0)
|
||||
assert changed
|
||||
sub.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hub_heartbeat_on_idle(hub):
|
||||
sub = hub.open_subscription("c2")
|
||||
changed = await sub.wait_for_change(timeout=0.2)
|
||||
assert not changed
|
||||
sub.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hub_multi_subscriber_fanout(hub):
|
||||
sub_a = hub.open_subscription("c3")
|
||||
sub_b = hub.open_subscription("c3")
|
||||
|
||||
await hub.publish("c3")
|
||||
|
||||
assert await sub_a.wait_for_change(timeout=2.0)
|
||||
assert await sub_b.wait_for_change(timeout=2.0)
|
||||
|
||||
sub_a.close()
|
||||
sub_b.close()
|
||||
assert hub.conversation_subscriber_count("c3") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hub_publish_no_subscribers_is_noop(hub):
|
||||
await hub.publish("c_noone")
|
||||
assert hub.conversation_subscriber_count("c_noone") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hub_unsubscribe_cleans_up(hub):
|
||||
sub = hub.open_subscription("c4")
|
||||
assert hub.conversation_subscriber_count("c4") == 1
|
||||
sub.close()
|
||||
assert hub.conversation_subscriber_count("c4") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hub_shutdown_stops_subscribers():
|
||||
h = ConversationEventHub(heartbeat_interval=0.2)
|
||||
sub = h.open_subscription("c_shutdown")
|
||||
h.shutdown()
|
||||
assert h.is_closed
|
||||
# After shutdown, generation should have advanced.
|
||||
assert sub.has_pending or sub.current_generation > sub.seen_generation
|
||||
sub.close()
|
||||
|
||||
|
||||
# ── P0-1: Race-free replay ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p0_1_event_during_replay_is_not_lost(svc, store, hub):
|
||||
"""Event published between list_events() and subscription must be caught
|
||||
by the stabilisation loop."""
|
||||
conv = await svc.create_conversation(user_id="u1", title="Race")
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="msg1")
|
||||
await _reset_conv_to_idle(store, conv["id"])
|
||||
|
||||
# Simulate: subscribe, replay, then a publish sneaks in before stabilise.
|
||||
sub = hub.open_subscription(conv["id"])
|
||||
last_seq = 0
|
||||
|
||||
# Phase 1: initial replay
|
||||
events = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
|
||||
for evt in events:
|
||||
last_seq = max(last_seq, evt["seq"])
|
||||
|
||||
# Event sneaks in here (between replay and stabilise)
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="sneaky")
|
||||
await hub.publish(conv["id"])
|
||||
|
||||
# Stabilise: should detect the pending generation
|
||||
assert sub.has_pending
|
||||
sub.mark_seen()
|
||||
late_events = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
|
||||
assert len(late_events) >= 1 # The sneaky event was caught
|
||||
for evt in late_events:
|
||||
last_seq = max(last_seq, evt["seq"])
|
||||
|
||||
sub.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p0_1_subscribe_before_replay_catches_concurrent_publish(svc, store, hub):
|
||||
"""Subscribe must be opened BEFORE any DB query so a publish during replay
|
||||
is detected via generation delta."""
|
||||
conv = await svc.create_conversation(user_id="u1", title="SubBefore")
|
||||
|
||||
sub = hub.open_subscription(conv["id"])
|
||||
|
||||
# No events yet
|
||||
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
|
||||
assert len(events) == 0
|
||||
|
||||
# Publish arrives
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first")
|
||||
await hub.publish(conv["id"])
|
||||
|
||||
# Stabilisation detects it
|
||||
assert sub.has_pending
|
||||
sub.mark_seen()
|
||||
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
|
||||
assert len(events) >= 1
|
||||
|
||||
sub.close()
|
||||
|
||||
|
||||
# ── P0-2: Terminal events auto-close stream ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p0_2_replay_terminal_event_closes_stream():
|
||||
from app.gateway.routers.maf_conversations import _TERMINAL_EVENT_TYPES, _is_terminal
|
||||
|
||||
assert "run.completed" in _TERMINAL_EVENT_TYPES
|
||||
assert "run.error" in _TERMINAL_EVENT_TYPES
|
||||
assert "run.cancelled" in _TERMINAL_EVENT_TYPES
|
||||
|
||||
assert _is_terminal({"type": "run.completed"})
|
||||
assert _is_terminal({"type": "run.error"})
|
||||
assert _is_terminal({"type": "run.cancelled"})
|
||||
assert not _is_terminal({"type": "agent.message.delta"})
|
||||
assert not _is_terminal({"type": "user.message.created"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p0_2_terminal_event_in_replay_stops_generator(store):
|
||||
"""If the replay phase hits a terminal event, the generator should stop."""
|
||||
from app.gateway.routers.maf_conversations import _is_terminal
|
||||
|
||||
# Simulate a conversation with events ending in run.completed
|
||||
events = [
|
||||
{"seq": 1, "type": "user.message.created"},
|
||||
{"seq": 2, "type": "agent.message.delta"},
|
||||
{"seq": 3, "type": "run.completed"},
|
||||
{"seq": 4, "type": "should_not_reach"},
|
||||
]
|
||||
|
||||
found_terminal = False
|
||||
for evt in events:
|
||||
if _is_terminal(evt):
|
||||
found_terminal = True
|
||||
break
|
||||
assert found_terminal
|
||||
# Verify the event after terminal would not be reached
|
||||
terminal_idx = next(i for i, e in enumerate(events) if _is_terminal(e))
|
||||
assert events[terminal_idx]["type"] == "run.completed"
|
||||
|
||||
|
||||
# ── P0-4: Projection dedup ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p0_4_dedup_same_source_event_id(svc, store):
|
||||
"""Projecting the same source_event_id twice should not create a duplicate."""
|
||||
conv = await svc.create_conversation(user_id="u1", title="Dedup")
|
||||
|
||||
result1 = await svc._publish_conversation_event(
|
||||
conversation_id=conv["id"],
|
||||
owner_user_id="u1",
|
||||
event_type="tool.started",
|
||||
event_data={"tool_name": "search"},
|
||||
source_event_id="evt_src_001",
|
||||
)
|
||||
assert result1 is True
|
||||
|
||||
result2 = await svc._publish_conversation_event(
|
||||
conversation_id=conv["id"],
|
||||
owner_user_id="u1",
|
||||
event_type="tool.started",
|
||||
event_data={"tool_name": "search"},
|
||||
source_event_id="evt_src_001",
|
||||
)
|
||||
assert result2 is False # Deduped
|
||||
|
||||
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
|
||||
tool_events = [e for e in events if e["type"] == "tool.started"]
|
||||
assert len(tool_events) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p0_4_different_source_event_ids_both_written(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="MultiSrc")
|
||||
|
||||
await svc._publish_conversation_event(
|
||||
conversation_id=conv["id"], owner_user_id="u1",
|
||||
event_type="tool.started", event_data={"tool_name": "a"},
|
||||
source_event_id="src_1",
|
||||
)
|
||||
await svc._publish_conversation_event(
|
||||
conversation_id=conv["id"], owner_user_id="u1",
|
||||
event_type="tool.started", event_data={"tool_name": "b"},
|
||||
source_event_id="src_2",
|
||||
)
|
||||
|
||||
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
|
||||
tool_events = [e for e in events if e["type"] == "tool.started"]
|
||||
assert len(tool_events) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p0_4_null_source_event_id_not_deduped(svc, store):
|
||||
"""Events with no source_event_id should never be deduped."""
|
||||
conv = await svc.create_conversation(user_id="u1", title="NoSrc")
|
||||
|
||||
await svc._publish_conversation_event(
|
||||
conversation_id=conv["id"], owner_user_id="u1",
|
||||
event_type="manager.dispatch", event_data={"reason": "x"},
|
||||
source_event_id=None,
|
||||
)
|
||||
await svc._publish_conversation_event(
|
||||
conversation_id=conv["id"], owner_user_id="u1",
|
||||
event_type="manager.dispatch", event_data={"reason": "y"},
|
||||
source_event_id=None,
|
||||
)
|
||||
|
||||
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
|
||||
assert len(events) == 2
|
||||
|
||||
|
||||
# ── SSE replay + cursor + real-time integration ──────────────────────────
|
||||
|
||||
|
||||
async def _reset_conv_to_idle(store: MafRoundtableStore, conv_id: str):
|
||||
async with store.session_factory() as session:
|
||||
row = await session.get(MafRoundtableConversationRow, conv_id)
|
||||
if row:
|
||||
row.safe_status = "idle"
|
||||
row.active_run_id = None
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_replay_delivers_past_events(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="Replay")
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="msg1")
|
||||
await _reset_conv_to_idle(store, conv["id"])
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="msg2")
|
||||
|
||||
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
|
||||
assert len(events) >= 2
|
||||
types = [e["type"] for e in events]
|
||||
assert types.count("user.message.created") == 2
|
||||
seqs = [e["seq"] for e in events]
|
||||
assert seqs == sorted(seqs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_after_seq_prevents_duplicates(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="Cursor")
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first")
|
||||
await _reset_conv_to_idle(store, conv["id"])
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="second")
|
||||
|
||||
all_events = await store.conversation_events.list_events(conv["id"], after_seq=0)
|
||||
last_seq = all_events[0]["seq"]
|
||||
|
||||
replay = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
|
||||
assert all(e["seq"] > last_seq for e in replay)
|
||||
assert len(replay) == len(all_events) - 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_time_event_pushed_after_subscribe(svc, store, hub):
|
||||
conv = await svc.create_conversation(user_id="u1", title="RT")
|
||||
|
||||
received_events: list[dict] = []
|
||||
|
||||
async def stream_consumer():
|
||||
sub = hub.open_subscription(conv["id"])
|
||||
last_seq = 0
|
||||
|
||||
# Stabilise (no events yet)
|
||||
while sub.has_pending:
|
||||
sub.mark_seen()
|
||||
|
||||
# Wait for change
|
||||
changed = await sub.wait_for_change(timeout=5.0)
|
||||
if changed:
|
||||
new_evts = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
|
||||
for evt in new_evts:
|
||||
received_events.append(evt)
|
||||
last_seq = max(last_seq, evt["seq"])
|
||||
sub.close()
|
||||
|
||||
task = asyncio.create_task(stream_consumer())
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="live msg")
|
||||
|
||||
await asyncio.wait_for(task, timeout=5.0)
|
||||
types = [e["type"] for e in received_events]
|
||||
assert "user.message.created" in types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_subscribers_both_receive(svc, store, hub):
|
||||
conv = await svc.create_conversation(user_id="u1", title="Fanout")
|
||||
|
||||
buf_a: list[dict] = []
|
||||
buf_b: list[dict] = []
|
||||
|
||||
async def consume(buf):
|
||||
sub = hub.open_subscription(conv["id"])
|
||||
last_seq = 0
|
||||
changed = await sub.wait_for_change(timeout=5.0)
|
||||
if changed:
|
||||
new_evts = await store.conversation_events.list_events(conv["id"], after_seq=last_seq)
|
||||
for evt in new_evts:
|
||||
buf.append(evt)
|
||||
last_seq = max(last_seq, evt["seq"])
|
||||
sub.close()
|
||||
|
||||
ta = asyncio.create_task(consume(buf_a))
|
||||
tb = asyncio.create_task(consume(buf_b))
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="broadcast")
|
||||
|
||||
await asyncio.wait_for(asyncio.gather(ta, tb), timeout=5.0)
|
||||
assert len(buf_a) >= 1
|
||||
assert len(buf_b) >= 1
|
||||
assert buf_a[0]["seq"] == buf_b[0]["seq"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_after_seq_resume(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="Reconnect")
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="batch1")
|
||||
await _reset_conv_to_idle(store, conv["id"])
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="batch2")
|
||||
|
||||
all_events = await store.conversation_events.list_events(conv["id"], after_seq=0)
|
||||
midpoint = all_events[len(all_events) // 2]["seq"]
|
||||
|
||||
replay = await store.conversation_events.list_events(conv["id"], after_seq=midpoint)
|
||||
assert all(e["seq"] > midpoint for e in replay)
|
||||
|
||||
|
||||
# ── SSE frame format ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_frame_format():
|
||||
from app.gateway.routers.maf_conversations import _format_heartbeat, _format_sse_event
|
||||
|
||||
frame = _format_sse_event({
|
||||
"seq": 42,
|
||||
"event_id": "evt_abc",
|
||||
"conversation_id": "conv_1",
|
||||
"run_id": "run_1",
|
||||
"type": "user.message.created",
|
||||
"occurred_at": "2026-08-03T00:00:00Z",
|
||||
"data": {"content": "hi"},
|
||||
})
|
||||
assert "event: user.message.created" in frame
|
||||
assert "data: " in frame
|
||||
assert "id: evt_abc" in frame
|
||||
assert frame.endswith("\n\n")
|
||||
|
||||
parsed = None
|
||||
for line in frame.splitlines():
|
||||
if line.startswith("data: "):
|
||||
parsed = json.loads(line[len("data: "):])
|
||||
break
|
||||
assert parsed is not None
|
||||
assert parsed["seq"] == 42
|
||||
assert parsed["type"] == "user.message.created"
|
||||
|
||||
hb = _format_heartbeat()
|
||||
assert hb == ": heartbeat\n\n"
|
||||
|
||||
|
||||
# ── Ownership ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ownership_check_before_stream(svc):
|
||||
conv = await svc.create_conversation(user_id="u1", title="OwnerOnly")
|
||||
|
||||
fetched = await svc.get_conversation(conversation_id=conv["id"], user_id="u1")
|
||||
assert fetched["id"] == conv["id"]
|
||||
|
||||
with pytest.raises(ConversationNotFoundError):
|
||||
await svc.get_conversation(conversation_id=conv["id"], user_id="u2")
|
||||
@@ -0,0 +1,390 @@
|
||||
"""Tests for the MAF roundtable V2 Conversation API (Phase 1 / R1).
|
||||
|
||||
Covers:
|
||||
- Create, list, detail, message pagination, user isolation.
|
||||
- Public API responses never leak internal_session_id, active_run_id, run_id,
|
||||
owner_user_id, thread ids, or secret-class fields.
|
||||
- First message creates a real internal session; run FK is valid.
|
||||
- Transaction rollback: any flush failure rolls back message + run + event + status.
|
||||
- Concurrent send: only one succeeds, the other gets 409; no duplicate runs.
|
||||
- Mixed tool-events and messages: last_stream_seq is contiguous and usable as SSE cursor.
|
||||
- Empty / overlong / forged-internal-field content → 422.
|
||||
- Non-owner cannot read/write; admin cannot write another user's conversation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import inspect, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.gateway.routers.maf_conversations import _user_ctx
|
||||
from app.maf_roundtable.conversation_schemas import (
|
||||
CreateConversationRequest,
|
||||
SendMessageRequest,
|
||||
)
|
||||
from app.maf_roundtable.conversation_service import (
|
||||
ConversationConflictError,
|
||||
ConversationDisabledError,
|
||||
ConversationNotFoundError,
|
||||
MafConversationService,
|
||||
)
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableMessageRow,
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableSessionRow,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'conv.db'}")
|
||||
async with engine.begin() as conn:
|
||||
import deerflow.persistence.maf_roundtable.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
yield make_maf_roundtable_store(sf)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def svc(store):
|
||||
return MafConversationService(store, maf_config=FakeMafConfig())
|
||||
|
||||
|
||||
FORBIDDEN_FIELDS = {
|
||||
"owner_user_id", "internal_session_id", "active_run_id",
|
||||
"thread_id", "capability_snapshot", "idempotency_key",
|
||||
"target_key", "internal_canary",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get_conversation(svc):
|
||||
conv = await svc.create_conversation(user_id="u1", title="Test", task_context={"business_code": "3Q"})
|
||||
assert conv["id"]
|
||||
assert conv["title"] == "Test"
|
||||
assert conv["safe_status"] == "idle"
|
||||
assert conv["last_stream_seq"] == 0
|
||||
assert conv["task_context"] == {"business_code": "3Q"}
|
||||
for field in FORBIDDEN_FIELDS:
|
||||
assert field not in conv, f"Leaked field: {field}"
|
||||
|
||||
fetched = await svc.get_conversation(conversation_id=conv["id"], user_id="u1")
|
||||
assert fetched["id"] == conv["id"]
|
||||
for field in FORBIDDEN_FIELDS:
|
||||
assert field not in fetched
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_conversations_owner_scoped(svc):
|
||||
await svc.create_conversation(user_id="u1", title="A")
|
||||
await svc.create_conversation(user_id="u1", title="B")
|
||||
await svc.create_conversation(user_id="u2", title="C")
|
||||
|
||||
list_u1, total_u1 = await svc.list_conversations(user_id="u1")
|
||||
assert total_u1 == 2
|
||||
assert all(c["id"] for c in list_u1)
|
||||
|
||||
list_u2, total_u2 = await svc.list_conversations(user_id="u2")
|
||||
assert total_u2 == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_conversation_wrong_owner_returns_404(svc):
|
||||
conv = await svc.create_conversation(user_id="u1", title="Private")
|
||||
with pytest.raises(ConversationNotFoundError):
|
||||
await svc.get_conversation(conversation_id=conv["id"], user_id="u2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_read_but_not_write(svc):
|
||||
conv = await svc.create_conversation(user_id="u1", title="AdminRead")
|
||||
fetched = await svc.get_conversation(conversation_id=conv["id"], user_id="admin", is_admin=True)
|
||||
assert fetched["id"] == conv["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_creates_real_session_and_run(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="SendTest")
|
||||
result = await svc.send_message(conversation_id=conv["id"], user_id="u1", content="Hello world")
|
||||
|
||||
assert result["message_id"]
|
||||
assert result["conversation_id"] == conv["id"]
|
||||
assert result["seq"] == 1
|
||||
|
||||
async with store.session_factory() as session:
|
||||
conv_row = (await session.execute(
|
||||
select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"])
|
||||
)).scalars().first()
|
||||
assert conv_row is not None
|
||||
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
|
||||
|
||||
session_row = (await session.execute(
|
||||
select(MafRoundtableSessionRow).where(MafRoundtableSessionRow.id == conv_row.internal_session_id)
|
||||
)).scalars().first()
|
||||
assert session_row is not None
|
||||
assert session_row.user_id == "u1"
|
||||
|
||||
run_row = (await session.execute(
|
||||
select(MafRoundtableRunRow).where(MafRoundtableRunRow.id == conv_row.active_run_id)
|
||||
)).scalars().first()
|
||||
assert run_row is not None
|
||||
assert run_row.session_id == conv_row.internal_session_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_wrong_owner_404(svc):
|
||||
conv = await svc.create_conversation(user_id="u1", title="X")
|
||||
with pytest.raises(ConversationNotFoundError):
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u2", content="hack")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_send_only_one_succeeds(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="Race")
|
||||
|
||||
async def try_send(content: str):
|
||||
try:
|
||||
r = await svc.send_message(conversation_id=conv["id"], user_id="u1", content=content)
|
||||
return ("ok", r)
|
||||
except ConversationConflictError:
|
||||
return ("conflict", None)
|
||||
|
||||
results = await asyncio.gather(try_send("msg1"), try_send("msg2"))
|
||||
statuses = [r[0] for r in results]
|
||||
assert statuses.count("ok") == 1
|
||||
assert statuses.count("conflict") == 1
|
||||
|
||||
async with store.session_factory() as session:
|
||||
runs = (await session.execute(
|
||||
select(MafRoundtableRunRow).join(
|
||||
MafRoundtableSessionRow,
|
||||
MafRoundtableRunRow.session_id == MafRoundtableSessionRow.id,
|
||||
).where(MafRoundtableSessionRow.user_id == "u1")
|
||||
)).scalars().all()
|
||||
assert len(runs) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_public_dto_no_run_id(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="PubDTO")
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="test content")
|
||||
|
||||
messages, last_seq = await svc.list_messages(conversation_id=conv["id"], user_id="u1")
|
||||
assert len(messages) == 1
|
||||
msg = messages[0]
|
||||
assert "run_id" not in msg
|
||||
assert msg["content"] == "test content"
|
||||
assert msg["message_kind"] == "user"
|
||||
assert last_seq == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_stream_seq_contiguous(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="SeqTest")
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first")
|
||||
|
||||
async with store.session_factory() as session:
|
||||
conv_row = (await session.execute(
|
||||
select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"])
|
||||
)).scalars().first()
|
||||
conv_row.safe_status = "idle"
|
||||
conv_row.active_run_id = None
|
||||
await session.commit()
|
||||
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="second")
|
||||
|
||||
async with store.session_factory() as session:
|
||||
conv_row = (await session.execute(
|
||||
select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"])
|
||||
)).scalars().first()
|
||||
assert conv_row.last_stream_seq == 6
|
||||
|
||||
msgs = (await session.execute(
|
||||
select(MafRoundtableMessageRow).where(
|
||||
MafRoundtableMessageRow.conversation_id == conv["id"]
|
||||
).order_by(MafRoundtableMessageRow.seq)
|
||||
)).scalars().all()
|
||||
seqs = [m.seq for m in msgs]
|
||||
assert seqs == [1, 4]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schema_rejects_forbidden_fields():
|
||||
with pytest.raises(Exception):
|
||||
CreateConversationRequest.model_validate({"title": "x", "owner_user_id": "hacker"})
|
||||
|
||||
with pytest.raises(Exception):
|
||||
CreateConversationRequest.model_validate({"title": "x", "internal_session_id": "s1"})
|
||||
|
||||
with pytest.raises(Exception):
|
||||
SendMessageRequest.model_validate({"content": "hi", "run_id": "r1"})
|
||||
|
||||
with pytest.raises(Exception):
|
||||
SendMessageRequest.model_validate({"content": "hi", "thread_id": "t1"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schema_rejects_empty_and_overlong_content():
|
||||
with pytest.raises(Exception):
|
||||
SendMessageRequest.model_validate({"content": ""})
|
||||
|
||||
with pytest.raises(Exception):
|
||||
SendMessageRequest.model_validate({"content": " "})
|
||||
|
||||
with pytest.raises(Exception):
|
||||
SendMessageRequest.model_validate({"content": "x" * 20001})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_context_whitelist(svc):
|
||||
conv = await svc.create_conversation(
|
||||
user_id="u1", title="Ctx",
|
||||
task_context={"business_code": "6BF", "secret_key": "LEAKED", "task_name": "T1"},
|
||||
)
|
||||
assert conv["task_context"] == {"business_code": "6BF", "task_name": "T1"}
|
||||
assert "secret_key" not in (conv["task_context"] or {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_context_rejects_unknown_or_nested_values():
|
||||
with pytest.raises(Exception):
|
||||
CreateConversationRequest.model_validate({"task_context": {"secret_key": "nope"}})
|
||||
with pytest.raises(Exception):
|
||||
CreateConversationRequest.model_validate({"task_context": {"task_id": {"secret": "nope"}}})
|
||||
with pytest.raises(Exception):
|
||||
CreateConversationRequest.model_validate({"title": "x", "session_id": "forged"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_feature_rejects_conversation_writes(store):
|
||||
disabled = MafConversationService(store, maf_config=SimpleNamespace(enabled=False))
|
||||
with pytest.raises(ConversationDisabledError):
|
||||
await disabled.create_conversation(user_id="u1", title="disabled")
|
||||
|
||||
|
||||
def test_router_uses_authenticated_user_object_for_owner_scope():
|
||||
user = SimpleNamespace(id="real-user", system_role="admin")
|
||||
request = SimpleNamespace(state=SimpleNamespace(user=user))
|
||||
assert _user_ctx(request) == ("real-user", True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cross_user_read_respects_diagnostic_config(store):
|
||||
owner_service = MafConversationService(store, maf_config=FakeMafConfig())
|
||||
conv = await owner_service.create_conversation(user_id="u1", title="Private")
|
||||
no_diagnostics = MafConversationService(
|
||||
store,
|
||||
maf_config=SimpleNamespace(enabled=True, admin_diagnostic_access=False),
|
||||
)
|
||||
with pytest.raises(ConversationNotFoundError):
|
||||
await no_diagnostics.get_conversation(
|
||||
conversation_id=conv["id"], user_id="admin", is_admin=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_internal_links_have_real_foreign_keys(store):
|
||||
async with store.session_factory() as session:
|
||||
def _fks(sync_session):
|
||||
inspector = inspect(sync_session.connection())
|
||||
return {
|
||||
(tuple(fk["constrained_columns"]), fk["referred_table"])
|
||||
for fk in inspector.get_foreign_keys("maf_roundtable_conversations")
|
||||
}
|
||||
|
||||
foreign_keys = await session.run_sync(_fks)
|
||||
assert (("active_run_id",), "maf_roundtable_runs") in foreign_keys
|
||||
assert (("internal_session_id",), "maf_roundtable_sessions") in foreign_keys
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_only_item_advances_the_same_sse_cursor(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="Unified cursor")
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first")
|
||||
|
||||
async with store.session_factory() as session:
|
||||
async with session.begin():
|
||||
row = (
|
||||
await session.execute(
|
||||
select(MafRoundtableConversationRow)
|
||||
.where(MafRoundtableConversationRow.id == conv["id"])
|
||||
.with_for_update()
|
||||
)
|
||||
).scalars().one()
|
||||
item = await store.append_conversation_item(
|
||||
session,
|
||||
conversation=row,
|
||||
event_type="manager.intent",
|
||||
event_data={"status": "understood"},
|
||||
)
|
||||
assert item["seq"] == 4
|
||||
|
||||
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
|
||||
events = await store.conversation_events.list_events(conv["id"])
|
||||
assert [event["seq"] for event in events] == [1, 2, 3, 4]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_repositories_cannot_create_a_second_cursor(svc, store):
|
||||
conv = await svc.create_conversation(user_id="u1", title="No split cursor")
|
||||
with pytest.raises(RuntimeError, match="append_conversation_item"):
|
||||
await store.messages.append_message(
|
||||
conversation_id=conv["id"], message_kind="manager", role="assistant", content="unsafe"
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="append_conversation_item"):
|
||||
await store.conversation_events.append_event(
|
||||
conversation_id=conv["id"], event_type="manager.intent"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_atomic_send_rolls_back_on_item_write_failure(svc, store, monkeypatch):
|
||||
conv = await svc.create_conversation(user_id="u1", title="Rollback")
|
||||
|
||||
async def _fail_append(*args, **kwargs):
|
||||
raise RuntimeError("simulated item write failure")
|
||||
|
||||
monkeypatch.setattr(store, "append_conversation_item", _fail_append)
|
||||
with pytest.raises(RuntimeError, match="simulated item write failure"):
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="must roll back")
|
||||
|
||||
async with store.session_factory() as session:
|
||||
refreshed = (
|
||||
await session.execute(
|
||||
select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"])
|
||||
)
|
||||
).scalars().one()
|
||||
assert refreshed.safe_status == "idle"
|
||||
assert refreshed.internal_session_id is None
|
||||
assert refreshed.active_run_id is None
|
||||
assert refreshed.last_stream_seq == 0
|
||||
assert (await session.execute(select(MafRoundtableMessageRow))).scalars().all() == []
|
||||
assert (await session.execute(select(MafRoundtableRunRow))).scalars().all() == []
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Deterministic Stage 3 tests for V2 conversation dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.conversation_service import MafConversationService
|
||||
from app.maf_roundtable.dispatch_contracts import (
|
||||
InvalidDispatchPlanError,
|
||||
MafDispatchAssignment,
|
||||
MafDispatchPlan,
|
||||
MafIntentDecision,
|
||||
resolve_intent,
|
||||
validate_dispatch_plan,
|
||||
)
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableParticipantRow,
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableSessionRow,
|
||||
)
|
||||
|
||||
|
||||
class FakeMafConfig:
|
||||
enabled = True
|
||||
default_mode = "structured"
|
||||
default_model_name = "test-model"
|
||||
default_reasoning_effort = None
|
||||
default_manager_agent_id = "manager-from-config"
|
||||
default_seat_agent_ids = ["seat-a", "seat-b"]
|
||||
business_routes = {}
|
||||
max_seats_per_run = 4
|
||||
default_max_rounds = 8
|
||||
default_write_policy = "confirm_high_risk"
|
||||
admin_diagnostic_access = True
|
||||
|
||||
|
||||
class RecordingDispatcher:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[dict, dict, str]] = []
|
||||
|
||||
def check_sdk_available(self) -> None:
|
||||
return
|
||||
|
||||
async def dispatch(self, session: dict, run: dict, *, user_id: str) -> None:
|
||||
self.calls.append((session, run, user_id))
|
||||
|
||||
|
||||
class CompletingDispatcher(RecordingDispatcher):
|
||||
def __init__(self, store) -> None:
|
||||
super().__init__()
|
||||
self._store = store
|
||||
self.ready = asyncio.Event()
|
||||
|
||||
async def dispatch(self, session: dict, run: dict, *, user_id: str) -> None:
|
||||
await super().dispatch(session, run, user_id=user_id)
|
||||
await self._store.runs.update_run(run["id"], {"status": "completed"})
|
||||
await self._store.sessions.update_session(
|
||||
session["id"], user_id=user_id, fields={"status": "completed"}
|
||||
)
|
||||
self.ready.set()
|
||||
|
||||
async def wait_for_run(self, run_id: str) -> None:
|
||||
await self.ready.wait()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'phase3.db'}")
|
||||
async with engine.begin() as conn:
|
||||
import deerflow.persistence.maf_roundtable.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
yield make_maf_roundtable_store(sf)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_route_creates_participants_and_dispatches(store):
|
||||
dispatcher = RecordingDispatcher()
|
||||
svc = MafConversationService(
|
||||
store,
|
||||
maf_config=FakeMafConfig(),
|
||||
dispatcher=dispatcher,
|
||||
)
|
||||
conv = await svc.create_conversation(
|
||||
user_id="u1",
|
||||
title="Trusted route",
|
||||
task_context={"business_code": "6BF"},
|
||||
)
|
||||
|
||||
# The unknown business code is rejected rather than falling back across
|
||||
# business boundaries.
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await svc.send_message(
|
||||
conversation_id=conv["id"], user_id="u1", content="请多专家并行分析这个任务"
|
||||
)
|
||||
assert "route" in str(exc_info.value)
|
||||
assert dispatcher.calls == []
|
||||
|
||||
# With no business code the configured default route is selected and the
|
||||
# runtime sees only server-owned participants.
|
||||
conv2 = await svc.create_conversation(user_id="u1", title="Default route")
|
||||
await svc.send_message(
|
||||
conversation_id=conv2["id"], user_id="u1", content="请多专家并行分析这个任务"
|
||||
)
|
||||
assert len(dispatcher.calls) == 1
|
||||
session, run, owner = dispatcher.calls[0]
|
||||
assert owner == "u1"
|
||||
assert run["session_id"] == session["id"]
|
||||
|
||||
async with store.session_factory() as db:
|
||||
session_row = (
|
||||
await db.execute(
|
||||
select(MafRoundtableSessionRow).where(
|
||||
MafRoundtableSessionRow.id == session["id"]
|
||||
)
|
||||
)
|
||||
).scalars().one()
|
||||
participants = (
|
||||
await db.execute(
|
||||
select(MafRoundtableParticipantRow)
|
||||
.where(MafRoundtableParticipantRow.session_id == session["id"])
|
||||
.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 all(p.thread_id is None for p in participants)
|
||||
|
||||
events = (
|
||||
await db.execute(
|
||||
select(MafRoundtableConversationRow).where(
|
||||
MafRoundtableConversationRow.id == conv2["id"]
|
||||
)
|
||||
)
|
||||
).scalars().one()
|
||||
assert events.active_run_id == run["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarification_does_not_create_or_dispatch_run(store):
|
||||
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="不清楚")
|
||||
assert result["message_id"]
|
||||
assert dispatcher.calls == []
|
||||
|
||||
async with store.session_factory() as db:
|
||||
conv_row = (
|
||||
await db.execute(
|
||||
select(MafRoundtableConversationRow).where(
|
||||
MafRoundtableConversationRow.id == conv["id"]
|
||||
)
|
||||
)
|
||||
).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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatcher_completion_bridges_back_to_conversation(store):
|
||||
dispatcher = CompletingDispatcher(store)
|
||||
svc = MafConversationService(store, maf_config=FakeMafConfig(), dispatcher=dispatcher)
|
||||
conv = await svc.create_conversation(user_id="u1", title="Completion")
|
||||
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="complete this task")
|
||||
watcher_tasks = list(svc._watchers.values()) # noqa: SLF001 - lifecycle assertion
|
||||
if watcher_tasks:
|
||||
await asyncio.gather(*watcher_tasks)
|
||||
|
||||
public = await svc.get_conversation(conversation_id=conv["id"], user_id="u1")
|
||||
assert public["safe_status"] == "completed"
|
||||
events = await store.conversation_events.list_events(conv["id"], after_seq=0)
|
||||
assert [event["type"] for event in events][-1] == "run.completed"
|
||||
assert all(event["run_id"] is None for event in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_projection_whitelists_user_visible_event_data(store):
|
||||
svc = MafConversationService(store, maf_config=FakeMafConfig())
|
||||
conv = await svc.create_conversation(user_id="u1", title="Projection")
|
||||
await svc.send_message(conversation_id=conv["id"], user_id="u1", content="complete this task")
|
||||
internal = await store.conversations.get_conversation(conv["id"], user_id="u1")
|
||||
run = await store.runs.list_runs(internal["internal_session_id"])
|
||||
|
||||
await svc.bridge_workflow_event(
|
||||
run_id=run[0]["id"],
|
||||
event={
|
||||
"event_type": "tool.requested",
|
||||
"agent_id": "seat-a",
|
||||
"turn_index": 1,
|
||||
"payload": {
|
||||
"tool_name": "safe_tool",
|
||||
"args": {"secret": "must-not-cross"},
|
||||
"tool_call_id": "internal-call-id",
|
||||
},
|
||||
},
|
||||
)
|
||||
await svc.bridge_workflow_event(
|
||||
run_id=run[0]["id"],
|
||||
event={
|
||||
"event_type": "agent.message.completed",
|
||||
"agent_id": "seat-a",
|
||||
"turn_index": 1,
|
||||
"payload": {
|
||||
"content": "answer",
|
||||
"metadata": {"thread_id": "thread-secret", "deerflow_run_id": "df-secret"},
|
||||
},
|
||||
},
|
||||
)
|
||||
events = await store.conversation_events.list_events(conv["id"], after_seq=3)
|
||||
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")
|
||||
assert "metadata" not in complete["data"]
|
||||
assert "thread-secret" not in str(complete)
|
||||
assert any(event["type"] == "seat.delivery.submitted" for event in events)
|
||||
|
||||
|
||||
def test_intent_resolution_is_deterministic_and_does_not_select_agents():
|
||||
assert resolve_intent("请多专家并行分析") .intent_type == "parallel_consultation"
|
||||
assert resolve_intent("请发布这条内容").intent_type == "tool_write"
|
||||
assert resolve_intent("不清楚").intent_type == "clarification"
|
||||
assert resolve_intent("给我一个普通答案").intent_type == "direct_answer"
|
||||
|
||||
|
||||
def test_weak_model_cannot_expand_or_duplicate_dispatch_plan():
|
||||
with pytest.raises(ValueError):
|
||||
MafDispatchPlan(
|
||||
manager_agent_id="manager",
|
||||
seat_agent_ids=["seat-a", "seat-a"],
|
||||
assignments=[
|
||||
MafDispatchAssignment(agent_id="seat-a", sort_order=1),
|
||||
MafDispatchAssignment(agent_id="seat-a", sort_order=2),
|
||||
],
|
||||
intent_type="parallel_consultation",
|
||||
max_rounds=8,
|
||||
)
|
||||
|
||||
plan = MafDispatchPlan(
|
||||
manager_agent_id="manager",
|
||||
seat_agent_ids=["seat-a"],
|
||||
assignments=[MafDispatchAssignment(agent_id="seat-a", sort_order=1)],
|
||||
intent_type="single_seat",
|
||||
max_rounds=8,
|
||||
)
|
||||
with pytest.raises(InvalidDispatchPlanError):
|
||||
validate_dispatch_plan(plan, trusted_agent_ids={"manager", "seat-b"}, max_seats=4)
|
||||
|
||||
with pytest.raises(ValueError, match="at least one configured seat"):
|
||||
MafDispatchPlan(
|
||||
manager_agent_id="manager",
|
||||
seat_agent_ids=[],
|
||||
assignments=[],
|
||||
intent_type="parallel_consultation",
|
||||
max_rounds=8,
|
||||
)
|
||||
|
||||
|
||||
def test_clarification_plan_is_not_constructible():
|
||||
with pytest.raises(ValueError):
|
||||
MafIntentDecision(
|
||||
intent_type="clarification",
|
||||
confidence=0.9,
|
||||
reason_code="needs_detail",
|
||||
)
|
||||
@@ -21,7 +21,10 @@ from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
MafRoundtableConversationEventRow,
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableEventRow,
|
||||
MafRoundtableMessageRow,
|
||||
MafRoundtableParticipantRow,
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableSeatRunRow,
|
||||
@@ -167,6 +170,9 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
|
||||
"maf_roundtable_approvals",
|
||||
"maf_roundtable_seat_runs",
|
||||
"maf_roundtable_action_secrets",
|
||||
"maf_roundtable_conversations",
|
||||
"maf_roundtable_messages",
|
||||
"maf_roundtable_conversation_events",
|
||||
}
|
||||
|
||||
# Every FK defined on an MAF model must reference another maf_roundtable_* table.
|
||||
@@ -180,6 +186,9 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
MafRoundtableSeatRunRow,
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableMessageRow,
|
||||
MafRoundtableConversationEventRow,
|
||||
]
|
||||
for model in maf_models:
|
||||
for fk in model.__table__.foreign_keys:
|
||||
|
||||
@@ -903,9 +903,10 @@ async def test_seat_matching_scopes_by_turn_index(svc, store):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seat_matching_count_mismatch_fails(svc, store):
|
||||
"""P1b: a pending tool call that does not match EXACTLY one waiting action
|
||||
(by tool_call_id) fails the turn —the executor never guesses."""
|
||||
async def test_seat_matching_zero_hits_warns_and_skips(svc, store):
|
||||
"""A pending tool call that matches 0 waiting actions (e.g. unclassified
|
||||
tool, hallucinated tool name, or WriteGuard let it through as read) should
|
||||
log a warning and skip — NOT crash the entire workflow."""
|
||||
from app.maf_roundtable.workflow_executors import SeatExecutor
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
@@ -923,7 +924,39 @@ async def test_seat_matching_count_mismatch_fails(svc, store):
|
||||
executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
|
||||
action_coordinator=ActionCoordinator(svc, user_id="u1"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="expected exactly 1"):
|
||||
# Should NOT raise — the unmatched tool call is logged and skipped.
|
||||
await executor.handle("msg", _FakeSeatCtx())
|
||||
# The unmatched call was not added to pending_approvals.
|
||||
assert len(state.pending_approvals) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seat_matching_multiple_hits_raises(svc, store):
|
||||
""">1 match for a single tool call is a genuine data-integrity error —
|
||||
the executor refuses to guess and raises RuntimeError."""
|
||||
from app.maf_roundtable.workflow_executors import SeatExecutor
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "t"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
# Two waiting actions with the same logical_action_id for turn 1.
|
||||
await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
|
||||
"tool_name": "update_task", "turn_index": 1, "logical_action_id": "call-1",
|
||||
"idempotency_key": "dup1", "risk_level": "R3", "status": "waiting_approval",
|
||||
})
|
||||
await store.actions.create_action({
|
||||
"session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1",
|
||||
"tool_name": "update_task", "turn_index": 1, "logical_action_id": "call-1",
|
||||
"idempotency_key": "dup2", "risk_level": "R3", "status": "waiting_approval",
|
||||
})
|
||||
|
||||
state = _seat_state(session["id"], run["id"])
|
||||
response = _FakeSeatResponse(tool_calls=[{"name": "update_task", "id": "call-1", "args": {}}])
|
||||
executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1",
|
||||
action_coordinator=ActionCoordinator(svc, user_id="u1"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="refusing to guess"):
|
||||
await executor.handle("msg", _FakeSeatCtx())
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user