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:
chenxin committed 2026-08-27 19:17:21 +08:00
1 parent 144415247e
commit 684261f4b4
97 files changed
+3075 -5554

No files matched your search

-19
View File
@@ -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",
]
-44
View File
@@ -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="你是魂镜当前默认人物。请使用中文,保持诚实、克制、清晰。",
)
-46
View File
@@ -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, ...] = ()
+2 -3
View File
@@ -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"]
-41
View File
@@ -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
-36
View File
@@ -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)
-30
View File
@@ -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 恒定层不一致")
-31
View File
@@ -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",
]
-178
View File
@@ -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(),
)
-30
View File
@@ -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
-115
View File
@@ -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
)
-5
View File
@@ -1,5 +0,0 @@
"""Skill 能力包领域纯逻辑(关键词触发匹配,禁止 IO)。"""
from app.modules.skills.manifest import SkillManifest, parse_skill_manifest
__all__ = ["SkillManifest", "parse_skill_manifest"]
-40
View File
@@ -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,
)
-20
View File
@@ -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")