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>
119 lines
3.8 KiB
Python
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
|