QwenPaw从概念到实现的 Agent 开发实战教程

Cosolar 4 阅读 AI Agent 与智能体

本教程结合 QwenPaw 官方架构文档与本地开源源码,从概念、原理、架构、实现细节多个维度,为新手全面剖析一个工业级 Agent 产品的设计与实现。内容基于源码逐层拆解,讲解重于代码,旨在让读者真正理解"如何从零设计一个可扩展、可治理、可运维的智能体系统"。

一、概念篇:QwenPaw 与 Agent OS

1.1 什么是 QwenPaw

QwenPaw 是 AgentScope 团队开源的个人 AI 助理平台(Qwen Personal Agent Workstation),基于 AgentScope 2.0 构建,采用 Apache 2.0 协议。它不是简单的"LLM + 提示词"应用,而是一个长驻服务(long-lived service),运行在你自己的环境中,一个安装可以托管多个独立智能体,每个智能体拥有隔离的工作区、记忆和技能。

与典型的"一次性调用"Agent 框架不同,QwenPaw 的设计目标是成为一个持续运行的操作系统级平台。它管理多个智能体的生命周期、安全边界、资源隔离和外部连接,就像操作系统管理进程、文件和设备驱动一样。这种设计哲学直接体现在它的架构理念——Agent OS 中。

1.2 Agent OS 理念

QwenPaw 的核心设计理念是 Agent OS——把智能体当作操作系统来设计。这个类比不是表面修辞,而是深刻影响了每一个架构决策。理解这个类比,就理解了 QwenPaw 的全部设计:

  • 工作区 = 进程地址空间:每个 Agent 有独立的工作目录,互不可见,就像进程间内存隔离
  • 工具 = 系统调用:Agent 通过工具操作外部世界,每个工具调用都经过权限检查,就像系统调用经过内核权限验证
  • 治理层 = 安全内核:不可绕过的策略评估,就像 Linux 的 SELinux/AppArmor
  • 沙箱 = 容器:内核级隔离执行不受信代码,就像 Docker/bwrap
  • 频道 = I/O 设备:Agent 通过频道与人类通信,就像进程通过设备驱动与外设交互
  • 驱动 = 设备驱动:Agent 通过驱动连接外部系统(MCP 服务器),就像内核加载设备驱动
  • 技能 = 动态链接库:技能为 Agent 注入领域知识,就像 dlopen 加载共享库
  • 模式 = 运行模式:不同模式打包不同的工具/提示词/行为,就像操作系统的运行级别

"内核"是 AgentScope 2.0,在进程内提供 ReAct 循环、会话存储、事件流和工具层(无独立运行时服务)。QwenPaw 是构建在其上的 OS 层,负责工作区边界、请求生命周期、资源轴管理和信任脊梁。官网架构文档明确指出:AgentScope 提供的是原语,工作区边界、请求生命周期、资源轴、信任脊梁这些都是 QwenPaw 自己的设计。

1.3 核心设计哲学

设计哲学 说明 源码体现
数据自主 本地部署,数据留在你的机器 工作区透明落盘:配置是 JSON,记忆是 Markdown,技能是文件夹
隔离优先 每个 Agent 独立工作区,互不可见 Workspace 类封装独立目录/服务/注册表
安全不可绕过 每次工具调用必经治理层 PolicyGuardedTool 在组装时包裹所有工具
每请求构建 Agent 非单例,每次请求全新组装 AgentBuilder.build() 依赖全部外部注入
声明式扩展 插件/技能/模式即配置,无需改源码 插件注册表 + 贡献者模式
安全降级 可选功能失败时静默回退到原生行为 Scroll 上下文失败→原生压缩;沙箱不可用→拒绝或降级

这些哲学不是空洞的口号,而是每一个都对应着具体的代码实现。例如"安全不可绕过"的实现方式是:在 AgentBuilder 组装工具时,每个工具函数都被 PolicyGuardedTool 包装——这不是一个可以绕过的装饰器,而是通过 type() 动态创建了一个继承自 AgentScope FunctionTool 的新类,覆写了 check_permissions__call__ 方法。AgentScope 的权限引擎看到的是一个合法的 FunctionTool 子类,而非一个外部拦截器——因此安全检查是 FunctionTool 接口的一部分,不可能被绕过。

二、原理篇:ReAct 循环与 AgentScope 基石

2.1 ReAct 模式

ReAct(Reason and Act)是当前 Agent 最主流的推理范式:模型先推理(Thought),再决定行动(Action),观察结果(Observation),循环往复直到完成任务。

ReAct 的核心思想是让 LLM 在"想"和"做"之间交替。每一步推理时,模型可以看到之前所有步骤的思考过程和工具返回结果,从而做出更明智的决策。这比"Chain of Thought"(只思考不行动)和"Act-only"(只行动不思考)都更有效——因为真实任务既需要规划也需要执行反馈。

2.2 QwenPawAgent 的继承与扩展

QwenPaw 的 QwenPawAgent 继承自 AgentScope 2.0 的 Agent 基类(同时混入 CodingModeMixin 获得代码模式能力)。关键设计在于:Agent 的所有依赖(模型、提示词、工具包、中间件、治理器、记忆管理器、上下文管理器)都由外部 AgentBuilder 注入,Agent 自身不构建任何依赖。这种设计使策略和配置与推理逻辑彻底解耦——同一个 Agent 类可以安全地用于不同模型、不同安全策略、不同频道上下文的场景。

AgentScope 2.0 提供了五个核心原语,QwenPaw 在每个原语上都做了扩展:

AgentScope 原语 QwenPaw 如何扩展
ReAct 循环 覆写 _reasoning() 加入媒体处理、门控检查、停止钩子、自动续行
消息与状态契约 覆写 state_dict()/load_state_dict() 支持 Scroll 上下文持久化
工具调用层 PolicyGuardedTool 包装每个工具,接入治理层
工作目录抽象 扩展自有工具(shell、代码搜索、浏览器、LSP 等)
流式事件模型 转发为 UI 事件流,加入心跳保活

2.3 推理循环的增强

QwenPaw 对 _reasoning() 方法的覆写体现了工业级 Agent 需要处理的复杂边界情况。在标准的 ReAct 循环之上,QwenPaw 增加了六个关键增强:

1. 注入后台工具提示:从 ToolCoordinator 弹出待处理的后台工具结果提示,追加到 Agent 上下文。这使得异步工具(如长时间运行的搜索任务)可以在后续迭代中被 Agent 感知。标准 ReAct 假设工具调用是同步的,但在实际场景中,有些工具(如浏览器自动化、大文件下载)可能需要数秒甚至数分钟才能完成。QwenPaw 通过后台执行 + 提示注入的方式,让 Agent 不会因为等待工具而阻塞。

2. 检查待处理的门控动作:来自上一轮迭代的 StopAction(如 INTERRUPT_AND_CONTINUE)会在本轮推理开始时产生停止文本并提前返回,避免浪费模型调用。这是一个微妙的优化——如果上一轮的门控系统决定需要中断并注入提示,就没有必要再调用一次模型来"发现"这个提示。

3. 主动剥离媒体块:当模型不支持多模态时(通过能力缓存探测),在推理前主动剥离上下文中的图片/音频/视频块,而不是等模型报错后再重试。这是一种预测性防御——比被动重试更高效。能力缓存通过 get_capability_cache/learn 机制实现:第一次发现模型不支持图片时,缓存这个事实,后续所有推理调用都提前剥离图片。错误判断非常严格:纯 400 不够,必须错误消息中包含 image/audio/video/vision/multimodal 等关键词,且排除内容安全、请求过大等无关 400。

4. 模型调用与被动重试:如果模型仍返回媒体相关错误(如 image_url 不支持),则学习到该模型 rejects_media=True(缓存),剥离媒体后重试。

5. 停止钩子执行:每次推理迭代结束后运行注册的 StopHandler,根据门控动作决定是终止、续行还是正常返回。

6. 上下文溢出恢复_call_model 捕获 provider 的上下文溢出错误(400 + 特定标记),让 ContextManager 尝试一次压缩恢复,然后重建模型输入并重试。这是"一次恢复"策略——第二次溢出直接抛出,不进入恢复循环,避免无限重试消耗资源。

2.4 状态持久化与跨版本迁移

QwenPawAgent 的 state_dict()load_state_dict() 不仅做简单的序列化/反序列化,还处理两个重要的工程问题:

Scroll 上下文持久化:当使用 Scroll 上下文策略时,state_dict() 会额外保存 Scroll 管理器的去重簿记和驱逐索引(通过 cm.to_dict()),确保恢复的会话不会把已恢复的窗口重新追加到 history.db。load_state_dict() 在恢复 AgentState 后,调用 cm.load_state(scroll) 重水化 Scroll 管理器,并通过 cm.reconcile_loaded_context(self) 调和已加载的上下文。

1.x 遗留格式迁移load_state_dict() 兼容旧版格式 {"memory": {"content": [[msg, marks], ...], "_compressed_summary": "..."}}。当检测到旧格式时,通过 parse_legacy_memory_state 解析并转换为 2.0 的 AgentState,使现有会话能在升级后继续使用。

孤儿工具消息清理:在 load_state_dictcompress_context 中都有 _sanitize_tool_messages 调用。孤儿工具结果消息(对应的工具调用已被驱逐)可能在会话 JSON 中持久存在,并在会话重新加载时泄漏到模型上下文中,导致 400 - Messages with role 'tool' must be a response to a preceding message with 'tool_calls' 错误。这个清理是"防御性编程"的典型例子——即使你认为不应该出现孤儿消息,也要在关键路径上做安全清理。

三、架构篇:系统全景与工作区隔离

3.1 整体架构全景

一图看懂 Agent OS:上层是运行时调度层(请求路由、生命周期、ReAct 循环);下层是每个智能体专属的工作区,内含按颜色区分的资源泳道(记忆、Skills、工具、其他)、治理面板和沙箱执行底座,旁边是独立的驱动(连接器)栏;整体构建在 AgentScope 基座之上。

这个全景图展示了 QwenPaw 的五大层次。理解这个架构的关键是把握三级正交控制的设计:

  • Runtime Hooks(请求级):包裹整个请求的 build + execute 生命周期,8 个阶段,处理会话加载、模式注入、引导等
  • Middlewares(推理级):包裹单次 reply 循环内的 model call / acting / reply,洋葱模型,处理工具结果截断、记忆注入等
  • Loop Gates(循环级):每次 ReAct 迭代后检查是否应该停止,门控系统,处理迭代限制、死循环检测等

这三者正交——你可以只修改其中一层而不影响另外两层。例如,添加一个新的安全检查应该用 Middleware(推理级),添加一个新的停止条件应该用 Gate(循环级),添加一个新的会话处理逻辑应该用 Hook(请求级)。

3.2 Workspace:隔离的基本单位

工作区(Workspace)是隔离的基本单位。一个安装可以运行多个 Agent,每个 Agent 恰好拥有一个工作区:一个磁盘目录加上操作该目录的活动服务。工作区之间互不可见——Agent A 无法看到 Agent B 的文件、记忆或对话,除非通过显式的消息协议通信。

deepseek_svg_20260731_685025.svg

每个工作区通过 ServiceManager 声明式注册服务,按优先级启停:

服务 优先级 可复用 职责
session 10 会话存储与恢复
memory_manager 20 长期记忆后端(ReMe)
driver_manager 20 MCP 外部能力连接器
chat_manager 20 聊天管理
channel_manager 30 通信渠道
cron_manager 40 定时任务
agent_config_watcher 50 配置热监听

"可复用"标记意味着该服务在 Agent 热重载时可以跨实例保留状态——例如记忆管理器不会因为重载而丢失记忆。这是一个关键的设计决策:它使得配置变更(如更换模型)可以零停机完成,而不会丢失正在进行中的记忆提取或长期记忆状态。

3.3 多 Agent 管理:懒加载与零停机热重载

MultiAgentManager 管理多个独立 Workspace 的生命周期,采用三个关键策略:

懒加载:Workspace 仅在首次请求时创建。通过 asyncio.Event 协调并发——第一个 caller 执行构建,其余等待。锁只在 dict 检查/变更时持有,慢启动在锁外执行,允许不同 Agent 的并行初始化。这避免了启动时一次性加载所有 Agent 的资源开销。

零停机热重载reload_agent 先构建新实例并完全启动,成功后原子替换旧实例,最后优雅停止旧实例。如果旧实例有活跃任务,则后台延迟清理。可复用服务(memory/chat)跨重载复用,避免记忆丢失。这个"build-before-swap"模式与后面要讲的 DriverManager 的重载模式完全一致——这是 QwenPaw 中一以贯之的设计模式。

分级启动策略:核心 Agent(default + QA)并发启动,自定义 Agent 受信号量限流(CUSTOM_AGENT_STARTUP_CONCURRENCY),避免资源耗尽。

3.4 请求路由

请求到 Agent 的路由优先级为:显式 agent_id 参数 > request.state.agent_id > X-Agent-Id header > 配置中的 active_agent > “default”。这确保了多 Agent 场景下请求能精确路由到目标工作区。

四、运行时:请求生命周期与 Agent 组装

4.1 八阶段 Hook 管道

运行时将一个请求转化为 UI 事件流,通过固定 8 阶段管道编排。每个阶段都可挂载 Hook,Hook 有三种返回语义:CONTINUE(继续)、SHORT_CIRCUIT(发出 payload 并结束,但 ON_ERROR/FINALLY 仍运行)、SKIP_AGENT(跳过 Agent 构建和执行)。

Hook 之间通过 before/after 约束做拓扑排序,支持循环依赖检测。内置钩子包括:BootstrapHook(首次交互引导)、SessionHook(会话加载/保存)、ContextVarsHook(ContextVar 注入)、MediaHook(媒体块处理)、SkillEnvHook(技能环境变量注入与清理)、CronHook(定时任务触发)、LangfuseHook(可观测性)。

这个设计的关键洞见是:将请求处理拆分为固定阶段使得扩展可以精准挂载。想注入技能环境变量?在 PRE_EXECUTE 挂一个 Hook。想在会话保存后做清理?在 FINALLY 挂一个 Hook。不需要修改任何核心代码。

4.2 AgentBuilder:依赖注入的核心

AgentBuilder 是组装核心。每个请求全新构建一个 Agent,所有依赖通过构造函数注入。组装流程按严格顺序执行十二个步骤:

为什么每请求构建而非单例? 因为每次请求的活跃模式、有效技能、频道上下文、模型选择可能不同。每次构建保证策略和配置始终最新,且 Agent 实例本身无状态。同时,治理器(governor)在构建时初始化,工作区工具在构建时用 PolicyGuardedTool 包装——安全检查从组装那一刻起就不可绕过。

4.3 模型工厂

deepseek_svg_20260731_c48372.svg

create_model_and_formatter 将模型创建封装在稳定接口后。通过 ProviderManager 解析活跃模型,获取 Provider 实例(OpenAI/Anthropic/DashScope/Ollama/llama.cpp 等),创建 ChatModel 后做双层包装:

  • TokenRecordingModelWrapper:记录每次调用的 Token 用量,用于成本追踪和预算控制
  • RetryChatModel:自动重试 + 速率限制,处理瞬态错误

模型能力探测会缓存模型是否支持图片/视频(get_capability_cache/learn),不支持的输入会被提前拒绝。这个缓存是全局的——一旦发现某个模型不支持图片,所有后续请求都会提前剥离图片块,避免浪费 API 调用。

4.4 中间件洋葱模型

中间件包裹 Agent 的推理循环内部(与 Runtime 的 8 阶段 Hook 正交),按洋葱模型排序,外层先执行:

关键区别:Runtime Hooks 是请求级编排(包裹整个 build+execute),Middlewares 是推理级包裹(包裹单次 reply 循环内的 model call / acting / reply),Loop Gates 是循环级门控(每次 ReAct 迭代后检查是否应该停止)。三者正交,互不干扰。

4.5 系统提示词动态构建

系统提示词不是静态字符串,而是由**贡献者模式(Contributor Pattern)**动态拼装。每个 PromptContributor 产生一个片段,按 priority 升序用 \n\n 连接。内置贡献者从工作区读取 SOUL.md(人格灵魂)、PROFILE.md(角色档案)、AGENTS.md(工作指令),并注入环境上下文(session/channel/shell/working_dir/active_model)、驱动提示(MCP 工具说明)、语言、心跳等动态信息。

插件可通过 PluginRegistry.get_prompt_sections() 注册片段,声明 after=anchor 实现按锚点位置插入,并支持 agent_id 过滤实现按 Agent 隔离。

这个设计的精妙之处在于:系统提示词是"拼装"而非"模板"。每个贡献者只关心自己那一片,不需要知道还有谁在贡献。新增一个模式?注册一个新的 PromptContributor。新增一个插件?注册一个 PromptSection。不需要修改任何现有的提示词代码。

五、工具系统:注册、过滤与治理包装

5.1 ToolDescriptor:工具的完整描述

QwenPaw 的工具系统从 @tool_descriptor 装饰器开始。这个装饰器是整个工具系统的入口——每个内置工具函数被装饰时,自动收集到全局注册表 _REGISTERED_TOOL_FUNCS,无需手动注册。

@tool_descriptor 接收的关键字参数涵盖了工具的"能做什么"、"需要什么条件"和"如何被治理"三个维度:

能力描述参数

  • name — 工具名(默认取 fn.__name__
  • enabled_by_default — 是否默认启用(默认 True)
  • async_execution — 是否异步执行(未指定时通过 inspect.iscoroutinefunction(fn) 自动检测)

门控条件参数(四维门控):

  • requires_modes — 模式门控,如 ("coding",) 表示只在编码模式可用
  • requires_skills — 技能门控,如 ("make-skill",) 表示需要 make-skill 技能激活
  • requires_features — 特性门控,所有要求的特性都必须启用(子集语义)
  • requires_sandbox — 沙箱资源需求,如 ("file_write",) 表示需要文件写入沙箱

治理规格参数

  • tool_type — 治理类型(file/network/internal/model/shell),决定工具经过哪些安全检查
  • target_param — 目标参数名,用于提取工具操作的目标(如文件路径、Shell 命令)
  • pattern_param — 模式参数名(如 Glob 搜索的 pattern),用于沙箱挂载路径推导
  • policy_name — 策略名称,在 policy.yaml 中用于匹配规则
  • fail_without_sandbox — 无沙箱时是否直接失败(而非降级执行)
  • default_policy — 默认策略(allow/ask/deny),用于冷启动自动生成用户规则
  • policy_reason — 策略原因描述

UI 元数据参数

  • ui_description — 面向用户的描述
  • ui_icon — 图标
  • display_to_user — 是否在 UI 中显示

装饰器执行时,首先调用 validate_tool_type()validate_default_policy() 校验治理参数合法性,然后构造一个 ToolDescriptor(frozen dataclass),挂载到 fn._tool_descriptor 属性上,最后通过 id(fn) 去重后追加到全局列表。id(fn) 去重是为了防止模块被重复导入时工具被重复注册。

5.2 内置工具全景

QwenPaw 内置约 30 个工具,覆盖文件操作、代码搜索、Shell 执行、浏览器控制、多 Agent 协调等场景。工具按模块组织,每个模块的工具有不同的治理类型和门控条件:

模块 工具 治理类型 特殊门控
file_io read_file, write_file, edit_file, append_file file -
file_search grep_search, glob_search - -
shell execute_shell_command shell -
browser_control browser_use network -
web_search web_search, web_fetch network -
desktop_screenshot desktop_screenshot - -
view_media view_image, view_video - -
agent_management list_agents, chat_with_agent, submit_to_agent, check_agent_task, spawn_subagent internal -
delegate_external_agent delegate_external_agent internal -
make_skill_tools materialize_skill internal requires_skills=(“make-skill”,)
ast_tool ast_search file requires_modes=(“coding”,)
run_tool_batch run_tool_batch - -
get_current_time get_current_time, set_user_timezone internal -

注意 internal 类型的工具(如 chat_with_agentget_current_time)不需要经过深度安全扫描——因为它们不触碰用户的文件系统或网络。而 shell 类型的工具会经过最严格的安全检查(四阶段评估 + 沙箱执行)。requires_modes=("coding",)ast_search 工具只在编码模式下可见——这是一种"按需暴露"的设计,避免非编码场景下工具列表过于臃肿。

5.3 ToolRegistry:工具注册与收集

ToolRegistry 是工具注册表的运行时实例。它从两个来源收集工具:

内置工具收集get_builtin_tool_funcs() 扫描全局注册表 _REGISTERED_TOOL_FUNCS,只返回 __module___BUILTIN_TOOLS_PREFIX"qwenpaw.agents.tools.")开头的函数。这个前缀过滤确保只有 QwenPaw 自己的工具被自动收集,而不是所有被 @tool_descriptor 装饰的函数(插件工具可能也用这个装饰器)。

插件工具注入:插件通过 PluginApi.register_tool() 注册的工具,通过 _bridge_to_runtime 桥接到运行时 ToolRegistry。桥接时先 unregisterregister,支持热重载——插件更新后工具定义自动刷新。

ToolRegistry 内部用 _descs 字典存储 ToolDescriptorregister() 时检查类型和重名冲突,unregister() 按 name 弹出。get_type(tool_name) 方法返回工具的治理类型,这是治理策略评估 Phase 0 的数据来源。

5.4 ToolRegistry.filter:四维过滤

工具过滤是每请求构建的关键步骤。ToolRegistry.filter() 执行四维过滤,决定哪些工具对当前请求可见。这个过滤是"逐项淘汰"式的——每个维度都是一道关卡,任一关卡失败就丢弃该工具:

四个维度的语义不同,理解语义差异很重要:

  • 模式门控requires_modes):交集语义——活跃模式中至少一个匹配即可。例如工具声明 requires_modes=("coding",),当活跃模式为 ("default", "coding") 时匹配。交集语义意味着模式是"叠加"的——启用更多模式只会暴露更多工具,不会隐藏已有工具。
  • 技能门控requires_skills):交集语义——生效技能中至少一个匹配即可。例如 materialize_skill 工具声明 requires_skills=("make-skill",),只有当 make-skill 技能被启用时,该工具才会出现在工具列表中。
  • 特性门控requires_features):子集语义——所有要求的特性都必须启用。子集语义更严格——如果工具要求 ("feature_a", "feature_b"),那么两个特性都必须启用。这与模式/技能的交集语义不同。
  • 配置门控allowed/denied):denied 优先于 allowed 优先于 enabled_by_default。这意味着:首先,在 denied 列表中的工具无论如何都不可用;其次,如果 allowed 列表非空,只有列表中的工具可用;最后,enabled_by_default=False 的工具需要显式 opt-in。

5.5 工具组装的最后一站

QwenPawLocalWorkspace 继承 AgentScope 的 LocalWorkspace,但用自己的 ToolRegistry 替换默认工具。list_tools 方法是工具组装的最后一站,执行三步:

第一步:解析配置门控。从 agent_config.tools.builtin_tools 读取配置:enabled=False 的工具进入 denied 集合,enabled=True 的非默认工具进入 allowed 集合(插件工具需显式 opt-in)。这允许用户在 Console 中精确控制哪些工具可用。

第二步:子 Agent 工具白名单。如果请求上下文中携带 subagent_allowed_tools(一个列表),则进一步过滤工具。白名单过滤逻辑非常精确:

  • None 或非列表值 → 继承所有工具(默认行为)
  • 空列表 [] → 拒绝所有工具(包括记忆工具和未来注入的工具)——这是"最小权限"的极端形式
  • 非空列表 → 只保留匹配的工具

这个设计确保子 Agent 的能力可以被精确控制——你可以创建一个只能读取文件、不能执行 Shell 的子 Agent。白名单在 toolkit 构建完成后对每个 tool group 应用,是"最后一道防线"——确保即使是后续注入的工具(如记忆工具)也受白名单约束。

第三步:PolicyGuardedTool 包装。过滤后的工具列表中的每个工具函数,都用 PolicyGuardedTool 包装,注入 ResourceGovernor 和请求上下文。这一步是安全不可绕过的关键——从这一刻起,工具的每次调用都必须先通过治理层的策略评估。

5.6 PolicyGuardedTool:不可绕过的治理包装

PolicyGuardedTool 是整个安全体系的枢纽。它不是简单的函数包装,而是通过 __new__ 动态创建一个继承自 AgentScope FunctionTool 的匿名类。每次调用 PolicyGuardedTool(...) 时,__new__ 方法通过 type() 动态创建一个新类,继承 FunctionTool,注入四个方法(__init___build_tc_speccheck_permissions__call__),然后实例化并返回。

为什么用动态类创建而非装饰器? 因为 AgentScope 的权限引擎调用的是 FunctionTool.check_permissions() 方法。通过创建 FunctionTool 的子类并覆写这个方法,治理检查成为 FunctionTool 接口的一部分——AgentScope 看到的是一个合法的 FunctionTool 子类,而非一个外部拦截器。这意味着无论 AgentScope 内部如何调用工具,治理检查都会被执行。

两层拦截机制

第一层 check_permissions(执行前决策)

  1. 解析有效审批级别(session 级别 > agent 级别),session 级别优先允许单个会话临时调整安全策略
  2. 若 OFF 模式:为 fail-closed 工具(如 REPL)编译沙箱配置,但跳过用户审批,直接 ALLOW。注意 OFF 只跳过"问用户",不跳过"沙箱执行"——fail-closed 工具即使在 OFF 模式也需要沙箱,否则会陷入"沙箱违规→审批"死循环
  3. 若 governor 为 None 且非 OFF 模式:fail-closed,DENY 所有工具调用并记录错误日志。这是安全不可绕过的关键——治理层不可用时,默认拒绝而非默认放行
  4. 正常流程:构造 ToolCallSpec(工具名、目标参数、Agent ID、会话 ID、原始参数),调用 governor.assert_policy() 进行策略评估和审计,根据裁决结果映射
  5. 裁决映射:ALLOW → 直接放行;DENY → 拒绝;SANDBOX_FALLBACK → 标记需要沙箱执行后放行(设置 _qp_sandbox_mode=True);ASK → 阻塞等待用户审批

第二层 __call__(执行时沙箱与违规重试)

  1. 如果标记了沙箱模式,将 sandbox_config 注入到工具参数中
  2. 调用 FunctionTool.__call__ 执行工具
  3. 检查返回值——如果沙箱返回 DENIED(违规),触发违规升级机制
  4. 违规升级:记录 ASK 审计日志 → 调用 _ask_user_approval() 请求用户批准 → 用户批准则移除沙箱配置并无沙箱重试(因为命令已被人工确认为安全)→ 用户拒绝则返回 DENIED 并附带 _NO_RETRY_INSTRUCTION(告知模型此拒绝是最终的,不要重试)

不可绕过的三重保证

  1. Fail-closed:governor 未初始化时(除非 execution_level=off),直接 DENY 所有工具调用
  2. 审批后规则持久化:用户批准后通过 add_approved_rule() 持久化 ALLOW 规则到 policy.yaml,未来类似调用自动批准
  3. 规则泛化:批准时通过 LLM 将精确目标泛化为保守的 glob 模式(如 git statusgit *),但破坏性命令(rm/sudo/mkfs/chmod 等)永远不泛化

六、治理与安全脊梁

6.1 信任脊梁全景

每个工具调用和每个外部操作都必须通过分层信任脊梁,才能触碰你的机器或数据。这是一个纵深防御体系——不是单一检查点,而是多层叠加的安全网。设计理念是:任何一层都可能被绕过或有漏洞,但多层叠加使得攻击者必须同时突破所有层才能造成损害。

6.2 四阶段策略评估详解

GovernancePolicy.evaluate() 实现了严密的四阶段评估流程,每一阶段都是一道安全闸门。理解每个阶段的输入、处理和输出,就理解了 QwenPaw 安全体系的核心。

Phase 0:工具注册表类型检查

这是第一道防线,也是最快速的检查。ToolRegistry 将工具分为五种类型:

类型 含义 Phase 0 处理
file 文件读写工具 继续后续阶段
shell Shell 执行工具 继续后续阶段(最严格检查)
browser 浏览器工具 继续后续阶段
internal 内部工具(如 chat_with_agent) 直接 ALLOW
unknown 未注册工具 直接 DENY(fail-closed)

未注册工具直接 DENY 是 fail-closed 设计的体现——如果一个工具不在注册表中,说明它可能是未经审核的代码,默认拒绝。内部工具直接 ALLOW 是因为它们不触碰文件系统或网络(如 Agent 间通信通过内存消息传递),不需要安全检查。

execution_level=off 时,跳过 Phase 1 的深度扫描(skip_deep_scan=True),但 Phase 0/2/3 仍然执行。

Phase 1:深度安全扫描

调用 run_deep_scan() 运行三个检测器,每个检测器负责一类安全威胁。在调用检测器之前,会先调用 _merge_config_rules() 合并 policy.yaml 中的规则与 config.jsonsecurity.tool_guard 配置——custom_rules 追加、disabled_rules 过滤、shell_evasion_checks 做 OR 合并。这个合并确保用户可以在 YAML 规则之外,通过 Console 配置额外的安全规则。

检测器 1:敏感路径检测detect_sensitive_paths

将配置的敏感路径(.ssh.aws.kube.env*.pem*.key 等)分为文件和目录两类。路径规范化使用 _normalize_path()(expanduser + 绝对化 + resolve(strict=False))。

对于 Shell 工具,用 shlex.split 分词提取路径 token,通过 _looks_like_path_token() 过滤掉明显不是路径的 token(如命令选项),然后逐个检查每个路径 token 是否匹配敏感路径。对于文件工具,直接检查 target 参数。对于其他工具,扫描所有字符串参数中看起来像路径的值。

匹配逻辑:精确文件匹配 or 目录前缀匹配(abs_path == trimmedstartswith(trimmed + "/")startswith(trimmed + "\\"))。命中生成 GuardFinding(severity="HIGH", detector="sensitive_path_detector")

检测器 2:危险模式检测detect_dangerous_patterns

基于 YAML 正则规则匹配危险命令。工具名有别名映射(Bash→execute_shell_command, Read→read_file 等)。扫描范围包括 raw_params 中所有字符串值和 target。每条规则可以限定 tools(哪些工具适用)和 params(扫描哪些参数),支持 exclude_patterns 先过滤安全模式。

正则编译结果会被缓存(key=patterns+exclude_patterns 内容元组,IGNORECASE),避免重复编译。每条规则命中生成 GuardFinding(severity=rule.severity, detector="pattern_detector"),每规则仅匹配一次。

检测器 3:Shell 逃逸检测detect_shell_evasion

这是最复杂的检测器——使用 _QuoteState 状态机逐字符跟踪 Shell 引号上下文,检测 7 类混淆技术:

  1. 命令替换:检测反引号(在非单引号外)、$()<()>()=()$[] 等命令替换语法。攻击者可能用命令替换在看似无害的命令中嵌入恶意代码。
  2. 混淆标志:ANSI-C 引用($'...')、locale 引用($"...")、空引号 + dash。这些技术可以绕过简单的字符串匹配检测。
  3. 反斜杠转义空白:引号外的反斜杠 + 空格/Tab。cat foo\ bar 实际访问的是 foo bar 而非两个文件。
  4. 反斜杠转义操作符:引号外的反斜杠 + shell 操作符(;|&<>)。例外:find 的 -exec {} \; 是合法用法。
  5. 换行符:引号外的换行/回车(heredoc 除外)。换行符可以隐藏多命令执行。
  6. 注释-引号反同步# 注释内含引号字符。这会导致引号状态失同步,使得后续代码的解析结果与人类阅读不同。
  7. 引号内换行:引号内换行后跟 # 开头的行。这可以在引号内隐藏注释后的参数注入。

每个检查命中生成 GuardFinding(severity="HIGH", detector="shell_evasion_detector", category="code_execution")

任何 CRITICAL 发现立即 DENY,不进入后续阶段。这是"快速失败"设计——已经发现致命威胁,不需要继续检查。

Phase 1.5:Shell 危险关键词检测

专门针对 Shell 工具的额外检查,通过 4 个正则模式检测:

  • 递归删除根文件系统(rm -rf /
  • Fork 炸弹(:(){ :|:& };:
  • 直接磁盘写入(>/dev/sdadd if=/dev/
  • 文件系统格式化(mkfs

命中即 DENY,不进入后续阶段。这个检查独立于 Phase 1 的原因是为了确保即使 Phase 1 的规则配置有遗漏,这些最危险的命令也一定会被拦截。

Phase 2:规则匹配

builtin_rules(内置保护,不可被 Agent 修改,不可被 YAML 覆盖)然后 user_rules(用户/审批生成的规则)的顺序首次匹配胜出(first-match-wins)。

builtin_rules 来自 DEFAULT_BUILTIN_RULES,由 _BUILTIN_ASK_SPECS(46 条 ASK 规则)和 _BUILTIN_DENY_SPECS(1 条 DENY 规则:rm * -rf *///*)组成。涵盖 .env/.ssh/*.pem/*.key/.aws/.gnupg/.kube/各 AI 厂商配置/shell rc/PowerShell profile/sudo/gh repo delete 等。这些规则永远从代码加载,YAML 中的 builtin_rules 键被忽略并警告(防篡改)——确保 Agent 无法通过修改配置文件来关闭内置保护。

user_rules 来自两个来源:_PATH_LEVEL_USER_RULES(workspace 内 Read/Write/Edit/Append/Grep/Glob/ViewImage 等 ALLOW + /tmp + CODING_PROJECT_DIR + gh ALLOW)和 _auto_default_user_rules()(从 @tool_descriptordefault_policy 自动生成 ToolName(**) 规则)。用户批准后通过 add_rule() 将新规则插入 user_rules[0](头部),所以新批准的规则在 user_rules 内部优先。

STRICT 模式会将所有 ALLOW 覆盖为 ASK,确保所有工具调用都需要用户批准。这是最高安全级别——即使工具匹配了 ALLOW 规则,也需要用户确认。

Phase 3:回退与阈值

无规则命中时的兜底逻辑。Shell 工具和其他工具有不同的回退策略:

Shell 工具:STRICT 模式 → ASK;有 findings → 根据 execution_level 阈值判断;无 findings(或 SMART 模式下 LOW 级别)→ SANDBOX_FALLBACK(沙箱执行)。Shell 工具默认路由到沙箱是"宁可隔离也不直接执行"的设计——即使命令看起来安全,也在沙箱中执行作为额外保险。

其他工具:通过 _apply_execution_level_fallback() 根据 execution_level 和 findings 严重程度决定。决策矩阵如下:

条件 STRICT SMART AUTO/OFF
无 findings ASK ALLOW ALLOW
有 findings ASK INFO/LOW → ALLOW;MEDIUM+ → ASK ASK

6.3 四档执行级别

模式 行为 适用场景
STRICT 所有工具调用都需审批,即使 INFO 级发现也触发审批,所有 ALLOW 覆盖为 ASK 高安全要求环境
SMART INFO/LOW 自动允许,MEDIUM+ 需审批(推荐默认) 日常使用
AUTO 仅 guarded_tools 列表中的工具需审批 受信任环境
OFF 完全禁用工具守护(但 fail-closed 工具仍编译沙箱配置) 开发测试

6.4 规则泛化:让审批不重复

当用户批准一个工具调用时,系统会尝试通过 LLM 将精确目标泛化为保守的 glob 模式,使未来类似调用自动批准。泛化不仅是为了减少用户审批次数,更是为了让安全策略随使用习惯自然演化——频繁批准的操作自动成为 ALLOW 规则,偶尔的敏感操作仍然需要审批。

泛化有三重安全防护,任何一重失败都回退到精确匹配(绝不 unsafely widen):

  1. 格式校验_is_safe_generalization 防护 1):泛化后的模式必须格式良好,非空、非裸通配符(*/**/*/* 被拒——这些是 allow-all 模式,过于宽泛)
  2. 覆盖验证(防护 2):模式必须能匹配原始 target。shell 用 fnmatch,file 用 wcmatch.glob(含 /** 目录自匹配)。如果不匹配则拒绝——刚批准的调用将不被覆盖会导致正确性 bug
  3. 锚点保留(防护 3):
    • Shell 工具:首词必须保持为模式的字面前缀(如 git statusgit * 合法,→ * 非法);且首词不在 _NO_GENERALIZE_COMMANDS
    • File 工具:父目录必须保持为模式的前缀(路径段边界)

_NO_GENERALIZE_COMMANDS 集合包含 rm, rmdir, dd, mkfs, sudo, chmod, chown, chgrp, kill, killall, pkill, reboot, shutdown, halt, poweroff, shred 等破坏性命令——这些命令永远不泛化,每次都要问。

LLM 调用有 6 秒超时(GENERALIZE_TIMEOUT_SECONDS=6.0),任何失败回退到精确匹配。builtin_rules 来源的 ASK 永不泛化(每次都问)——内置保护规则代表"始终需要人工确认"的安全策略,不应该被自动化。

6.5 审计日志

AuditLog 使用单例模式,将治理决策记录到 SQLite 数据库(~/.qwenpaw/governance/audit.db)。使用单文件 SQLite 而非文件日志的原因是:审计日志需要可查询(按时间、工作区、Agent、工具名、决策类型过滤),SQLite 提供了高效的查询能力。

数据库 Schema 包含 audit_events 表(ts, workspace_dir, agent_id, session_id, tool_name, target, decision, reason, extra)和 4 个索引(ts, workspace_dir, agent_id, tool_name)。WAL 模式提升并发写入性能。

AuditLog 的自动清理机制:每 _CHECK_INTERVAL=1000 次插入检查总量,达 MAX_RECORDS=100000_auto_purge() 删最旧 PURGE_COUNT=10000 条。VACUUM 延迟到 close() 时执行,避免运行时 VACUUM 的性能开销。进程退出时通过 atexit.register(AuditLog.close_instance) 确保资源清理。

audit_level 可选 "all"(记录所有决策)、"write_only"(仅记录写操作)、"none"(关闭审计)。

七、沙箱系统:多平台内核级隔离

7.1 沙箱的生命周期模型

QwenPaw 的沙箱采用每次工具调用创建新实例的生命周期模型。这意味着每个 Shell 命令都在一个全新的隔离环境中执行——不存在跨调用的状态泄漏。完整流程是:治理策略评估返回 SANDBOX_FALLBACKResourceGovernor 编译 SandboxConfigPolicyGuardedTool 将配置注入工具参数 → 底层工具通过 create_sandbox(config) 创建沙箱实例 → 沙箱在 __aenter__ 中设置隔离环境 → sandbox.execute(command) 执行 → __aexit__ 销毁所有隔离资源。

"每次调用新实例"的设计比"复用沙箱"更安全——即使前一个命令在沙箱中创建了恶意文件或修改了环境变量,下一个命令的沙箱也是全新的,不受影响。代价是每次调用都有沙箱创建/销毁的开销,但对于安全关键场景,这个代价是值得的。

7.2 SandboxConfig:完整的隔离规格

SandboxConfig 不仅是一个"开关",而是完整的隔离规格。它采用允许列表模型——未列出的路径和能力默认拒绝。完整字段如下:

字段 类型 默认值 作用
mode SandboxMode 必填 隔离模式 (SEATBELT/BUBBLEWRAP/LANDLOCK/WINDOWS/NONE)
workspace_dir str 必填 沙箱根工作目录
mounts List[MountSpec] [] 额外路径权限声明
allow_read_all bool True True=拒绝列表模式(全盘可读,deny_paths遮蔽);False=允许列表模式(仅mounts可读)
deny_paths List[str] [] 无论其他设置都显式拒绝的敏感路径
network_allow List[str] [] 域名允许列表;["*"]全开,[]全封
network_ports Optional[List[PortRule]] None TCP端口级控制(Landlock v4原生,其他平台降级)
max_processes Optional[int] None 最大子进程数
max_memory_mb Optional[int] None 最大内存MB
timeout_seconds int 30 命令执行超时
env_vars Dict[str,str] {} 额外环境变量(也用于遮蔽黑名单变量)
env_mode str "inject" "inject"追加到当前环境;"allowlist"只传声明变量
shell_executable Optional[str] None 命令执行shell
platform_hints Dict[str,Any] {} 平台原生参数透传

辅助数据类:

  • MountSpec(path, writable=False, executable=True):单条路径权限声明。executable=False 的路径会被禁止执行程序——这是防止沙箱内运行未授权可执行文件的关键
  • PortRule(port, direction=“connect”, allow=True):TCP端口规则,仅在 Landlock ABI v4+ 原生支持,其他平台降级为域名级控制
  • ExecutionResult(exit_code, stdout, stderr, timed_out=False, duration_ms=0, sandbox_violation=None):执行结果,sandbox_violation 字段承载违规信息(如 “Permission denied”),供 PolicyGuardedTool 检测违规
  • SandboxCapability(supported, mode, reason, landlock_abi_version=0):探测结果

7.3 多平台后端

QwenPaw 的沙箱系统支持四个平台、七个后端实现。不同平台使用不同的内核隔离机制,但对外提供统一的 SandboxConfig 接口。后端选择由 create_sandbox() 工厂函数根据 config.mode 派发:

Bubblewrap(Linux 优先)

Bubblewrap 是 Linux 上最常用的沙箱后端,通过 bwrap 命令创建挂载命名空间隔离。_build_bwrap_args() 构建参数的顺序精心设计:

第一步:基础文件系统allow_read_all=True(黑名单模式)时,将整个根文件系统以只读方式挂载(--ro-bind / /),然后通过 deny_paths 遮蔽敏感路径。allow_read_all=False(白名单模式)时,创建空的 tmpfs 作为根(--tmpfs /),然后只挂载系统必需路径(/usr/lib/lib64/bin/sbin/etc/proc/sys/run)只读。

第二步:最小 /dev。挂载最小化的 /dev 目录(--dev /dev),只包含 null、zero、random 等基本设备。这不是挂载真实的 /dev,而是 bwrap 创建的合成 devtmpfs。

第三步:常驻可写路径/tmp/dev/shm 始终可写,因为很多程序需要临时目录。

第四步:可写挂载。工作区始终以读写方式挂载。从 user_rules 编译的挂载点也在此添加——FILE_READ_TOOLS(Read/ViewImage/ViewVideo/SendFileToUser)对应只读挂载,FILE_WRITE_TOOLS(Write/Edit/Append)对应读写挂载。

第五步:deny_paths 遮蔽。对于目录类型的 deny_path(如 ~/.ssh),使用 --tmpfs 遮蔽——目录对沙箱进程不可见,看到的是空目录。对于文件类型的 deny_path(如 ~/.bashrc),使用 --ro-bind /dev/null <path>——文件存在但内容为空。DEFAULT_SANDBOX_DENY_PATHS 包含 ~/.ssh~/.aws~/.gnupg~/.kube~/.config/gcloud~/.azure~/.docker/config.json~/.env~/.claude~/Library/Keychains、Chrome/Firefox 凭据、~/.git-credentials~/.gitconfig~/.terraformrc~/.vault-token~/.npmrc~/.yarnrc~/.pypirc~/.config/nix~/.netrc 等。注意 ~/.config/gh 被故意排除——gh CLI 需要读取 auth 配置才能正常工作。

第六步:命名空间隔离--unshare-user --uid 0 --gid 0 创建新的 user namespace(沙箱内 root 映射到外部非特权用户),--unshare-pid --proc /proc 创建新的 PID namespace。--new-session --die-with-parent 确保沙箱进程随父进程退出,不会产生孤儿进程。

第七步:环境变量清理env_blacklist 中的环境变量(如 OPENAI_API_KEYANTHROPIC_API_KEYAWS_SECRET_ACCESS_KEYAWS_ACCESS_KEY_ID)被设为空字符串,防止 Agent 通过 Shell 命令窃取密钥。

第八步:硬编码 shell 防注入。最终命令通过 -- /bin/sh -c cmd 执行,硬编码 /bin/sh 防止 PATH 注入——如果用 $SHELLsh,攻击者可能通过修改 PATH 来替换 shell 程序。

违规检测通过 _BWRAP_VIOLATION_RE 匹配 stderr 中的 “Permission denied”、“bwrap:”、“Operation not permitted”、“EACCES” 等模式。

Landlock(Linux 回退)

Landlock 是 Linux 内核级的安全模块(LSM),通过 ctypes 直接调用 syscall 实现。与 Bubblewrap 的命名空间隔离不同,Landlock 是基于路径的访问控制——不创建新的命名空间,而是在当前进程中限制对路径的访问。

核心机制是 _generate_sandbox_script() 生成一个 Python 强制脚本,子进程执行该脚本。脚本内依次执行:prctl(PR_SET_NO_NEW_PRIVS)landlock_create_rulesetlandlock_add_rule(逐路径)→ landlock_restrict_selfos.execvp('/bin/sh', ['/bin/sh','-c',cmd])PR_SET_NO_NEW_PRIVS 确保子进程不能获得新特权,landlock_restrict_self 将规则集应用到当前进程。

路径规则编译:系统路径(/usr,/lib,/etc,/proc,/sys,/dev,/run,/bin,/sbin)→ 读 + 执行权限;/tmp → 读 + 写 + 执行;config.mounts → 读 + (可写?写:无) + (可执行?执行:无)。workspace 挂载为 critical(失败则中止)。

Landlock 的一个关键限制是:不能在授予父目录后撤销子目录访问。因此 allow_read_all=True 时不能直接授予 “/”,策略是:枚举 HOME 子目录逐个授予,跳过 deny_paths;若 HOME 枚举失败则 fail-closed(不授予 “/”,仅保留系统路径)——宁可功能受限也不暴露敏感路径。

Landlock ABI v4+ 支持网络端口级控制:network_allow=[] → 处理所有网络访问但无规则(全拒);"*" in network_allow → 不处理网络(全开);端口级规则通过 LANDLOCK_RULE_NET_PORT 添加。

ABI 版本检测通过 landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) 返回值获取。

Seatbelt(macOS)

macOS 使用 sandbox-exec -p '<profile>' <shell> -c '<cmd>' 执行沙箱命令。_compile_seatbelt_profile() 生成 Seatbelt .sb 策略字符串,包含:

  • (version 1) (deny default) 开头——默认拒绝所有操作,然后逐项允许
  • 允许 process-exec*、process-fork、signal、sysctl-read
  • 系统路径只读:/System、/usr/lib、/usr/share、/Library、/dev/null|zero|random|urandom|tty|dtracehelper
  • 网络控制:"*" in network_allow(allow network*);空 → (deny network*)
  • 文件读:allow_read_all=True(allow file-read*);False → 仅声明 mounts
  • deny_paths:(deny file-read* (subpath "...")) + (deny file-write* (subpath "..."))
  • executable=False 的 mounts → (deny process-exec* (subpath "..."))
  • platform_hints["seatbelt_extra_rules"]:管理员逃逸舱(原样嵌入,禁止来自用户输入)

_sanitize_seatbelt_path() 防注入——转义反斜杠和双引号,拒绝换行符。Seatbelt 策略是声明式的 Scheme 语法,如果不做路径净化,攻击者可能通过路径中的特殊字符注入策略代码。

Windows AppContainer

AppContainer 是 Windows 的应用隔离机制,使用 capability SID(S-1-15-2-*)隔离进程。仅显式 ACE 授权的路径可访问。触发条件是 allow_read_all=False

__aenter__ 流程:计算 ACL 指纹(SHA256[:16])→ 查找可复用容器 → 若无则创建新 profile + 设置 ACL + 保存元数据。ACL 设置:workspace → FULL_ACCESS;mounts writable → FULL_ACCESS,否则 → READ_EXECUTE;Python 安装目录 → READ_EXECUTE;deny_paths → DENY_ALL + DENY_ACCESS。

进程创建通过 SECURITY_CAPABILITIES + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES + CreateProcessW。网络能力返回 [internetClient, internetClientServer, privateNetworkClientServer],不支持域级过滤。

复用机制通过 ACL 指纹实现——相同配置的沙箱可以复用已存在的容器 profile,避免频繁创建/销毁的性能开销。清理通过 shutdown_cleanup() 遍历 ~/.qwenpaw/containers/*.json,跳过 owner_pid 存活的,清理其余。

Windows Elevated(管理员)

触发条件是 allow_read_all=True + 管理员权限。机制是专用本地用户 + WRITE_RESTRICTED token。创建流程:

  1. 创建本地用户(随机密码)+ 加入 QwenpawUsers 组 + 授予 BATCH_LOGON 权限
  2. LogonUser → 获取 SID → 创建用户 profile
  3. 授予 windowstation/desktop 访问
  4. 网络封锁时通过 PowerShell 安装 WFP 防火墙规则
  5. 授予 workspace/mounts 写权限(cap_sid + user_sid);deny_paths → deny-all ACE;授予 NUL 设备访问;授予中间目录遍历权限
  6. 创建 restricted token:限制 SID 列表 = [capabilities…, user_sid, logon_sid, Everyone],flags = DISABLE_MAX_PRIVILEGE | _LUA_TOKEN | WRITE_RESTRICTED
  7. DPAPI 加密密码持久化到元数据

复用通过指纹 + DPAPI 解密密码重新 LogonUser 实现。

Windows Unelevated(非管理员)

触发条件是 allow_read_all=True + 非管理员。机制是 WRITE_RESTRICTED token(无需 admin),读写由 capability SID 门控,读/执行不受限。

与 Elevated 版本的区别:限制 SID 列表 = [cap_sid, logon_sid, Everyone],flags = DISABLE_MAX_PRIVILEGE | WRITE_RESTRICTED(注意无 _LUA_TOKEN),写权限通过 capability SID 的 ACE 控制。

网络软封锁:network_allow 为空时设置 HTTP_PROXY/HTTPS_PROXY=http://127.0.0.1:9(无效代理)——注意这是软封锁,非内核级,技术上有绕过的可能。这是在非管理员权限下的最佳努力方案。

7.4 能力探测:probe_sandbox_support

沙箱能力探测使用 @functools.lru_cache(maxsize=1) 缓存,每个进程只执行一次。探测按平台分发,有严格的优先级和回退逻辑:

Linux 探测优先 Bubblewrap,回退 Landlock。Bubblewrap 探测检查 bwrap 是否在 PATH 上,然后执行测试命令确认 user namespace 可用(bwrap --ro-bind / / --dev /dev --unshare-user --unshare-pid --proc /proc -- /bin/true)。Landlock 探测三步走:(1) 内核版本 >= 5.13;(2) /sys/kernel/security/lsm 包含 “landlock”;(3) 通过 landlock_create_ruleset 系统调用(x86_64 syscall 号 444)获取 ABI 版本。

macOS 探测检查 sandbox-exec 是否存在。

Windows 探测检查 Windows 10+ 和 CreateRestrictedToken API(advapi32.dll),AppContainer 为可选(需 icacls + userenv.dll)。

7.5 沙箱配置编译

ResourceGovernor.compile_sandbox_config()user_rules 动态编译沙箱配置。它遍历所有用户规则,解析 ToolName(pattern) 格式的匹配规则。通过 _resolve_mount_path(pattern, ws) 从规则模式提取挂载路径——剥离尾部 */,WORKSPACE_DIR 占位符替换,绝对路径直接用,相对路径拼接 workspace。

如果是文件读取工具(Read/ViewImage/ViewVideo/SendFileToUser),添加只读挂载(但已存在写权限则保留写);如果是文件写入工具(Write/Edit/Append),添加读写挂载。workspace 始终读写挂载插入到 mounts[0]。coding_project_dir 若与 workspace 不同,额外读写挂载。

最终构造:SandboxConfig(mode=detect_platform_mode(), workspace_dir=ws, mounts=mounts, deny_paths=DEFAULT_SANDBOX_DENY_PATHS, network_allow=["*"], timeout_seconds=60, env_vars={k:"" for k in env_blacklist})

注意 env_vars 的实现:将 env_blacklist 中的每个键映射为空字符串,实现环境变量遮蔽。这不是删除环境变量(在进程层面做不到),而是将其值设为空——子进程看到的环境变量存在但为空,无法获取到真实的密钥值。

这种设计意味着沙箱的文件系统视图是最小权限的——Agent 只能看到和修改它被明确允许的路径。即使 Agent 被诱导执行恶意命令,攻击面也被限制在已授权的路径内。

7.6 沙箱降级

当沙箱不可用时(平台不支持或全局开关关闭),ResourceGovernor.assert_policy() 中的降级逻辑生效。_sandbox_usable() = _sandbox_available(平台支持)AND _sandbox_globally_enabled()(config.json 的 security.sandbox_enabled 开关)。

降级为 GovernanceDecision(action=ALLOW, reason="sandbox disabled by config/unavailable, running unsandboxed")

关键安全说明:只有沙箱隔离层被丢弃,Phase 0-2 保护(类型检查、深度扫描、builtin/user 规则)仍然完全有效。命令已通过 Phase 1 CRITICAL→DENY、Phase 1.5 shell 危险关键词、所有 builtin/user DENY/ASK 规则的检查。STRICT 模式不会到达此处(它在 evaluate() 中提前返回 ASK)。

_sandbox_globally_enabled() 的 fail-safe:config 读取失败时返回 True(路由到沙箱而非无沙箱运行)——宁可尝试沙箱执行(可能失败),也不默认无沙箱运行。

7.7 沙箱违规升级

沙箱执行可能被拒绝(ToolResultState.DENIED),这触发了 PolicyGuardedTool.__call__ 中的违规升级机制。完整流程:

  1. 检测违规:检查返回的 ToolChunkstate == ToolResultState.DENIED
  2. 提取违规信息:优先从 result.metadata["sandbox_violation"],否则从 content 文本中解析 “Sandbox violation:” 后的内容
  3. 记录 ASK 审计日志(GovernanceDecision(action=ASK, reason="sandbox violation: ...")
  4. 调用 _ask_user_approval() 请求用户批准(传入 violation_msg
  5. 用户批准:移除 sandbox_config + 设置 _qp_sandbox_mode=False + 重新调用 FunctionTool.__call__无沙箱重试
  6. 用户拒绝:返回 ToolChunk(state=DENIED, content=[TextBlock(text="...blocked and user denied approval.\n\n" + _NO_RETRY_INSTRUCTION)])

这形成了"沙箱→人工审批→无沙箱执行"的渐进信任升级路径。设计理念是:沙箱违规不一定意味着恶意——可能是沙箱配置过于严格,阻止了合法操作。让用户决定是否信任这个命令,比直接拒绝更实用。但如果用户拒绝了,_NO_RETRY_INSTRUCTION 明确告知模型不要重试——这是最终决定。

八、MCP 驱动层:连接外部世界

8.1 频道与驱动的区别

QwenPaw 明确区分两种连接方向:**频道(Channels)**是人如何到达 Agent(人→Agent),**驱动(Drivers)**是 Agent 如何到达外部系统(Agent→外部)。频道将平台原生消息转换为统一的 AgentRequest/AgentResponse;驱动将外部工具服务器变成 Agent 可调用的工具。

驱动层是协议中立的连接器层。核心设计理念是:Driver 核心负责策略/凭据/生命周期管理,而具体的协议处理(如 MCP)通过 Handler 注册机制插入。当前实现的协议是 MCP(Model Context Protocol),但抽象比 MCP 更广——其他连接器协议可以在相同的凭据和策略模型后面插入。DriverManager 通过 register_handler_type(protocol, cls, endpoint_validator) 将协议与具体 Handler 类绑定——Manager 本身不感知任何具体协议。

8.2 DriverCard:驱动的声明卡片

DriverCard 是驱动层的核心数据模型,描述一个外部能力提供者的完整声明。关键设计原则是**“DriverCard 无密钥”**——卡片本身不存储任何密钥值,只存储 CredentialRef(凭据引用),密钥在独立的 CredentialStore 加密存储。

完整字段:

  • name:全局唯一驱动名(也是存储键),validate_card_name 拒绝空名、含 \x00///\\/.. 的名字(防止路径注入)
  • protocol:协议类型(当前为 "mcp"
  • endpoint:端点配置,包含 transport(stdio/streamable_http/sse)、command/args(stdio)或 url(HTTP)、env/headers(凭据绑定)
  • credentials:凭据引用映射,使用 CredentialRef(kind, ref) 保持 DriverCard 本身不含密钥
  • config:额外配置(display_name, description)
  • enabled:是否启用
  • policy:驱动级访问策略(DriverPolicy

validate_card 执行四层校验,每一层都是一道安全闸门:

第一层:身份校验_validate_card_identity)——校验 name 格式安全(防路径注入)、protocol 非空、endpoint 和 config 是 dict、policy 是 DriverPolicy 实例。

第二层:凭据校验_validate_card_credentials)——校验 credentials 是 dict、每个 alias 非空、每个 credential_ref.kind 非空。

第三层:策略校验_validate_driver_policy)——校验 default_effect{allow, deny, ask} 中、每条 rule 的 effect 和 target kind 合法、principal 的四个字段格式正确、subject_type=="user"subject_value 非空。

第四层:端点绑定校验_validate_endpoint_bindings)——遍历 endpoint 中的 envheaders,校验所有凭据引用指向已声明的凭据别名。支持新格式(source-based:{source: "literal"|"credential", value/credential/field/format})和旧格式(public/secret_refs)。这一层保持了"DriverCard 无密钥"的安全不变量——如果绑定校验失败,说明配置引用了不存在的凭据,可能是配置错误或攻击。

8.3 DriverManager:生命周期管理

DriverManager 是驱动层的生命周期拥有者和调度中心,协议中立。它通过 register_handler_type(protocol, cls, endpoint_validator) 将协议与具体 Handler 类绑定——Manager 本身不感知任何具体协议。

Build-before-swap 重建模式是 DriverManager 最关键的设计。reload_driver 的执行流程:

  1. 从磁盘 YAML 加载 DriverCard
  2. 校验 card(coerce + validate + protocol 解析 + endpoint validator)
  3. 若 card.enabled_build_and_init_handler(card) 构建并初始化新 handler(build + init)
  4. _card_store.save(card) 持久化 card
  5. 加锁交换:从 _handlers 取出旧 handler,放入新 handler
  6. 锁外关闭旧 handler:避免 shutdown 阻塞锁

这个模式的核心思想是"先建后换"——新 handler 完全构建并初始化成功后,才替换旧的。如果构建失败,旧 handler 保持运行,服务不中断。_build_and_init_handler 内部处理了 asyncio.CancelledError 和普通异常:失败时自动调用 _shutdown_handler 清理半初始化的 handler。

Shutdown 有 10 秒超时保护,超时后 cancel task 并启动后台 reaper 任务(_reap_lifecycle_task)持续重试清理。

refresh_driver 检测是否需要重连——通过 _requires_reconnect(old, new) 比较 name/protocol/endpoint/credentials/enabled 是否变更。如果只是 config/policy 变更,调用 handler.sync_runtime_metadata(card) 仅刷新元数据,无需重连。这避免了不必要的 MCP 连接重建——改变驱动策略不应该导致连接中断。

8.4 策略评估引擎

驱动层有独立的策略评估系统,与治理层并行。evaluate_policy 实现多维度匹配 + 特异性排序

匹配条件使用 AND 语义——所有条件都满足才匹配:

  1. 主体匹配subject_matches):对 context_subjects 中的任一 subject 匹配。支持 *(全匹配)、user:*(前缀通配符)、精确匹配。subject 来自 _subjects_from_context:显式 subject → user:{user_id}session:{session_id}channel:{channel} → 兜底 user:unknown
  2. 结构化调用者匹配principal_matches):_source_matches AND _subject_scope_matches。source 匹配检查 source_type=="channel" 时从 request_context 取 channel 值比较;subject scope 匹配检查 user 比较 user_id、session 比较 session_id。
  3. 目标能力匹配target_matches):目标 kind + name 匹配。kind 可以是 tool*name 可以是具体工具名或 *
  4. 条件满足condition_satisfied):TimeRange 时间段 + weekdays 星期限制。支持跨午夜时间区间(after > before 时,current >= after OR current <= before)。时间使用配置的时区,默认 UTC。

多个规则匹配时,按特异性排序——5 维元组降序排序,取最高优先级规则:

排序键(从高到低):
1. target_name_specificity  — name 精确=1, 通配=0
2. target_kind_specificity  — kind 精确=1, 通配=0
3. principal_specificity    — 结构化选择器评分 0-4
4. subject_specificity       — subject 精确=2, typed通配=1, 全局=0
5. _STRICTNESS[effect]      — deny=3, ask=2, allow=1

这个排序确保最具体的规则优先。例如,"允许 user:alice 在工作日使用 search 工具"比"允许所有用户使用所有工具"更具体,因此优先。无匹配时返回 default_effect(默认拒绝)。

8.5 审批门控

ApprovalGate 是一个 Protocol,核心只有一个方法 request_approval(context)——阻塞直到应用层审批流程批准或拒绝。这种 Protocol 解耦设计意味着:驱动核心拥有策略评估,但不知道产品如何向人类请求审批。应用层注入 QwenPawDriverApprovalGate 实现具体的审批 UI 和流程。

审批触发逻辑在 DriverHandler._authorize_invocation 中:effect == "ask" 且执行级别未禁用审批时需要审批;effect == "allow" 在"全部工具需审批"模式下也需要审批。

QwenPawDriverApprovalGate 的执行流程:从 context 提取 session_id → 构建 driver_label 和 driver_ref → 创建 pending 审批请求(包含 source_type、severity、display 信息、driver 元信息)→ wait_for_approval(request_id, timeout) 阻塞等待 → APPROVED 返回继续执行,否则抛出 DriverPermissionDeniedError

8.6 凭据系统

凭据系统是驱动层的安全基石,包含四个组件:

凭据存储AsyncCredentialStore):异步 per-workspace YAML 存储。密钥值使用对称加密(来自 security.secret_store),写入使用原子替换(NamedTemporaryFile + os.replace + os.fsync)+ POSIX 0o600 权限限制(Windows 依赖加密)。支持 env: 前缀的 ref 直接从环境变量读取(不经过文件),但不能持久化。公共 API 是 async 的,底层通过 asyncio.to_thread 隔离同步 I/O,使用 threading.RLock 保证线程安全。

存储格式:

version: 1
credentials:
  <ref>:
    kind: <kind>
    public: {...}
    secrets: {加密后的值}
    meta: {...}

凭据 Provider(5 种内置 Provider):

Provider kind 行为
NoneProvider none 直接返回空凭据,无 I/O
DirectProvider static 每次从 store 读取完整 record,无缓存
OAuth2CCProvider oauth2_cc 客户端凭证模式,带内存缓存(token + expires_at),双重检查锁,过期前 300s 刷新,on_secrets_changed 清空缓存
OAuth2AuthCodeProvider oauth2_auth_code 授权码模式,先检查 access_token 有效性,无效则用 refresh_token 换新 token,换 token 后自动回写到 store
AKSKProvider ak_sk HMAC-SHA256 签名,从 store 读 access_key + secret_key,用 HMAC(secret_key, "{ak}:{timestamp}:{ref}") 生成签名

OAuth 令牌交换有重试机制:最多 3 次尝试,瞬态错误(HTTP 408/425/429/5xx)重试,线性退避(0.2s × attempt)。

凭据绑定resolve_binding):将 endpoint 的 env/header 绑定规范解析为运行时字符串值。支持新格式(source: "literal"/"credential",支持 format 模板如 "Bearer {value}")和旧格式(public/secret_refs,发出 deprecation 警告)。lookup_credential_value 解析 "alias.field" 格式——有 alias 只在该 credential 中查找,无 alias 按优先级查找(static → default → 其他)。

自动认证头推断implicit_auth_headers):从凭据自动推断认证头。推断优先级:已有 Authorization 头 → 不覆盖;credential.values 中有 headers dict → 直接返回;有 access_tokentokenBearer {token};有 username + passwordBasic {base64}。这个设计使得大多数 OAuth2 驱动不需要手动配置 Authorization 头——系统自动从凭据推断。

8.7 MCPDriverHandler:MCP 协议实现

MCPDriverHandler 是 MCP 协议的具体实现,继承 DriverHandler 模板方法基类。

_setup 凭据注入与连接建立

  1. endpoint 读取 transport(默认 "stdio"
  2. await self._resolve_credentials() 解析所有凭据别名
  3. stdio 传输resolve_binding(endpoint.get("env") or {}, credentials) 将 env 绑定解析为实际环境变量字典 → 构建 StdIOStatefulClient(name, command, args, env, cwd)
  4. HTTP 传输resolve_binding(endpoint.get("headers") or {}, credentials) 解析 headers → headers.update(implicit_auth_headers(credentials, headers)) 追加推断的认证头 → 构建 HttpStatefulClient(name, transport, url, headers)
  5. await self._client.connect() 连接 MCP 服务器

MCP 工具调用完整流程invoke_capability):

  1. parse_capability_id(capability_id) 解析 capability ID → (protocol, driver_name, kind, action, tool_name)
  2. 校验 protocol/driver_name/kind/action 匹配
  3. _subjects_from_context(request_context) 构建主题元组
  4. _guarded_execute(策略门控 → 凭据解析 → MCP client.call_tool)
  5. 异常映射:DriverPermissionDeniedErrordriver_policy_deniedApprovalRequiredErrordriver_policy_approval_required;其他 → execution_error

_mcp_tool_to_capability 转换:将 MCP 工具转换为 Driver 能力声明。剥离 mcp__{driver_name}__ 前缀,生成 capability_id(URI 格式 driver://mcp/{driver}/tools/{name}#invoke,name URL 编码),设置 exposure.tool_name{display_namespace}__{sanitized_name})满足 OpenAI 工具名 ^[a-zA-Z0-9_-]+$ 约束。_sanitize_tool_name 将非 [A-Za-z0-9_-] 字符替换为 _,去除首尾下划线,空结果回退为 "tool"。能力列表有 10 秒 TTL 缓存避免频繁调用 list_tools

8.8 MCP 客户端:单任务生命周期管理

MCP 客户端(stdio 和 HTTP)共享 _MCPClientMixin,解决了一个关键的工程问题:AgentScope 原生的跨任务 AsyncExitStack 泄漏——connect() 在任务 A 进入 AsyncExitStack,close() 在任务 B 退出,导致 anyio CancelScope 错误和 CPU 泄漏。

这个问题在 FastAPI/uvicorn 的异步环境中很容易出现:一个 HTTP 请求在任务 A 中调用 connect()(进入 AsyncExitStack),然后另一个请求在任务 B 中调用 close()(退出 AsyncExitStack)。anyio 的 CancelScope 要求 enter 和 exit 在同一个任务中,否则会抛出异常并导致 CPU 泄漏。

解决方案是在单一专用后台任务中运行整个上下文管理器生命周期,使用 Event 信号控制 reload/stop。_run_lifecycle 生命周期主循环:

  1. while not _stop_event.is_set():
  2. AsyncExitStack 内:_setup_transport(stack) → 创建 ClientSessionsession.initialize()is_connected=True_ready_event.set()
  3. _wait_for_reload_or_stop() 阻塞等待事件
  4. _reload_event → 清除事件,继续循环(重连)
  5. _stop_event → 清除缓存,退出循环

AsyncExitStack 始终在同一任务中退出,无跨任务问题。connect() 启动生命周期任务并等待 _ready_event(30s 超时),reload() 设置 _reload_event 触发重连,close() 设置 _stop_event 并等待任务退出(5s 超时,超时则启动 reaper 任务持续 cancel)。

传输错误自动重连_handle_transport_error(exc) 识别传输错误类型(anyio.ClosedResourceErroranyio.BrokenResourceErrorhttpx.TransportErrorEOFErrorConnectionResetErrorBrokenPipeError),标记 is_connected=False清除 _cached_tools(供 list_tools 降级使用),清除 _ready_event,设置 _reload_event 触发重连。

list_tools 在重连窗口期有容错:若 is_connected=False 但有活跃 task,等待最多 3s 重连;重连成功 → 刷新 schemas 并缓存;仍未连接 → 返回缓存的 tools;无缓存且未连接 → 抛 RuntimeError。

8.9 MCP 工具→Agent 工具适配

DriverCapabilityTool 将一个 Driver 能力包装为 AgentScope ToolBase。关键设计:

  • check_permissions 始终返回 ALLOW——因为策略已由 Driver 层处理,不需要重复检查
  • __call__ 构建 DriverInvocation(capability_id, payload=kwargs, request_context) → 调用 self._invoker(invocation)(即 DriverManager.invoke_capability)→ 通过 _tool_chunk_from_driver_result(result) 转换为 AgentScope ToolChunk

_tool_chunk_from_driver_result 的结果转换:成功时检查 value.isError(MCP CallResult 标志)设置 SUCCESS 或 ERROR 状态;_blocks_from_value 转换 MCP 返回值为 AgentScope 内容块——TextContent → TextBlock,ImageContent → DataBlock,EmbeddedResource → TextBlock。失败时构建错误 payload 返回 ERROR 状态的 ToolChunk。

build_driver_agent_tools 是组装入口:调用 driver_manager.list_capabilities(kind="tool") 获取所有工具能力,过滤 exposure.as_tool=True 的,创建 DriverCapabilityTool 实例列表。这些工具与内置工具一起进入 AgentBuilder 的工具集,最终被 PolicyGuardedTool 包装——即使是外部 MCP 工具,也受治理层管控。

九、Skills 技能系统

9.1 三层存储模型

Skills 系统采用三层存储模型,每一层有不同的生命周期和可见性:

  1. 打包内置层agents/skills/ 下的 -en/-zh 双语变体,随 QwenPaw 安装包分发,只读。通过 _get_packaged_builtin_registry() 发现,支持语言偏好匹配。
  2. 共享池层<WORKING_DIR>/skill_pool/ 全局共享技能池,可被所有 Agent 引用。由 SkillPoolService 管理,清单文件 skill_pool/skill.json。支持额外只读根目录(通过 config.skill_paths 配置),主池优先。
  3. 工作区层<workspace>/skills/ 每个 Agent 独立的技能实例。由 SkillService 管理,清单文件 <workspace>/skill.json。兼容旧 skill 目录(自动重命名)。

这个三层模型的设计意图是:

  • 内置层提供开箱即用的基础能力,不需要网络下载
  • 共享池层允许用户安装一次、所有 Agent 复用,避免重复存储
  • 工作区层允许每个 Agent 有独立的技能配置(启用/禁用、频道路由、自定义参数)

resolve_effective_skills(workspace_dir, channel_name) 解析当前工作区在某频道下生效的技能:读取 manifest → 过滤 enabled=True → 检查 channels 路由(“all” 或特定频道)→ 验证技能目录存在。这个解析在每次请求构建时执行,确保技能配置变更立即生效。

9.2 SKILL.md 格式与解析

技能是一个文件夹,核心是 SKILL.md 文件,使用 YAML frontmatter + Markdown body 格式。frontmatter 必须包含 namedescription(触发字符串——LLM 根据这个判断何时使用该技能),metadata 是可选的 dict。body 是给 LLM 的指令文本。

使用 python-frontmatter 库解析。关键解析函数:

  • read_frontmatter_safe_from_path:用 frontmatter.loads() 解析,失败时返回 {"name": skill_name, "description": ""}——"安全"的含义是即使 SKILL.md 格式错误也不会中断系统
  • validate_skill_content:校验 frontmatter 必须有非空 namedescriptionmetadata 必须是 dict
  • extract_version:依次从 post.version / metadata.version / metadata.builtin_skill_version 提取版本
  • _extract_requirements:从 metadata.{openclaw|qwenpaw|clawdbot}.requiresmetadata.requires 提取 require_bins(必需的二进制)和 require_envs(必需的环境变量)
  • _extract_emoji_from_metadata:从 metadata.qwenpaw.emoji 提取 emoji

技能目录下的 ../scripts 子目录中的 Python 文件即为可执行脚本,references/ 目录可以包含参考文件。这种结构使得技能既是"知识包"(SKILL.md 的指令文本)又是"工具包"(scripts/ 的可执行脚本)。

9.3 内置技能列表

QwenPaw 内置 21 个技能(每个有 en/zh 两个语言变体,共 42 个变体):

技能 说明
pdf, docx, pptx, xlsx 文档处理(PDF/Word/PPT/Excel)
browser_cdp, browser_visible 浏览器控制(CDP/可见浏览器)
cron 定时任务管理
make_plan 计划制定
make-skill 技能创建(唯一带绑定工具的技能)
chat_with_agent 与其他 Agent 对话
multi_agent_collaboration 多 Agent 协作
channel_message 频道消息
dingtalk_channel 钉钉频道
file_reader 文件读取
himalaya 邮件客户端
guidance 引导
QA_source_index QA 源索引

9.4 技能如何工作:知识注入而非工具转换

理解技能系统的关键在于:技能本身不直接变成工具。技能的工作方式是将 SKILL.md 内容注入 Agent 的系统提示词,使 LLM 获得使用相关工具(如 execute_shell_commandrun_tool_batch)的领域知识。

技能通过 Toolkitskills_or_loaders 参数注入。AgentBuilder._resolve_skill_loader_dirs 将技能名映射到包含 SKILL.md 的目录路径列表,AgentScope 的 Toolkit 读取这些目录中的 SKILL.md 内容并注入到模型上下文。例如,pdf 技能的 SKILL.md 告诉 LLM:“当用户需要对 PDF 文件进行操作时,使用 pypdf 和 pdfplumber 库,通过 execute_shell_command 执行 Python 脚本来读取/提取/填写 PDF”。

斜杠命令触发/<skill_name> <input> 触发时,将 SKILL.md 内容合并到用户消息中:"Use the [{display_name}] skill in {skill_dir} to fulfill user's task: {user_input}\n\n{post.content}"。这重写最后一条用户消息的 text block,使模型在当前对话中直接获得技能指令。

make-skill 的绑定工具机制make-skill 技能通过 @tool_descriptor(requires_skills=("make-skill",)) 声明了一个绑定工具 materialize_skill。只有当 make-skill 技能被启用时,该工具才会出现在 Agent 的工具列表中。materialize_skill 的执行流程:校验输入 → 规范化技能名 → 检查冲突 → 生成 SKILL.md 内容 → SkillService.create_skill 写入文件 + 清单 + 安全扫描 → 分析 extra_files 中的引用并生成验证提示。这是一种"技能带工具"的模式——技能不仅提供知识,还提供专属工具。

9.5 技能配置环境变量注入

apply_skill_config_env_overrides 是技能系统与运行时环境的关键桥梁。它作为上下文管理器,在 Agent 执行前注入、执行后清理。

对于每个生效技能,读取其 configrequirements.require_envs,只注入声明在 require_envs 中的配置键到环境变量。同时总是注入完整 config 的 JSON 字符串到 QWENPAW_SKILL_CONFIG_<SKILL_NAME> 环境变量,供技能脚本读取。

使用引用计数机制管理环境变量,支持同一 Agent turn 内多个技能引用同一变量,避免冲突。_acquire_skill_env_key 获取引用,_release_skill_env_key 按引用计数释放。此机制通过 SkillEnvHook(PRE_EXECUTE 阶段注入)和 SkillEnvCleanupHook(FINALLY 阶段清理)钩子自动调用。

引用计数的必要性在于:如果技能 A 和技能 B 都需要 API_KEY 环境变量,当技能 A 完成并清理时,不应该清除技能 B 还在使用的 API_KEY。引用计数确保只有当所有引用都释放后才清除环境变量。

9.6 SkillService:工作区技能生命周期

SkillService 管理单个工作区内的技能生命周期。关键方法和安全设计:

  • create_skill:验证内容 → staged 目录写入 → 安全扫描 → 复制到目标 → 更新清单(失败时回滚)。staged 写入是"先写到临时目录,扫描通过后再复制到目标"——确保只有安全的技能才会出现在工作区中。
  • save_skill:原地编辑(_save_skill_in_place,内容变化时重新扫描)或重命名保存(_save_skill_as_rename,复制旧目录 → 写入新内容 → 扫描 → 更新清单 → 删除旧目录)。冲突检测 + 建议名。
  • import_from_zip:解压验证 → 安全扫描 → 冲突检测 → 批量导入。
  • enable_skill重新执行安全扫描 → 更新清单 enabled=True。重新扫描的原因是技能文件可能在禁用期间被修改——启用时必须重新验证安全性。
  • disable_skill:更新清单 enabled=False(不删除文件,允许重新启用)。
  • delete_skill:仅允许删除已禁用的技能 → 删除目录 → 更新清单。这个限制防止 Agent 被诱导删除正在使用的技能。
  • load_skill_file:路径遍历防护,只允许 references/../scripts 下的文件。_safe_child_path 拒绝绝对路径和遍历路径。

SkillPoolService 管理共享池技能,额外支持:自动更新机制(通过 SHA-256 哈希比较 SKILL.md 内容检测变更,推送更新到目标工作区)、工作区上传/下载(含冲突检测和预检)。

9.7 Hub 安装流程

hub.py 支持从 8 种来源安装技能:skills-sh、GitHub、lobehub、qwenpaw、modelscope、aliyun、skillsmp、clawhub。每个来源由三元组 (来源名, URL匹配器, 异步获取器) 定义,_match_provider 按顺序匹配,首次匹配获胜。无匹配时回退到 "url" 来源。

安装流程:验证 URL → 匹配 provider → fetcher 获取 bundle JSON → normalize 提取内容 → SkillService.create_skill 写入磁盘(在 asyncio.to_thread 中执行避免阻塞)→ 可选 enable_skill 启用。

HTTP 配置:重试状态码 408/409/425/429/500/502/503/504,超时 30s(可配置),重试 3 次,指数退避(base=0.8, cap=6s)。GitHub 响应缓存 TTL 300s,最多 500 条。ZIP 限制最多 4096 条目,200MB。

9.8 池清单对账

核心设计理念是:文件系统是内容的真实来源,清单是描述性元数据。这意味着如果磁盘上的技能文件被手动删除,清单中的对应条目也应该被移除;如果磁盘上新增了技能文件夹,清单应该自动发现它。

reconcile_pool_manifest 流程:

  1. 确保池目录和清单文件存在
  2. 加载打包内置注册表和语言偏好
  3. _discover_pool_skill_dirs() 按优先级扫描所有池根目录(主池优先,额外根目录次之),同名跳过并警告
  4. 对每个发现的技能目录调用 _build_reconciled_pool_entry():外部根目录 → source="customized";主池 → classify_pool_skill_source() 分类为 builtin/customized;保留现有 configtagsinstalled_fromauto_update* 字段
  5. 删除清单中不在磁盘上的条目
  6. 通过 mutate_json() 原子写入

工作区对账 reconcile_workspace_manifest 类似,但保留 enabledchannelsconfig 等用户状态——对账不会丢失用户的启用/禁用配置。

9.9 存储层的工程细节

存储层(store.py)有几个值得学习的工程实践:

跨进程 JSON 锁:使用 fcntl(POSIX flock)或 msvcrt(Windows locking)实现文件锁,防止多进程并发写入冲突。锁文件名为 .{原文件名}.lock。这个跨平台设计确保了 QwenPaw 在多进程场景(如多个 Worker 进程)下的数据一致性。

原子写入write_json_atomic):自动递增 version 字段(取 max(当前+1, 当前时间戳*1000))→ 写入同目录临时文件 → temp_path.replace(path) 原子替换 → finally 清理残留临时文件。原子写入确保即使写入过程中崩溃,文件也不会处于半写入状态。版本递增使得读取方可以检测到数据变更。

mtime 缓存_read_json_mtime_cached):通过 lru_cache(maxsize=256)path + mtime_ns 缓存 JSON 读取。os.stat(path).st_mtime_ns 作为缓存键——文件修改后 mtime 变化,缓存自动失效。这在频繁读取清单文件时显著减少 I/O,同时保证缓存一致性。read_skill_manifest()read_skill_pool_manifest() 使用此缓存。

ZIP 安全_extract_and_validate_zip):总解压大小限制 200MB → 路径遍历防护(每个 info.filename 解析后必须 is_relative_to(root_path))→ 符号链接检测(external_attr >> 16 & 0o120000 == 0o120000 → 拒绝)→ extractall 仅在全部检查通过后执行。这三个检查分别防止了 ZIP 炸弹、路径遍历攻击和符号链接攻击。

路径安全normalize_skill_dir_name 拒绝空、NUL、./..、路径分隔符;safe_skill_dir normalize + resolve + is_relative_to 双重检查;_safe_child_path 拒绝绝对路径和遍历路径。这些函数构成了技能路径安全的防线。

十、记忆与上下文管理

10.1 分离设计

QwenPaw 将容易混淆的两件事彻底分离:记忆(Agent 跨对话记住什么)和上下文(当前模型窗口里放什么)。这个分离是 QwenPaw 记忆系统的核心设计决策——很多 Agent 框架把这两者混在一起,导致"记忆"和"上下文窗口"的概念纠缠不清。

维度 记忆(Memory) 上下文(Context)
职责 长期存储与检索 请求级窗口管理
基类 BaseMemoryManager (ABC) ContextManager (Protocol)
生命周期 跨会话持久 单请求/单会话
关注点 “记住什么” “当前窗口放什么”
后端 ReMe / AnalyticDB / Noop Scroll / 原生压缩

10.2 记忆系统

BaseMemoryManager 定义了完整的生命周期接口:start() 初始化存储、close() 释放资源、get_memory_prompt() 返回记忆指导提示词、list_memory_tools() 返回暴露给 Agent 的记忆工具。

1.svg

基类内置了一个串行 FIFO 的摘要任务队列系统——_summarize_worker 后台工作器从队列取出任务串行执行,避免并发写入冲突。还内置了自动记忆搜索消息构造——_build_auto_memory_search_msg 构造一个合成的 AssistantMsg,模拟 memory_search 工具调用(包含 ThinkingBlock、ToolCallBlock 和 ToolResultBlock),并估算 token 消耗。

使用注册表模式,通过 @memory_registry.register("none") 装饰器注册后端。NoopMemoryManager 是空实现(enabled = False),所有方法返回空,用于完全禁用记忆功能。ReMeLightMemoryManager 集成 ReMe 记忆库,暴露 memory_search 语义搜索工具,通过 _run_reme_job 调用 ReMe 的后台 job 系统,并将 QwenPaw 的活跃模型注入 ReMe 的 LLM 组件。

10.3 MemoryMiddleware:记忆接入推理循环

MemoryMiddleware 在三个生命周期级别工作:

系统提示词注入on_system_prompt):将 get_memory_prompt() 返回的记忆指导文本追加到系统提示词。指导文本告诉 LLM:MEMORY.md 是长期记忆,每日笔记是短期记录,memory_search 用于查精选的长期记忆。

模型调用前自动搜索on_model_call):在每次模型调用前,检查是否有新的用户消息轮次。如果是新轮次且非自动化请求(cron/heartbeat 跳过),则触发 auto_memory_search,将检索到的记忆消息注入到模型输入中。通过 turn marker 去重,避免同一轮次重复搜索。

回复后自动记忆on_reply):在 Agent 回复完成后,累积待处理的用户轮次标记。当达到 auto_memory_interval 阈值时,触发批量记忆提取——从对话中提取有价值的信息写入记忆文件。

压缩前预刷写on_compress_context):当配置了 summarize_when_compact 且即将发生上下文压缩时,先刷写待处理的自动记忆,避免压缩丢失未提取的记忆。

10.4 MEMORY.md 与记忆巩固

MEMORY.md 是长期记忆文件,每日笔记({daily_dir}/YYYY-MM-DD.md)是短期记忆。后台的 “dream” 进程会定期将每日笔记中有价值的内容整理进 MEMORY.md——这是"记忆巩固"过程,类似于人类睡眠时的记忆整理。

记忆指导提示词告诉 LLM:

  • MEMORY.md 是精选、提炼的记忆(不是原始日志)
  • 每日笔记是轻量的短期记录
  • 避免覆盖——先 read_file,再 write_file / edit_file
  • memory_search 用于查持久的事实、偏好、决策与待办

10.5 主动记忆

主动记忆系统通过 generate_proactive_response 实现:创建一个独立的 ReAct Agent(配备 browser_use、read_file、execute_shell_command 工具),从工作区收集记忆上下文,用 LLM 提取可能的用户任务(最多3个),依次执行查询,将结果发送给用户。

关键的是中断检测机制:在整个流程中反复检查 Agent 是否正在处理用户请求或有新的聊天更新。如果 Agent 正忙或有新消息到达,则中断主动响应生成——这确保了主动记忆不会干扰用户的实时交互。

10.6 上下文管理与 Scroll 策略

ContextManager 是一个 @runtime_checkable 的 Protocol,包含三个方法:recover_from_context_overflow(溢出恢复)、compress(压缩)、on_save(持久化通知)。当未注入 ContextManager 时,Agent 保持原生 AgentScope 行为——策略是纯增量的、完全可选的。

默认的上下文管理在窗口满时摘要旧轮次。可选的 Scroll 策略更强大:每一轮都持久化到 history.db(SQLite),滚出窗口的轮次带紧凑索引,Agent 可以通过工具按需回放任何早期片段——不摘要、不丢失。

Scroll 压缩流水线有 8 个阶段:persist(持久化)→ trigger(触发判断)→ pre-fold(预折叠已完成轮次的工具结果)→ split(分割为可驱逐中间段和最近尾部)→ summarize(更新延续摘要)→ add_eviction(中间段折叠到 EvictionIndex)→ live-fold(驱逐后仍压力过大则替换剩余工具结果为恢复指针)→ active-fold(超硬限制则折叠旧活跃轮次)。

关键设计原则是持久化优先(Durability first)——如果写透失败(降级持久性),绝不驱逐,因为中间段不可持久。工具结果折叠是最低成本的回收方式,先于对话驱逐。保护最近 5 个工具结果不被折叠。

10.7 recall_history_python:沙箱化历史回溯 REPL

这是一个非常创新的设计——让模型通过运行 Python 代码来查询对话历史。每次调用都是一个新的沙箱化进程(无状态单元格),preamble 自动注入 MemorySpace 对象(ms),提供 expand(lo, hi)(按序号范围读取)、search(query, k=10)(FTS 搜索)、recall_tool(tool_call_id)(获取工具调用/结果)、sql_query(sql, params)(只读 SQL 查询)等方法。

失败安全格式化确保失败永远不会被误读为答案:非零退出但有输出 → “RECALL INCOMPLETE — 输出是真实已检索的历史,可以使用”;非零退出无输出 → “RECALL FAILED — 历史未被读取”;零退出无输出 → “无输出不代表历史为空,请 print() 你的结果”。

沙箱安全门检查部署层环境变量和 per-agent 配置双重门控,防止不可信的 agent.json 自行关闭沙箱。

10.8 上下文溢出恢复

当模型提供商拒绝过大输入时,QwenPawAgent._call_model 会尝试一次上下文恢复:将 trigger_ratio 设为接近 0 来强制触发一次压缩,然后检查是否成功驱逐或折叠了内容。如果恢复改变了模型输入,则重建模型输入并重试。这是"一次恢复"策略——第二次溢出直接抛出,不进入恢复循环。

十一、Loop Engineering 与模式系统

Loop Engineering 是 QwenPaw 2.0 的核心创新之一,它将 Agent 的推理循环从"简单跑完就停"升级为"可编程、可组合、可声明式配置的控制系统"。这一章将深入剖析门控系统的完整实现、五种内置模式的内部机制、声明式循环编译器的工作原理,以及门控如何与 ReActAgent 的推理循环交互。

11.1 门控系统架构

11.1.1 核心抽象:StopGate 与 StopAction

门控系统的设计哲学是:循环的终止不是 LLM 的决定,而是工程策略的决定。LLM 可能因为上下文不足、工具失败或幻觉而提前停止,也可能陷入无限循环。门控系统通过一组可组合的策略来精确控制"何时该停、何时该继续"。

每个门控返回三种动作之一:

  • BYPASS:无意见,跳过此门控,继续检查下一个
  • INTERRUPT_AND_CONTINUE:中断当前停止意图,注入一段续行提示后让 Agent 继续循环
  • TERMINATE:立即终止循环,输出最终消息

StopHandlerResult 数据类不仅携带动作,还包含续行消息(continuation_message)、终止原因(reason)、是否重置其他门控(reset_peers)、续行元数据标签(continuation_metadata)和最终消息(final_message)。其中 reset_peers 是一个关键设计——当 RubricGate 要求 Agent 修改时,它需要重置 IterationGate 的计数器,让 Agent 有"重新开始"的机会而非被旧的迭代计数截断。

StopGate 抽象基类定义了门控的生命周期契约。每个门控有一个 name 属性(唯一标识符)和一个 priority 属性(数字越小越先执行,默认 100)。核心方法 check(ctx) 接收上下文字典,返回 StopHandlerResult 或 None。此外还有 build_continuation()(构建续行提示文本)、reset_turn()(重置单轮状态)和 reset_session()(重置会话状态)两个生命周期回调。

11.1.2 会话隔离:LoopGate 基类

实际的有状态门控(如迭代计数器)需要按会话隔离——不同用户的会话不能共享迭代计数。LoopGate 通过 _sessions: dict[str, Any] 字典实现这一点:所有状态操作通过 _session_id() 获取当前会话 ID,activate(state) / deactivate() 方法管理会话级状态。这确保了多 Agent 场景下,不同会话的迭代计数互不干扰。

继承层次为:StopGateLoopGate → 具体门控(IterationGateDoomLoopGateTokenBudgetGate 等)。还有一个 FileLoopGate 基类,为基于磁盘状态文件的循环插件(如 RalphGate)提供文件系统状态管理和迭代上限保护。

11.1.3 七种内置门控详解

以下表格汇总了七种门控的核心参数和优先级:

门控类型 name priority 类别 互斥组 核心配置参数
DoomLoopGate doom-loop 5 safety - window_size, similarity_threshold, stages
IterationGate iteration 10 limits - max_iterations (默认40, 范围1-500)
TokenBudgetGate token-budget 20 limits - max_total_tokens (默认120000), max_prompt_tokens, max_completion_tokens
TimeoutGate timeout 30 limits - max_seconds (默认1800, 范围1-86400)
ToolCallBudgetGate tool-call-budget 40 limits - max_calls (默认30), per_tool (字典)
QualitativeRubricGate qualitative-rubric 90 quality completion_rubric rubric (评估文本), max_evaluations (默认1)
CompletionRubricGate completion-rubric 90 quality completion_rubric prompt, completion_signal (默认"COMPLETED"), max_evaluations (默认3)

DoomLoopGate(死循环检测) 是最复杂的门控。它通过三步机制工作:首先,_auto_record_from_ctx() 从 Agent 上下文中提取最新的工具调用,计算参数的 MD5 哈希(截断前 2048 字节)作为签名。然后,_detect_repetition() 在滑动窗口(默认大小 3)中计算相似度——公式为 1 - (unique - 1) / (total - 1),其中 unique 是去重后的签名数。当相似度超过阈值(默认 1.0,即完全重复)时,进入多阶段升级:第一阶段(after=3)注入警告提示让 Agent 修改行为(INTERRUPT_AND_CONTINUE),第二阶段(after=4)直接终止循环(TERMINATE)。这种渐进式响应避免了"一次重复就杀掉"的过度激进。

IterationGate(迭代限制) 是最简单也最常用的门控。每轮 check() 调用时递增计数器,达到 max_iterations 时 TERMINATE。reset_turn() 会将计数器归零,配合 RubricGate 的 reset_peers 使用——当 Rubric 要求修改时,迭代计数也同时重置。

TokenBudgetGate / TimeoutGate / ToolCallBudgetGate 三种预算门控结构类似,但跟踪的资源不同。TokenBudgetGate 通过 TokenRecordingModelWrapper 读取每轮的 prompt/completion token 使用量,按迭代号累积。TimeoutGate 使用 time.monotonic() 计算从激活以来的经过时间。ToolCallBudgetGate 从 Agent 上下文提取工具调用名称,支持全局限制和单工具限制(per_tool 字典)。

QualitativeRubricGate(定性评估) 仅在 Agent 产出纯文本响应(无工具调用)时触发。它注入一段评估准则(如"在停止前验证任务是否完成")作为续行消息,要求 Agent 重新审视。关键设计是 reset_peers=True——这会重置其他门控(如迭代计数器),让 Agent 获得新的预算来修改工作。评估次数有上限(max_evaluations,默认 1),避免无限循环修改。

CompletionRubricGate(完成信号检测) 使用两阶段协议:第一轮,Agent 产出文本候选,门控保存为 state.candidate,然后注入评估提示;第二轮,Agent 输出完成信号(如 “COMPLETED”)则 TERMINATE 并返回候选消息作为最终输出。如果达到 max_evaluations 次仍未通过,也终止但返回最后的候选。这个门控解决了一个微妙的问题:LLM 经常在任务未完全完成时就输出"完成"响应,两阶段协议强制它进行自我验证。

11.1.4 StopHandler:门控编排器

StopHandler 持有有序的 StopGate 列表,按优先级升序执行。其 __call__ 方法的编排逻辑体现了三个关键设计决策:

第一,TERMINATE 优先:遇到任何 TERMINATE 立即返回,不再检查后续门控。这确保安全门控(如 DoomLoop)能快速终止。第二,INTERRUPT_AND_CONTINUE 记录首个:多个 CONTINUE 时只记录第一个触发的门控,但会继续检查后续门控——如果有任何 TERMINATE,TERMINATE 仍然优先。第三,容错隔离:单个门控抛异常会被捕获并跳过,不影响其他门控的执行。

当所有门控都返回 BYPASS 时,StopHandler 默认返回 TERMINATE。这个设计意味着"没有门控说继续,就停止"——安全优先。

reset_peers 机制在 StopHandler 中实现:当触发的门控要求重置时,遍历所有其他门控调用 reset_turn()。这确保了 RubricGate 触发修改时,IterationGate 和 DoomLoopGate 的计数器都被重置。

11.1.5 作用域过滤

作用域过滤是让模式特定门控优先于默认门控的机制。每个 StopHandlerRegistration 有一个 scope 字段:DefaultMode 的 handler scope 为 “default”,GoalMode 为 “goal”,MissionMode 为 “mission”。

过滤逻辑分两遍执行:第一遍找到第一个活跃的非 “default” 作用域;第二遍保留无作用域的 handler、匹配活跃作用域的 handler、以及(当无活跃作用域时)scope=“default” 的 handler。_registration_is_active 判定逻辑:如果注册对象有 is_active 回调,调用它;否则检查 handler 内是否有 LoopGate 处于活跃状态。

这意味着当用户激活 Goal 模式时,Default 模式的迭代/死循环门控被跳过,Goal 模式的专属门控(GoalTurnGate、GoalBudgetGate、RubricGate)接管控制。

11.2 模式系统

11.2.1 AgentMode 基类与四类贡献

模式(Mode)是将相关的命令、工具、钩子和提示词片段打包在一个开关下面的机制。AgentMode 基类定义了四个可重写的内容方法,每个返回一个列表:

  • commands():斜杠命令(CommandSpec),用户通过 /command 激活模式或执行操作
  • tools():工具描述符(ToolDescriptor),模式特定的工具(如 Goal 模式的 get_goal/update_goal
  • hooks():生命周期钩子(HookBase),模式特定的行为(如 Coding 模式的项目目录注入)
  • prompt_contributors():提示词片段(PromptContributor),注入到系统提示词中

setup(workspace) 方法在模式注册时立即执行,将所有贡献推入工作区的对应注册表。这个"注册即生效"的设计让模式可以在运行时动态加载。

模式还有两个生命周期回调:on_turn_start(ctx) 在每轮用户消息开始时触发(用于重置状态或热加载配置),on_conversation_reset(ctx)/new/clear 时触发(用于清理会话状态)。is_active(ctx) 是核心判定方法,返回当前模式是否处于活跃状态。

11.2.2 ModeGatedHook vs LifecycleHook

模式注册的钩子分两种基类,这个区分解决了"忘记加模式检查"的重复 bug:

HookBase(即 LifecycleHook)无条件执行,由 Runtime 按阶段和拓扑排序执行。适用于跨模式的通用逻辑(如会话加载/保存)。

ModeGatedHook 继承自 HookBase,但在 run() 方法中自动加入模式活跃检查:if not self.owner_mode.is_active(ctx): return HookResult()。子类实现 _run() 而非 run()。这个设计确保了模式特定的钩子在模式不活跃时自动跳过,开发者无需手动编写条件判断。

11.2.3 内置模式详解

DefaultMode 是始终活跃的回退模式。它的核心设计是配置热加载:通过 _make_config_key 将运行配置序列化为 JSON 字符串,只在配置变更时重建门控链。每轮 on_turn_start 时调用 reset_turn() 重置门控状态。_build_gates 根据配置中的 loop.iterationloop.doom_looploop.rubric 开关构建对应的门控列表。

CodingMode 不注册自己的 StopHandler/gates,而是通过三个贡献注入编码能力:ProjectDirInjectionHook(ModeGatedHook,在 PRE_AGENT_BUILD 阶段注入项目目录)、CodingModeMixin(为 ReActAgent 添加 LSP/AST 工具和 Inline Diff 功能)、CodingModeContributor(注入编码模式系统提示词)。is_active 检查 agent_config.coding_mode.enabled

GoalMode 实现了基于评分准则的目标驱动循环。它注册 4-5 个专属门控:GoalTurnGate(priority=10,跟踪跨请求的目标轮次——这是"外循环",不同于 IterationGate 的单请求"内循环")、GoalBudgetGate(priority=20,Token 预算)、RubricGate(priority=30,使用 GoalStatusRubric 检查 session.active 状态)。GoalSession 维护目标文本、迭代号、Token 使用量、上次评审结果等状态。用户通过 update_goal(status="complete") 标记完成,RubricGate 检测到后终止循环。

MissionMode 实现了两阶段任务循环:PRD 生成 → 执行确认 → 执行 → 完成。MissionGate(priority=50)通过读取 prd.json 检查所有 user stories 是否 passes=True。状态文件布局在 {workspace}/missions/{loop_id}/ 目录下,包含 loop_config.json(循环配置)、prd.json(产品需求文档)、progress.txt(进度跟踪)、task.md(当前任务)。两个钩子分别负责状态加载(MissionStateLoadHook,ModeGatedHook)和状态保存(MissionStateSaveHook,普通 HookBase 在 POST_RESPONSE 阶段执行)。

DeclarativeLoopMode 是最灵活的模式——它从用户保存的配置编译出完整的门控管线。每个自定义模式有唯一的 slash_command,用户通过该命令激活模式。LoopModeActivationStore 管理每工作空间按 session 的活跃模式 ID,CustomLoopController 提供共享的 /mode off 命令来退出自定义模式。

模式生命周期的完整对照:

回调 触发时机 DefaultMode GoalMode MissionMode DeclarativeLoopMode
setup(workspace) 注册时 注册 default handler 注册 goal handler + gates 注册 mission handler + gate 编译配置 + 注册 custom handler
on_turn_start(ctx) 每轮开始 热加载配置 + reset_turn reset_turn(活跃时) 恢复 gate 状态 reset_turn(活跃时)
on_conversation_reset /new、/clear reset_session 清除 session reset_session reset_session
is_active(ctx) 每次请求 始终 True session.active gate 活跃 activation_store 匹配

11.3 声明式循环编译

compile_loop_mode 函数将声明式配置(CustomLoopModeConfig)编译成可执行的 StopHandler,整个过程分五步原子完成:

  1. 参数验证:遍历所有门控配置(包括 enabled=False 的),调用 gate_catalog.validate_params(gate.type, gate.params) 验证参数类型和范围。这确保即使禁用的门控也有正确参数,启用时不会出错。
  2. 过滤启用门控:只保留 enabled=True 的门控实例。
  3. 互斥组验证validate_exclusive_groups 检查启用的门控是否声明了同一互斥组。当前唯一的互斥组是 "completion_rubric",由 QualitativeRubricGateCompletionRubricGate 共享——两者不能同时启用,因为它们都试图控制"何时算完成"。
  4. ConfiguredGate 包装:用 ConfiguredGate 包装每个门控,绑定用户定义的 instance_id(作为 name)和 order(按 index * 10 设置优先级,间隔 10 便于后续插入新门控)。ConfiguredGate 是纯委托适配器,将内置门控的 name 和 priority 覆盖为用户配置的值,同时保留底层门控的所有行为。
  5. 原子替换handler.replace(configured) 一次性替换所有门控,确保不会出现"半编译"状态。

GateCatalog 是 7 种内置门控的白名单注册表,每条目包含类型名、显示标题、类别(limits/safety/quality)、参数模型(Pydantic)、工厂函数和可选的互斥组。这个设计让新门控的添加只需在 catalog 中注册一条目,无需修改编译器代码。

一个自定义模式配置示例:

loop:
  custom_modes:
    - id: "safe_coder"
      name: "Safe Coder"
      description: "迭代限制 + doom loop + 完成检查"
      slash_command: "safecode"
      enabled: true
      gates:
        - id: "iter_cap"
          type: "iteration"
          enabled: true
          params:
            max_iterations: 30
        - id: "doom_guard"
          type: "doom_loop"
          enabled: true
          params:
            window_size: 3
            similarity_threshold: 0.9
        - id: "completion"
          type: "completion_rubric"
          enabled: true
          params:
            prompt: "所有测试通过且代码已格式化时才完成"
            completion_signal: "DONE"
            max_evaluations: 2

11.4 门控与推理循环的交互

门控系统最终要通过 ReActAgent 的推理循环发挥作用。完整的调用链路如下:

ReActAgent._reasoning()
  │
  ├─ 1. check_pending_gates(self)     ← 检查上一轮延迟的 TERMINATE
  │     └─ 如果有 pending_stop,输出终止文本并 return
  │
  ├─ 2. super()._reasoning()          ← 执行 LLM 推理,产出 final_msg
  │
  ├─ 3. stop_result = await self._run_stop_handlers(final_msg)
  │     └─ run_stop_handlers(handlers, agent=self, final_msg=final_msg, ...)
  │         ├─ _filter_by_scope(handlers)    ← 作用域过滤
  │         ├─ sorted(handlers, key=priority) ← 优先级排序
  │         └─ for reg in handlers: await reg.handler(ctx)
  │             └─ StopHandler.__call__(ctx)
  │                 └─ for gate in gates: await gate.check(ctx)
  │
  ├─ 4a. final_msg is None(工具调用轮次):
  │     └─ apply_stop_result(self, stop_result, is_tool_call=True)
  │         └─ 如果 TERMINATE,存入 agent._gate_pending_stop(延迟终止)
  │     └─ return
  │
  ├─ 4b. stop_result.action == INTERRUPT_AND_CONTINUE(文本轮次):
  │     └─ continuation = stop_result.continuation_message or "Continue working..."
  │     └─ self.state.context.append(Msg(role="user", content=[TextBlock(text=continuation)],
  │           metadata={QWENPAW_MESSAGE_TAG_KEY: LOOP_CONTINUATION_MESSAGE_TAG}))
  │     └─ return    ← 外循环继续,下一轮推理会看到注入的续行消息
  │
  └─ 4c. TERMINATE 或默认:
        └─ yield stop_result.final_message or final_msg

延迟终止机制是一个关键的工程细节。当门控在工具调用轮次返回 TERMINATE 时,不会立即终止——因为工具调用的结果还需要被处理。apply_stop_result 将 TERMINATE 结果存入 agent._gate_pending_stop,下一轮 check_pending_gates 检查到这个 pending 状态时才真正终止。这避免了"工具调用了但结果还没处理就被杀掉"的不一致状态。

续行消息标签系统通过 metadata 中的标签区分消息来源:LOOP_CONTINUATION_MESSAGE_TAG 标记一般续行消息,RUBRIC_EVALUATION_MESSAGE_TAG 标记 CompletionRubricGate 的评估请求。这些标签让上下文管理器(如 Scroll)能正确处理续行消息——例如在压缩时保留续行消息的上下文关联。

十二、频道系统:人如何到达 Agent

频道系统是 QwenPaw 连接外部世界的桥梁——它将钉钉、飞书、Discord、Telegram 等 18+ 个异构消息平台统一为一套标准协议,让同一个 Agent 能同时在所有平台上服务。这一章将深入剖析从平台原生消息到 Agent 响应的完整数据流。

12.1 核心数据结构:统一消息协议

频道系统的核心是 AgentRequestAgentResponse 两个统一数据模型。AgentRequest 包含 input(Message 列表)、session_iduser_idstream(是否流式)、metadata。Message 的 content 字段是一个多态列表,支持 7 种内容类型:TextContentImageContentAudioContentVideoContentFileContentDataContentRefusalContent,每种都带有 deltaindexstatus 等流式簿记字段。

ChannelMessageConverter Protocol 定义了频道必须实现的消息转换契约:build_agent_request_from_native(native_payload) 将平台原生消息转为 AgentRequest,send_response(to_handle, response, meta) 将 AgentResponse 发送回平台。ChannelAddress 数据类标识消息来源(kind: dm/channel/webhook/console, id, extra)。

12.2 BaseChannel:频道基类与消息处理流水线

BaseChannel 是所有频道的抽象基类(约 2200 行),它绑定了 ProcessHandler(Agent 请求处理函数)并管理完整的消息处理流水线。核心配置包括:streaming_enabled(是否支持流式回复)、dm_policy/group_policy(私聊/群聊策略:open/closed)、allow_from(访问控制白名单)、require_mention(群聊中是否需要 @ 机器人才响应)、access_control_dm/access_control_group(是否启用访问控制)。

12.2.1 去抖动缓冲机制

consume_one 是消息消费的入口,实现了两种去抖动机制:

时间去抖动_debounce_seconds > 0):当平台短时间内连续发送多条消息时(如用户快速发了多条短消息),系统会缓冲这些消息并在延迟后批量合并处理。机制实现上,每条原生消息按 get_debounce_key(通常为 session_id)分组缓冲到 _debounce_pending 字典,同时启动一个 asyncio.Task 延迟刷新。新消息到达时取消旧的定时器并重新计时——这确保了"等用户发完再处理"的效果。刷新时调用 merge_native_items 合并多条消息为一个 payload。

无文本去抖动_no_text_debounce=True):处理"媒体先到、文本后到"的场景。当收到一条没有文本内容的消息(如纯图片)时,内容被缓冲到 _pending_content_by_session,等待后续携带文本的消息到达后合并发送。音频消息绕过此机制立即处理,因为音频本身就是完整的输入。

12.2.2 请求处理流水线

_consume_one_request 方法是去抖动之后的处理核心,按五步流水线执行:

  1. 无文本去抖动检查:调用 _debounce_payload 决定是否继续缓冲
  2. 访问控制门_access_control_gate 检查发送者权限
  3. 控制命令检测:如果是 /slash 命令,走 CommandRegistry 路径而非 Agent 路径
  4. 消息转换_payload_to_requestbuild_agent_request_from_native 将原生 payload 转为 AgentRequest
  5. 运行处理循环_run_process_loop_consume_with_tracker(带 TaskTracker 的路径)

_run_process_loop 通过 self._process(request) 获取 Agent 的事件流(AsyncIterator[Event]),然后按事件类型分发:content 事件走流式钩子,message + Completed 事件走完成回调,response 事件记录最终响应。错误时走 _on_consume_error,成功时走 _on_process_completed

12.2.3 流式回复机制

streaming_enabled=True 时,频道可以实时推送 Agent 的生成过程,而非等待完整响应。流式机制通过三个可重写钩子实现:

  • on_streaming_start(request, to_handle, event, send_meta, stream_type):开始一个流式消息框
  • on_streaming_delta(request, to_handle, event, send_meta, stream_type, accumulated_text):增量更新消息内容
  • on_streaming_end(request, to_handle, event, send_meta, stream_type, accumulated_text):最终化消息

增量刷新采用非阻塞 fire-and-forget 模式,带有两个保护:前一次刷新仍在进行中时等待(超时 5 秒后取消),以及最小间隔控制(_STREAM_DELTA_MIN_INTERVAL_S)。当检测到新的 content index 时(如 Agent 从文本切换到代码块),先结束当前流式框再开始新的——这确保了不同类型内容不会混在同一个消息框中。

12.3 ChannelManager:队列与消费者

ChannelManager 管理所有频道的生命周期和消息路由。核心设计是线程安全入队 + asyncio 消费者的分离架构。

12.3.1 线程安全入队

外部平台的消息回调可能在任意线程(如 WebSocket 回调、轮询线程),需要安全地切换到 asyncio 事件循环。enqueue 方法通过 self._loop.call_soon_threadsafe(self._enqueue_one, channel_id, payload) 实现线程安全。_enqueue_one 执行优先级分类(通过 CommandRegistry.get_priority_level)和 session 提取,然后创建 _enqueue_with_timeout 任务(30 秒超时保护)。

12.3.2 UnifiedQueueManager:三元组队列

消息路由的核心是 UnifiedQueueManager,它使用 (channel_id, session_id, priority_level) 三元组作为队列键。这个设计实现了三个重要特性:

  • 按需创建消费者:无固定 worker 池,首个消息到达时才创建队列和消费者协程
  • 会话+优先级隔离:不同 session 或不同优先级的消息可并发处理,同一 session 内有序
  • 自动清理:默认 600 秒空闲后自动清理队列和消费者,避免资源泄漏
  • 有界入队:30 秒超时防止队列满时无限阻塞

12.3.3 批量合并处理

消费者循环在取出一条消息后,会尝试排空同一队列中的剩余消息(queue.get_nowait()),形成批量处理。_process_batch 根据消息类型选择合并策略:原生 payload 用 merge_native_items 合并,AgentRequest 用 merge_requests 合并。这进一步减少了 Agent 的调用次数——如果用户连发 3 条消息,可能只需一次 Agent 调用。

12.3.4 频道生命周期管理

频道支持运行时热替换:replace_channel 先启动新频道(锁外),然后在锁内交换并停止旧频道。restart_channel 加载最新配置后 clone 新实例并替换。stop_all 按依赖顺序停止:取消启动任务 → 取消入队任务 → 停止队列管理器 → 停止所有频道。

12.4 内置频道与懒加载

内置 18 个频道平台通过 _BUILTIN_SPECS 字典注册,每个映射为 (module_name, class_name) 元组。懒加载机制通过 importlib.import_module 按需加载,关键设计是容错隔离:单个可选依赖的 ImportError 不会中断 CLI 启动——只有 console 频道(_REQUIRED_CHANNEL_KEYS)是必须加载的,其他频道加载失败会被跳过。

进程级缓存(_BUILTIN_CHANNEL_CACHE)确保频道类只加载一次,使用 threading.Lock 保护缓存初始化。get_channel_registry 合并内置频道和插件注册的频道,但插件不能覆盖内置频道——同名时内置优先。

12.5 具体频道实现示例

不同平台的消息格式和 API 能力差异巨大,以下是三个典型实现:

DingTalk(钉钉)频道(3700+ 行):流式回复通过 AI Card 实现。on_streaming_start 创建或复用 AI Card,显示前缀(💭 用于 reasoning 类型)。on_streaming_delta 调用 _stream_ai_card(card, display_text, finalize=False) 增量更新卡片内容。on_streaming_end 最终化卡片并单独投递媒体部分。消息转换时,session_webhook 等 meta 字段被特殊保留,用于回复消息时的路由。

Telegram 频道(1590+ 行):流式回复通过 placeholder 消息 + 编辑实现。on_streaming_start 发送一条占位消息并记录 message_id。on_streaming_delta 调用 _edit_stream_message 编辑消息内容(超过 4096 字符只显示尾部)。on_streaming_end 将最终文本转为 Telegram HTML 格式编辑到消息中;如果超过分块大小(TELEGRAM_SEND_CHUNK_SIZE),则删除占位消息并分块重新发送。

Discord 频道(1057+ 行):消息转换较为直接,使用 session_id 作为发送目标。build_agent_request_from_native 从 meta 中提取 user_id,通过 resolve_session_id 生成会话标识。

12.6 消息转换:入向与出向

入向转换(平台原生 → AgentRequest):每个频道实现 build_agent_request_from_native,将平台原生 payload 解析为 content_parts(使用运行时 Content 类型)和 session_id,然后调用 build_agent_request_from_user_content 构建标准 AgentRequest。如果 content_parts 为空,默认填入一个空白文本——确保 Agent 总能收到输入。

出向转换(AgentResponse → 平台格式):_response_to_text 从 response.output 中逆序查找最后一个 MESSAGE 类型的消息,提取 TextContent 和 RefusalContent。send_content_parts 将文本部分合并为一条消息发送,媒体部分作为 URL 附加——先发文本后发媒体,确保文本总是先被用户看到。

12.7 访问控制

频道支持细粒度的访问控制,分私聊和群聊两个维度:

  • allow_from 白名单限制谁可以与 Agent 交互
  • access_control_dm/access_control_group 分别控制私聊和群聊是否启用访问控制
  • dm_policy/group_policy 控制整体策略(open 允许所有,closed 拒绝所有)
  • 优先使用 acl_sender_id(真实发送者 ID)进行检查,而非 session 级别的发送者——这避免了共享会话场景下的权限混淆

十三、多 Agent 协调

QwenPaw 的多 Agent 协调体系分为内部协调(同一安装内的 Agent 间通信)和外部协调(通过 ACP 协议与第三方 Agent 框架交互)两个层面。内部协调让多个专业 Agent 分工合作,外部协调让 QwenPaw 能编排 Codex、Qoder 等外部工具。

13.1 内部协调:chat_with_agent

chat_with_agent 工具允许一个 QwenPaw Agent 向同一安装中的另一个 Agent 发送消息并等待响应。它是一个 @tool_descriptor(async_execution=True, tool_type="internal", policy_name="ChatWithAgent") 标注的异步工具。

执行流程分四步:首先验证目标 Agent 是否存在(agent_exists)。然后从当前上下文获取 root_session_id——这个 ID 用于跨会话审批路由,确保子 Agent 的审批请求能正确路由到父 Agent 的会话。接着通过 build_agent_chat_request 构建请求,该函数会添加身份前缀 [Agent {caller_agent_id} requesting] 并设置 request_context.root_agent_id。最后使用 cancellable_wait 等待 SSE 响应流——支持取消和超时(默认 300 秒),成功响应包含 [SESSION: ...] 头便于后续复用会话。

除了同步等待的 chat_with_agent,还有 submit_to_agent(后台任务版本,返回 [TASK_ID: ...] 供后续查询)和 check_agent_task(检查后台任务状态)两个配套工具。list_agents 工具列出所有已配置的 Agent,让 LLM 知道可以与谁协作。

13.2 spawn_subagent:临时子 Agent 生成

spawn_subagent 是更灵活的协调机制——它在当前工作空间内生成临时子 Agent,共享相同的 Agent 身份和工作空间,但启动全新的会话上下文(不继承父 Agent 的对话历史)。

核心参数包括:task(任务描述)、fork(是否创建 git worktree 隔离工作目录)、background(是否后台运行)、timeout(超时秒数,默认 600)、allowed_tools(工具白名单)、skills(技能过滤器)、batch(批量任务列表)。

会话隔离:子 Agent 获得形如 sub-{uuid4前8位} 的独立会话 ID。_build_subagent_request_context 注入审批路由元数据(root_session_id 等继承自父 Agent)和工具/技能过滤器。

Fork 模式:当 fork=True 时,调用 /api/fork/agent 创建 git worktree,注入 fork_project_dirACP_CODING_PROJECT_META_KEY——这让子 Agent 在隔离的代码副本上工作,修改不影响父 Agent 的项目。

批量模式batch 参数接受任务列表,_spawn_batch 并行分发。最多 10 个任务,并发上限 3(asyncio.Semaphore(MAX_SPAWN_BATCH_CONCURRENCY)),避免资源耗尽。

13.3 子 Agent 工具白名单

subagent_allowed_tools 机制确保子 Agent 的能力可以被精确控制。过滤逻辑在 AgentBuilder.apply_subagent_tool_whitelist 中实现,规则简单而严格:

  • None 或非列表值:继承所有工具(不过滤)
  • 空列表 []:拒绝所有工具——包括记忆工具和后续注入的工具
  • 非空列表:只保留名称匹配的工具

白名单过滤在 QwenPawAgent.__init__ 中调用,时机在内存工具注册之后(self._apply_subagent_tool_whitelist(toolkit))。这个"最后一道防线"设计确保即使是后续注入的工具也受白名单约束——注释中明确说明"subagent_allowed_tools=[] truly denies every tool (including memory / future post-toolkit injections)"。

13.4 ACP:外部 Agent 通信协议

ACP(Agent Communication Protocol)让 QwenPaw 能编排外部 Agent 进程(如 Codex CLI、Qoder),将其工作流式返回为工具结果。

13.4.1 ACPHostedClient:权限与会话管理

ACPHostedClient 是 ACP 的核心客户端,处理权限请求和会话更新。它维护几个关键状态:_session_acc(SessionAccumulator,累积工具调用状态)、_assistant_text(累积的助手文本)、_emitted_assistant_text(已发送的文本,用于计算增量)、_pending_permission(挂起的权限请求)。

权限处理分两条路径。对于可信 Agent(trusted=True),硬阻断规则(is_hard_blocked)仍然拦截——通过 is_command_destructive 检查命令是否危险,通过 is_path_outside_boundary 检查路径是否越界。非硬阻断的请求自动批准,从选项中选择最宽松的 allow 选项(优先级顺序:allow_once > allow_always > allow > yes > approve)。对于不可信 Agent,发送权限请求事件并挂起等待用户响应(await self._permission_future)。

会话更新处理支持多种 ACP 更新类型:AgentMessageChunk 累积助手文本(先刷新思考模式,再累积内容);AgentThoughtChunk 激活思考模式;ToolCallStart/ToolCallProgress 更新工具调用状态快照并发射事件。CurrentModeUpdateAgentPlanUpdateAvailableCommandsUpdate 等状态更新也会被处理。

智能合并机制_merge_assistant_text)解决了一个微妙的问题:ACP 协议可能发送完整文本而非增量,多次发送时需要合并。合并逻辑检查三种情况:新文本是旧文本的超集(前缀匹配)→ 替换;尾部有重叠 → 拼接非重叠部分;无重叠 → 直接追加。增量发送只发送 _assistant_text 中超出 _emitted_assistant_text 的部分。

13.4.2 ACPService:会话生命周期

ACPService 管理外部 Agent 的会话。会话以 (chat_id, agent) 二元组为键,每个会话有独立的 turn_lock 防止并发 turn。会话创建流程:创建 ACPHostedClientspawn_agent_process 启动子进程 → conn.initialize 协议握手 → conn.new_session(cwd=cwd) 创建会话。

run_turn 方法使用 asyncio.wait 同时等待 prompt 任务和权限请求,返回 "completed""permission_required" 状态。这种"同时等待两个事件"的设计确保 Agent 的输出流和用户的权限响应能并行处理。

13.4.3 ACP 事件渲染

tool_adapter.py 将 ACP 事件渲染为 ToolChunk 返回给 LLM:render_event_text 根据事件类型(text/tool_start/tool_end/status/permission_request/error)渲染文本。format_permission_suspended_response 格式化权限请求,包含 Agent 名称、工具名、操作类型、涉及文件、命令、摘要和选项列表——让 LLM 能理解权限请求的完整上下文并做出决策。

13.5 Harness 适配器

Harness 是第三方 Agent 适配器的通用契约,将 Codex、Qoder 等第三方 Agent 框架的输出标准化为 QwenPaw 协议。

HarnessRuntime 管理适配器生命周期,核心方法 stream() 运行一个 harness turn 并发射 QwenPaw 协议事件。它先将 AgentRequest.input 提取为 prompt 和附件(图片/文件/音频/视频,路径转为 HarnessAttachment),然后调用 adapter.run_turn()adapter.run_command() 获取事件流。事件流中的 HarnessEvent(TEXT_DELTA、REASONING_DELTA、TOOL_STARTED、TOOL_PROGRESS、TOOL_COMPLETED 等)被转换为 QwenPaw 的 AgentResponse 事件序列:先发射 Created 响应,再发射 InProgress,然后逐个发射文本增量、推理增量、工具事件,最后发射 Completed。

HarnessSessionBridge 将第三方 Agent 的 turn 物化为 QwenPaw 会话格式。append_turn 原子地追加用户请求和规范化输出到会话上下文——_response_messagesAgentResponse.output 中的 Message 转换为上下文块(MESSAGE→text、REASONING→thinking、PLUGIN_CALL→tool_call、PLUGIN_CALL_OUTPUT→tool_result)。hydrate 方法在 QwenPaw 无上下文时从 provider 历史恢复——但已有 QwenPaw 上下文时不覆盖,避免冲突。

HarnessCapabilities 定义了 18 项能力标志(authentication、model_selection、reasoning_stream、tool_stream、session_resume、attachments 等),让 QwenPaw 能根据适配器的能力动态调整行为——例如不支持 reasoning_stream 的适配器不会收到推理增量事件。

HarnessEvent 事件类型枚举包括:TEXT_DELTA(文本增量)、REASONING_DELTA(推理增量)、TOOL_STARTED/TOOL_PROGRESS/TOOL_COMPLETED(工具生命周期)、COMPLETED/CANCELLED/ERROR(终止事件)。这种统一的事件模型让不同框架的输出能被一致地处理。

13.6 多 Agent 协调的完整数据流

父 Agent (LLM 决策调用工具)
  ↓
chat_with_agent / spawn_subagent / submit_to_agent
  ↓
build_agent_chat_request (添加身份前缀 + request_context)
  ↓
HTTP POST /api/console/chat (SSE 流)
  ↓
QwenPaw 服务端:
  ├─ 内部 Agent 路径: AgentBuilder 构建 → _apply_subagent_tool_whitelist → 推理循环
  ├─ ACP 路径: ACPService.run_turn → ACPHostedClient
  │    ├─ session_update (AgentMessageChunk/ToolCallStart 等)
  │    ├─ request_permission (trusted 自动批准 / 非 trusted 挂起等待)
  │    └─ _merge_assistant_text (智能合并重叠文本)
  └─ Harness 路径: HarnessRuntime.stream → HarnessAdapter.run_turn
       ├─ HarnessEvent 转换为 QwenPaw Event
       └─ HarnessSessionBridge.append_turn (持久化)
  ↓
SSE 响应流回父 Agent
  ↓
collect_final_agent_chat_response_async (解析最后非 turn_usage 事件)
  ↓
format_agent_chat_text → ToolChunk 返回给 LLM

十四、定时任务与心跳机制

QwenPaw 的定时任务系统让 Agent 能在无人值守的情况下自动执行任务——每天早上发送新闻摘要、定期整理记忆、按 cron 表达式执行复杂工作流。这个系统的核心是 APScheduler 调度器、CronManager 管理器和 CronExecutor 执行器三层架构。

14.1 CronManager:调度管理器

CronManager 继承自 ManagerBase,使用 APScheduler 的 AsyncIOScheduler 管理定时任务。初始化时创建四个关键数据结构:_statesDict[str, CronJobState],每任务的当前状态)、_historyDict[str, list[CronExecutionRecord]],每任务的执行历史,上限 50 条)、_rtDict[str, _Runtime],每任务的并发信号量)、_lock(全局操作锁)。

_Runtime 是一个简单的数据类,只包含一个 asyncio.Semaphore——它实现了每任务的最大并发控制,防止长时间运行的任务堆积。

14.1.1 启动流程

start() 方法分五个阶段完成初始化:

  1. 加载任务文件await self._repo.load() 从 JSON 仓库加载所有任务定义,prune_orphan_history 清理无效 job ID 的历史记录
  2. 注册调度器监听器并启动:监听 EVENT_JOB_MISSED(任务错过调度时间)和 EVENT_JOB_MAX_INSTANCES(达到最大并发实例数)两种事件
  3. 逐个注册任务:遍历所有 job 调用 _register_or_update,无效任务自动禁用(enabled=False)并持久化
  4. 调度心跳任务:当 heartbeat.enabled=True 时注册心跳 job(ID 为 _heartbeat),misfire grace 为 60 秒
  5. 调度 Dream 任务:当 dream_cron 配置存在时注册 Dream job(ID 为 _dream),misfire grace 为 600 秒
  6. 启动 keepalive 循环asyncio.create_task(self._keepalive_loop())

内部 Job ID 使用下划线前缀(_heartbeat_dream)与用户 job 区分。在调度器事件监听中,内部 Job 的事件被忽略——心跳和 Dream 有自己的错误处理逻辑。

14.1.2 Keepalive 循环:WSL2 兼容性

Keepalive 循环解决了一个实际的工程问题(issue #6471):在 WSL2 等平台上,APScheduler 的 AsyncIOScheduler 通过 loop.call_later 唤醒处理到期任务,但空闲的事件循环中长延迟的 call_later 不能可靠地被唤醒——导致 cron 任务在下一个 HTTP 请求到来前误触发或不触发。

解决方案是一个自包含的 asyncio 任务,每 60 秒执行一次 asyncio.sleep,保持事件循环持续 ticking。这个"笨办法"虽然不优雅,但确保了跨平台的可靠性。

14.1.3 任务注册与触发器构建

_register_or_update 方法负责将一个 CronJobSpec 注册到调度器,流程为:验证并构建触发器 → 创建并发信号量 → 移除已存在的同 ID 任务 → 添加新任务 → 如果未启用则暂停 → 更新下次运行时间状态。

_build_trigger 支持三种触发器类型:

  • cron 类型:强制 5 字段(分时日月周,不支持秒),使用 APScheduler 的 CronTrigger
  • once 类型(无重复):使用 DateTrigger,在指定时间执行一次
  • once 类型(有重复):使用 IntervalTrigger,按 repeat_every_days 天间隔重复,支持 repeat_end_type 控制结束条件

14.2 CronExecutor:任务执行引擎

CronExecutor 负责单次任务的实际执行,支持两种任务类型:

文本任务task_type="text"):直接调用 channel_manager.send_text() 将固定文本发送到指定渠道。简单但实用——如定时提醒、每日问候。

Agent 任务task_type="agent"):构建 AgentRequest,设置 request_context 中的 source="cron"approval_level。支持两种执行模式:stream 模式实时转发每个事件到渠道(用户能看到 Agent 的思考过程),final 模式消费完整流后仅投递最后一条消息(更简洁)。还有 silent 模式——消费流但不投递到渠道,仅保留会话和 trace 状态(用于后台知识整理等不需要输出的任务)。

会话隔离:根据 share_session 决定会话 ID。共享模式下使用 cron:{job.id} 作为会话 ID(所有运行累积在同一会话中);隔离模式下使用 {target_session_id}:cron:{job.id} 并设置 session_source="cron"。这决定了定时任务的结果是否出现在用户的交互式会话中。

工具安全级别tool_safety=True 时设置 approval_level=AUTO(有风险工具需审批,可能阻塞无人值守执行),tool_safety=False 时设置 approval_level=OFF(所有工具无需审批)。无人值守场景通常选择后者,但需要配合沙箱使用。

投递失败回退:当 Agent 任务执行成功但消息投递失败时(如渠道离线),结果会保存到 Inbox——用户下次上线时能看到。save_result_to_inbox 的默认值遵循产品规则:text + cron 组合默认 OFF(定时文本通知不需要存 Inbox),其他组合默认 ON。

14.3 数据模型

定时系统的数据模型层次清晰:

CronJobSpec 是完整的任务定义,包含 id、name、enabled、schedule(调度配置)、task_type(text/agent)、text/request(任务内容)、dispatch(投递配置)、runtime(运行时配置)、save_result_to_inbox。

ScheduleSpec 支持两种调度类型:cron(cron 表达式,5 字段)和 once(一次性任务,支持按天重复)。DispatchSpec 配置投递目标(channel、target、mode=stream/final/silent)。JobRuntimeSpec 配置运行时参数:max_concurrency(最大并发数)、timeout_secondsmisfire_grace_secondsshare_sessiontool_safety

CronJobState 记录任务的实时状态:next_run_at(下次运行时间)、last_run_at(上次运行时间)、last_status(success/error/running/skipped/cancelled)、last_errorCronExecutionRecord 记录每次执行的历史:run_atstatuserrortrigger(scheduled/manual)。

14.4 心跳机制

心跳机制让 Agent 能定期"主动思考"——读取 HEARTBEAT.md 文件作为提示词,运行 Agent,可选将结果发送到最后一个活跃频道。

配置模型HeartbeatConfig 包含 enabled(是否启用)、every(间隔字符串如 “30m” 或 cron 表达式)、target(投递目标:last/inbox/main)、timeout_seconds(超时,默认值有上下限约束)、active_hours(活跃时段,如 08:00-22:00)。

间隔解析parse_heartbeat_every 支持 “30m”(30 分钟)、“1h”(1 小时)、“2h30m”(2 小时 30 分)、“90s”(90 秒)等格式。is_cron_expression 判断是否为 5 字段 cron 表达式——前 4 字段仅接受数字 cron 字符,第 5 字段(周)额外接受三字母英文缩写(mon-sun)。

活跃时段检查_in_active_hours 在用户时区检查当前时间是否在 [start, end] 范围内。支持跨午夜时段(如 22:00-06:00)——当 start > end 时使用 now >= start or now <= end 判断。未配置 active_hours 时总是活跃。

执行流程run_heartbeat_once):首先检查活跃时段,非活跃时段直接跳过。然后读取 HEARTBEAT.md 文件(使用 read_text_file_with_encoding_fallback 处理编码问题),空文件跳过。接着构建请求,使用隔离的记忆上下文(session_id="main", user_id="main")——确保自动化不污染交互式历史。根据 target 投递结果:last 模式流式转发到最后交互渠道,inbox 模式运行后提取增量消息预览发送到 Inbox,main 模式仅运行不投递。所有模式都有超时保护(asyncio.wait_for)。

14.5 Dream 记忆优化

Dream 是一个后台 Agent 过程,定期整理记忆文件——类似人类睡眠时的记忆巩固,将短期记忆(每日笔记)中有价值的内容整理为长期记忆(MEMORY.md)。

配置dream_cron 默认为 "0 23 * * *"(每天 23:00 执行),dream_cron_enabled 默认为 True。Dream 的 misfire grace 为 600 秒(10 分钟),比心跳的 60 秒更长——因为 Dream 是资源密集型任务,允许更大的调度延迟容忍。

抖动机制_dream_callback 在执行前引入 0-60 秒的随机延迟(random.randint(0, DREAM_JITTER_MAX_SECONDS))。这避免了一个多实例部署中所有 Agent 同时执行 Dream 导致的资源峰值。

执行链路CronManager._dream_callback() → 随机延迟 → workspace.memory_manager.dream()ReMeLightMemoryManager.dream() 调用 _run_reme_job("auto_dream", needs_llm=True),这个方法先同步当前 LLM 模型到 ReMe 框架(_update_qwenpaw_model),然后委托给 ReMe 的 run_job 执行。Dream Agent 是一个带文件编辑工具的轻量级 ReAct Agent,它审视记忆文件,整合冗余或过时的条目。执行结果会推送到 Inbox(INBOX_RESULT_JOB_NAMES 包含 “auto_dream”)。

14.6 调度器事件监听

CronManager 注册了两种 APScheduler 事件监听器:

  • EVENT_JOB_MISSED:任务错过调度时间(如系统休眠后恢复)。记录延迟秒数和 grace 时间,帮助诊断调度问题。内部 Job(心跳和 Dream)的事件被忽略。
  • EVENT_JOB_MAX_INSTANCES:达到最大并发实例数(前一次执行还未完成,下一次调度已到)。跳过的调度被记录,让用户知道任务被跳过了。

这些事件通过 asyncio.create_task 异步处理,避免阻塞调度器的主循环。

十五、安全组件:Tool Guard / File Guard / Skill Scanner

除了运行时的治理策略评估(Phase 0-3),QwenPaw 还有三个独立的安全组件,构成纵深防御的额外层。这三个组件与治理系统的关系是"独立但协作"——治理系统在工具执行前的策略评估阶段运行,而安全组件在更细的粒度上提供额外检查。

15.1 纵深防御架构

QwenPaw 的安全防御分为四个层次,每层解决不同维度的问题:

防御层 时机 组件 检查内容
第一层 安装时 Skill Scanner 恶意代码模式扫描
第二层 工具执行前 治理策略评估 (Phase 0-3) 用户规则、审批级别、敏感路径
第三层 工具执行前 Tool Guard 引擎 命令分类、路径检测、规则匹配、Shell 逃逸
第四层 文件操作时 File Guard 敏感文件路径阻断

这种分层设计确保了即使某一层被绕过或配置错误,其他层仍能提供保护。例如,即使用户在治理层配置了 allow 规则放行某个命令,Tool Guard 的 SharedSafetyToolGuardian 仍会拦截灾难性命令。

15.2 Tool Guard 引擎

ToolGuardEngine 是编排器,通过 get_guard_engine() 懒加载单例。它编排四个 Guardian,按顺序执行:

  1. SharedSafetyToolGuardianalways_run=True):调用 classify_destructive_command(command, cwd=workspace_root) 分类命令。catastrophic(灾难性,如递归删除系统根)→ CRITICAL 自动拒绝;system_power(系统电源操作,如关机重启)→ CRITICAL 需审批。这个 Guardian 是"不可关闭"的——即使其他 Guardian 被禁用,它仍然运行,确保最基本的安全底线。

  2. FilePathToolGuardianalways_run=True):敏感文件路径检测。从 config.security.file_guard.sensitive_files 加载敏感文件列表,兼容三个密钥目录。路径规范化支持 POSIX 和 Windows(Windows 路径用 ntpath 规范化,小写 + 正斜杠,NTFS 大小写不敏感)。检查方式因工具类型而异:Shell 命令用 shlex.split 分词(Windows 用 posix=False),提取重定向目标和路径 token;已知文件工具检查特定参数名;其他工具扫描所有字符串参数中看起来像路径的值。

  3. RuleBasedToolGuardian:YAML 正则规则匹配。对 rm 命令特殊处理——提取目标路径检查是否在工作区外。规则从 YAML 文件加载,支持自定义扩展。

  4. ShellEvasionGuardian:Shell 混淆/逃逸检测。与治理层 Phase 1 的 detect_shell_evasion 共享相同的 7 项检查和 _QuoteState 状态机。检测技术包括:引号嵌套逃逸、变量替换逃逸、反引号命令替换、$() 命令替换、转义字符混淆、Base64 编码管道、分号/管道符注入。

纯函数提取设计:Governance 层的 detectors.py 是从这些 Guardian 中提取的纯函数版本,在策略评估的 Phase 1 中调用。这种"从 Guardian 提取纯函数"的设计避免了在策略评估路径中实例化完整的 Guardian 对象,提高了性能——策略评估每轮推理都会执行,而 Tool Guard 只在实际工具调用时执行。

配置控制_guard_enabled() 优先级为 QWENPAW_TOOL_GUARD_ENABLED env > config.json > 默认 True。is_guarded(tool_name)_guarded_tools 为 None 时守卫所有工具。这意味着可以通过环境变量快速关闭 Tool Guard(用于调试),但默认行为是开启的。

15.3 File Guard

File Guard 独立于 Tool Guard,阻止对敏感文件的访问。它作为文件操作工具的最后一道屏障运行。

敏感文件集从 config.jsonsecurity.file_guard.sensitive_files 加载,默认包含 .qwenpaw.secret 等密钥目录。_is_sensitive(abs_path) 检查使用两种匹配方式:精确匹配 _sensitive_files 或前缀匹配 _sensitive_dirs。前缀匹配使用段边界匹配——abs_path == trimmedstartswith(trimmed + "/")。这确保 /home/user/.ssh 匹配但 /home/user/.ssh_config 不误匹配,避免了过度阻断。

15.4 Skill Scanner

与前两者不同——它是安装时扫描而非运行时检查。这意味着恶意技能在安装阶段就被拦截,永远不会出现在工作区中。SkillScanner 递归遍历技能目录,安全措施包括:跳过符号链接(防止路径遍历攻击——符号链接可能指向 /etc/passwd 等系统文件)、resolve(strict=True) 后验证 is_relative_to(skill_dir)(确保所有文件都在技能目录内)、限制文件数量(默认 500)和大小(默认 10MB,防止超大文件 DoS)、跳过图片/字体/压缩包/二进制等扩展名(聚焦代码文件)。

PatternAnalyzerrules/signatures/*.yaml 加载签名规则,两遍扫描(逐行快速匹配 + 多行跨行模式匹配),覆盖 8 个威胁类别:

签名文件 威胁类别 检测内容
command_injection.yaml 命令注入 eval/exec/import/compile
data_exfiltration.yaml 数据外泄 网络上传、文件外发
hardcoded_secrets.yaml 硬编码密钥 API Key、密码、token
obfuscation.yaml 混淆 base64、编码绕过
prompt_injection.yaml 提示词注入 系统提示词覆盖
social_engineering.yaml 社会工程 钓鱼、欺骗
supply_chain.yaml 供应链 恶意依赖
unauthorized_tool_use.yaml 未授权工具使用 非法工具调用

每条规则支持 exclude_patterns(排除安全模式——如测试代码中的 eval 调用)、file_types(限定文件类型——如只在 .py 文件中检测)、known_test_values(过滤已知测试凭证——如 sk-test-123)。Severity override 允许策略配置覆盖规则的默认严重程度。去重按 rule_id:file_path:line_number 三元组,避免同一发现被多次报告。

安装时调用链store.scan_skill_dir_or_raise(skill_dir, skill_name)scan_skill_directory()SkillScanner.scan_skill() → 若不安全抛出 SkillScanError。在 SkillService.create_skill()enable_skill() 中,扫描在 staged 目录上执行,通过后才复制到目标位置——确保不安全的技能永远不会出现在工作区中。这个"先扫描后安装"的设计比"安装后扫描"更安全,因为恶意代码永远不会到达可执行的位置。

十六、插件架构

QwenPaw 的插件架构是一个完整的扩展生态系统——从插件清单格式、加载/卸载流程、工具所有权管理到插件市场,每一层都有精细的设计。这个架构让第三方开发者能用最小的成本扩展 Agent 的能力,同时确保安全性和隔离性。

16.1 插件清单格式

插件清单使用 Pydantic BaseModel 定义(PluginManifest),是每个插件的唯一身份标识。核心字段包括:id(唯一标识,非空)、version(语义版本,非空)、name/description/author(显示信息,支持国际化)、entry(入口点配置,包含 frontendbackend 两个可选路径)、dependencies(Python 依赖列表)、qwenpaw_version(版本约束)、meta(元数据字典,用于类型推断和插件特定配置)、plugin_type(插件类型枚举)。

国际化支持通过 _coerce_manifest_str 函数实现。name/description/author 字段既可以是普通字符串,也可以是 {"zh-CN": ..., "en-US": ...} 映射。解析优先级为 en-US > en > zh-CN > zh > "",确保国际化插件在不同语言环境下都有合理的显示名称。

版本约束使用 QwenPawVersionConstraint(左闭右开 >=min, <max)。当 max 省略时,允许范围为同一 minor 的所有 patch 版本(从 min 推导为 {major}.{minor+1}.0)。这个设计平衡了兼容性(允许 patch 升级)和安全性(防止 minor 升级引入的不兼容)。

遗留格式处理通过 _normalise_input model_validator 在字段验证前执行,处理三种遗留格式:国际化文本映射自动转为显示字符串;顶层 entry_point 自动合并到 entry.backend;缺失或无效的 typemeta 字段推断。这确保了旧格式插件在新版本中仍能正常加载。

16.2 八种插件类型

类型 说明 注册方法
TOOL 注册 Agent 工具(LLM 可调用的函数) register_tool
PROVIDER 注册自定义 LLM Provider/模型端点 register_provider
HOOK 启动/关闭时运行代码 register_startup_hook / register_shutdown_hook
COMMAND /slash 控制命令 register_control_command
CHANNEL 自定义消息渠道 register_channel
FRONTEND 前端 JS bundle,UI 动态加载 清单声明
APP PawApp(后端路由+UI页面) 完整应用
GENERAL 不匹配任何特定类别的回退 -

当 manifest 无显式 type 时,_infer_type_from_metameta 字段推断类型:meta.toolsmeta.tool_name → TOOL,meta.chat_modelmeta.provider_id → PROVIDER,meta.hook_type → HOOK,meta.command_namemeta.commands → COMMAND,meta.channel → CHANNEL,entry.frontend 存在 → FRONTEND,以上都不匹配 → GENERAL。

除了以上类型对应的注册方法,PluginApi 还提供:register_middleware(请求中间件)、register_http_router(HTTP REST 端点)、register_mode(Agent 模式)、register_runtime_hook(运行时阶段钩子)、register_agent_stop_handler(停止事件处理器)、register_prompt_section(系统提示词段落)、register_skill_provider(技能提供者)、register_workspace_created_hook(工作区创建钩子)、register_uninstall_hook(卸载钩子)、register_slash_command(工作空间级斜杠命令)。

16.3 PluginRegistry:中央注册表

PluginRegistry 是单例模式(__new__ + _initialized 标志),管理所有插件贡献的注册。它维护以下注册数据结构:

  • _providers:LLM Provider 注册(ProviderRegistration,包含 provider_id、provider_class、base_url 等)
  • _startup_hooks / _shutdown_hooks / _uninstall_hooks / _workspace_created_hooks:生命周期钩子列表(HookRegistration,包含 plugin_id、hook_name、callback、priority)
  • _control_commands:控制命令列表(ControlCommandRegistration,包含 handler、priority_level)
  • _channels:频道注册(ChannelRegistration,包含 channel_key、channel_class、config_fields)
  • _middleware_registrations:中间件注册(MiddlewareRegistration,包含 factory、priority)
  • _http_router_registrations + _http_prefix_to_plugin:HTTP 路由注册和前缀到插件的映射
  • _prompt_sections + _prompt_section_names:提示词段落注册和名称去重
  • _plugin_manifests:所有插件的清单字典

HTTP 路由挂载是注册表中最巧妙的设计。_mount_plugin_http_on_app 方法将插件路由插入到 SPA catch-all 路由之前:先找到 SPA catch-all 路由的索引(_find_console_spa_route_index),然后通过 app.include_router 添加路由,从末尾移除新添加的路由,最后将它们插入到 SPA 路由之前。这个"插入到 catch-all 之前"的设计至关重要——如果不这样做,SPA 的 /{full_path:path} catch-all 路由会拦截所有请求,插件路由永远无法到达。挂载后还会使缓存的 OpenAPI schema 失效(app.openapi_schema = None),确保 API 文档反映最新的路由状态。

插件卸载unregister_plugin)按相反顺序清理所有注册:移除 HTTP 路由 → 移除频道 → 释放工具所有权 → 移除清单 → 移除所有 Provider → 过滤所有 hook 列表 → 移除控制命令 → 移除中间件 → 移除提示词段落。这种"全面清扫"确保卸载后不会有残留的注册项影响系统行为。

16.4 PluginApi:开发者接口

PluginApi 是插件开发者的唯一接口,每个方法都处理了注册的完整样板。

16.4.1 register_tool:工具注册的完整流程

工具注册是最复杂的方法,因为它涉及多个系统的同步。注册通过 _startup_register 延迟到启动钩子执行(priority=50,在 Agent 上下文初始化后):

  1. 所有权声明_claim_tool_ownership):在全局 _TOOL_PLUGIN_OWNERS 映射中记录工具名到插件 ID 的归属。如果工具名已被其他插件声明,抛出 GovernanceRegistrationConflict。这通过 threading.Lock 保护,确保多线程安全。
  2. 治理白名单同步_register_to_governance):将工具注册到治理层的 DEFAULT_REGISTRY。这一步修复了 issue #6114——之前插件注册的工具在 Phase 0 被拒绝,因为 register_tool 从未同步治理注册表。
  3. 模块注入:将工具函数添加到 qwenpaw.agents.tools 模块,并追加到 tools.__all__。这让工具出现在工具列表中。
  4. 运行时桥接_bridge_to_runtime):为工具创建 ToolDescriptor(如果不存在),然后注入到所有工作空间的 ToolRegistry——先 unregister 旧的再 register 新的,支持热重载。同时更新 _bootstrap_kwargs 中的 builtin_tool_funcs,移除同名的旧函数并添加新的。
  5. 配置写入:在当前 Agent 配置中创建 BuiltinToolConfig 条目(默认禁用)。

如果任何步骤失败,执行回滚操作——移除模块属性、取消运行时桥接、释放工具所有权和治理注册。这种"全有或全无"的事务性设计确保不会出现部分注册的不一致状态。

16.4.2 工具所有权管理

_TOOL_PLUGIN_OWNERS 是全局的 Dict[str, str] 映射(tool_name → owning plugin_id),通过 threading.Lock 保护。它确保不同插件不能重复注册同名工具——重复注册抛出 GovernanceRegistrationConflict,错误消息明确指出冲突的两方。

release_tool_ownership_for_plugin 在插件卸载时调用,释放该插件的所有工具所有权,并从治理注册表中移除对应条目(DEFAULT_REGISTRY.unregister_owner(plugin_id))。这确保卸载的插件不会在系统中留下"幽灵工具"。

16.4.3 其他注册方法

register_provider:注册自定义 LLM Provider。Provider 类需要实现 QwenPaw 的 Provider 接口,注册后出现在模型设置页面的 Provider 列表中。

register_startup_hook:注册启动钩子。priority 越小越早执行(默认 100)。启动钩子在 Agent 启动时执行,用于初始化资源、注册工具等。

register_http_router:在 /api + prefix 下暴露 REST 端点。验证 prefix 唯一性后调用 _mount_plugin_http_on_app 挂载。

register_middleware:注册 AgentScope MiddlewareBase 工厂。工厂在每个请求的 Agent 组装时调用一次:factory(ctx, agent_config) -> MiddlewareBase | None。返回 None 表示跳过此请求的中间件。优先级控制排序(越小越在外层洋葱模型)。

16.5 插件加载与卸载流程

加载流程通过 PluginLoader 管理,从发现到注册分多个阶段:

  1. 发现discover_plugins):扫描插件目录,跳过 .disabled 后缀和隐藏目录,加载每个 plugin.json
  2. 生命周期锁plugin_lifecycle):序列化单个插件的 load/unload/reinstall。可重入条件:同一 PluginLoader 实例 + 同一 asyncio task + 同一 plugin_id。不同插件 ID 可并发进行。
  3. 版本检查_check_version_compatibility 验证插件的 qwenpaw_version 约束
  4. 依赖安装_ensure_dependencies_installed):双重探测——先用 importlib.metadata 检查(权威),再用 find_spec 导入探测(覆盖 frozen build)。使用 plugin_install_lock 进程间锁避免并发安装冲突。安装工具优先级:python -m pipuv pip install → frozen build 使用 bundled Python + --target
  5. 入口点验证_validate_entry_points):验证 backend 入口文件存在
  6. 后端模块加载_load_backend_module):动态创建模块 spec,执行模块,获取 pluginapp 对象,创建 PluginApi 并设置 registry,调用 plugin_def.register(api) 注册所有能力。失败时调用 _cleanup_failed_load 回滚

卸载流程_unload_plugin_unlocked)按相反顺序执行:执行 shutdown hooks → 执行 uninstall hooks → 从 sys.modules 移除模块(按名称前缀和按 __file__ 路径双重清扫)→ 从 sys.path 移除插件目录 → 清理插件工具 → 调用 registry.unregister_plugin 清除所有注册 → 可选删除文件。

16.6 插件市场

插件市场(Plugin Market)聚合多个来源的插件搜索结果,让用户能一键发现和安装插件。

市场架构采用多 Provider 聚合设计:MarketProvider Protocol 定义了来源提供者的契约(available() 检查可用性、search() 搜索插件)。内置四个 Provider:Aliyun(阿里云)、ClawHub、ModelScope(魔搭)、QwenPaw 官方。

搜索服务search_market)并行搜索所有请求的 Provider(asyncio.gather),聚合结果、错误和分页信息。每个 Provider 独立返回结果列表和是否有更多数据,搜索上限 50 条。

官方插件目录 CDNdownload_catalog.py)从 https://download.qwenpaw.agentscope.io 获取 JSON 目录,支持 gzip 压缩传输。支持版本升级检测(_is_upgrade_available),使用 packaging.version.Version 比较版本号。

MarketResult 数据结构包含:source(来源 Provider key)、slug(唯一标识)、name/description/author(显示信息)、source_url(源 URL)、versionicon_urlstats(额外统计信息如下载量、点赞数)。

16.7 完整安装调用链路

用户安装插件 (CLI/API)
  → PluginLoader.load_plugin_from_path(source_path, config, install_dir, force=...)
    → plugin_lifecycle(plugin_id) 获取生命周期锁
      → _read_source_manifest() 读取 plugin.json
      → _load_plugin_from_path_unlocked()
        → 路径安全检查 (防止 path-traversal)
        → 复制文件到 install_dir
        → _install_requirements_locked() 安装依赖 (进程间锁)
        → load_plugin()
          → _check_version_compatibility() 版本检查
          → _ensure_dependencies_installed() 确保依赖
          → _validate_entry_points() 验证入口点
          → _load_backend_module()
            → 动态导入模块
            → 获取 plugin/app 对象
            → 创建 PluginApi(plugin_id, config, manifest_dict)
            → api.set_registry(registry)
            → registry.register_plugin_manifest()
            → plugin_def.register(api)  ← 插件注册所有能力
          → 记录 PluginRecord
      → after_load 回调 (provider/command/agent config 设置)

十七、Agent 设计实战学习要点

17.1 核心设计原则

17.2 核心设计模式速查

设计模式 QwenPaw 实现 解决的问题
依赖注入 AgentBuilder 构造函数注入 Agent 无状态,策略可变
装饰器/包装器 PolicyGuardedTool 动态类继承 FunctionTool 安全检查不可绕过
责任链 8 阶段 Hook 管道 + Gate 链 可插拔的处理流程
洋葱模型 Middleware 链 推理循环的横切关注点
贡献者模式 PromptManager + PromptContributor 动态拼装系统提示词
声明式注册 AgentMode.setup() + WorkspacePlugins 扩展不需改源码
Protocol 解耦 ApprovalGate / ContextManager Protocol 核心不知产品如何实现
懒加载 + 去重 MultiAgentManager 按需启动,并发安全
Build-before-swap DriverManager.reload_driver 零停机热重载
引用计数 apply_skill_config_env_overrides 环境变量冲突管理
单任务生命周期 MCP _run_lifecycle 跨任务 AsyncExitStack 泄漏
安全降级 Scroll 失败→原生、沙箱不可用→拒绝 可选功能失败不影响核心
Fail-closed governor 未初始化→DENY、Landlock HOME 枚举失败→不授予"/" 安全默认
允许列表模型 SandboxConfig 未列出的默认拒绝 最小权限
探测缓存 @lru_cache(maxsize=1) 沙箱探测 避免重复探测开销
mtime 缓存 _read_json_mtime_cached 文件未修改时跳过 I/O
原子写入 write_json_atomic NamedTemporaryFile + replace 防止半写入状态
跨进程锁 fcntl/msvcrt 文件锁 多进程并发安全

17.3 新手实战路线图

第一步:理解 ReAct 循环。阅读 ../src/qwenpaw/agents/react_agent.py 中的 _reasoning() 方法,理解 Thought → Action → Observation 的交替。尝试用 AgentScope 2.0 跑一个最简 ReAct Agent。

第二步:实现依赖注入。参考 AgentBuilder.build() 的组装流程,把模型、工具、提示词作为构造参数传入,确保 Agent 类不内部创建依赖。

第三步:加入工具治理。实现一个简单的 PolicyGuardedTool 包装器,在 check_permissions 中做 allow/deny 判断。体会"工具包装使检查不可绕过"的设计——关键是用 type() 动态创建 FunctionTool 子类,而非外部装饰器。

第四步:设计 Hook 管道。定义 3-5 个生命周期阶段,实现 HookAction(CONTINUE / SHORT_CIRCUIT / SKIP_AGENT),加入会话加载/保存钩子。

第五步:实现沙箱。从最简单的 NoneSandbox 开始,逐步实现基于 Bubblewrap 或 AppContainer 的隔离。理解 deny_paths 遮蔽(目录用 tmpfs,文件用 /dev/null)和 env_blacklist 清空(映射为空字符串)的实现。

第六步:实现模式与门控。定义 AgentMode 基类,打包命令/工具/钩子/提示词。实现 IterationGate(最大迭代次数限制)和 DoomLoopGate(重复检测)。

第七步:分离记忆与上下文。记忆用 Markdown 文件持久化,可人工审计。上下文用窗口管理策略(摘要或 Scroll)。通过中间件接入推理循环。

第八步:集成 MCP。实现一个简单的 MCP 驱动,支持 stdio 传输。理解凭据注入和策略门控的实现。特别注意单任务生命周期管理——将 AsyncExitStack 的 enter/exit 放在同一个后台任务中。

第九步:扩展插件生态。定义 plugin.json 清单格式,实现工具/钩子/命令/提示词的注册接口,加入安装时安全扫描。理解工具所有权管理——防止不同插件重复注册同名工具。

17.4 关键源码索引

子系统 关键文件 学习重点
Agent 主体 ../src/qwenpaw/agents/react_agent.py ReAct 循环覆写、媒体处理、停止钩子、状态持久化
Agent 组装 ../src/qwenpaw/runtime/builder.py 依赖注入流程、技能注入
多 Agent 管理 ../src/qwenpaw/app/multi_agent_manager.py 懒加载、去重、热重载
工作区隔离 ../src/qwenpaw/app/workspace/workspace.py 服务管理、插件注册表
工具注册 ../src/qwenpaw/runtime/tool_registry.py ToolDescriptor、四维过滤、@tool_descriptor
工具组装 ../src/qwenpaw/app/workspace/local_workspace.py list_tools、PolicyGuardedTool 包装、子Agent白名单
治理策略 ../src/qwenpaw/governance/policy.py 四阶段评估、builtin/user规则、execution_level
工具包装 ../src/qwenpaw/governance/tool_adapter.py __new__动态类、两层拦截、fail-closed
资源治理器 ../src/qwenpaw/governance/resource_governor.py 沙箱配置编译、降级、审计
深度扫描 ../src/qwenpaw/governance/detectors.py 敏感路径/危险模式/Shell逃逸三检测器
规则泛化 ../src/qwenpaw/governance/generalize.py 三重安全防护、_NO_GENERALIZE_COMMANDS
审计日志 ../src/qwenpaw/governance/audit.py SQLite单例、自动清理、WAL模式
沙箱配置 ../src/qwenpaw/sandbox/config.py SandboxConfig、能力探测、create_sandbox工厂
Bubblewrap ../src/qwenpaw/sandbox/bubblewrap_sandbox.py 多步构建、deny_paths遮蔽、env清空
Landlock ../src/qwenpaw/sandbox/linux_sandbox.py ctypes syscall、路径规则编译、ABI检测
macOS Seatbelt ../src/qwenpaw/sandbox/macos_sandbox.py .sb策略编译、路径净化
Windows沙箱 src/qwenpaw/sandbox/windows_*.py AppContainer/Elevated/Unelevated三后端
Tool Guard ../src/qwenpaw/security/tool_guard/engine.py 4 Guardian编排
File Guard ../src/qwenpaw/security/tool_guard/guardians/file_guardian.py 敏感路径检测、跨平台路径规范化
Skill Scanner ../src/qwenpaw/security/skill_scanner/scanner.py 安装时扫描、符号链接防护
签名规则 ../src/qwenpaw/security/skill_scanner/rules/signatures 8类威胁签名
MCP 驱动基类 ../src/qwenpaw/drivers/handler.py 模板方法、策略授权
MCP Handler ../src/qwenpaw/drivers/handlers/mcp.py 凭据注入、工具调用、能力转换
MCP 客户端 ../src/qwenpaw/drivers/handlers/mcp_stateful_client.py 单任务生命周期、自动重连
驱动管理 ../src/qwenpaw/drivers/manager.py build-before-swap、refresh检测
驱动策略 ../src/qwenpaw/drivers/policy.py 多维度匹配+特异性排序
驱动校验 ../src/qwenpaw/drivers/contracts.py DriverCard四层校验
凭据存储 ../src/qwenpaw/drivers/credentials/store.py 加密、原子写入、env:引用
凭据Provider ../src/qwenpaw/drivers/credentials/providers.py 5种Provider实现
凭据绑定 ../src/qwenpaw/drivers/credentials/bindings.py resolve_binding、implicit_auth_headers
工具适配 ../src/qwenpaw/drivers/adapters/agentscope_tool.py DriverCapabilityTool
技能注册 ../src/qwenpaw/agents/skill_system/registry.py 三层存储、生效解析、池对账、环境变量注入
技能存储 ../src/qwenpaw/agents/skill_system/store.py 跨进程锁、原子写入、mtime缓存、ZIP安全
技能安装 ../src/qwenpaw/agents/skill_system/hub.py 8种来源安装
技能服务 ../src/qwenpaw/agents/skill_system/workspace_service.py 生命周期管理、安全扫描
池服务 ../src/qwenpaw/agents/skill_system/pool_service.py 自动更新、上传/下载
Loop Gates ../src/qwenpaw/loop/gates/base.py 门控抽象与动作
模式系统 ../src/qwenpaw/modes/base.py 四类贡献打包
钩子系统 ../src/qwenpaw/runtime/hooks.py 8 阶段管道
中间件 ../src/qwenpaw/agents/middlewares.py 洋葱模型
提示词构建 ../src/qwenpaw/runtime/prompt_manager.py 贡献者模式
记忆基类 ../src/qwenpaw/agents/memory/base_memory_manager.py 抽象接口、任务队列
ReMe 集成 ../src/qwenpaw/agents/memory/reme_light_memory_manager.py ReMe job 系统
主动记忆 ../src/qwenpaw/agents/memory/proactive 中断检测
上下文管理 ../src/qwenpaw/agents/context/base.py ContextManager Protocol
Scroll 策略 ../src/qwenpaw/agents/context/scroll/manager.py 8阶段压缩流水线
历史 REPL ../src/qwenpaw/agents/context/scroll/repl.py 沙箱化回溯
频道基类 ../src/qwenpaw/app/channels/base.py 消息转换、访问控制、去抖动
频道注册 ../src/qwenpaw/app/channels/registry.py 18个内置频道、懒加载
Cron 管理 ../src/qwenpaw/app/crons/manager.py APScheduler、keepalive
心跳 ../src/qwenpaw/app/crons/heartbeat.py 活跃时段、HEARTBEAT.md
插件架构 ../src/qwenpaw/plugins/architecture.py 8种插件类型、清单格式
插件 API ../src/qwenpaw/plugins/api.py 工具注册+治理同步、所有权管理
插件注册表 ../src/qwenpaw/plugins/registry.py 单例、HTTP路由挂载

总结

QwenPaw 是一个值得深入学习的 Agent 开源项目,它的价值不在于某一个巧妙技巧,而在于系统级的工程思考

  1. Agent OS 理念:把 Agent 当操作系统设计——工作区是进程,工具是系统调用,治理是权限系统,频道是 I/O 设备,沙箱是容器。这种类比让复杂系统变得可理解。

  2. 安全是架构不是补丁:从 AgentBuilder 组装工具的那一刻起,PolicyGuardedTool 就已经包裹了每个工具。安全不是事后加的过滤器,而是构建时不可绕过的契约。四阶段策略评估 + 多平台沙箱 + Tool Guard + Skill Scanner 构成纵深防御。fail-closed 是贯穿全局的设计原则——governor 不可用→DENY,Landlock HOME 枚举失败→不授予"/",泛化失败→精确匹配,审批等待崩溃→DENIED。

  3. 三级正交控制:Runtime Hooks(请求级)+ Middlewares(推理级)+ Loop Gates(循环级)三层正交,让功能可以精准挂载到正确的层级,不互相干扰。

  4. 声明式生态:模式、技能、插件都是声明式扩展——一个文件夹、一个 JSON 清单就能增加能力,不需要 fork 代码。三层存储模型让技能从"安装→共享→工作区配置"形成完整的生命周期。

  5. 透明可审计:配置是 JSON,记忆是 Markdown,技能是文件夹,审计日志在 SQLite。一切都可以不启动系统直接读写和版本控制。

  6. 安全降级:任何可选功能失败时都静默回退到原生行为——Scroll 失败→原生压缩,沙箱不可用→拒绝或降级,频道依赖缺失→跳过该频道。核心 Agent 功能始终可用。

  7. 工程细节的深度:从跨任务 AsyncExitStack 泄漏的解决方案(单任务生命周期),到 WSL2 上事件循环不唤醒的 keepalive,到 Shell 引号状态机的逃逸检测(7 类混淆技术),到引用计数的环境变量管理,到 mtime 缓存避免重复 I/O,到原子写入防止半写入状态——每一个细节都体现了工业级产品的打磨深度。

  8. MCP 驱动层的协议中立设计:Driver 核心负责策略/凭据/生命周期管理,具体协议通过 Handler 注册机制插入。凭据系统的 5 种 Provider 覆盖了从无凭据到 OAuth2 到 AKSK 签名的完整认证场景。"DriverCard 无密钥"的安全不变量确保驱动配置文件本身不含敏感信息。

对于想要学习 Agent 开发的新手,QwenPaw 的源码是一座金矿——它不仅展示了"如何让 LLM 调用工具",更展示了"如何构建一个可运维、可治理、可扩展的 Agent 平台"。从 ReAct 循环开始,逐步加入治理、沙箱、模式、记忆、MCP、插件,你就能理解一个工业级 Agent 系统的完整诞生过程。

参考资源