M5:百工与司命——能力注册表、授契行契请命、禁域裁决与观察标证
- 百工域 domains/baigong:capabilities(统一能力契约:执行类型 builtin/qiankun/mcp/http + 四维风险标注 + 来源版本)、mandates(授契 grant / 行契 charter 预算时间窗,撤销立即生效)、tool_calls(净化摘要 + 观察标证);请命复用 pending_confirmations 加 expires_at/brief_json(超时=拒绝执行 HJ-1302,六要素说明) - 司命裁决器 siming.py:四维取最高档(能力/数据/后果/情境);禁域为代码常量不可配置(递归删根/格式化/写裸设备/fork 炸弹、file:// 协议、密钥材料外发)——专项测试断言恶意标注与篡改对象仍拒 HJ-1301 - 执行链:禁域→裁决(HJ-1303 未授契拒且双留痕)→请命→执行(乾坤经 SandboxClient HMAC;MCP 经 call_tool;内置无副作用)→净化标证(观察非事实,敏感遮蔽+体量截断)→行动账 - MCP 登记/启停/刷新自动派生与撤下 capabilities(博闻登记接入点);内置能力种子(echo/datetime/qiankun.shell/web.fetch) - 端点:/capabilities /mandates /petitions /tool-calls + /tools 兼容投影;前端百工页四区(能力目录四维标注/授契行契/请命批准拒绝/行动账观察标证) - 退役:modules/safety|skills|tool_runtime|agent、application/tool_runtime、services/skills|safety、tool_policies/skills/model_endpoint_overrides 表(迁移 006)、前端 SkillManager/ToolsConsole/旧 tools 页 - 验收(test_baigong_siming 四用例):未授契被拒留痕、请命超时不执行、禁域不可关闭、结果为观察不入忆;后端 454、前端 220、BFF 4、神识 5 包全绿(2 个预存环境失败不变)
This commit is contained in:
1 parent
144415247e
commit
684261f4b4
97 files changed
+3075
-5554
No files matched your search
@@ -1,19 +0,0 @@
|
||||
"""通用 Agent 的纯领域契约。"""
|
||||
|
||||
from app.modules.agent.definition import (
|
||||
DEFAULT_CHAT_AGENT,
|
||||
AgentDefinition,
|
||||
ExecutionPolicy,
|
||||
ToolPolicy,
|
||||
)
|
||||
from app.modules.agent.task import AgentTask, Plan, PlanStep
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CHAT_AGENT",
|
||||
"AgentDefinition",
|
||||
"AgentTask",
|
||||
"ExecutionPolicy",
|
||||
"Plan",
|
||||
"PlanStep",
|
||||
"ToolPolicy",
|
||||
]
|
||||
@@ -1,44 +0,0 @@
|
||||
"""通用 Agent 定义。
|
||||
|
||||
定义保持纯数据;模型调用、工具执行与持久化仍由既有运行时负责。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPolicy:
|
||||
"""一次 Agent 运行的硬性边界。"""
|
||||
|
||||
max_iterations: int = 8
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_iterations < 1:
|
||||
raise ValueError("max_iterations 必须 >= 1")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolPolicy:
|
||||
"""本次运行允许暴露给模型的工具名称。空值表示沿用运行时默认工具集。"""
|
||||
|
||||
allowed_tools: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentDefinition:
|
||||
"""可组合的 Agent 身份、提示、技能与执行边界。"""
|
||||
|
||||
name: str
|
||||
system_prompt: str
|
||||
model_hint: str | None = None
|
||||
skill_refs: tuple[str, ...] = ()
|
||||
tool_policy: ToolPolicy = field(default_factory=ToolPolicy)
|
||||
execution_policy: ExecutionPolicy = field(default_factory=ExecutionPolicy)
|
||||
|
||||
|
||||
DEFAULT_CHAT_AGENT = AgentDefinition(
|
||||
name="镜师",
|
||||
system_prompt="你是魂镜当前默认人物。请使用中文,保持诚实、克制、清晰。",
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
"""通用 Agent 的任务与计划纯领域契约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
TaskStatus = Literal["queued", "running", "waiting_confirmation", "completed", "failed", "cancelled"]
|
||||
PlanStatus = Literal["draft", "approved", "running", "completed", "failed"]
|
||||
StepStatus = Literal["pending", "running", "completed", "failed", "skipped"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanStep:
|
||||
"""可追踪的 Agent 计划步骤。"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
tool: str | None = None
|
||||
risk_level: str = "L2"
|
||||
status: StepStatus = "pending"
|
||||
result_summary: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Plan:
|
||||
"""任务的显式执行计划。"""
|
||||
|
||||
steps: tuple[PlanStep, ...]
|
||||
assumptions: tuple[str, ...] = ()
|
||||
risks: tuple[str, ...] = ()
|
||||
status: PlanStatus = "draft"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentTask:
|
||||
"""一次 Agent 执行的目标与可选计划。"""
|
||||
|
||||
id: str
|
||||
objective: str
|
||||
agent_name: str
|
||||
run_id: str | None = None
|
||||
status: TaskStatus = "queued"
|
||||
plan: Plan | None = None
|
||||
constraints: tuple[str, ...] = ()
|
||||
@@ -1,6 +1,5 @@
|
||||
"""运行时:Run / Event / Trace 与小周天执行状态(见后端架构计划 5.2)。"""
|
||||
"""运行时事件契约(Event/EventStore):一炁事件流的数据结构。"""
|
||||
|
||||
from app.modules.runtime.events import Event, EventStore, InMemoryEventStore
|
||||
from app.modules.runtime.goals import build_goals_context
|
||||
|
||||
__all__ = ["Event", "EventStore", "InMemoryEventStore", "build_goals_context"]
|
||||
__all__ = ["Event", "EventStore", "InMemoryEventStore"]
|
||||
@@ -1,41 +0,0 @@
|
||||
"""运行时契约。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.modules.agent import AgentDefinition
|
||||
|
||||
|
||||
class RuntimeContract(BaseModel):
|
||||
"""从业务上下文传递给 LLM 基础设施的结构化契约。"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
character_id: str
|
||||
session_id: str
|
||||
run_id: str
|
||||
model_hint: str
|
||||
agent: AgentDefinition | None = None
|
||||
system_instructions: list[str] = Field(default_factory=list)
|
||||
# 卡的 system_prompt(酒馆):作道体增补层注入,绝不覆盖恒定层(§6 安全边界)
|
||||
system_prompt: str | None = None
|
||||
session_goal: str | None = None
|
||||
personality_summary: str | None = None
|
||||
# 道体核心使命(设计理念 §2.1):对应太一 highest goal 的恒定快照,组装时不可被覆盖。
|
||||
mission: str | None = None
|
||||
# 道体安全边界:人物自身基础行为约束(区别于司命 safety_constraints 的动态治理)
|
||||
safety_guidelines: str | None = None
|
||||
# 世谱(Lorebook)注入:before/after 相对 personality_summary 的位置(酒馆 before_char/after_char)
|
||||
lorebook_before: str | None = None
|
||||
lorebook_after: str | None = None
|
||||
emotion_state: str | None = None
|
||||
memory_context: str | None = None
|
||||
temporal_context: str | None = None
|
||||
active_goals: str | None = Field(
|
||||
default=None,
|
||||
description="格式化后的活跃目标上下文(由 build_goals_context 生成)。",
|
||||
)
|
||||
recent_messages: list[Any] = Field(default_factory=list)
|
||||
safety_constraints: list[str] = Field(default_factory=list)
|
||||
trace_id: str
|
||||
@@ -1,36 +0,0 @@
|
||||
"""运行时目标上下文构建(纯函数,无 IO)。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
_LEVEL_LABEL: dict[str, str] = {
|
||||
"highest": "最高目标",
|
||||
"long_term": "长期目标",
|
||||
"session": "会话目标",
|
||||
"task": "任务目标",
|
||||
"action": "行动",
|
||||
}
|
||||
|
||||
|
||||
def build_goals_context(goals: list[dict[str, Any]]) -> str:
|
||||
"""把活跃目标列表格式化为 system prompt 片段。
|
||||
|
||||
按 priority 降序排列;空列表返回空串。
|
||||
"""
|
||||
if not goals:
|
||||
return ""
|
||||
|
||||
sorted_goals = sorted(goals, key=lambda g: g.get("priority", 0), reverse=True)
|
||||
lines: list[str] = ["当前活跃目标:"]
|
||||
for g in sorted_goals:
|
||||
level_label = _LEVEL_LABEL.get(g.get("level", ""), g.get("level", ""))
|
||||
progress = g.get("progress", 0.0)
|
||||
bar = _progress_bar(progress)
|
||||
line = f"- [{level_label}] {g['title']} [{bar} {int(progress * 100)}%]"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _progress_bar(value: float, width: int = 8) -> str:
|
||||
filled = int(value * width)
|
||||
return "■" * filled + "□" * (width - filled)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""道体完整性校验。
|
||||
|
||||
组装运行时契约后,确认恒定层(mission / safety_guidelines)仍等于 Character 的值,
|
||||
未被任何动态注入(system_prompt / 世谱 / 眉批)在数据层覆盖或稀释。
|
||||
这是魂镜相对酒馆的核心安全增量——酒馆的 system_prompt 设计上完全替换前端 prompt,
|
||||
魂镜则保证道体恒定层不可被覆盖(设计理念 §2.2、融合设计 §6)。
|
||||
|
||||
注:字段层校验防组装错误与数据层覆盖;prompt 层的注入诱导(「忽略你的使命」)
|
||||
属另一类问题,不在此校验范围。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def validate_daoti_integrity(
|
||||
contract: RuntimeContract,
|
||||
*,
|
||||
mission: str | None = None,
|
||||
safety_guidelines: str | None = None,
|
||||
) -> None:
|
||||
"""校验契约道体字段仍等于 Character 恒定层;失配抛 ValueError。
|
||||
|
||||
``None`` 的道体字段跳过校验(未设定时不强求)。
|
||||
"""
|
||||
if mission is not None and contract.mission != mission:
|
||||
raise ValueError("道体完整性校验失败:mission 与 Character 恒定层不一致")
|
||||
if safety_guidelines is not None and contract.safety_guidelines != safety_guidelines:
|
||||
raise ValueError("道体完整性校验失败:safety_guidelines 与 Character 恒定层不一致")
|
||||
@@ -1,31 +0,0 @@
|
||||
"""司命枢:权限、风险与审批(见 safety-governor-design)。"""
|
||||
|
||||
from app.modules.safety.confirmation import (
|
||||
ConfirmationStore,
|
||||
InMemoryConfirmationStore,
|
||||
PendingConfirmation,
|
||||
new_confirmation_id,
|
||||
)
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation, SafetyError
|
||||
from app.modules.safety.governor import (
|
||||
PermissionPolicy,
|
||||
SafetyDecision,
|
||||
SafetyGovernor,
|
||||
SafetyResult,
|
||||
ToolPolicy,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfirmationStore",
|
||||
"InMemoryConfirmationStore",
|
||||
"PendingConfirmation",
|
||||
"PermissionDenied",
|
||||
"PermissionPolicy",
|
||||
"RequiresConfirmation",
|
||||
"SafetyDecision",
|
||||
"SafetyError",
|
||||
"SafetyGovernor",
|
||||
"SafetyResult",
|
||||
"ToolPolicy",
|
||||
"new_confirmation_id",
|
||||
]
|
||||
@@ -1,178 +0,0 @@
|
||||
"""待确认动作的存储(高风险工具调用的确认中转)。
|
||||
|
||||
司命决策为 ask 时不直接执行,而是登记一个 PendingConfirmation 并返回 confirmation_id;
|
||||
前端展示确认卡片,用户 approve/deny 后由安全 API 消费。
|
||||
|
||||
提供两种实现:
|
||||
- ``InMemoryConfirmationStore``:进程内 dict,默认开发模式,不依赖 DB
|
||||
- ``DbConfirmationStore``:数据库持久化,生产多 worker 模式,确认项跨进程可见
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def new_confirmation_id() -> str:
|
||||
return f"confirm_{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingConfirmation:
|
||||
"""一次待确认的工具调用。"""
|
||||
|
||||
confirmation_id: str
|
||||
tool: str
|
||||
input: dict[str, Any]
|
||||
risk_level: str
|
||||
reason: str
|
||||
run_id: str | None = None
|
||||
character_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
created_at: str = field(default_factory=_now_iso)
|
||||
|
||||
|
||||
class ConfirmationStore(Protocol):
|
||||
"""确认存储协议(全异步接口)。"""
|
||||
|
||||
async def create(self, pending: PendingConfirmation) -> PendingConfirmation: ...
|
||||
|
||||
async def get(self, confirmation_id: str) -> PendingConfirmation | None: ...
|
||||
|
||||
async def pop(self, confirmation_id: str) -> PendingConfirmation | None: ...
|
||||
|
||||
async def list_pending(self) -> list[PendingConfirmation]: ...
|
||||
|
||||
async def mark_timed_out(self, confirmation_id: str) -> None: ...
|
||||
|
||||
|
||||
class InMemoryConfirmationStore:
|
||||
"""内存确认存储,供本地与测试使用。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._items: dict[str, PendingConfirmation] = {}
|
||||
|
||||
async def create(self, pending: PendingConfirmation) -> PendingConfirmation:
|
||||
self._items[pending.confirmation_id] = pending
|
||||
return pending
|
||||
|
||||
async def get(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
return self._items.get(confirmation_id)
|
||||
|
||||
async def pop(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
return self._items.pop(confirmation_id, None)
|
||||
|
||||
async def list_pending(self) -> list[PendingConfirmation]:
|
||||
return sorted(self._items.values(), key=lambda item: item.created_at)
|
||||
|
||||
async def mark_timed_out(self, confirmation_id: str) -> None:
|
||||
self._items.pop(confirmation_id, None)
|
||||
|
||||
|
||||
class DbConfirmationStore:
|
||||
"""数据库确认存储,供多 worker 生产环境使用。
|
||||
|
||||
待确认项持久化到 ``pending_confirmations`` 表,跨进程可见。
|
||||
需要从 ``app.state.session_factory`` 或 ``async_sessionmaker`` 注入。
|
||||
"""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def create(self, pending: PendingConfirmation) -> PendingConfirmation:
|
||||
async with self._session_factory() as session:
|
||||
record = PendingConfirmationRecord(
|
||||
id=pending.confirmation_id,
|
||||
tool=pending.tool,
|
||||
input_json=json.dumps(pending.input, ensure_ascii=False),
|
||||
risk_level=pending.risk_level,
|
||||
reason=pending.reason,
|
||||
run_id=pending.run_id,
|
||||
character_id=pending.character_id,
|
||||
trace_id=pending.trace_id,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
return pending
|
||||
|
||||
async def get(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
return self._record_to_pending(record)
|
||||
|
||||
async def pop(self, confirmation_id: str) -> PendingConfirmation | None:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
record.status = "consumed"
|
||||
await session.commit()
|
||||
return self._record_to_pending(record)
|
||||
|
||||
async def list_pending(self) -> list[PendingConfirmation]:
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord)
|
||||
.where(PendingConfirmationRecord.status == "pending")
|
||||
.order_by(PendingConfirmationRecord.created_at.asc())
|
||||
)
|
||||
records: Sequence[PendingConfirmationRecord] = result.scalars().all()
|
||||
return [self._record_to_pending(r) for r in records]
|
||||
|
||||
async def mark_timed_out(self, confirmation_id: str) -> None:
|
||||
"""将待确认记录标记为超时(Agent 循环确认等待超时)。"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return
|
||||
record.status = "timed_out"
|
||||
record.resolved_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _record_to_pending(record: PendingConfirmationRecord) -> PendingConfirmation:
|
||||
return PendingConfirmation(
|
||||
confirmation_id=record.id,
|
||||
tool=record.tool,
|
||||
input=json.loads(record.input_json) if record.input_json else {},
|
||||
risk_level=record.risk_level,
|
||||
reason=record.reason,
|
||||
run_id=record.run_id,
|
||||
character_id=record.character_id,
|
||||
trace_id=record.trace_id,
|
||||
created_at=record.created_at.isoformat() if record.created_at else _now_iso(),
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""司命异常。错误码映射契约字典 API 标准错误码。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class SafetyError(Exception):
|
||||
"""司命决策异常基类。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
class PermissionDenied(SafetyError):
|
||||
"""动作被司命拒绝(映射 permission_denied)。"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__("permission_denied", message)
|
||||
|
||||
|
||||
class RequiresConfirmation(SafetyError):
|
||||
"""高风险动作需用户确认(映射 requires_confirmation)。"""
|
||||
|
||||
def __init__(self, message: str, *, risk_level: str | None = None) -> None:
|
||||
super().__init__("requires_confirmation", message)
|
||||
self.risk_level = risk_level
|
||||
@@ -1,115 +0,0 @@
|
||||
"""司命决策器:按工具策略 + 风险等级 + 是否已确认,输出 allow / ask / deny。
|
||||
|
||||
风险分级与权限策略取值见契约字典(risk_level L0–L4、permission_policy
|
||||
allow/ask/always_ask/forbidden);决策枚举对齐 AuditEvent.decision(allow/deny/ask)。
|
||||
|
||||
策略来源:``SafetyGovernor()`` 用代码内置默认;``from_policy_dict(data)`` 从已解析字典
|
||||
加载(文件 IO 由 ``services/safety.load_safety_governor`` 承担,本模块保持纯领域、零 IO)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class PermissionPolicy(StrEnum):
|
||||
allow = "allow"
|
||||
ask = "ask"
|
||||
always_ask = "always_ask"
|
||||
forbidden = "forbidden"
|
||||
|
||||
|
||||
class SafetyDecision(StrEnum):
|
||||
allow = "allow"
|
||||
ask = "ask"
|
||||
deny = "deny"
|
||||
|
||||
|
||||
# 风险等级排序,便于阈值判断
|
||||
RISK_RANK: dict[str, int] = {"L0": 0, "L1": 1, "L2": 2, "L3": 3, "L4": 4}
|
||||
|
||||
|
||||
def default_policy(risk_level: str) -> PermissionPolicy:
|
||||
"""无显式工具策略时,按风险等级推导默认策略。"""
|
||||
rank = RISK_RANK.get(risk_level, 2)
|
||||
if rank >= 4:
|
||||
return PermissionPolicy.forbidden
|
||||
if rank >= 3:
|
||||
return PermissionPolicy.always_ask
|
||||
if rank >= 2:
|
||||
return PermissionPolicy.ask
|
||||
return PermissionPolicy.allow
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolPolicy:
|
||||
"""工具的固定策略(由工具定义或配置提供)。"""
|
||||
|
||||
risk_level: str # L0–L4
|
||||
permission_policy: PermissionPolicy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SafetyResult:
|
||||
decision: SafetyDecision
|
||||
reason: str
|
||||
risk_level: str
|
||||
policy: PermissionPolicy
|
||||
|
||||
|
||||
class SafetyGovernor:
|
||||
"""司命决策器。无状态、同步——便于测试与上层组合确认流。"""
|
||||
|
||||
def __init__(self, policies: dict[str, ToolPolicy] | None = None) -> None:
|
||||
self._policies: dict[str, ToolPolicy] = dict(policies or {})
|
||||
|
||||
@classmethod
|
||||
def from_policy_dict(cls, data: dict) -> SafetyGovernor:
|
||||
"""从已解析的策略字典构造 Governor(纯逻辑,不读文件)。
|
||||
|
||||
文件读取由 ``services/safety.load_safety_governor`` 承担,调用方传入已
|
||||
``yaml.safe_load`` 的字典;空字典或缺 ``tools`` 返回默认(空策略)Governor。
|
||||
"""
|
||||
policies: dict[str, ToolPolicy] = {}
|
||||
tools = data.get("tools") if isinstance(data, dict) else None
|
||||
if isinstance(tools, dict):
|
||||
for name, cfg in tools.items():
|
||||
if not isinstance(cfg, dict):
|
||||
continue
|
||||
policies[name] = ToolPolicy(
|
||||
risk_level=str(cfg.get("risk_level", "L2")),
|
||||
permission_policy=PermissionPolicy(str(cfg.get("permission_policy", "ask"))),
|
||||
)
|
||||
return cls(policies)
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
*,
|
||||
tool: str,
|
||||
risk_level: str | None = None,
|
||||
confirmed: bool = False,
|
||||
) -> SafetyResult:
|
||||
policy = self._policies.get(tool)
|
||||
if policy is None:
|
||||
resolved_risk = risk_level or "L2"
|
||||
perm = default_policy(resolved_risk)
|
||||
else:
|
||||
resolved_risk = policy.risk_level
|
||||
perm = policy.permission_policy
|
||||
|
||||
# L4 一律禁止,即便策略写 allow
|
||||
if RISK_RANK.get(resolved_risk, 2) >= 4:
|
||||
return SafetyResult(
|
||||
SafetyDecision.deny, "L4 风险禁止执行", resolved_risk, PermissionPolicy.forbidden
|
||||
)
|
||||
if perm is PermissionPolicy.forbidden:
|
||||
return SafetyResult(SafetyDecision.deny, "工具策略为 forbidden", resolved_risk, perm)
|
||||
if perm is PermissionPolicy.allow:
|
||||
return SafetyResult(SafetyDecision.allow, "策略放行", resolved_risk, perm)
|
||||
# ask / always_ask
|
||||
if confirmed:
|
||||
return SafetyResult(SafetyDecision.allow, "用户已确认", resolved_risk, perm)
|
||||
return SafetyResult(
|
||||
SafetyDecision.ask, f"策略 {perm.value} 需用户确认", resolved_risk, perm
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Skill 能力包领域纯逻辑(关键词触发匹配,禁止 IO)。"""
|
||||
|
||||
from app.modules.skills.manifest import SkillManifest, parse_skill_manifest
|
||||
|
||||
__all__ = ["SkillManifest", "parse_skill_manifest"]
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Skill Manifest:将现有 Skill 记录转换为运行时无 IO 描述。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SkillLike(Protocol):
|
||||
name: str
|
||||
description: str
|
||||
trigger: str
|
||||
prompt_fragment: str
|
||||
risk_level: str
|
||||
status: str
|
||||
tool_bindings_json: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillManifest:
|
||||
"""一次运行需要的技能声明。"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
triggers: tuple[str, ...]
|
||||
prompt: str
|
||||
allowed_tools: tuple[str, ...]
|
||||
risk_level: str
|
||||
|
||||
|
||||
def parse_skill_manifest(skill: SkillLike, tool_bindings: list[str]) -> SkillManifest:
|
||||
"""从既有 Skill ORM 构建 manifest,避免改变数据库结构。"""
|
||||
return SkillManifest(
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
triggers=tuple(item.strip() for item in skill.trigger.split(",") if item.strip()),
|
||||
prompt=skill.prompt_fragment,
|
||||
allowed_tools=tuple(tool_bindings),
|
||||
risk_level=skill.risk_level,
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Skill 关键词触发匹配(纯函数,无 IO)。
|
||||
|
||||
按 ``trigger`` 逗号分隔关键词,``context_text`` 包含任一关键词(大小写不敏感)则命中。
|
||||
仅 ``status=enabled`` 的 skill 参与;返回保持输入顺序。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def match_skills[T](context_text: str, skills: Sequence[T]) -> list[T]:
|
||||
text = context_text.lower()
|
||||
result: list[T] = []
|
||||
for skill in skills:
|
||||
if getattr(skill, "status", "enabled") != "enabled":
|
||||
continue
|
||||
trigger = getattr(skill, "trigger", "") or ""
|
||||
keywords = [k.strip().lower() for k in trigger.split(",") if k.strip()]
|
||||
if any(kw in text for kw in keywords):
|
||||
result.append(skill)
|
||||
return result
|
||||
@@ -1,10 +0,0 @@
|
||||
"""百工工具运行时领域结构(ToolSpec / ToolResult / ToolInput)。
|
||||
|
||||
工具的实际编排(``ToolRuntime``)与内置工具注册(``builtins``)已迁至
|
||||
``application/tool_runtime/``——它们需要协调沙箱(integrations)、司命(modules)、
|
||||
事件存储(services)等多层,属于应用编排而非纯领域。本目录只保留无 IO 的工具规格数据结构。
|
||||
"""
|
||||
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolResult, ToolSpec
|
||||
|
||||
__all__ = ["ToolInput", "ToolResult", "ToolSpec"]
|
||||
@@ -1,92 +0,0 @@
|
||||
"""工具定义:ToolSpec 描述工具元数据与请求构造方式,ToolResult 为执行产出。
|
||||
|
||||
字段口径对齐 docs/architecture/service-boundaries.md(工具定义字段、风险分级、
|
||||
权限策略)。工具的实际执行有两条路径:
|
||||
- 沙箱路径:``build_request`` 产出沙箱 ExecRequest,由 SandboxClient 执行(文件/shell 类)。
|
||||
- 直接执行路径:``executor`` 非 None 时由 runtime 注入 http_client、密钥等上下文直接执行
|
||||
(HTTP 类工具如 fetch_url,不走沙箱)。
|
||||
|
||||
本模块是纯领域数据结构(无 IO):``ExecRequest`` 仅在类型注解中引用(``TYPE_CHECKING``),
|
||||
运行时不依赖 ``integrations``;实际 ExecRequest 的构造在 ``application/tool_runtime/``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# 仅类型注解用:build_request 产出沙箱 ExecRequest,但本模块运行时不依赖 integrations
|
||||
# (保持 modules 零 IO 依赖);实际构造在 application/tool_runtime/builtins。
|
||||
from app.integrations.sandbox.models import ExecRequest
|
||||
|
||||
ToolInput = dict[str, Any]
|
||||
RequestBuilder = Callable[[ToolInput], "ExecRequest"]
|
||||
# 前向引用 ToolResult(定义在下方),避免循环依赖;TYPE_CHECKING 下解析为真实类。
|
||||
ToolExecutor = Callable[[ToolInput, dict[str, Any] | None], Awaitable["ToolResult"]]
|
||||
|
||||
|
||||
class ToolRuntimeLike(Protocol):
|
||||
"""Agent loop 所需的最小工具运行时契约。"""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
tool: str,
|
||||
input: ToolInput,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
confirmed: bool = False,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> ToolResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResult:
|
||||
"""工具执行产出(映射沙箱 ExecResponse 或 executor 直接产出)。"""
|
||||
|
||||
ok: bool
|
||||
exit_code: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_ms: int
|
||||
timeout: bool
|
||||
truncated: bool
|
||||
trace_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolSpec:
|
||||
"""工具规格:名称、风险、权限策略与请求构造器/执行器。
|
||||
|
||||
执行路径二选一:
|
||||
- ``executor`` 非 None:直接执行,跳过沙箱(HTTP 类工具);``build_request`` 此时可为占位。
|
||||
- ``executor`` 为 None:走沙箱路径,用 ``build_request`` 构造 ExecRequest。
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
risk_level: str # L0–L4
|
||||
permission_policy: PermissionPolicy
|
||||
build_request: RequestBuilder = field(default_factory=lambda: _placeholder_builder)
|
||||
default_timeout_ms: int = 30_000
|
||||
side_effect: bool = True
|
||||
audit_required: bool = True
|
||||
# 是否按 run/trace 隔离工作目录(文件类工具):runtime 会把 workdir 改写为
|
||||
# ``<workdir>/<run_key>``,防跨 run 信息泄漏与文件名冲突;run_key 经 sanitize 防路径注入。
|
||||
isolated_workspace: bool = False
|
||||
# 非 None 时走直接执行路径(HTTP 类工具),跳过沙箱;context 由 runtime 注入。
|
||||
executor: ToolExecutor | None = None
|
||||
# 工具入参 JSON Schema(供 LLM 生成正确参数),None 表示无参数。
|
||||
input_schema: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _placeholder_builder(
|
||||
_input: ToolInput,
|
||||
) -> ExecRequest: # pragma: no cover - 仅占位,executor 工具不走此路径
|
||||
"""executor 工具的 build_request 占位:正常不应被调用(runtime 见 executor 非 None 会跳过)。"""
|
||||
raise NotImplementedError("该工具使用 executor 直接执行,不应调用 build_request")
|
||||
Reference in new issue
Block a user