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

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

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

119 lines
3.8 KiB
Python

"""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