Files
2026-08-03 19:27:54 +08:00

288 lines
12 KiB
Python

"""Microsoft Agent Framework 多智能体会商实验功能开关配置。
本段为**部署级**特性开关:默认关闭。关闭时:
- Gateway 启动**不**加载 MAF SDK(SDK 延迟导入,仅在实际启用并触发会商 run
时才在 ``app.maf_roundtable`` 内 import)。
- ``/api/maf-roundtable/*`` 路由仍注册,但所有写接口与健康检查返回 ``503
Service Disabled``;只读的会话列表/详情可继续工作以便管理员查看历史。
- 前端入口由 ``GET /api/maf-roundtable/health`` 的 ``enabled`` 字段决定是否
显示。
段内只有开关与运行参数,无密钥,可公开只读(仿 ``y_log_config`` 的写法)。
``MafMode`` / ``MafWritePolicy`` 是部署配置与 API schema 共用的单一来源枚举,
避免两处各自维护一套合法值。
"""
from __future__ import annotations
import re
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
# ── shared enums (config + API schemas) ───────────────────────────────────
MafMode = Literal["structured", "group_chat"]
MafWritePolicy = Literal["auto", "confirm_high_risk", "confirm_all_writes"]
MafParticipantRole = Literal["manager", "seat"]
MafToolSideEffect = Literal["read", "skill", "write"]
MafRiskLevel = Literal["R0", "R1", "R2", "R3", "R4"]
class MafToolMetadata(BaseModel):
"""Deployment-owned side-effect classification for one actual tool name.
This lives in the MAF configuration rather than in the browser request or
model output. The key in ``MafRoundtableConfig.tool_metadata`` must be the
exact runtime tool name (including the MCP server prefix when applicable).
"""
side_effect: MafToolSideEffect = Field(
description="Tool side effect class used by the MAF WriteGuard",
)
risk_level: MafRiskLevel = Field(
description="External action risk level used by MAF approval policy",
)
target_key_fields: list[str] = Field(
default_factory=list,
description=(
"仅服务端配置的、用于派生目标租约键的顶层参数字段。"
"参数值只参与稳定哈希,绝不直接写入 target_key。"
),
)
target_key_namespace: str = Field(
default="default",
min_length=1,
max_length=96,
description="服务端目标键命名空间;用于隔离不同租户/业务域。",
)
@field_validator("target_key_fields")
@classmethod
def _validate_target_key_fields(cls, fields: list[str]) -> list[str]:
"""Only allow explicit top-level server-owned argument names.
In particular, a model/client supplied ``target_key`` is never a
valid input. Nested paths are deliberately unsupported: a production
adapter must register the exact stable business-id fields it owns.
"""
valid = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
normalized: list[str] = []
for field in fields:
if not isinstance(field, str) or not valid.fullmatch(field):
raise ValueError("target_key_fields must be simple top-level argument names")
if field == "target_key":
raise ValueError("target_key must not be supplied as a target key input")
if field not in normalized:
normalized.append(field)
return normalized
@field_validator("target_key_namespace")
@classmethod
def _validate_target_key_namespace(cls, namespace: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,96}", namespace):
raise ValueError("target_key_namespace contains unsupported characters")
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 in normalized:
raise ValueError("seat_agent_ids must not contain duplicates")
normalized.append(value)
return normalized
@field_validator("manager_agent_id", mode="before")
@classmethod
def _normalize_manager_agent_id(cls, value: object) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("manager_agent_id must be non-empty")
return normalized
@model_validator(mode="after")
def _validate_manager_not_seat(self) -> MafBusinessRoute:
if self.manager_agent_id in self.seat_agent_ids:
raise ValueError("manager_agent_id must not appear in seat_agent_ids")
return self
class MafRoundtableConfig(BaseModel):
"""Microsoft Agent Framework 多智能体会商实验功能开关。
默认 ``enabled=False``:这是一个独立于现有 ``RoundtablePlanningPage`` 的
实验性功能,不替换旧会商;首期仅管理员/白名单可见,关闭时完全不影响
Gateway 启动与旧功能行为。
"""
enabled: bool = Field(
default=False,
description="是否启用 MAF 多智能体会商实验功能;关闭时 SDK 延迟不加载,写接口返回 503。",
)
# 默认编排模式(用户可在创建会商时覆盖)。
default_mode: MafMode = Field(
default="structured",
description="默认编排模式:structured(固定流程)| group_chat(动态群聊)",
)
# 默认写入策略(auto / confirm_high_risk / confirm_all_writes)。
default_write_policy: MafWritePolicy = Field(
default="confirm_high_risk",
description="默认外部写入策略:auto / confirm_high_risk / confirm_all_writes",
)
# 默认最大轮次(群聊硬上限)。
default_max_rounds: int = Field(
default=8,
ge=1,
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,
description="管理员是否可跨用户读取所有 MAF 会商用于诊断(仅读,不含写)",
)
# Phase 3: per-seat wall-clock timeout (seconds) for structured expert turns.
seat_timeout_seconds: int = Field(
default=600,
ge=30,
le=7200,
description="结构化流程中单个专家席位的超时秒数;超时记为 partial_failure 并继续其余席位",
)
# Phase 3: allow one directed format-repair turn when seat JSON fails schema.
schema_repair_enabled: bool = Field(
default=True,
description="席位输出未通过 SeatTurnOutput 校验时是否允许一次定向格式修复",
)
# Server-trusted metadata keyed by the exact runtime tool name. Do not
# infer this from MCP server names or prefixes: the actual discovered name
# is what the capability snapshot and runtime WriteGuard see.
tool_metadata: dict[str, MafToolMetadata] = Field(
default_factory=dict,
description="MAF 专用工具副作用/风险元数据,键必须是实际运行时 tool name",
)
approval_timeout_seconds: int = Field(
default=3600,
ge=60,
le=604800,
description="外部写入审批有效期(秒);到期后自动拒绝,绝不执行。",
)
approval_scan_interval_seconds: int = Field(
default=30,
ge=5,
le=3600,
description="MAF approval expiry scan interval in seconds",
)
target_lease_seconds: int = Field(
default=900,
ge=30,
le=7200,
description="外部业务目标写入租约有效期(秒);过期运行中的动作转 unknown。",
)
conversation_event_backend: Literal["memory"] = Field(
default="memory",
description="会商 SSE 事件广播后端;当前仅支持 memory(单 worker)。",
)
require_single_worker_for_conversation_sse: bool = Field(
default=True,
description="当 backend=memory 且检测到多 worker 时,禁止会商 SSE 写入入口。",
)
background_reconcile_interval_seconds: int = Field(
default=10,
ge=5,
le=60,
description="后台自动 reconcile 扫描间隔(秒);补偿投影失败的审计事件。",
)
@field_validator("default_manager_agent_id", mode="before")
@classmethod
def _normalize_default_manager_agent_id(cls, value: object) -> str | None:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
raise ValueError("default_manager_agent_id must be non-empty when provided")
return normalized
@field_validator("default_seat_agent_ids")
@classmethod
def _validate_default_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("default_seat_agent_ids must contain non-empty agent ids")
if value in normalized:
raise ValueError("default_seat_agent_ids must not contain duplicates")
normalized.append(value)
return normalized
@field_validator("business_routes")
@classmethod
def _validate_business_route_codes(
cls, routes: dict[str, MafBusinessRoute]
) -> dict[str, MafBusinessRoute]:
code_pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$")
normalized_routes: dict[str, MafBusinessRoute] = {}
for code, route in routes.items():
normalized_code = str(code).strip()
if not code_pattern.fullmatch(normalized_code):
raise ValueError(
"business route code must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$"
)
if normalized_code in normalized_routes:
raise ValueError("business route codes must be unique after trimming")
normalized_routes[normalized_code] = route
return normalized_routes
@model_validator(mode="after")
def _validate_routes(self) -> MafRoundtableConfig:
if self.default_manager_agent_id and self.default_manager_agent_id in self.default_seat_agent_ids:
raise ValueError("default manager_agent_id must not appear in default_seat_agent_ids")
if len(self.default_seat_agent_ids) > self.max_seats_per_run:
raise ValueError("default_seat_agent_ids exceeds max_seats_per_run")
for code, route in self.business_routes.items():
if len(route.seat_agent_ids) > self.max_seats_per_run:
raise ValueError(f"business route {code!r} exceeds max_seats_per_run")
return self
__all__ = [
"MafMode",
"MafBusinessRoute",
"MafParticipantRole",
"MafRiskLevel",
"MafRoundtableConfig",
"MafToolMetadata",
"MafToolSideEffect",
"MafWritePolicy",
]