Files
deerflow-code/offline-backend-20260512/backend/app/maf_roundtable/schemas.py
T
2026-08-03 19:27:54 +08:00

274 lines
9.9 KiB
Python

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