"""Pydantic request/response schemas for the MAF roundtable API. These are the wire contracts between the frontend (Phase 6) and the backend. Phase 1 ships the session / participant / run / event contracts; the action / approval contracts are defined too so Phase 5 can wire the Action Coordinator without a schema break. Sensitive fields (credentials / tokens / cookies) must already be redacted by the writer before they reach these schemas — the backend does not re-redact. ``MafMode`` / ``MafWritePolicy`` / ``MafParticipantRole`` are imported from the harness config module so API validation and deployment-config validation share a single source of truth. """ from __future__ import annotations from typing import Any, Self from pydantic import BaseModel, Field, model_validator from deerflow.config.maf_roundtable_config import MafMode, MafParticipantRole, MafWritePolicy # ── sessions ────────────────────────────────────────────────────────────── class ParticipantInput(BaseModel): """One seat / manager agent bound to a session at creation time.""" agent_id: str = Field(..., min_length=1, max_length=64) display_name: str = Field(default="", max_length=255) role: MafParticipantRole = "seat" sort_order: int = 0 # Subtractive only: tools removed from the server-resolved allow-list for this seat. excluded_tools: list[str] = Field(default_factory=list) class CreateSessionRequest(BaseModel): title: str = Field(default="", max_length=255) task_text: str = Field(..., min_length=1) task_payload: dict[str, Any] | None = None # Optional: omitted → service layer fills from MafRoundtableConfig.default_mode. mode: MafMode | None = None model_name: str | None = Field(default=None, max_length=128) reasoning_effort: str | None = Field(default=None, max_length=32) manager_agent_id: str | None = Field(default=None, max_length=64) # Optional: omitted → MafRoundtableConfig.default_max_rounds. max_rounds: int | None = Field(default=None, ge=1, le=64) # Optional: omitted → MafRoundtableConfig.default_write_policy. write_policy: MafWritePolicy | None = None participants: list[ParticipantInput] = Field(default_factory=list) # Subtractive session-wide tool exclusions (applied per seat when building snapshot). excluded_tools: list[str] = Field(default_factory=list) # Fields that must not be explicitly set to null on PUT (→ 422). # Clearable fields (task_payload / model_name / reasoning_effort / # manager_agent_id / fact_ledger / final_result) keep null = "clear value". _NON_NULLABLE_UPDATE_FIELDS = frozenset( {"title", "task_text", "mode", "max_rounds", "write_policy", "participants"} ) class UpdateSessionRequest(BaseModel): """Partial update — only allowed when the session is not yet ``running``. Distinguishes "field omitted" (no change) from "field set to null": - Non-nullable fields (title / task_text / mode / max_rounds / write_policy / participants): explicit ``null`` → 422. - Clearable fields (task_payload / model_name / reasoning_effort / manager_agent_id / fact_ledger / final_result): explicit ``null`` clears. Do **not** use ``exclude_none=True`` — that would collapse the two cases. """ title: str | None = Field(default=None, max_length=255) task_text: str | None = None task_payload: dict[str, Any] | None = None mode: MafMode | None = None model_name: str | None = Field(default=None, max_length=128) reasoning_effort: str | None = Field(default=None, max_length=32) manager_agent_id: str | None = Field(default=None, max_length=64) max_rounds: int | None = Field(default=None, ge=1, le=64) write_policy: MafWritePolicy | None = None participants: list[ParticipantInput] | None = None # fact_ledger / final_result are mutable runtime fields (updated by the run lifecycle). fact_ledger: dict[str, Any] | None = None final_result: dict[str, Any] | None = None @model_validator(mode="after") def _reject_explicit_null_for_non_nullable(self) -> Self: for name in _NON_NULLABLE_UPDATE_FIELDS: if name in self.model_fields_set and getattr(self, name) is None: raise ValueError(f"{name} cannot be null") return self class ParticipantResponse(BaseModel): id: str session_id: str agent_id: str display_name: str role: str sort_order: int thread_id: str | None = None sandbox_id: str | None = None status: str last_turn_index: int | None = None capability_snapshot: dict[str, Any] | None = None created_at: str | None = None updated_at: str | None = None class SessionResponse(BaseModel): id: str user_id: str title: str task_text: str task_payload: dict[str, Any] | None = None mode: str status: str model_name: str | None = None reasoning_effort: str | None = None manager_agent_id: str | None = None max_rounds: int write_policy: str capability_snapshot: dict[str, Any] | None = None fact_ledger: dict[str, Any] | None = None final_result: dict[str, Any] | None = None participants: list[ParticipantResponse] = Field(default_factory=list) created_at: str | None = None updated_at: str | None = None class SessionListResponse(BaseModel): sessions: list[SessionResponse] total: int # ── runs ────────────────────────────────────────────────────────────────── class RunResponse(BaseModel): id: str session_id: str status: str workflow_version: str | None = None maf_checkpoint: dict[str, Any] | None = None last_event_seq: int started_at: str | None = None finished_at: str | None = None error_code: str | None = None error_message_safe: str | None = None token_usage: dict[str, Any] | None = None latency_ms: int | None = None created_at: str | None = None updated_at: str | None = None class RunListResponse(BaseModel): runs: list[RunResponse] class CreateRunRequest(BaseModel): workflow_version: str | None = Field(default=None, max_length=64) # ── events ─────────────────────────────────────────────────────────────── class EventResponse(BaseModel): id: str session_id: str run_id: str seq: int event_type: str agent_id: str | None = None turn_index: int | None = None payload: dict[str, Any] | None = None created_at: str | None = None class EventListResponse(BaseModel): events: list[EventResponse] latest_seq: int # ── actions ─────────────────────────────────────────────────────────────── class ActionResponse(BaseModel): id: str session_id: str run_id: str | None = None agent_id: str | None = None tool_name: str logical_action_id: str | None = None risk_level: str status: str arguments: dict[str, Any] | None = None result: dict[str, Any] | None = None external_object_id: str | None = None approval_id: str | None = None attempt_count: int started_at: str | None = None finished_at: str | None = None created_at: str | None = None updated_at: str | None = None class ActionListResponse(BaseModel): actions: list[ActionResponse] # ── approvals ───────────────────────────────────────────────────────────── class ApprovalResponse(BaseModel): id: str session_id: str run_id: str | None = None action_id: str | None = None requested_by_agent_id: str | None = None status: str summary: str payload: dict[str, Any] | None = None decided_by: str | None = None decided_at: str | None = None expires_at: str | None = None created_at: str | None = None updated_at: str | None = None class ApprovalListResponse(BaseModel): approvals: list[ApprovalResponse] class ApprovalDecisionRequest(BaseModel): # approved | rejected decision: str = Field(..., pattern="^(approved|rejected)$") # ── health ──────────────────────────────────────────────────────────────── 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 conversation_dispatch_ready: bool = False conversation_dispatch_status: str = "configuration_unavailable" conversation_stream_ready: bool = False conversation_stream_status: str = "unknown" conversation_event_backend: str = "memory" store_available: bool default_mode: str default_write_policy: str default_max_rounds: int admin_diagnostic_access: bool write_guard_enabled: bool 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."