AgentHub 的 Skill 与定时任务:能力扩展与两条定时链路
本文是 AgentHub 架构系列的第 6 篇,讲两块能力:Skill (自包含、可拔插的能力扩展)和定时任务 (Cron 业务定时 + Heartbeat AI 巡检)。这两块表面不相干,底层却是同一个设计------都通过"伪造一条 InboundMessage 走统一 dispatch 链路"来触发 Agent ,并且在多 agent 演进后都带上了 agent 维度。工具层(能力怎么执行)见 05-工具层,护栏细节见 07-护栏层。
AgentHub 的 Skill 是一层"给 Agent 看的操作手册 + 自带脚本"的能力扩展:宿主对具体业务零认知,只提供一个通用的 run_skill_script 工具去跑脚本,Skill 的索引在每次组装 system prompt 时按 agent 的白名单注入,Agent 据描述匹配后再读完整指令;而 Cron 和 Heartbeat 这两套定时机制,本质都是"特殊的消息生产者"------它们不直接调 Agent,而是伪造一条 InboundMessage 投进消息总线,复用与用户消息完全相同的处理链路,只是一个由用户配 cron 表达式驱动、一个由系统定时让 AI 自检。
一、Skill:给 Agent 的操作手册
1. Skill 为什么不做成"又一个内置工具"
先厘清 Skill 和工具的分层,这是理解整个设计的起点:
- 工具(Tool) 是 Agent 的"手",是代码级 的能力:
read_file、fetch_url、run_command这些都在 AgentHub 代码里注册,粒度细。加一个新工具意味着改宿主代码、重新打包。 - Skill 是更高一层的扩展:一坨"指令 + 脚本"的组合,告诉 Agent 在某类业务任务下该按什么步骤、调哪些脚本去干 。粒度粗,内部往往编排多个工具调用。装进
skills/目录即可用,不改宿主代码。
关键的设计抉择是:为一个业务能力(比如生成周报),到底是给 AgentHub 加一个专用工具,还是做成一个 Skill? AgentHub 选了后者,理由是可移植性和解耦:
- 专用工具会把业务逻辑焊死在宿主里。周报怎么算、字段怎么取、邮件怎么发,全塞进 AgentHub 代码,宿主从此"认识"周报------这是耦合。
- Skill 把逻辑全放进它自己的
scripts/下,宿主只提供一个通用的run_skill_script去执行脚本。宿主对周报零认知,只是个"能跑 Python 脚本的执行器" 。同一个 Skill 拿到任何提供"跑脚本 + 读写文件"能力的 agent 框架(Claude Code、Codex 等)都能跑,不跟 AgentHub 绑死。
教学案例说明:下文以
weekly-report(个人/团队日报周报的自动生成与邮件发送)为例讲解 Skill 的自包含模式。该 Skill 已从当前代码库移除 (skills/根目录当前只有.gitkeep,全局位置无安装;已有的是按 agent 安装的skills/<agentName>/<skill>/,如skills/default/case-timeout-notification/------一个查询案件时效并定时推送的 Skill),以下机制描述基于它曾经的实现,原理适用于任何遵循同一模式的 Skill。
2. SKILL.md ------ Skill 的"身份证 + 说明书"
每个 Skill 根目录有个 SKILL.md,YAML front matter + Markdown 正文两部分:
yaml
---
name: weekly-report
description: '个人日报/周报与团队周报的自动生成和邮件发送。触发:生成日报/生成周报/...'
metadata:
author: <作者>
version: "<版本号>"
---
# weekly-report
本技能自包含、可拔插:所有工具都是 scripts/ 下的纯 Python 3(仅标准库)脚本...
## 激活条件
- 收到消息包含"生成日报"
- 收到消息包含"生成周报"
...
## 脚本调用约定
- 工作目录:以本技能根目录为当前目录
- 解释器:Python 3.8+,仅用标准库
...
- front matter :
name+description+metadata。其中description是整个机制的关键------它是唯一进 system prompt 的内容,Agent 靠它判断"当前用户需求该不该用这个 Skill"。 - 正文:详细指令------激活条件、脚本调用约定、硬纪律(如"禁止臆造数据""禁止伪造执行结果")、分步骤流程。
本质上 SKILL.md 正文是一段按需加载的 prompt 片段。
3. 两阶段存储:下载缓存 vs 安装
Skill 在磁盘上有两个位置,不能混:
| 阶段 | 位置 | 是什么 |
|---|---|---|
| 下载缓存 | workspace/cache/<repo>/... |
技能市场拉取后的暂存,仅落盘,不生效 |
| 安装(全局) | <项目根>/skills/<skillName>/ |
真正生效的位置之一,所有 agent 共享 |
| 安装(agent 专属) | <项目根>/skills/<agentName>/<skillName>/ |
真正生效的位置之二,仅该 agent 可见(查找时优先于全局) |
设计意图:下载与生效解耦 。从市场拉取只是把文件放进 cache,不影响运行中的 Agent;只有显式"安装"(把 Skill 从 cache copy 到 skills/)后才会被 Agent 看到。安装时市场模块(target: "agenthub" 分支)还会做规范化------把嵌套子目录里的 SKILL.md 提到 Skill 根(src/skills/market.ts)。
安装分两个作用域:loadSkill(repo, skill, "global") 装到 skills/<skillName>/,loadSkill(repo, skill, <agentName>) 装到 skills/<agentName>/<skillName>/------后者正是 findSkillMd"agent 专属优先"那一档的来源。卸载语义跟着细化:unloadSkill(skill, agentName) 只删 agent 专属副本、全局安装保留;不传 agentName 才删全局。
安装目标还不止 AgentHub 自己:InstallTarget 是五枚举(market.ts)------agenthub / claude-code(~/.claude/skills)/ codex(~/.codex/skills)/ zcode(~/.zcode/skills)/ opencode(~/.config/opencode/skill),getInstallTargets 按各宿主目录是否存在报 available。装到外部工具时还会做安装后校验(目标目录找不到 SKILL.md 即报错)并补写插件元数据(claude-code 写 marketplace 元数据、codex 写 plugin 元数据)。这正是"同一个 Skill 拿到其他 agent 框架也能跑"的落地------市场直接替你装过去。顺带一提,loadSkill/unloadSkill 入口都有 assertSafeName 净化(拒绝 /、``、..、盘符前缀),防止用 Skill 名做路径穿越。
另一个容易误判的文件:项目根有 skills-lock.json(记录 source/skillPath/computedHash),但 src 下无任何代码读写它------疑似外部安装器的产物,不是 SkillMarket 维护的,别把它当成本套机制的一部分。
所以"装一个 Skill" = 市场下载到 cache + copy 到 skills/ 两步,Agent 只认后者。
4. Skill 怎么被 Agent 发现 ------ system prompt 按 agent 白名单注入索引
这是 Skill 机制里最需要讲清楚的一环,也是多 agent 演进后变化最大的一环。
单 agent 时代 的做法是:ContextBuilder.buildSystem() 每次组装 system prompt 时,扫描整个 skills/ 目录,把所有已安装 Skill 的索引注入。
多 agent 时代 改成了按 agent 白名单查找,不再盲扫目录 。ContextBuilder 持有该 agent 的 enabledSkills(来自 agent.json 的 skills 字段),buildSystem 只遍历这份清单逐个查找(src/agent/context-builder.ts):
javascript
buildSystem(channel) {
const soul = readOptional(join(this.agentDir, "SOUL.local.md"))
|| readOptional(join(this.agentDir, "SOUL.md"));
const skills: SkillSummary[] = [];
for (const name of this.enabledSkills) { // ★ 按 agent.json.skills 清单,不扫目录
const s = this.findSkillMd(name);
if (s) skills.push(s);
}
const skillsBlock = buildSkillsBlock(skills, projectDir);
const parts = [soul];
parts.push(`## 当前时间\n\n${now.toLocaleString(...)}(${时区},星期x)...`);
// ★ 当前时间注入:soul 之后、skillsBlock 之前
// LLM 无实时时钟,"五分钟后/明早九点"等相对时间及 cron 表达式换算须以此为准
if (skillsBlock) parts.push(skillsBlock); // Skill 索引拼在当前时间后面
// ... system_allowed_paths 注入、platform-policy 追加 ...
return parts.filter(Boolean).join("\n\n");
}
(顺带说一句,system prompt 里注入"当前时间"这个小细节很容易被忽视,但对定时任务场景是刚需:LLM 没有时钟,用户说"每天早上九点"、Agent 要生成 cron 表达式时,全靠这段注入换算,否则它会按训练数据猜时区。)
findSkillMd 的查找顺序是"agent 专属优先 > 全局" :
csharp
private findSkillMd(skillName: string): SkillSummary | null {
const agentPath = join(skillsDir, this.agentName, skillName, "SKILL.md"); // agent 专属
const globalPath = join(skillsDir, skillName, "SKILL.md"); // 全局
const skillMd = existsSync(agentPath) ? agentPath
: existsSync(globalPath) ? globalPath : null;
// ... 解析 front matter,拿 name + description ...
}
也就是同名 Skill,skills/<agentName>/<skillName>/ 覆盖 skills/<skillName>/------既能给某个 agent 定制专属版本,又能共享一套全局 Skill。
注入的索引只含 `name + description + SKILL.md 路径,不含正文:
swift
## 可用技能(Skills)
以下为当前可用技能。当某技能与用户需求匹配时,用 `read_file` 读取其 SKILL.md 路径获取完整说明并严格执行;若没有匹配的技能,告知用户并建议联系管理员。
- weekly-report:个人日报/周报与团队周报的自动生成和邮件发送。触发:生成日报/...
SKILL.md:skills/weekly-report/SKILL.md
...
只放摘要是为了省 token :system prompt 每轮都带,把所有 Skill 正文塞进去会迅速撑爆上下文;放摘要,Agent 匹配到哪个再 read_file 读那一个的全文。
5. Skill 发现的完整流程
串起来看 Agent 用 Skill 的真实路径:
scss
1. buildSystem 组装 system prompt 时,按该 agent 的 enabledSkills 白名单
逐个 findSkillMd(agent 专属优先 > 全局),把命中的索引
(name + description + 路径) 注入 system prompt
2. 用户发"生成周报" → 走正常 dispatch → 该 agent 自己的 loop.process
→ Runner 跑 ReAct,LLM 看到 system prompt 里的 Skill 索引
→ LLM 发现 weekly-report 的 description 匹配"生成周报"
3. LLM 调 read_file("skills/weekly-report/SKILL.md") 读完整指令
→ 拿到激活条件、脚本调用约定、硬纪律等
4. LLM 按 SKILL.md 指令,调 run_skill_script 执行脚本:
- run_skill_script(skills/weekly-report/scripts/get_config.py)
- run_skill_script(skills/weekly-report/scripts/render_report.py, ...)
- run_skill_script(skills/weekly-report/scripts/send_email.py, ...)
5. 脚本 stdout 喂回 LLM,LLM 组织成回复给用户
要点 :Skill 发现不是 Agent 主动 list_files 去扫目录(技术上能做),而是组装 system prompt 时就把索引给了 Agent,Agent 据 description 匹配后 read_file 读全文。ContextBuilder 替 Agent 把"有哪些 Skill 可用"这件事准备好了,Agent 只需匹配 + 读详情。
6. run_skill_script:自包含的执行边界
Agent 不直接跑脚本,而是通过通用工具 run_skill_script 执行,该工具本身就是"自包含"设计的落地(src/tools/builtin/run-skill-script.ts):
- 路径约束 :脚本路径经
resolveSkillRoot归一化后,必须位于skills/之下 ,否则Access denied。这既是安全边界(不能借此跑任意系统脚本),也定位出脚本所属的 Skill 根目录。 - 工作目录 = Skill 根 :脚本以其所属
skills/<name>/为 cwd 执行,所以 Skill 内部可以用相对路径组织自己的文件。 - 解释器探测 :优先读该 Skill 的
config.local.json/config.json里的python_path,没有则自动探测系统 Python(Windows 扫常见安装路径、查注册表 PEP 514 登记、试 PATH;类 Unix 试python3/python)。 - 注入操作人身份 :执行前读
auth/current_user.json,把当前用户名注入环境变量AGENTHUB_OPERATOR,脚本据此知道"是谁在操作"(比如周报脚本据此定位是给谁生成)。 - 输出回传:成功回 stdout,失败附带 stderr,统一截断到 16000 字符防止撑爆上下文。
配合 SKILL.md 里强调的"所有工具都是 scripts/ 下的纯 Python 3(仅标准库)脚本",自包含的三层含义就完整了:逻辑全在 Skill 自己的 scripts/ 下(宿主零认知)+ 仅用标准库(任何有 Python 的机器能跑,不用 pip)+ 只依赖通用的 run_skill_script(换宿主也能跑) 。
7. 另一条路径:Cron 触发时显式加载 SKILL.md 全文
除了对话场景的"索引 + 按需读全文",还有一条显式加载全文的路径------Cron 任务触发时(src/scheduler/cron.ts):
csharp
// CronScheduler.loadSkills
private loadSkills(skillNames: string[]): string {
const parts: string[] = [];
for (const name of skillNames) {
const skillPath = join(this.rootDir, "skills", name, "SKILL.md");
if (existsSync(skillPath)) parts.push(readFileSync(skillPath, "utf-8").trim()); // 直接读全文
else logger.warn("skill not found for cron job", { skill: name });
}
return parts.join("\n\n---\n\n");
}
// fire 里:const content = skillContent ? `${skillContent}\n\n${job.prompt}` : job.prompt;
两条路径的区别与互补:
- system prompt 路径(对话场景) :只注入索引,Agent 有主动权按需
read_file读全文------省 token。 - Cron 路径(定时场景) :直接把指定 Skill 的 SKILL.md 全文 前置到 prompt------因为 Cron 是无人值守触发,必须一次把指令给足,不能指望 Agent 自己去发现该用哪个 Skill。
(一个可注意的细节:loadSkills 目前读的是全局路径 skills/<name>/SKILL.md,没有走 findSkillMd 的"agent 专属优先"逻辑,即使这个 Cron 任务归属某个非 default agent。这是 per-agent 化不彻底的一处,见后文"局限"。)
二、Cron 与 Heartbeat:同一个"伪造 InboundMessage"底座
这是本文的第二个核心。AgentHub 有两套定时机制,容易混:
| 机制 | 触发 | 干啥 | 谁定义任务 |
|---|---|---|---|
| Cron | cron 表达式(如 0 9 * * 1-5 工作日 9 点) |
业务定时:跑某 Skill + prompt,结果推给指定 IM | 用户/Agent 通过工具配置 |
| Heartbeat | 固定间隔(默认 30 分钟) | AI 主动巡检:让 LLM 自问"有没有待处理的事" | 系统内置,固定 prompt |
它们最重要的共同点:都不直接调用 Agent,而是构造一条 InboundMessage 投进 bus.inbound,复用与用户消息完全相同的 dispatch 链路。 这是 AgentHub 的统一设计------Cron/Heartbeat 只是"特殊的消息生产者"。
Cron 的 fire(多 agent 版) :
typescript
private fire(job: CronJob): void {
const channel = /* 从 deliver "wecom:ZhangSan" 切出 */;
const chat_id = /* ... */;
const skillContent = this.loadSkills(job.skills);
const content = skillContent ? `${skillContent}\n\n${job.prompt}` : job.prompt;
this.bus.inbound.push({
id: randomUUID(),
session_key: `${job.agent}:${channel}:cron_${job.id}`, // ★ agent:channel:cron_<id>
channel,
chat_id,
user_id: "system", // 系统发的,非真人
content, // SKILL.md 全文 + prompt
timestamp: Date.now(),
agent_hint: job.agent, // ★ 归属 agent 直达:不设 hint 会被
}); // routeAgent 按 chat_id 通配路由到 default
}
CronJob 结构在多 agent 演进后新增了 agent 字段 (缺省 "default"),fire 时 session*key 从旧版的 ${channel}:cron*{id} 变成 {job.agent}:{channel}:cron_{id}------每个 Cron 任务不仅独立会话(不跟用户日常对话混),还明确归属某个 agent,由那个 agent 的 loop 来处理。agent_hint 字段是这条归属链路的最后一公里:routeAgent 拿到 hint 直接路由,不再按 chat_id 通配猜测(没有 hint 时,新建 agent 没有 wecom filter,它的任务触发消息会被通配路由全部落到 default)。
调度机制本身是 setTimeout 链式:每次算出"下次触发的 delay",setTimeout 到点 fire 再排下一次,而不是 setInterval 硬轮询。其中有两个工程细节:
- cron-parser 按系统时区解析表达式(它默认按 UTC,否则 "0 9 * * 1-5" 会被当成 UTC 9 点)。
- setTimeout 的 delay 是 32 位有符号整数,上限约 24.8 天,超出会被 Node/Bun 截断成 1ms 导致死循环;代码里保守取 2e9 ms(约 23 天)封顶,超限时不 fire、只重排,分段逼近目标时间(src/scheduler/cron.ts)。
manage_cron_jobs 工具的两个新语义 (src/tools/builtin/cronjob.ts):一是 deliver 可填 "current" 或省略------从 handler 收到的 sessionKey(agent:channel:chat_id)里解析出 channel:chat_id 作为推送目标,工具描述还提醒 LLM 参照系统提示里的当前时间做相对时间换算(与上面的当前时间注入呼应);二是任务归属隔离 ------list 只列当前 agent 的任务,update/delete 校验 existing.agent !== agentName 直接拒绝跨 agent 操作,agent 被删除时 removeJobsByAgent 连带清理它的全部任务和运行中 timer。
Heartbeat 的 tick(多 agent 版) :
php
private tick(): void {
for (const agentName of this.registry.listActive()) { // ★ 遍历所有 active agent
this.bus.inbound.push({
id: randomUUID(),
session_key: `${agentName}:cli:heartbeat`, // ★ agent:cli:heartbeat
channel: "cli",
chat_id: "heartbeat",
user_id: "system",
content: "【定时巡检】请检查是否有待处理的任务或需要跟进的事项,如有请列出并说明下一步行动。",
timestamp: Date.now(),
agent_hint: agentName, // ★ 同样直达归属 agent,防通配路由到 default
});
}
}
Heartbeat 从"单个全局巡检"改成遍历所有 active agent 各推一条 ,session_key 从旧版固定的 cli:heartbeat 变成 ${agentName}:cli:heartbeat------每个上线的 agent 都定时自检一次,各查各的、各推各的。
"AI 驱动"体现在 prompt 的"如有"二字 :Agent 收到巡检消息后会真去调工具(list_files 看待办、或调业务工具查系统)检查------有事就列出来 + 说明下一步并 reply;没事就输出空内容。配合空响应静默规则 (GatewayCore 见 Agent 返回空串就跳过 reply,不推 IM),实现"有事才报、没事闭嘴",不骚扰用户。这跟传统"到点就推一条固定消息"的定时任务本质不同------推不推由 LLM 判断。
三、几个值得展开的设计取舍
为什么 Cron/Heartbeat 触发时是伪造一条 InboundMessage,而不是直接调用 Agent?
因为复用统一链路 能一次性白拿到所有横切能力,不用重复实现。用户消息经过的 dispatch 链路上有:找 agent(按 session_key/agent_hint 路由到对应 agent)、命令路由、会话获取与历史加载、per-session 锁(串行化保证) 、Agent Loop/Runner、reply 分发、可观测性打点。
如果 Cron/Heartbeat 直接调 Agent,这些都要各自重写一遍------尤其是锁 :定时触发和用户消息可能同时命中同一会话,不走统一链路就绕过了串行化,会产生并发处理同一会话的竞态。伪造成 InboundMessage 投进总线后,它和用户消息在锁面前一视同仁,天然排队。代价只是构造一条 user_id: "system" 的消息,收益是整条链路的能力全部复用。这也是"消息总线解耦渠道与 Agent"这个架构决策的红利------任何东西只要能造出一条 InboundMessage,就能驱动 Agent。
Cron 和 Heartbeat 为什么做成两套机制而不是一套?
Cron 是用户/Agent 配置的业务定时 :cron 表达式驱动(能表达"工作日 9 点"这种复杂时间),跑指定 Skill + prompt,结果推给配置的 IM 目标(企微/飞书)。Heartbeat 是系统内置的 AI 巡检:固定间隔(默认 30 分钟),固定 prompt 让 LLM 自问"有没有待处理的事",走 cli 渠道不推 IM。
不合并成一套,是因为两者的语义和主体不同:Cron 是"用户明确要在某时间做某件确定的事",prompt 和产出都是确定的、要外推给人;Heartbeat 是"让 Agent 定时自检"的兜底机制,推不推、推什么完全由 LLM 临场判断,产出更像 Agent 自己的巡检记录。硬把它们合并,要么 Cron 被迫接受"AI 决定要不要执行"的不确定性,要么 Heartbeat 被迫配置成确定任务,都别扭。分成两套,各自的触发方式(setTimeout 链式 vs setInterval)、prompt 来源、推送目标都能独立演化。
补充 Cron 任务的归属隔离 :manage_cron_jobs 的 list 只列当前 agent 的任务,update/delete 校验 existing.agent !== agentName 拒绝跨 agent 操作;deliver 填 "current" 或省略时从 sessionKey 解析当前会话做推送目标;agent 被删除时 removeJobsByAgent 连带清理其任务。所以多 agent 下每个 agent 的定时任务互不可见、互不可改。
Skill 的索引为什么只放 description 不放正文?代价是什么?
收益是省 token :system prompt 每一轮 LLM 调用都要带,如果把所有 Skill 的完整 SKILL.md(激活条件、脚本约定、硬纪律、分步流程,动辄上千 token)都塞进去,几个 Skill 就能把上下文占满,挤掉真正的对话历史。只放 name + description + 路径 的一行摘要,再让 Agent 匹配到后用 read_file 读那一个的全文,是典型的"惰性加载"。
代价是多一次工具往返 :Agent 得先看索引、再 read_file 读全文、才能执行,比全量注入多一跳。而且 description 写得好不好直接决定匹配准不准------描述模糊,Agent 可能匹配不到或匹配错,所以 SKILL.md 的 description 字段要精心写(覆盖触发词)。对比之下,Cron 场景因为无人值守、必须一次给足,就反过来直接前置全文------两种加载策略的取舍,取决于"有没有人在场纠偏" :有人在场就惰性加载省 token,无人值守就全量前置保确定性。
Skill 的自包含设计具体怎么做到不依赖宿主?
三个层面:
- 逻辑全在 Skill 自己的
scripts/下------业务逻辑(周报怎么算、邮件怎么发)全是 Skill 里的 Python 脚本,AgentHub 对具体业务零认知,只是个"能跑脚本的执行器"。 - 仅用 Python 标准库------不用 pip 装依赖,任何装了 Python 3 的机器都能直接跑。
- 只依赖通用工具
run_skill_script------宿主不为某个 Skill 注册专用工具,Skill 只要求宿主提供"跑脚本 + 读写文件"这种通用能力。
结果就是可移植:同一个 Skill 拿到 Claude Code、Codex 等其他 agent 框架也能跑,因为它们同样提供跑脚本的能力------市场模块的五枚举安装目标就是这个思路的落地。宿主与 Skill 通过一个极窄的接口(执行一个脚本、传参、拿 stdout)解耦,run_skill_script 还会把操作人身份注入环境变量,让脚本无需宿主传参就知道"是谁在操作"。
四、已知局限与演进方向
几处诚实的不足:
- Skill 隔离只做了配置层,没做执行层强制 。配置/可见性层是做了的:每个 agent 的
agent.json有skills白名单,ContextBuilder只把白名单里的 Skill 索引注入该 agent 的 system prompt,查找时还遵循"agent 专属优先 > 全局"。但执行层没做强制校验 :run_skill_script的校验只有一条------脚本路径必须位于skills/之下,不校验这个脚本是否属于当前 agent 的白名单 。也就是说,一个持有该工具的 agent 理论上能跑skills/下任意 Skill 的脚本,哪怕那个 Skill 不在它白名单里------只要它知道(或猜到)脚本路径。白名单目前是"提示层"的软隔离(控制 Agent 看得见什么),不是"执行层"的硬隔离(控制 Agent 能跑什么)。要堵这个口,可以在run_skill_script里加一步:从resolveSkillRoot拿到 Skill 名后,校验它在当前 agent 的enabledSkills里,不在就Access denied。实际场景下 agent 拿到的脚本路径大多来自自己 system prompt 里的索引,越权需要主动构造路径、动机不强,但从纵深防御角度这确实该补。 - Cron 的
loadSkills没跟上 per-agent 化 :它读的是全局skills/<name>/SKILL.md,没走findSkillMd的"agent 专属优先"。所以一个归属非 default agent 的 Cron 任务,加载的仍是全局版 Skill,拿不到该 agent 的专属定制版。 - 调度器状态不持久 :setTimeout/setInterval 的 timer 都在内存,进程重启后靠
loadJobs从 jobs.json 重排------重启期间到点的 Cron 任务会漏触发(不会补跑)。Heartbeat 同理,重启即丢失当前周期。 - Heartbeat 随 active agent 数量线性放大:每个 tick 给每个 active agent 都推一条巡检消息。agent 一多,固定间隔下的巡检负载和 LLM 调用量会线性增长,且都挤在同一时刻投递。
- 空响应静默依赖 Skill/prompt 自觉:"无变化即静默"不是框架强制的,而是靠 SKILL.md 和 Heartbeat prompt 引导 Agent 在无事时输出空串。如果某个 Skill 写得不好、无事也啰嗦,静默就失效,用户会被定时消息骚扰。
按收益排序的演进方向:
- 补执行层校验(优先) :
run_skill_script里加 agent 白名单校验,把 Skill 隔离从软(可见性)升级到硬(可执行性);同时把 Cron 的loadSkills也切到findSkillMd的 agent 优先查找,两处 per-agent 语义对齐。 - 调度持久化 + 错过补偿:给 Cron 记录每个任务的"上次成功触发时间",重启后对比 cron 表达式判断是否有错过的窗口、按策略补跑(至少告警)。
- Skill 依赖不再限死标准库 :当前"仅标准库"换来了零安装的可移植性,但也限制了 Skill 能力。可以给 Skill 加一个隔离的依赖安装机制(每个 Skill 独立 venv,
run_skill_script探测/创建),在保持自包含的同时放开三方库------代价是牺牲一部分"任何机器直接跑"的简单性,需要权衡。 - Heartbeat 错峰 + 可 per-agent 关闭:给各 agent 的巡检投递加抖动避免同刻扎堆,并支持某些 agent 关掉 Heartbeat(不是所有 agent 都需要主动巡检)。
小结
Skill 用"宿主零认知 + 通用 run_skill_script + 仅标准库脚本"三板斧做到自包含可移植,索引按 agent 白名单惰性注入 system prompt(agent 专属优先于全局)、Agent 据描述匹配后读全文执行;Cron 和 Heartbeat 则共用"伪造 InboundMessage 走统一 dispatch 链路"这一个底座,白拿路由/锁/可观测性等全部横切能力,多 agent 演进后 Cron 任务带上 agent 字段、Heartbeat 遍历所有 active agent 各自巡检,且两者都用 agent_hint 把消息直达归属 agent、Cron 任务还有 per-agent 归属隔离(list 只见自己、update/delete 拒绝跨 agent)。两条加载策略的对比也很有意思------对话场景惰性注入、无人值守全量前置,取决于"有没有人在场纠偏"。整套设计的主线是用极窄的接口解耦、用统一的链路复用:Skill 通过"跑一个脚本"接进宿主,定时任务通过"投一条消息"接进总线,两者都只用一个最小的接触面,换来宿主侧几乎为零的认知负担。