渐进式披露与 skill
提交规范, 周报模板和代码审查清单只在各自的任务里有用. 如果把全文都放进系统提示词, 每次请求模型都要带上这些说明. 渐进式披露把信息按使用时机分成三层, 让模型先知道有哪些技能, 再读取完成任务所需的细节.
| 层级 | 模型看到什么 | 什么时候看到 |
|---|---|---|
| 技能索引 | 名称和描述 | 每轮任务开始 |
| 技能正文 | SKILL.md 中的步骤和规则 | 模型调用 load_skill 后 |
| 技能附件 | 模板等文件的内容 | 模型调用 read_file 后 |
description 同时说明技能能做什么和什么时候适用. 例如, weekly-report 的描述提到按固定模板生成周报, 模型看到用户说"帮我写周报"时, 就有依据选择它. 项目说明 AGENTS.md 每轮直接进入系统提示词, 适合放工作区范围和通用规则. 技能则适合放特定任务的做法. 本文以 lecture-rewrite/ 中的 LyrjAgent 实现为准. 参考 NamiAgent 第 5 课讲义 docs/05-skills.md.
发现技能
get_meta_and_body() 拆分元数据和正文
输入是 SKILL.md 路径, 输出是 (meta, body). 文件开头用 --- 包起来的 name 和 description 属于元数据, 后面的 Markdown 是正文. 相对路径以 BASE_DIR 为基准, 即 lecture-rewrite/.
def get_meta_and_body(skill_path: str | Path) -> tuple[dict[str, str], str]:
if not skill_path:
return {}, ''
path = BASE_DIR / Path(skill_path)
content = path.read_text(encoding='utf-8')
parts = content.split('---', 2)
if len(parts) != 3:
return {}, content.strip()
meta: dict[str, str] = {}
for line in parts[1].strip().splitlines():
if ':' not in line:
continue
key, val = line.split(':', 1)
meta[key.strip()] = val.strip()
return meta, parts[2].strip()
例如, 元数据中的 description: 写周报: 按固定模板整理 会得到值 写周报: 按固定模板整理, 因为每行只在第一个冒号处拆开. 当前实现只处理一行一个键值的简单格式. 讲义按行检查首行是否为 ---, 当前实现直接按字符串切两次. 如果普通正文里恰好有两处 ---, 也可能被误当成元数据边界.
传入空字符串时, 函数直接返回 ({}, ''). 文件没有两处 --- 时, 返回空元数据和去掉首尾空白的全文. 如果文件不存在或编码不对, 读文件时会抛出异常, 由调用者决定怎么处理.
find_skills() 建立索引
SKILLS_DIR 默认指向 lecture-rewrite/skills/. find_skills() 按路径排序扫描 */SKILL.md, 只匹配技能根目录的下一层. 例如, skills/weekly-report/SKILL.md 会被发现, 根目录里的 skills/README.md 不参与扫描.
下面是 find_skills() 的完整逻辑, 省略了原函数的文档字符串和注释.
def find_skills(skill_dir: str | Path = SKILLS_DIR) -> dict[str, dict[str, str]]:
skill_dir = BASE_DIR / Path(skill_dir)
if not skill_dir.is_dir():
return {}
found: dict[str, dict[str, str]] = {}
for path in sorted(skill_dir.glob('*/SKILL.md')):
if not path.resolve().is_relative_to(skill_dir.resolve()):
continue
try:
meta, _ = get_meta_and_body(path)
except (OSError, UnicodeDecodeError) as err:
print(f'[跳过] 无法读取 {path}: {err}')
continue
name = meta.get('name') or path.parent.name
found[name] = {
'description': meta.get('description') or '当前skill没有描述信息',
'path': str(path),
}
return found
- 确定扫描位置. 参数可以是字符串或
Path. 相对路径基于BASE_DIR, 传绝对路径时直接使用该路径. 根目录不存在或没有匹配文件时, 结果都是{}. - 检查路径并读取.
resolve()会跟随符号链接. 如果SKILL.md解析后指向技能根目录外, 就跳过它. 单个文件读取失败或 UTF-8 解码失败时, 打印提示后继续扫其他技能. - 登记技能条目.
meta, _中的_接收被丢弃的正文. 返回字典形如{'weekly-report': {'description': '按固定模板整理本周工作...', 'path': '.../weekly-report/SKILL.md'}}. 这里已经从磁盘读了整份文件, 按需加载节省的是传给模型的内容.
技能缺少 name 时用父目录名, 缺少 description 时用 当前skill没有描述信息. 参考讲义会直接跳过缺少描述的技能. 如果两个文件声明了相同的 name, 后扫描到的条目会覆盖前一个, 因为两次都在给同一个 found[name] 赋值.
拼接系统提示词
expand_system_promopt() 接收基础提示词 system_prompt 和可选的项目说明路径 agents_path. 返回值是一段新的系统提示词. 两部分追加内容分别判断, 所以即使技能目录为空, 已有的工作区说明也能正常加载.
def expand_system_promopt(
system_prompt: str,
agents_path: str | Path = WORKSPACE_DIR / 'AGENTS.md',
) -> str:
skills = find_skills()
if skills:
system_prompt += (
'\n\n当任务与某个技能的描述匹配时, 先调用 load_skill 读取完整说明, 再按说明执行.\n\n'
'以下是可用技能列表信息:\n\n'
+ '\n\n'.join(
f"技能名称: {name}\n"
f" 描述: {info['description']}"
for name, info in skills.items()
)
)
agents_path = Path(agents_path)
if agents_path.is_file():
agents_md = agents_path.read_text(encoding='utf-8').strip()
system_prompt += f'\n\n以下是项目的 AGENTS.md 内容(路径为:{agents_path}):\n\n{agents_md}'
return system_prompt
第一段用 join() 把每项技能的名称和描述拼成列表, 条目间空一行. 索引里的 path 留给 Python 查找文件. 加在列表前面的提示告诉模型何时调用工具, Python 代码没有对用户输入做关键词匹配.
默认的 agents_path 是 lecture-rewrite/test_workspace/AGENTS.md, 也就是 WORKSPACE_DIR 里的说明文件. 文件存在时, 当前实现读取全文并去掉首尾空白. 讲义把工作区说明截到 3000 字符, 整合版没有这个上限.
agent.py 的 build_system() 在每次 run_task() 开始时重新构建第一条 system 消息. 新增技能后, 下一轮任务就会重新扫描. 当前交互入口只在进程内保留对话列表, 因此讲义里恢复 JSON 会话后刷新系统提示词的步骤, 在这个入口里没有用到.
按名称加载正文
load_skill() 返回什么
agent.py 导入 skills 时, @tool 把 load_skill 的参数说明加入 TOOLS, 并把 Python 函数登记到 TOOL_NAMES. TOOLS 随模型请求发送, 让模型知道调用需要一个必填字符串 name. TOOL_NAMES 留在程序里, 供 excute_tool() 按名字找到函数.
下面保留装饰器和函数逻辑, 省略原函数的文档字符串.
@tool(
name='load_skill',
desc='按照skill名称获取技能正文',
args={'name': '技能的名称'},
)
def load_skill(name: str) -> str:
found = find_skills()
if name not in found:
return f'未找到技能 {name}, 可用技能: {", ".join(found) or "无"}'
path = Path(found[name]['path'])
_, body = get_meta_and_body(path)
extras = ', '.join(sorted(item.name for item in path.parent.iterdir() if item.name != 'SKILL.md'))
return (
f'{SKILL_MARK} {name}\n'
f'技能目录: {path.parent}\n'
f'附带文件: {extras or "无"}\n'
'技能中的相对路径以此技能目录为基准, 读取附件时请使用完整路径.\n\n'
f'{body}'
)
- 按名称查找.
load_skill()会重新扫描, 然后用name精确查字典. 传入weekly时, 会返回可用名称, 模型可以据此改用weekly-report. - 读取正文. 找到技能后, 再调用
get_meta_and_body()读取目标文件. 这里用_, body丢掉元数据, 和扫描时的meta, _正好相反. - 标记结果并列出附件. 返回字符串以
[skill] weekly-report开头, 接着给出技能目录的绝对路径, 附件名template.md和周报指南正文.iterdir()只列出同级条目的名字, 不打开附件.
结果会作为带有 tool_call_id 的 tool 消息回填. 下一次请求模型时, 模型读到指南里要求参考 template.md, 才能用技能目录拼出完整路径, 再调用 read_file. 这是周报示例的第三层披露.
接入权限与目录只读
登记 load_skill 的风险等级
注册决定工具能不能被发现, permissions.py 中的 TOOL_RISKS 决定工具通过硬规则后要不要人工确认. load_skill 只扫描并读取技能, 所以登记为 RiskLevel.SAFE. 它的 name 参数是技能名, 文件路径由扫描结果提供.
excute_tool() 先检查注册表和实参, 再调用 check_permission(). 即使是 SAFE 工具, 也先过硬规则. 硬规则通过后, check_risk_and_confirm() 才根据等级决定是否询问用户. 核心入口如下.
def check_permission(
name: str, args: dict[str, Any], workspace: Path = DEFAULT_WORKSPACE,
) -> PermisssionCheckResult:
result = hard_rules_check(name, args, workspace)
return check_risk_and_confirm(name, args) if result.enable_excute else result
例如, load_skill 收到 name='weekly-report' 时可直接执行. 缺少 name 或传入额外参数时, 会在实参绑定阶段失败. run_shell 有单独的风险等级, 因此 git-commit 技能加载成功后, 后续 Shell 调用仍需逐条确认.
先拒绝技能目录写入
SKILLS_DIR 和 WORKSPACE_DIR 是 lecture-rewrite/ 下的两个同级目录. check_skill_file_readonly() 对写入工具比较解析后的目标路径和技能根目录. 这条规则覆盖 SKILL.md 以及 template.md 等附件. 以下是函数里的文件写入分支.
if name in WRITE_TOOLS:
path = Path(str(args.get('path', '.'))).expanduser()
if not path.is_absolute():
path = DEFAULT_WORKSPACE / path
if path.resolve().is_relative_to(SKILLS_DIR.resolve()):
return PermisssionCheckResult(False, '技能目录只读, 不能修改技能或附件')
当前 WRITE_TOOLS 只包含 write_file. 假设模型调用 write_file 改 weekly-report/template.md, 硬规则会直接返回拒绝. 即使先前对普通工作区文件选过 always, 这个会话授权也只在风险确认阶段生效, 到不了这次被硬规则拦下的写入.
技能正文和附件会影响模型后续决策. 限制写入能避免一次任务中的错误内容被保存进技能, 影响以后加载同一技能的任务. 当前代码还会用正则拦截部分明显修改 SKILL.md 的 Shell 命令. 其余 Shell 调用依然要经过命令检查和用户确认, 写保护的范围由这些应用层规则决定.
再放行技能目录读取
技能目录在普通工作区之外, read_file 和 list_dir 因此需要额外的读权限. hard_rules_check() 先处理 Shell 检查和技能写保护, 然后只对文件工具检查路径范围.
def hard_rules_check(
name: str, args: dict[str, Any], workspace: Path = DEFAULT_WORKSPACE,
) -> PermisssionCheckResult:
if name == 'run_shell':
result = check_dangerous_shell(str(args.get('command', '')))
if not result.enable_excute:
return result
result = check_skill_file_readonly(name, args)
if not result.enable_excute:
return result
if name in PATH_TOOLS:
return check_path_out_of_workspace(
args, workspace, allow_skills=name in {'read_file', 'list_dir'},
)
return PermisssionCheckResult(True)
PATH_TOOLS 包含 read_file, write_file 和 list_dir. 只有 read_file 和 list_dir 会把 allow_skills 设为 True. check_path_out_of_workspace() 先判断目标是否位于普通工作区, 再根据 allow_skills 判断是否位于技能目录. 两处都不匹配就返回越界.
def check_path_out_of_workspace(
args: dict[str, Any], workspace: Path = DEFAULT_WORKSPACE,
allow_skills: bool = False,
) -> PermisssionCheckResult:
if 'path' not in args:
return PermisssionCheckResult(True)
target = Path(str(args['path'])).expanduser()
if not target.is_absolute():
target = workspace / target
target = target.resolve()
if target.is_relative_to(workspace.resolve()):
return PermisssionCheckResult(True)
if allow_skills and target.is_relative_to(SKILLS_DIR.resolve()):
return PermisssionCheckResult(True)
return PermisssionCheckResult(False, f'路径越界: {target}')
resolve() 会先处理 .. 和符号链接, 再比较目录归属. 例如, ../agent.py 虽然从工作区出发, 解析后却落在工作区外, 也不在技能目录里.
| 操作 | 结果 |
|---|---|
read_file 读技能模板的绝对路径 | 允许读取 |
write_file 写同一个模板 | 硬规则拒绝 |
read_file 读 ../agent.py | 路径越界 |
这个区别也解释了为什么 load_skill() 会提醒使用附件的完整路径. 单独传 template.md 会从 test_workspace/ 找, 不会从刚加载的技能目录找. 前一篇权限笔记介绍了硬规则和风险分级的关系. 三重门把关 Agent 工具权限
保留技能正文
回填时跳过即时截断
普通工具结果超过 500 字符时, cut_tool_output() 只保留首尾和省略提示. 技能正文中间可能有执行步骤, 所以整合版在 run_task() 回填工具结果前, 单独判断成功的 load_skill 调用.
is_skill = name == 'load_skill' and result.startswith(skills.SKILL_MARK)
if not is_skill:
result = cut_tool_output(result)
messages.append({
'role': 'tool', 'tool_call_id': call.id, 'content': result,
})
这里要求工具名和 [skill] 前缀同时匹配. 查不到技能时, load_skill() 返回的是错误提示, 会走普通结果分支. read_file 读取的模板也走普通分支. 参考讲义中的正文会经过普通截断, 整合版在这里增加了例外.
下一轮跳过旧工具结果清理
replace_tool_output() 先筛出普通工具结果的下标, 然后从这些下标里选出较早的结果. 所以带 [skill] 前缀的工具消息既不会被换成占位符, 也不占最近 kept 条普通结果的名额.
def replace_tool_output(
kept: int,
messages: list[dict[str, Any]],
placehoder: str = TOOL_OUTPUT_PLACEHOLDER,
) -> None:
tool_ids = [
i for i, msg in enumerate(messages)
if msg['role'] == 'tool'
and not str(msg.get('content') or '').startswith(SKILL_MARK)
]
old = tool_ids[:-kept] if kept > 0 else tool_ids
for i in old:
messages[i]['content'] = placehoder
假设历史里按顺序有一条周报技能正文, 一条旧文件读取结果和一条新目录列表. 传入 kept=1, 技能正文和新目录列表保留原样, 旧文件读取结果的 content 变成 PLACEHOLDER. 实际 Agent 默认保留最近 5 条普通工具结果. 即使 kept=0, 也只是清理全部普通结果.
注意这里检查的是 tool 消息的内容前缀. 上一层还检查了工具名, 两处判断条件有区别. 清理只修改 content, 工具消息及其调用 ID 仍在原位.
进入旧对话摘要后仍会压缩
summary_context() 默认按最近 20 轮用户对话切分. 更早的轮次在整理成摘要输入时, 每条消息仍会经过 cut_tool_output() 处理. 如果成功生成摘要, 早期技能正文所在的轮次会被摘要替代. 因此长任务继续执行时, 仍可能需要重新调用 load_skill 取得完整步骤. 前一篇笔记梳理了三层处理的基本思路. 工具内容处理与上下文
Git 提交实测
我用整合版 Agent 试提交 5 个 Python 模块, 1 个集成测试文件和 README.md. 为这次练习准备的提示词指定了 git-commit 技能和仓库要求的提交标题. 当时 Git 检查确认本地生成了 c42cd8a, 包含预期的 7 个文件, 工作区干净, 远端 main 还没有这笔提交.
实际标题是 feat: 整合Agent运行入口与集成测试, 对应技能里的格式. 学习仓库要求使用完整目录名作为前缀. 整合版默认加载的是测试工作区的 AGENTS.md, 仓库根目录的提交约定需要单独提供给模型. Git 记录可以核对最终的标题和文件范围, 这次检查没有核实 Agent 的工具调用日志.
整合版的 8 项集成测试通过. 与本课最直接相关的验证有这几项.
test_shared_registry()验证模型使用的工具清单和权限执行器查询的函数表来自同一套注册逻辑.test_skill_loading_and_prompt()验证实际能加载周报技能, 返回附件名和技能目录, 系统提示词里有技能名称和工作区说明.test_skill_directory_readonly()模拟已记住写入授权, 确认写模板仍被拒绝, 同一份模板可以读出原文.test_context_keeps_skill()以kept=0验证普通结果被替换, 技能正文保留.test_agent_keeps_full_skill_and_refreshes_system()用超过截断阈值的模拟技能结果验证正文完整回填, 并检查工具调用 ID 和系统提示词刷新.
这些测试使用模拟模型, 能检查本地代码如何处理技能调用. 模型只凭描述能否自行选中技能, 还没有单独验证.