refactor(agent): align policy contracts with runtime (#6306)

This commit is contained in:
InfinityPacer
2026-08-13 22:14:43 +08:00
committed by GitHub
parent 57e7f86f38
commit 402543f636
9 changed files with 42 additions and 573 deletions
-16
View File
@@ -4,14 +4,9 @@ from app.agent.policy.contracts import (
ActionEffect,
ActionPolicy,
AuthSource,
CanonicalInvocation,
ConversationKind,
DeliveryTarget,
ConfirmationMode,
ExecutionOutcome,
ExecutionReceipt,
InboundEnvelope,
InboundProvenance,
MigrationState,
PolicyDecision,
PolicyObservation,
@@ -19,10 +14,7 @@ from app.agent.policy.contracts import (
PrincipalRole,
PrincipalType,
RecoveryMode,
RECEIPT_STATE_TRANSITIONS,
ReceiptState,
ResultSensitivity,
TERMINAL_RECEIPT_STATES,
ToolInvocation,
ToolOrigin,
ToolPolicyContext,
@@ -48,16 +40,11 @@ __all__ = [
"ActionPolicy",
"AgentToolPolicyOrchestrator",
"AuthSource",
"CanonicalInvocation",
"ConversationKind",
"DeliveryTarget",
"ConfirmationMode",
"DEFAULT_TOOL_POLICY_ORCHESTRATOR",
"DEFAULT_TOOL_POLICY_REGISTRY",
"ExecutionOutcome",
"ExecutionReceipt",
"InboundEnvelope",
"InboundProvenance",
"MigrationState",
"PolicyDecision",
"PolicyObservation",
@@ -66,10 +53,7 @@ __all__ = [
"PrincipalType",
"REDACTED_VALUE",
"RecoveryMode",
"RECEIPT_STATE_TRANSITIONS",
"ReceiptState",
"ResultSensitivity",
"TERMINAL_RECEIPT_STATES",
"ToolInvocation",
"ToolOrigin",
"ToolPolicyContext",
-130
View File
@@ -1,130 +0,0 @@
"""严格工具调用规范化,不读取设置值或执行工具实现。"""
import hashlib
import json
from typing import Any, Mapping
from pydantic import BaseModel, ValidationError
from app.agent.policy.contracts import (
ActionPolicy,
CanonicalInvocation,
ToolRevision,
)
from app.agent.tools.impl._system_setting_utils import (
list_setting_specs,
resolve_setting_spec,
)
class CanonicalizationError(ValueError):
"""当前工具 schema 或静态前提无法产生严格调用时的稳定失败。"""
def _stable_json(value: Any) -> str:
"""生成拒绝 NaN 且保留 Unicode 的稳定紧凑 JSON。"""
try:
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
except (TypeError, ValueError, ValidationError) as error:
raise CanonicalizationError("调用参数无法规范化") from error
def _accepted_argument_names(args_schema: type[BaseModel]) -> set[str]:
"""返回严格入口可接受的字段名与字符串别名。"""
accepted = set(args_schema.model_fields)
for field in args_schema.model_fields.values():
for alias in (field.alias, field.validation_alias):
if isinstance(alias, str):
accepted.add(alias)
return accepted
def canonicalize_invocation(
*,
tool: Any,
arguments: Mapping[str, Any],
policy: ActionPolicy,
tool_revision: ToolRevision,
) -> CanonicalInvocation:
"""使用当前 Pydantic schema 与静态设置定义生成不可变调用摘要。"""
tool_name = str(getattr(tool, "name", "") or "")
args_schema = getattr(tool, "args_schema", None)
if not tool_name or not isinstance(args_schema, type) or not issubclass(
args_schema, BaseModel
):
raise CanonicalizationError("工具缺少严格 Pydantic 参数契约")
raw_arguments = dict(arguments or {})
unknown_arguments = set(raw_arguments) - _accepted_argument_names(args_schema)
if unknown_arguments:
raise CanonicalizationError("工具参数校验失败")
try:
validated = args_schema.model_validate(raw_arguments)
normalized = validated.model_dump(
mode="json",
exclude_unset=False,
exclude_none=False,
)
schema_json = _stable_json(args_schema.model_json_schema())
except (TypeError, ValueError) as error:
raise CanonicalizationError("工具参数校验失败") from error
preconditions: tuple[tuple[str, str], ...] = ()
setting_key = normalized.get("setting_key")
if tool_name == "query_system_settings":
if setting_key:
spec = resolve_setting_spec(setting_key)
if spec is None:
raise CanonicalizationError("系统设置项不存在")
specs = [spec]
else:
try:
specs = list_setting_specs(
group=normalized.get("group"),
keyword=normalized.get("keyword"),
)
except ValueError as error:
raise CanonicalizationError("系统设置选择器无效") from error
if not specs:
raise CanonicalizationError("系统设置选择器没有匹配项")
preconditions = tuple(
(
"setting",
f"{spec.source}:{spec.key}:{spec.group}",
)
for spec in specs
)
payload = {
"canonical_version": "p1-g2a2-canonical-v1",
"action_subtype": policy.effect.value,
"arguments": normalized,
"policy_version": policy.policy_version,
"preconditions": preconditions,
"schema_digest": hashlib.sha256(schema_json.encode("utf-8")).hexdigest(),
"tool_name": tool_name,
"tool_revision": {
"factory": tool_revision.factory,
"implementation": tool_revision.implementation,
"plugin": tool_revision.plugin,
},
}
canonical_json = _stable_json(payload)
return CanonicalInvocation(
tool_name=tool_name,
arguments=normalized,
canonical_json=canonical_json,
digest=hashlib.sha256(canonical_json.encode("utf-8")).hexdigest(),
policy_version=policy.policy_version,
tool_revision=tool_revision,
schema_digest=payload["schema_digest"],
preconditions=preconditions,
)
__all__ = ["CanonicalizationError", "canonicalize_invocation"]
+4 -164
View File
@@ -2,23 +2,9 @@
from dataclasses import dataclass, field
from enum import Enum
from types import MappingProxyType
from typing import Any, Mapping, MutableMapping, Optional
def _freeze_contract_value(value: Any) -> Any:
"""递归冻结确认边界中的容器,避免等待期间被调用方修改。"""
if isinstance(value, Mapping):
return MappingProxyType(
{str(key): _freeze_contract_value(item) for key, item in value.items()}
)
if isinstance(value, (list, tuple)):
return tuple(_freeze_contract_value(item) for item in value)
if isinstance(value, set):
return frozenset(_freeze_contract_value(item) for item in value)
return value
class ToolOrigin(str, Enum):
"""工具调用的宿主可信入口。"""
@@ -49,24 +35,6 @@ class AuthSource(str, Enum):
AGENT_TOKEN = "agent_token"
class InboundProvenance(str, Enum):
"""入站事实经宿主验证后的可信等级。"""
WEB_SESSION = "web_session"
VERIFIED_ADAPTER = "verified_adapter"
ADMIN_INTEGRATION = "admin_integration"
UNTRUSTED = "untrusted"
class ConversationKind(str, Enum):
"""目标会话的隐私边界。"""
WEB = "web"
PRIVATE = "private"
GROUP = "group"
UNKNOWN = "unknown"
class PrincipalRole(str, Enum):
"""策略授权使用的角色层级。"""
@@ -118,92 +86,19 @@ class ResultSensitivity(str, Enum):
class MigrationState(str, Enum):
"""工具策略从兼容观测迁移到宿主执行的状态"""
"""工具策略当前采用宿主强制还是兼容观测"""
ENFORCED = "enforced"
LEGACY_SHADOW = "legacy_shadow"
class ExecutionOutcome(str, Enum):
"""P1-G1 handler 生命周期终态;成功不代表业务授权或副作用已完成。"""
"""工具 handler 观测终态;成功不代表业务授权或副作用已完成。"""
SUCCEEDED = "succeeded"
FAILED = "failed"
class ReceiptState(str, Enum):
"""严格执行回执的单向生命周期状态。"""
WAITING_CONFIRMATION = "waiting_confirmation"
VALIDATING = "validating"
EXECUTING = "executing"
DELIVERING = "delivering"
SUCCEEDED = "succeeded"
CANCELLED = "cancelled"
EXPIRED = "expired"
EXPIRED_RESTART = "expired_restart"
EXPIRED_ORPHANED = "expired_orphaned"
PREPARATION_FAILED = "preparation_failed"
PROMPT_DELIVERY_FAILED = "prompt_delivery_failed"
VALIDATION_FAILED = "validation_failed"
VALIDATION_RECORD_FAILED = "validation_record_failed"
EXECUTION_FAILED = "execution_failed"
DELIVERY_FAILED = "delivery_failed"
UNKNOWN_AFTER_RESTART = "unknown_after_restart"
UNKNOWN_ORPHANED = "unknown_orphaned"
TERMINAL_RECEIPT_STATES = frozenset({
ReceiptState.SUCCEEDED,
ReceiptState.CANCELLED,
ReceiptState.EXPIRED,
ReceiptState.EXPIRED_RESTART,
ReceiptState.EXPIRED_ORPHANED,
ReceiptState.PREPARATION_FAILED,
ReceiptState.PROMPT_DELIVERY_FAILED,
ReceiptState.VALIDATION_FAILED,
ReceiptState.VALIDATION_RECORD_FAILED,
ReceiptState.EXECUTION_FAILED,
ReceiptState.DELIVERY_FAILED,
ReceiptState.UNKNOWN_AFTER_RESTART,
ReceiptState.UNKNOWN_ORPHANED,
})
RECEIPT_STATE_TRANSITIONS = {
ReceiptState.WAITING_CONFIRMATION: frozenset({
ReceiptState.VALIDATING,
ReceiptState.CANCELLED,
ReceiptState.EXPIRED,
ReceiptState.EXPIRED_RESTART,
ReceiptState.EXPIRED_ORPHANED,
ReceiptState.PREPARATION_FAILED,
ReceiptState.PROMPT_DELIVERY_FAILED,
}),
ReceiptState.VALIDATING: frozenset({
ReceiptState.EXECUTING,
ReceiptState.CANCELLED,
ReceiptState.EXPIRED,
ReceiptState.EXPIRED_RESTART,
ReceiptState.EXPIRED_ORPHANED,
ReceiptState.VALIDATION_FAILED,
ReceiptState.VALIDATION_RECORD_FAILED,
}),
ReceiptState.EXECUTING: frozenset({
ReceiptState.DELIVERING,
ReceiptState.EXECUTION_FAILED,
ReceiptState.UNKNOWN_AFTER_RESTART,
ReceiptState.UNKNOWN_ORPHANED,
}),
ReceiptState.DELIVERING: frozenset({
ReceiptState.SUCCEEDED,
ReceiptState.DELIVERY_FAILED,
ReceiptState.UNKNOWN_AFTER_RESTART,
ReceiptState.UNKNOWN_ORPHANED,
}),
}
@dataclass(frozen=True)
class PolicyPrincipal:
"""由可信入口建立、不可由工具参数覆盖的调用主体。"""
@@ -229,62 +124,15 @@ class ToolInvocation:
source: Optional[str] = None
@dataclass(frozen=True)
class DeliveryTarget:
"""受保护结果的精确宿主路由,actor 与 recipient 不得互相替代。"""
channel: str
source_instance_id: str
tenant_or_account_id: str
conversation_kind: ConversationKind
conversation_id: str
recipient_id: str
actor_id: str
server_session_id: str
@dataclass(frozen=True)
class InboundEnvelope:
"""由可信宿主入口创建的不可变入站事实。"""
provenance: InboundProvenance
target: DeliveryTarget
inbound_event_id: str
raw_text: str = field(repr=False)
normalized_text: str = field(repr=False)
has_images: bool = False
has_audio: bool = False
has_files: bool = False
is_callback: bool = False
@dataclass(frozen=True)
class ToolRevision:
"""严格调用绑定的工具实现、工厂和插件目录版本。"""
"""记录目录项绑定的工具实现、工厂和插件目录版本。"""
implementation: str
factory: str
plugin: str
@dataclass(frozen=True)
class CanonicalInvocation:
"""经当前工具 schema 校验、可绑定确认与版本前提的调用。"""
tool_name: str
arguments: Mapping[str, Any] = field(repr=False)
canonical_json: str = field(repr=False)
digest: str
policy_version: str
tool_revision: ToolRevision
schema_digest: str
preconditions: tuple[tuple[str, str], ...] = ()
def __post_init__(self) -> None:
"""阻止调用方在确认等待期间修改规范化参数。"""
object.__setattr__(self, "arguments", _freeze_contract_value(self.arguments))
@dataclass(frozen=True)
class ActionPolicy:
"""参数级动作策略及其兼容迁移状态。"""
@@ -325,7 +173,7 @@ class PolicyObservation:
@dataclass(frozen=True)
class ExecutionReceipt:
"""P1-G1 的非持久化脱敏回执 envelope"""
"""工具策略生命周期生成的非持久化脱敏回执"""
invocation_id: str
tool_name: str
@@ -391,14 +239,9 @@ __all__ = [
"ActionEffect",
"ActionPolicy",
"AuthSource",
"CanonicalInvocation",
"ConfirmationMode",
"ConversationKind",
"DeliveryTarget",
"ExecutionOutcome",
"ExecutionReceipt",
"InboundEnvelope",
"InboundProvenance",
"MigrationState",
"PolicyDecision",
"PolicyObservation",
@@ -406,10 +249,7 @@ __all__ = [
"PrincipalRole",
"PrincipalType",
"RecoveryMode",
"RECEIPT_STATE_TRANSITIONS",
"ReceiptState",
"ResultSensitivity",
"TERMINAL_RECEIPT_STATES",
"ToolInvocation",
"ToolOrigin",
"ToolPolicyContext",
+4 -4
View File
@@ -37,7 +37,7 @@ def call_policy_hook(
*args: Any,
**kwargs: Any,
) -> Optional[_HookResult]:
"""以 fail-open 方式调用 P1-G1 观测 hook,故障只记录稳定类型。"""
"""以 fail-open 方式调用兼容观测 hook,故障只记录稳定类型。"""
try:
return hook(*args, **kwargs)
except Exception as error:
@@ -76,7 +76,7 @@ class AgentToolPolicyOrchestrator:
"""让 Agent middleware 与 direct manager 复用同一策略生命周期。"""
def __init__(self, registry: ToolPolicyRegistry = DEFAULT_TOOL_POLICY_REGISTRY) -> None:
"""绑定固定工具迁移注册表。"""
"""绑定工具策略解析表。"""
self.registry = registry
def start(
@@ -103,12 +103,12 @@ class AgentToolPolicyOrchestrator:
reason_code="legacy_shadow_allow",
)
elif policy.confirmation is ConfirmationMode.REQUIRED:
# 严格运行时接管前保持既有调用能力,但不得把敏感动作记为安全读取
# 通用编排器保持 shadow;支持的 Agent 入口会在 ToolNode 前独立完成确认
decision = PolicyDecision(
allowed=True,
confirmation_required=False,
shadow=True,
reason_code="strict_runtime_pending",
reason_code="confirmation_policy_shadow_allow",
)
else:
decision = PolicyDecision(
+21 -17
View File
@@ -1,4 +1,4 @@
"""固定工具迁移注册表与参数级策略解析。"""
"""工具策略例外与参数级策略解析。"""
from typing import Any, Mapping
@@ -13,7 +13,7 @@ from app.agent.policy.contracts import (
)
# 这些读取已具备清晰的无副作用语义,用于证明新宿主边界不会改变正常结果
# 这些非管理员读取在运行时解析为强制 SAFE_READ;管理员门禁仍沿用原有授权事实源
SAFE_READ_TOOL_NAMES = frozenset(
{
"list_slash_commands",
@@ -25,8 +25,8 @@ SAFE_READ_TOOL_NAMES = frozenset(
)
# 其余固定工具先显式处于兼容观测状态,待领域叶子 Goal 逐个迁移
LEGACY_SHADOW_TOOL_NAMES = frozenset(
# 该清单只校验固定工具 inventory;未命中的固定或动态工具同样默认 LEGACY_SHADOW
BUILTIN_LEGACY_SHADOW_INVENTORY = frozenset(
{
"add_custom_filter_rule",
"add_download_tasks",
@@ -112,25 +112,29 @@ LEGACY_SHADOW_TOOL_NAMES = frozenset(
class ToolPolicyRegistry:
"""解析固定和动态工具的 P1-G1 迁移策略。"""
"""解析固定和动态工具的宿主策略。"""
def __init__(
self,
*,
safe_read_tool_names: frozenset[str] = SAFE_READ_TOOL_NAMES,
legacy_shadow_tool_names: frozenset[str] = LEGACY_SHADOW_TOOL_NAMES,
builtin_legacy_shadow_inventory: frozenset[str] = (
BUILTIN_LEGACY_SHADOW_INVENTORY
),
) -> None:
"""建立互斥的固定工具迁移表"""
overlap = safe_read_tool_names & legacy_shadow_tool_names
"""建立 SAFE_READ 例外与固定工具 inventory"""
overlap = safe_read_tool_names & builtin_legacy_shadow_inventory
if overlap:
raise ValueError(f"工具策略迁移表存在重复项: {sorted(overlap)}")
raise ValueError(f"工具策略 inventory 存在重复项: {sorted(overlap)}")
self._safe_read_tool_names = safe_read_tool_names
self._legacy_shadow_tool_names = legacy_shadow_tool_names
self._builtin_legacy_shadow_inventory = builtin_legacy_shadow_inventory
@property
def builtin_tool_names(self) -> set[str]:
"""返回注册表覆盖的全部固定工具名"""
return set(self._safe_read_tool_names | self._legacy_shadow_tool_names)
def builtin_tool_inventory(self) -> set[str]:
"""返回用于测试校验的固定工具 inventory"""
return set(
self._safe_read_tool_names | self._builtin_legacy_shadow_inventory
)
def resolve(
self,
@@ -139,7 +143,7 @@ class ToolPolicyRegistry:
arguments: Mapping[str, Any],
requires_admin: bool,
) -> ActionPolicy:
"""根据工具名和宿主权限声明解析当前迁移策略。"""
"""根据工具名和宿主权限声明解析当前参数级策略。"""
required_role = (
PrincipalRole.SYSTEM_ADMIN if requires_admin else PrincipalRole.USER
)
@@ -166,7 +170,7 @@ class ToolPolicyRegistry:
confirmation=ConfirmationMode.NONE,
recovery=RecoveryMode.NONE,
result_sensitivity=ResultSensitivity.NORMAL,
# 角色门禁仍可能异步识别渠道管理员;G1 不复制旧授权事实源
# 角色门禁仍由既有授权事实源判断,管理员读取保持兼容观测
migration_state=(
MigrationState.LEGACY_SHADOW
if requires_admin
@@ -174,7 +178,7 @@ class ToolPolicyRegistry:
),
)
# 固定未迁移工具和动态工具都保持现有执行能力,但不得被视为安全读取。
# 除明确例外外,固定和动态工具都保持现有能力,但不得被视为安全读取。
return ActionPolicy(
effect=ActionEffect.UNKNOWN,
required_role=required_role,
@@ -189,8 +193,8 @@ DEFAULT_TOOL_POLICY_REGISTRY = ToolPolicyRegistry()
__all__ = [
"BUILTIN_LEGACY_SHADOW_INVENTORY",
"DEFAULT_TOOL_POLICY_REGISTRY",
"LEGACY_SHADOW_TOOL_NAMES",
"SAFE_READ_TOOL_NAMES",
"ToolPolicyRegistry",
]