OpenHuman 会话工具边界策略(agent_policy):基于渠道权限天花板的确定性工具分级与系统提示注入
2026/9/11 21:12:37 网站建设 项目流程

OpenHuman 会话工具边界策略(agent_policy):基于渠道权限天花板的确定性工具分级与系统提示注入

【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman

OpenHuman 是一个面向 macOS、Windows 与 Linux 的开源个人 AI(本地优先记忆、Agent 编排与深度研究)。在单 Agent 会话中,模型能看到的工具集合与其运行时实际能调用的工具必须保持一致,且都要被"渠道(channel)配置的权限天花板"约束。src/openhuman/tools/agent_policy/模块正是负责这项工作的纯逻辑域:它为每个 Agent 会话生成一份确定、不可变的ToolPolicySession快照(每个工具的允许/拒绝/隐藏决策、允许/阻断/隐藏工具名集合、粗粒度任务风险等级),并把活动边界渲染成系统提示中的一小节。读完本文,你将掌握 OpenHuman 中"渠道权限 → 每工具决策 → 风险等级 → 提示注入"的完整链路,以及空配置回退、宽松解析、可见性/权限双轴分类等关键设计。

模块定位:一份纯逻辑、无副作用的安全快照

agent_policycrate::openhuman::tools下的独立子模块,其唯一职责是把输入映射为输出,不涉及持久化、RPC 或事件。模块文档明确写道:"This domain is pure logic — no persistence, no RPC, no events."

一次build_session调用接收六个输入:

  • 活跃的agent_id
  • 发起会话的channel(如webclitelegram);
  • entrypoint(如chatagent);
  • 配置好的channel -> permission字符串映射channel_permissions: &HashMap<String, String>
  • 可用工具注册表tools: &[Box<dyn Tool>]
  • 可选的显式可见工具名集合visible_tool_names: &HashSet<String>

输出是一份确定性、不可变ToolPolicySession:同一输入必然产生同一输出,快照没有变更(mutation)API,从生成到会话结束始终保持一致。这样设计的好处是:会话中途无论提示被拼接多少次,模型看到的是同一份边界,不会出现"分类结果漂移"。

模块由四个文件构成(目录):

文件角色
mod.rs仅导出:模块文档 +mod声明 +pub use重导出引擎、提示渲染器与类型
types.rs无 Serde 的纯领域类型:TaskRiskLevelTaskProfileToolPolicyActionToolPolicyDecisionToolCapabilityToolPolicySession(含查询辅助方法),并持有NO_TOOLS_ALLOWED_SENTINEL哨兵常量
engine.rsToolPolicyEngine::build_session分类逻辑;私有辅助函数permission_for_channel/parse_permission_level;内联#[cfg(test)]测试套件
prompt.rsrender_tool_policy_boundary+TOOL_POLICY_BOUNDARY_HEADING常量;UTF-8 安全的truncate_utf8;内联#[cfg(test)]测试套件

核心职责拆解

README 将模块职责归纳为六条,逐一对应到代码中的具体实现:

  1. 解析渠道权限天花板:从channel -> permission字符串映射中解析出某渠道的PermissionLevelpermission_for_channel),并处理各类回退。
  2. 逐工具分类:把注册表中的每个工具对照天花板与可选可见性集合,产出ToolPolicyActionAllow/RequireApproval/Deny/HideFromPrompt)。
  3. 构建不可变快照:生成附加到 Agent 会话上的ToolPolicySession(profile、capabilities、允许/阻断/隐藏工具名集合、决策映射)。
  4. 推导任务风险等级:由最高允许权限推导粗粒度TaskRiskLevel(Low/Medium/High/Critical)。
  5. 渲染系统提示边界:输出有界的一节## Tool Policy Boundary,列出活跃的 agent/channel/entrypoint、允许权限、风险、允许工具与受限数量摘要。
  6. 运行时 fail-closed:对未知或未列出的工具名,默认决策为Deny,保证"查不到就是拒绝"。

权限模型:PermissionLevel天花板与排序

模块依赖crate::openhuman::tools::PermissionLevelTooltrait(name()permission_level()),这是它与 openhuman/core 之间唯一的耦合点(见 tools/mod.rs 的重导出)。权限层级通过PermissionLevel的 Ord 排序实现天花板比较,这也是整个分类的核心。

权限到风险等级的映射在 types.rs 中明确定义:

允许权限(PermissionLevel)风险等级(TaskRiskLevel)Display 输出
None/ReadOnlyLowlow
WriteMediummedium
ExecuteHighhigh
DangerousCriticalcritical

TaskRiskLevelCopy + Eq枚举,其Display实现把等级渲染为小写字符串,供提示文本直接使用。

分类算法:两条相互独立的轴

ToolPolicyEngine::build_session(engine.rs)对每个工具计算两个布尔量,然后三选一:

  • explicitly_hidden = !visible_tool_names.is_empty() && !visible_tool_names.contains(&name)可见性轴。只要显式可见集合非空且工具不在其中,该工具就被判定为HideFromPrompt
  • exceeds_permission = required_permission > allowed_permission权限轴。工具所需权限高于渠道天花板即Deny

分类优先级为:先看可见性(HideFromPrompt),再看权限(Deny),否则 Allow。也就是说:

if explicitly_hidden => HideFromPrompt(入 hidden_tool_names) else if exceeds_permission => Deny(入 blocked_tool_names) else => Allow(入 allowed_tool_names)

注意两条轴完全独立:一个工具可以因为"超过权限天花板"被Deny(进blocked_tool_names),也可以因为"不在显式可见集合中"被HideFromPrompt(进hidden_tool_names),两者可能同时发生但归类互不影响。README 特别指出"Hidden takes precedence over deny in the classification order",与上述代码顺序一致。

ToolPolicyAction::RequireApproval在 match 中已被处理(路由到blocked_tool_names),但当前build_session永远不会产生该动作——这是为未来"审批流"预留的扩展位。

每个工具都会生成一条ToolPolicyDecision与一条ToolCapability存入快照,并通过log::trace!(target: "openhuman::tools::agent_policy", ...)输出[tool-policy] classified tool ...级别的诊断日志,便于按日志目标过滤排查(RUST_LOG 可指定openhuman::tools::agent_policy)。

渠道权限解析的宽松规则

私有函数parse_permission_level(engine.rs)采用宽松解析:trim后转小写、去掉-/_,然后匹配别名:

规范化 token结果 PermissionLevel
noneNone
readonly/readReadOnly
writeWrite
execute/execExecute
dangerous/dangerDangerous
其他任何 tokenNone(回退 ReadOnly)

因此配置里写read_onlyread-onlyReadOnlyREAD都能正确归一化为ReadOnly;写danger等价于dangerous;完全无法识别的 token 不会报错,而是回退到ReadOnly——默认偏保守。

两个关键回退语义(Legacy 逃生门 vs ReadOnly 兜底)

permission_for_channel(engine.rs)实现了最容易踩坑的语义差异:

  • 空映射 = 完全放行(legacy 逃生门):如果channel_permissions映射为空,直接返回PermissionLevel::Dangerous,即不施加任何限制,保留升级前的行为。这保证了老安装(以及未 seed 映射的单元测试夹具)不会在升级瞬间被锁死。
  • 只要映射非空,缺失渠道一律 ReadOnly:一旦存在任意渠道策略,那么映射中缺失的渠道、或值无法解析的渠道,都回退到PermissionLevel::ReadOnly,而不是无限制。

真正的加固落在配置层:AgentConfig::migrate_channel_permissions_if_legacy在启动时对 legacy 安装执行迁移,用安全的逐渠道默认值 seed 映射,使天花板在升级后的第一次启动就生效。这正是"引擎保持纯逻辑、加固下沉到配置层"的分层设计。

快照查询 API 与 fail-closed 兜底

ToolPolicySession在 types.rs 提供五个查询辅助方法:

  • is_allowed(name) -> bool:判断工具名是否在allowed_tool_names中;
  • has_restrictions() -> boolblocked_tool_nameshidden_tool_names任一非空即为 true;
  • restricted_tool_count() -> usize:阻断数 + 隐藏数之和;
  • visible_tool_names_for_prompt() -> HashSet<String>:见下文哨兵机制;
  • decision_for(name) -> ToolPolicyDecision:查决策映射,查不到时默认返回Denyrequired_permission: Noneallowed_permission取 profile 值)——这就是 fail-closed 兜底:未知工具名在运行时永远得不到放行。

ToolPolicyDecision::is_denied()的定义值得注意:任何非Allow的动作(包括RequireApprovalDenyHideFromPrompt)都视为"拒绝"。也就是说,被隐藏的工具在运行时同样不可调用。

空-但-受限的哨兵机制

当存在限制但没有任何工具被允许时,visible_tool_names_for_prompt()会插入哨兵常量:

const NO_TOOLS_ALLOWED_SENTINEL: &str = "__openhuman_no_policy_allowed_tools__";

这样提示渲染能区分"空但受限"({"__openhuman_no_policy_allowed_tools__"})与"完全不受限"(空集合),避免模型把"受限的空表面"误解成"不受限"。

系统提示渲染:## Tool Policy Boundary

render_tool_policy_boundary(session, max_bytes) -> Option<String>(prompt.rs)在会话无限制(!has_restrictions())时返回None(不注入任何提示);否则渲染一段紧凑的系统提示小节:

## Tool Policy Boundary - Agent: orchestrator - Channel: web - Entry point: chat - Allowed permission: read_only - Risk: low - Allowed tools: read_notes - Restricted tools: 1 omitted by policy

渲染内容的要点:

  • TOOL_POLICY_BOUNDARY_HEADING = "## Tool Policy Boundary"是固定的标题常量;
  • 逐行列出AgentChannelEntry pointAllowed permissionPermissionLevel的 Display)、RiskTaskRiskLevel的 Display);
  • 允许工具非空时才输出Allowed tools:行(逗号连接,BTreeSet保证有序);
  • 受限数量 > 0 时输出Restricted tools: N omitted by policy摘要行,逐个列出被隐藏/阻断的工具名——既告诉模型边界存在,又避免把敏感工具名泄露进上下文;
  • 输出经过truncate_utf8保证不超过max_bytes

truncate_utf8(prompt.rs)保证截断发生在字符边界上,不会把多字节 UTF-8 字符切断;当空间足够时追加\n[...truncated]标记(仅当max_bytes >= marker.len()时),max_bytes == 0时直接清空。

实际接入点在会话轮次提示构建处:render_tool_policy_boundary(&self.tool_policy_session, 2048)(turn/context.rs),即每轮对话的系统提示固定预留最多 2048 字节给边界小节。

会话集成:builder / runtime / turn / types 四处接线

README 列出模块在会话生命周期中的四个使用方,源码均可以证实:

  • builder/setters.rs:在构建会话时调用ToolPolicyEngine::build_session(...)生成工具策略会话与渠道策略会话(注意 setters 里出现了两次调用,对应工具级与会话级两种策略维度);
  • builder/mod.rs:tool_policy: &ToolPolicySession作为构建参数传递;
  • runtime.rs 与 runtime_impl_01_part_01.rs:运行时重建ToolPolicySession,保证提示中缺失的工具是"真被策略拒绝",而不只是"没出现在提示里";
  • types.rs:ToolPolicySession作为pub(super)字段挂在会话类型上,随会话生命周期存活;
  • turn/context.rs:每轮把边界小节注入提示。

从源码结构看,这种"构建时生成快照 → 运行时查询决策 → 每轮注入提示"的三角结构,确保了提示可见性与运行时执行严格对齐:模型只能看到被允许的工具,运行时查询同样以快照为准,双端共用同一份不可变数据。

测试验证:从单元测试看行为契约

模块附带两套内联测试,直接固化了上文全部行为:

engine_tests.rs 覆盖四类关键行为:

  1. 未知渠道回退 ReadOnlyweb=write映射下查询unknown-channel,断言allowed_permission == ReadOnlyread_notes允许、write_notes不允许——验证"有配置后缺失渠道不再放行"。
  2. 空映射保留 legacy 全量表面:空映射下allowed_permission == Dangerous,三个工具全部允许且!has_restrictions()——验证逃生门语义。
  3. 超过天花板即过滤web=writerun_script(Execute)被拒绝——验证权限轴。
  4. 显式可见集合收窄允许面cli=execute+visible={"run_script"}时,read_notes/write_notes进入hidden_tool_names而非blocked_tool_namesblocked_tool_names为空——验证可见性轴独立于权限轴。
  5. fail-closed 默认拒绝decision_for("missing_tool").is_denied() == true,未知工具名默认 Deny。

prompt_tests.rs 覆盖提示渲染契约:

  • 受限会话渲染出## Tool Policy BoundaryAgent: orchestratorAllowed tools: read_notesRestricted tools: 1 omitted by policy,且包含被拒绝工具名write_notes
  • 80 个长工具名的会话在max_bytes=192下渲染结果len <= 192且位于字符边界——验证 UTF-8 安全截断;
  • 空工具表与无限制会话均返回None——验证"无限制不注入提示"。

实战注意事项与设计启示

结合 README 的 gotchas 与源码,以下是接入或排查时最值得记住的几点:

  1. 空映射 ≠ 安全:刚升级但还没 seed 渠道策略的实例,Dangerous意味着工具表面完全放行。务必依赖migrate_channel_permissions_if_legacy在首次启动完成 seed,或在部署时预置映射。
  2. 写配置时名字可以很随意read_onlyread-onlyREAD都解析为ReadOnlydanger==dangerous;无法识别的值回退ReadOnly,不会硬失败。
  3. 两条轴别混淆blocked_tool_names(超权限)与hidden_tool_names(不可见)加起来才是restricted_tool_count()is_denied()把隐藏也算作拒绝,运行时不可调用。
  4. 提示中的边界是有字节上限的render_tool_policy_boundarymax_bytes参数(会话接入时传 2048)由truncate_utf8强制保证,工具名列表过长时会安全截断。
  5. 快照不可变、决策确定性:调试时看到的分类结果就是模型与运行时共用的那份数据;要改变策略只能重建会话快照,不存在运行时篡改入口。

agent_policy的克制值得借鉴:把"策略分类"做成纯函数式、确定性、不可变的快照域,把迁移、seed 等副作用隔离在配置层,把运行时默认值设为Deny(fail-closed),再用 UTF-8 安全的提示渲染控制上下文开销——四个文件就支撑起"提示可见性 = 运行时执行边界 = 渠道权限天花板"的完整闭环。

【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询