288 lines
12 KiB
Python
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",
|
|
]
|