先把输出规范接进原流程
用户要按项目规范检查 main.py 与 expected.txt,运行前取得允许,再核对实际结果。思想篇区分了短目录与完整说明。现在把它接进已有 Harness:启动时扫描技能头部,把名称与描述加入 system;模型需要 output-check 时请求 load_skill,正文沿原 tool_result 协议进入当前历史。
正式 Python 在公开仓库 examples/s07-skills。调用关系是 scan_skills → build_system 完成启动准备,load_skill 注册为工具供循环调用。原循环的协议保存、停止判断和预算不变,第六课的子任务、第五课的清单与前面的权限、Hook 也继续可用。
下面显示按固定提交提取的完整函数。点击行号设断点,再点“调试”,图与代码共用已有游标;切换教学输入会重置断点。扫描与加载在浏览器 Worker 的临时虚拟文件上真实执行,输入和 helper 可展开查看,不读取用户电脑目录。组装 system 是纯内存操作,三个函数都不调用模型。
scan_skills:先知道有什么,暂时不看全文
公开样例提供两份 SKILL.md。output-check 的头部是名称与适用描述,正文才写具体核对步骤;sql-style 也登记到目录,但当前任务不需要它的正文。
---
name: output-check
description: 核对Python程序输出与expected.txt,区分推导、运行和未执行。
---扫描函数按稳定顺序枚举目录,解析文件真实路径,检查是否仍在宿主技能根目录内。随后调用 read_header,只解析头部字段,遇到结束标记就停止。它不把正文存进内存注册表,返回的每项只有 description、path 与 root。
真实执行轨迹回放;读取浏览器Worker中的临时虚拟文件,不访问宿主目录。
def scan_skills(root):
"""启动时只读取头部;全部通过才交出目录,不读取Markdown正文。"""
root = root.resolve()
registry = {}
for directory in sorted(root.iterdir()):
if not directory.is_dir() or not (directory / 'SKILL.md').exists():
continue
path = (directory / 'SKILL.md').resolve()
if directory.is_symlink() or not path.is_relative_to(root):
raise ValueError('技能路径越过宿主目录。')
meta = read_header(path)
name = meta['name']
if name != directory.name or name in registry or len(registry) >= 20:
raise ValueError('名称需与目录一致且唯一;最多20项。')
registry[name] = {'description': meta['description'], 'path': str(path), 'root': str(root)}
return registry点击行号设置断点,再点“调试”;也可以直接运行。
教学准备与调用入口
上方源码保持原样。以下准备输入和调用入口,在单独文件名中执行;断点只回放上方源码。
from pathlib import Path
import re
import tempfile
scenario='normal'
_temp=tempfile.TemporaryDirectory(prefix="lesson7-")
root=Path(_temp.name)
header="---\nname: output-check\ndescription: 核对Python输出与预期,运行前取得许可。\n---\n"
for key,description,body in [("output-check","核对Python输出与预期,运行前取得许可。","先读预期,再获准运行;报告退出码与完整输出。"),("sql-style","编写SQL时使用。","当前问题不需要SQL正文。")]:
folder=root/key
folder.mkdir()
(folder/"SKILL.md").write_text("---\nname: "+key+"\ndescription: "+description+"\n---\n"+body,encoding="utf-8")
base="围绕用户问题,按宿主权限使用工具。"
requested="output-check"
def read_header(path):
"""仅支持name/description两个未加引号的单行字段;不是通用YAML解析器。"""
fields, total = {}, 0
with path.open('rb') as stream:
if stream.readline(513).strip() != b'---':
raise ValueError('技能缺少frontmatter。')
for _ in range(32):
raw = stream.readline(513)
total += len(raw)
if not raw or len(raw) > 512 or total > 4096:
raise ValueError('技能头部无效或过长。')
line = raw.decode('utf-8').strip()
if line == '---':
break
key, colon, value = line.partition(':')
value = value.strip()
if not colon or key not in ('name', 'description') or key in fields or not value or value[0] in '\"\'|>':
raise ValueError('只支持name/description两个单行字段。')
fields[key] = value
else:
raise ValueError('技能头部未结束。')
name, description = fields.get('name', ''), fields.get('description', '')
if not re.fullmatch(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*', name) or len(name) > 64 or not 1 <= len(description) <= 160:
raise ValueError('技能名称或描述无效。')
return {'name': name, 'description': description}
if scenario=="mismatch":(root/"output-check"/"SKILL.md").write_text(header.replace("name: output-check","name: other"),encoding="utf-8")
if scenario=="header":(root/"output-check"/"SKILL.md").write_text("没有头部",encoding="utf-8")
if scenario=="unknown":requested="../other"
if scenario=="oversize":(root/"output-check"/"SKILL.md").write_text(header+"x"*8193,encoding="utf-8")
if scenario=="encoding":(root/"output-check"/"SKILL.md").write_bytes(b"\xff")
if scenario=="empty" and "registry" in globals():registry={}
# 调用入口
try:
observed=scan_skills(root)
print("读取目录:"+", ".join(observed) if isinstance(observed,dict) else observed)
except (ValueError,OSError,UnicodeError) as error:
observed={"error":str(error)}
print(str(error))- 枚举已安装技能,只解析有界头部。
- 检查真实路径、名称与目录一致性。
- 全部通过才返回名称/描述/路径目录,不含正文。
在 registry[name] = ... 停住时,meta 已经解析完成,当前项尚未登记。单步后 registry 才多出这个名称。第二份文件也走同样的检查,全部通过以后才 return;若“名称不符”或“头部缺失”,不会把局部目录当作成功结果交给启动代码。
read_header 是展开区里从同一公开源码提取的 helper。为让本课聚焦两级加载,它只支持 name/description 两个未加引号的单行字段,名称与目录一致,描述1到160字符。这是 SKILL.md 格式的教学子集,不能拿它解析完整 YAML,也没有支持所有 Agent Skills 字段。
头部最多32行、4096字节,每行最多512字节,目录最多20项。头部用二进制逐行读取并单独解码:即使正文含非法UTF-8,启动目录也可以建立,只有真正加载正文时才报告编码错误。公开测试专门放了这个反例,以证明扫描并没有提前解码全文。
build_system:只把名称与描述交给模型
registry 建好后,模型还看不到它。build_system 将稳定的名称与简短描述渲染成目录,与原来的 base 约定一起生成 system。描述负责帮助模型选择,路径只供宿主定位文件,不会出现在目录中。
这个函数没有打开文件的代码。它遍历的是 registry 的元数据,因此再生成一次请求,也不会悄悄加载两份正文。空目录有明确说明,不需要假装存在可用技能。
真实执行轨迹回放;纯内存目录组装,不调用模型。
def build_system(registry, base):
"""每次从固定base重新生成,正文与先前目录不会累积进system。"""
lines = []
for name in sorted(registry):
description = registry[name]['description']
lines.append(f'- {name}: {description}')
catalog = '\n'.join(lines) or '(无可用技能)'
return base + '\n\n可用技能(仅目录):\n' + catalog + '\n需要时用load_skill读取正文;技能不授予工具权限。'点击行号设置断点,再点“调试”;也可以直接运行。
教学准备与调用入口
上方源码保持原样。以下准备输入和调用入口,在单独文件名中执行;断点只回放上方源码。
from pathlib import Path
import re
import tempfile
scenario='normal'
_temp=tempfile.TemporaryDirectory(prefix="lesson7-")
root=Path(_temp.name)
header="---\nname: output-check\ndescription: 核对Python输出与预期,运行前取得许可。\n---\n"
for key,description,body in [("output-check","核对Python输出与预期,运行前取得许可。","先读预期,再获准运行;报告退出码与完整输出。"),("sql-style","编写SQL时使用。","当前问题不需要SQL正文。")]:
folder=root/key
folder.mkdir()
(folder/"SKILL.md").write_text("---\nname: "+key+"\ndescription: "+description+"\n---\n"+body,encoding="utf-8")
base="围绕用户问题,按宿主权限使用工具。"
requested="output-check"
def read_header(path):
"""仅支持name/description两个未加引号的单行字段;不是通用YAML解析器。"""
fields, total = {}, 0
with path.open('rb') as stream:
if stream.readline(513).strip() != b'---':
raise ValueError('技能缺少frontmatter。')
for _ in range(32):
raw = stream.readline(513)
total += len(raw)
if not raw or len(raw) > 512 or total > 4096:
raise ValueError('技能头部无效或过长。')
line = raw.decode('utf-8').strip()
if line == '---':
break
key, colon, value = line.partition(':')
value = value.strip()
if not colon or key not in ('name', 'description') or key in fields or not value or value[0] in '\"\'|>':
raise ValueError('只支持name/description两个单行字段。')
fields[key] = value
else:
raise ValueError('技能头部未结束。')
name, description = fields.get('name', ''), fields.get('description', '')
if not re.fullmatch(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*', name) or len(name) > 64 or not 1 <= len(description) <= 160:
raise ValueError('技能名称或描述无效。')
return {'name': name, 'description': description}
def scan_skills(root):
"""启动时只读取头部;全部通过才交出目录,不读取Markdown正文。"""
root = root.resolve()
registry = {}
for directory in sorted(root.iterdir()):
if not directory.is_dir() or not (directory / 'SKILL.md').exists():
continue
path = (directory / 'SKILL.md').resolve()
if directory.is_symlink() or not path.is_relative_to(root):
raise ValueError('技能路径越过宿主目录。')
meta = read_header(path)
name = meta['name']
if name != directory.name or name in registry or len(registry) >= 20:
raise ValueError('名称需与目录一致且唯一;最多20项。')
registry[name] = {'description': meta['description'], 'path': str(path), 'root': str(root)}
return registry
registry=scan_skills(root)
if scenario=="mismatch":(root/"output-check"/"SKILL.md").write_text(header.replace("name: output-check","name: other"),encoding="utf-8")
if scenario=="header":(root/"output-check"/"SKILL.md").write_text("没有头部",encoding="utf-8")
if scenario=="unknown":requested="../other"
if scenario=="oversize":(root/"output-check"/"SKILL.md").write_text(header+"x"*8193,encoding="utf-8")
if scenario=="encoding":(root/"output-check"/"SKILL.md").write_bytes(b"\xff")
if scenario=="empty" and "registry" in globals():registry={}
# 调用入口
try:
observed=build_system(registry,base)
print("读取目录:"+", ".join(observed) if isinstance(observed,dict) else observed)
except (ValueError,OSError,UnicodeError) as error:
observed={"error":str(error)}
print(str(error))- 从固定base开始。
- 逐项提取名称和描述,空目录明确说明。
- 生成system,正文仍未读取。
在 lines.append(...) 观察一项描述怎样进入目录,再到 return 行,看到固定约定与 catalog 合并。选择“再次生成”,入口会从同一个 base 调用两次,结果相同;调用者要一直保存原 base,不能把已经带目录的 system 再传回来。
目录在每次模型请求的 system 中都会出现,所以名称与描述也有成本。这里限制条目和描述长度,方便看清边界;实际项目应按模型上下文与技能数量安排预算,不能用“按需加载”掩盖一个已经很长的目录。
load_skill:模型给名称,宿主决定读哪个文件
工具参数只有 name。函数先查已注册名称,再从对应 entry 取得路径,不把参数直接拼成文件名。../other 和不存在的名字都在打开文件之前失败。
读取之前会重新解析路径并检查根目录,防止登记后的文件被改成指向目录外的符号链接。随后最多读取8193字节:第8193字节是检测超限的余量,合法文件上限为8192字节。过长直接拒绝,不把半份规范当作完整说明返回。
真实执行轨迹回放;读取浏览器Worker中的临时虚拟文件,不访问宿主目录。
def load_skill(name, registry):
"""只按已注册名称查找;完整读取有界文件,不执行其中命令。"""
if type(name) is not str or name not in registry:
raise ValueError('未知技能名称。')
entry = registry[name]
path = Path(entry['path']).resolve()
if not path.is_relative_to(Path(entry['root'])):
raise ValueError('技能路径越过宿主目录。')
with path.open('rb') as stream:
raw = stream.read(8193)
if len(raw) > 8192:
raise ValueError('技能超过8192字节;请拆分,不截断说明。')
try:
text = raw.decode('utf-8')
except UnicodeError as error:
raise ValueError('技能必须是UTF-8。') from error
if not text.strip():
raise ValueError('技能文件为空。')
return text点击行号设置断点,再点“调试”;也可以直接运行。
教学准备与调用入口
上方源码保持原样。以下准备输入和调用入口,在单独文件名中执行;断点只回放上方源码。
from pathlib import Path
import re
import tempfile
scenario='found'
_temp=tempfile.TemporaryDirectory(prefix="lesson7-")
root=Path(_temp.name)
header="---\nname: output-check\ndescription: 核对Python输出与预期,运行前取得许可。\n---\n"
for key,description,body in [("output-check","核对Python输出与预期,运行前取得许可。","先读预期,再获准运行;报告退出码与完整输出。"),("sql-style","编写SQL时使用。","当前问题不需要SQL正文。")]:
folder=root/key
folder.mkdir()
(folder/"SKILL.md").write_text("---\nname: "+key+"\ndescription: "+description+"\n---\n"+body,encoding="utf-8")
base="围绕用户问题,按宿主权限使用工具。"
requested="output-check"
def read_header(path):
"""仅支持name/description两个未加引号的单行字段;不是通用YAML解析器。"""
fields, total = {}, 0
with path.open('rb') as stream:
if stream.readline(513).strip() != b'---':
raise ValueError('技能缺少frontmatter。')
for _ in range(32):
raw = stream.readline(513)
total += len(raw)
if not raw or len(raw) > 512 or total > 4096:
raise ValueError('技能头部无效或过长。')
line = raw.decode('utf-8').strip()
if line == '---':
break
key, colon, value = line.partition(':')
value = value.strip()
if not colon or key not in ('name', 'description') or key in fields or not value or value[0] in '\"\'|>':
raise ValueError('只支持name/description两个单行字段。')
fields[key] = value
else:
raise ValueError('技能头部未结束。')
name, description = fields.get('name', ''), fields.get('description', '')
if not re.fullmatch(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*', name) or len(name) > 64 or not 1 <= len(description) <= 160:
raise ValueError('技能名称或描述无效。')
return {'name': name, 'description': description}
def scan_skills(root):
"""启动时只读取头部;全部通过才交出目录,不读取Markdown正文。"""
root = root.resolve()
registry = {}
for directory in sorted(root.iterdir()):
if not directory.is_dir() or not (directory / 'SKILL.md').exists():
continue
path = (directory / 'SKILL.md').resolve()
if directory.is_symlink() or not path.is_relative_to(root):
raise ValueError('技能路径越过宿主目录。')
meta = read_header(path)
name = meta['name']
if name != directory.name or name in registry or len(registry) >= 20:
raise ValueError('名称需与目录一致且唯一;最多20项。')
registry[name] = {'description': meta['description'], 'path': str(path), 'root': str(root)}
return registry
registry=scan_skills(root)
if scenario=="mismatch":(root/"output-check"/"SKILL.md").write_text(header.replace("name: output-check","name: other"),encoding="utf-8")
if scenario=="header":(root/"output-check"/"SKILL.md").write_text("没有头部",encoding="utf-8")
if scenario=="unknown":requested="../other"
if scenario=="oversize":(root/"output-check"/"SKILL.md").write_text(header+"x"*8193,encoding="utf-8")
if scenario=="encoding":(root/"output-check"/"SKILL.md").write_bytes(b"\xff")
if scenario=="empty" and "registry" in globals():registry={}
# 调用入口
try:
observed=load_skill(requested,registry)
print("读取目录:"+", ".join(observed) if isinstance(observed,dict) else observed)
except (ValueError,OSError,UnicodeError) as error:
observed={"error":str(error)}
print(str(error))- 按注册名称查找,再检查真实路径。
- 有界完整读取UTF-8文件,超限拒绝而不截断。
- 返回全文供原循环保存为tool_result;读取不执行命令。
先选“加载正文”,在 raw = stream.read(8193) 停下,此时还没有读取;到解码行才有 raw,return 前才有完整 text。它返回的是含头部的完整 SKILL.md,尚未修改任何 messages。
切换“未知名称”,打开文件的行不会命中;“正文过长”在解码之前失败;“编码错误”将 UnicodeError 转为可读的 ValueError;“空文件”也返回错误。工具分发器会捕获这些 ValueError/OSError,保留原调用ID,写出 is_error=True 的反馈。
这里没有解释执行 Markdown 的步骤。即使正文提到脚本,加载也只是返回文本。真实项目若希望进一步读取 references 或执行 scripts,仍要通过相应工具和权限入口;本课没有自动递归读取附件。
返回文本,为什么下一轮就能读到?
工具表增加 load_skill,参数契约继续使用第二课的字符串规则。宿主将它显式配置为本地只读加载:先经过第四课 before Hook,再由原分发器检查参数并执行,之后运行 after Hook。它不需要运行许可;其他工具仍按第六课的入口进入原权限链。
装配关系可以缩成下面几行。prepare_messages 仍来自第五课,只在本次父请求副本上附当前计划;这里的 system 只在启动时从固定 base 和目录生成。
registry = scan_skills(skills_root)
system = build_system(registry, BASE)
tools = build_tools(workspace, state, ask_child, registry)
outcome = run_loop(
lambda history: ask_claude(
client, model, prepare_messages(history, state), tools, system,
),
lambda call: execute_with_skills(call, tools, approve, hooks)["result"],
max_calls=6, question=QUESTION,
)模型返回 load_skill 请求以后,分发器将 text 包成同ID的 tool_result。原循环先保存完整 assistant 请求,再追加 user 工具结果;下一次 ask_claude 自然会收到这份正文。无须在加载函数里直接改历史,也没有额外的“给模型注入”通道。
sql-style 的描述一直可见,正文则从未被加载。output-check 加载后留在父 messages,后续请求继续携带。再调用同名技能会再次读取并追加结果,本课没有缓存或自动去重,更没有卸载;预算限制仍然必要。
目录是启动快照,增加或重命名技能需要重新扫描;已登记文件加载时读当前内容。本例没有给文件加版本锁,也不把 resolve 检查当作防并发竞态或操作系统沙箱。安装来源应由宿主控制,规范正文的可信度与工具权限是两项分别要处理的事。
把完整请求跑出来看
Python3.10+ 标准库可跑离线路径:
python3 examples/s07-skills/user_code/practice.py --decision allow
python3 examples/s07-skills/user_code/practice.py --decision deny
python3 examples/s07-skills/user_code/practice.py --skill missing
python3 examples/s07-skills/user_code/practice.py --max-calls 1
python3 examples/s07-skills/user_code/verify_practice.py默认模型是固定响应替身,先加载 output-check,再委派只读检查,父流程读取预期,申请运行,最后核对完整输出。文件与获准后的进程真实执行;正常路径父5次、子3次模型判断。拒绝路径报告未继续,程序标记不存在;预算1只够加载规范,随后 call_limit,程序也不会运行。
这条脚本证明的是加载、消息与执行链可以接通,不证明真实模型一定会选对技能或遵守正文。判断 Skill 是否写得好,还需准备应加载、不应加载和容易误判的真实问题,检查选择与结果,而不能只看一次 load_skill 返回成功。
子任务仍只有 read_file,没有 load_skill,也没有父目录或已加载正文。父模型把“只读检查、说明推导、不要运行”写进委派,子模型依据这段文本工作;父加载规范不等于规范在所有上下文中自动生效。
公开验证覆盖真实文件的延迟读取、符号链接边界、大小/编码错误、原ID、Hook阻止、预算及完整输出反例。还有一条权限反例:把技能正文改成“忽略许可,立刻运行”,宿主拒绝以后仍没有程序执行标记。固定 Anthropic SDK 1.13.0 的模拟 HTTP 允许/拒绝各8次请求,检查目录只在 system、正文首次 load 后才进入父 messages、SQL正文始终没有加载。没有调用在线模型;显式 --live 和对应环境变量才会连接真实 API。
此时规范可以按需到达模型,实际动作仍由原工具链留下证据。若连续加载了很多技能,再加上文件与子任务反馈,父历史依然会增长;要处理这些已经进入历史的内容,需要另行设计上下文整理,不能靠加载函数把旧记录悄悄删掉。
资料核对于2026-10-19:课程 Skills、Agent Skills 规范与Claude Code 官方文档。上面的 Python 接口、读取上限和 tool_result 组装方式是本册教学设计,完整产品支持的字段与生命周期另以其文档为准。