Day 19 / 共 20 天 · 第 4 周 平台化与收官

从零开发一个新 Agent + L4 Skills

学了 18 天,今天把知识用起来——亲手 scaffold 一个新 agent,跑通全流程。今天我们不停在"命令示意",而是把 scaffold 命令、6 pattern 推导表、注册表懒加载、L4 skills 挂载全部翻到真源码逐行读。你会看到平台的一条核心信念:新住户"薄到只有 30 行",全靠底座工厂 + 显式注册表把脏活兜住。盖完这户,Day 20 就把 20 天串成一张完整地图收官。

📍 你在 20 天里的位置(第 4 周:平台化与收官)
D17 部署 D18 CI 闸门 D19 起新 Agent + Skills 番外 独立仓实战 D20 收官
💡 用一个类比先兜住今天(延续「盖楼/物业」世界观) 起一个新 agent,就像在这栋楼里装修一户新房scaffold 脚手架 = 物业给你一套「毛坯 + 基础装修包」——水电、消防、门牌、访客登记(护栏/envelope/可观测)全预装好,省你 80% 力气;填 server 的 _invoke = 你只管摆自己的家具、走自己的动线(业务图);加评测样本 + Critic = 入住前必过的验房(准入门槛);去物业登记 = 把你这户写进楼里的住户名册(一个显式 dict),前台才认;最后 L4 Skills = 教物业的 AI 助手"住户说人话时该去按哪户门铃"。今天每一步都会翻到真源码。
🤔 先说痛点:为什么"起一个新 agent"在别的项目里那么痛? 没有平台时,起一个能上生产的 agent 你得手写:HTTP 路由、鉴权、限流、预算、请求脱敏、case 落库、Prometheus 指标、SSE 流式、错误兜底、成本记账……这些跟你的业务毫无关系,却占了 90% 的代码,而且每个 agent 重来一遍、还容易漏(漏脱敏就出事故)。本框架的答案是:这 90% 全下沉到 toolkit 工厂 + 平台注册表,业务作者只写那 10% 的业务图。今天就是逐行验证这句话。
L01

两种起 agent 的方式

框架提供两条路径,先搞清你该走哪条:

① 平台 monorepo 内加

agent 直接进 apps/,成为 uv workspace 成员。用 agentctl new agent 脚手架 + agent-onboarding skill 指路。平台核心团队、大多数情况走这条。

② 独立 git 仓开发

在自己的仓里,通过公司 Nexus 一行 BOM 依赖用 toolkit。用 standalone-repo-agent skill + examples/standalone-agent-template 模板。没有 monorepo 权限时走这条(见番外篇 Day 21)。

今天主要讲①(monorepo 内),②见 番外篇 9 步。回忆 Day 05:无论哪条,agent 的目录结构完全一致(state/builder/nodes/specialists/prompts/server),所以学会一种就都会。

一个真实数字先兜个底:打开平台看 gov_agents_server/main.py:62AGENT_REGISTRY,现在已经挂了 18 个 agent(sre-rca / risk-reviewer / doc-checker / biz-link-gov / agent-creator / spec-author…)。它们全是照今天这套路子加进来的——你今天学的不是玩具流程,是平台真实的扩张方式。
L02

决策树 + scaffold 命令(读真实签名)

agent-onboarding skill 会带你走一棵决策树。核心几问(呼应 Day 01 的分层铁律):

Q1:这个能力是横切的(≥2 个 agent 会用)吗?

是 → 应该放进 toolkit(L2),不是新 agent。否 → 继续。

Q2:是单个 agent 的业务逻辑吗?

是 → 放 apps/(L3),起新 agent。

Q3:需要严格的规格评审流程吗?

是 → 走 OpenSpec cookbook(Day 20)。否 → 走快速 cookbook(docs/new-agent-cookbook.md)。

为什么先问"是不是横切能力"? 这是 Day 01 铁律的直接应用——一个能力多个 agent 都要用,它就该沉到 toolkit,而不是在某个 agent 里写死(否则第二个 agent 要用就得复制,违反渐进抽象)。动手前想清楚"这东西属于哪一层",比急着写代码重要。

决定了"起新 agent",就用 CLI 脚手架 agentctl new agent。它的真实签名在 packages/gov-agents-cli/src/gov_agents_cli/commands/new_agent.py:51,参数比你想的多——直接读源码里的 Typer option(裁剪自 new_agent()):

def new_agent(                                                    # new_agent.py:51
    name: Annotated[str, typer.Argument(help="agent 名 · kebab-case · 如 release-notes-bot")],
    pattern: Annotated[str, typer.Option("--pattern",
        help="6 选 1 · supervisor / rag / monitor / doc / business-data / minimal")] = "supervisor",
    starters: Annotated[str | None, typer.Option("--starters",
        help="comma-sep · 子集 of {core, supervisor, rag, monitor, doc, business-data}")] = None,
    specialists: Annotated[str, typer.Option("--specialists",
        help="comma-sep snake_case · supervisor/monitor pattern 用")] = "",
    critic: Annotated[str, typer.Option("--critic", help="4 选 1 · l1 / l2 / both / off")] = "both",
    create_change: Annotated[str | None, typer.Option("--create-change",
        help="同时起 OpenSpec change · 传 change-name 如 add-release-notes-bot")] = None,
    dry_run: Annotated[bool, typer.Option("--dry-run", help="只 echo 不写文件")] = False,
) -> None:

逐个翻译这些开关,它们全是前几天学过的东西:--pattern 选你的 agent 长啥样(6 选 1,L03 拆);--starters 就是 Day 05 讲的 BOM extras(要哪几组能力);--specialists 是 Day 05 的专家名字(会帮你生成对应 specialists/*.py);--critic both 默认给你装上 Day 07 的双层 Critic--create-change 顺手起一个 Day 20 的 OpenSpec 提案--dry-run 先干跑看要生成啥、不落盘。

⚖️ 设计取舍①:为什么 --pattern 默认值是 "supervisor" 而不是 "minimal" 你可能觉得"最小骨架"当默认更安全。但平台的真实经验是:90% 的业务 agent 最后都长成"多专家并行 + Synth + Critic"那个样子(就是 sre-rca 那张图)。把最常用的形态设成默认,多数人一条命令就到位;真要极简的人才显式写 --pattern minimal。默认值应该服务大多数,而不是服务"理论上最安全"。
⚖️ 设计取舍②:为什么 --critic 默认 both(L1+L2 全开)? Critic 是要花钱的(L2 调 Haiku)。但平台宁可默认全开、让你显式 --critic off 关掉,也不默认关。因为"防幻觉"是"可信"的底线,把它设成 opt-out(默认开、要关得主动)而非 opt-in——防止有人图省事忘了加,产出一个会胡说的 agent。这跟"评测样本 + Critic 是准入门槛"(Day 10)是同一条纪律:可信靠机制默认强制,不靠自觉。
L03

6 个 pattern:翻开推导表源码

上面 --pattern 6 选 1 到底是什么?别停在文档,直接读推导规则的真源码 packages/gov-agents-cli/src/gov_agents_cli/_patterns.py:9

# _patterns.py:9 —— 6 个 pattern · 对应 6 starter(minimal 不带 supervisor)
PATTERNS: frozenset[str] = frozenset(
    {"supervisor", "rag", "monitor", "doc", "business-data", "minimal"})

# _patterns.py:22 —— pattern → 默认 starter 推导表(用户没传 --starters 走这里)
PATTERN_DEFAULTS: dict[str, list[str]] = {
    "supervisor":    ["core", "supervisor"],
    "rag":           ["core", "supervisor", "rag"],
    "monitor":       ["core", "supervisor", "monitor"],
    "doc":           ["core", "supervisor", "doc"],
    "business-data": ["core", "supervisor", "business-data"],
    "minimal":       ["core"],
}

逐行读: PATTERNSfrozenset 锁死这 6 个合法值——不可变,天然当"白名单"用,传别的名字直接报错。 PATTERN_DEFAULTS 是核心:每个 pattern 映射到一组 BOM extras(starter 包)。看规律——除了 minimal,其它 5 个都自带 ["core", "supervisor"],再叠各自领域包(rag 加检索、monitor 加监控、doc 加文档、business-data 加业务库)。这正好印证了 Day 05 那句"薄业务 + 厚 toolkit":pattern 的本质就是"帮你勾好该装哪几组 toolkit 能力"

紧接着的 resolve_starters()_patterns.py:34)有一处很有意思的强制:

def resolve_starters(pattern: str, user_starters: str | None) -> list[str]:  # _patterns.py:34
    if user_starters:
        result = [s.strip() for s in user_starters.split(",") if s.strip()]
        unknown = [s for s in result if s not in KNOWN_STARTERS]
        if unknown:
            raise ValueError(f"unknown starters: {unknown} · known: {sorted(KNOWN_STARTERS)}")
        if "core" not in result:            # ← core 必带 · 自动补齐
            result.insert(0, "core")
        return result
    ...
    return list(PATTERN_DEFAULTS[pattern])
💡 逐行讲:这段在守两条底线 第一条:你手写的 starter 只要有一个不在 KNOWN_STARTERS 白名单里,立刻 raise ValueError 并列出合法值——早失败、给明确提示,绝不生成一个"装了不存在的包"的坏骨架。第二条:无论你怎么写,core 一定被补进去(result.insert(0, "core"))。因为 core 是可信底座的地基(critic/failsafe/llm/api 全在里面),漏了它 agent 根本跑不起来——所以框架不信任用户一定记得写,替你兜住。
⚠️ 边界/易错点:pattern 名写错会怎样?_patterns.py:50 的兜底:if pattern not in PATTERN_DEFAULTS: raise ValueError(f"invalid pattern: {pattern!r} · choose one of: {sorted(PATTERNS)}")。它不会默默给你一个空骨架,而是把 6 个合法值全列出来让你改。这是"fail-fast + 可操作报错"的典型——错误信息本身就是修复指南。
数据结构:pattern → starters 映射(PATTERN_DEFAULTS) supervisor rag monitor doc minimal core supervisor + rag + monitor + doc / … core 永远打底(resolve_starters 强制补齐)· minimal 只要 core
图注:pattern 不是"模板文件",而是"该勾哪几组 toolkit 能力包"的推导表——本质是 BOM extras 的预设组合。
L04

复制模板 + 命名铁律(读 cookbook 真 diff)

快速通道其实不一定用 scaffold——cookbook docs/new-agent-cookbook.md §1 推荐最朴素的办法:挑一个业务模式最像你的现有 agent,cp -r 一份来改。真命令(裁自 cookbook 第 53-68 行):

# cookbook §1 · 复制模板(5 min)
cp -r apps/sre-rca-agent apps/my-agent
cd apps/my-agent
# 顶层 Python 包名必须独特(snake_case · 不能跟其它 app 撞)
mv sre_rca my_agent
⚠️ 命名铁律(cookbook 第 68 行硬性规则) cookbook 原文:"每个 app 顶层包名必须独特 · pip install -e 同名包互相覆盖,只有一个能 import。" ✅ 正例 sre_rca / biz_link_gov / my_agent;❌ 反例 app / agent / src为什么这么严? 因为所有 app 装在同一个 uv workspace 里(可编辑安装),两个包都叫 agent 时,Python 的 import 只会命中一个——另一个 agent 神秘地"消失",且报错极难懂。这是 Day 02 讲的"可编辑安装"机制留下的坑,用命名纪律绕过。

复制完,要让平台"认"这户,cookbook §1 列了几处真实 diff。先是根 workspace 登记(这是 Day 02 的 uv workspace):

# pyproject.toml (repo root) —— 加你的 agent
[tool.uv.workspace]
members = [ ...,
+ "apps/my-agent" ]

[tool.pytest.ini_options]
testpaths = [ ...,
+ "apps/my-agent/tests" ]        # ← 让 CI 跑你的测试(Day 18)

再是向 supervisor 注册(下一讲 L06 会把这张表翻个底朝天):

# packages/gov-agents-server/src/gov_agents_server/main.py
AGENT_REGISTRY: dict[str, tuple[str, str]] = { ...,
+ "my-agent": ("my_agent.server", "build_router") }
为什么改动要"同一个 PR"? cookbook checklist(第 539 行)反复强调:workspace members、testpaths、AGENT_REGISTRY、以及可选的 OpenSpec change,必须跟代码同 commit。因为漏一个,CI 就会失败或平台起不来——把它们绑在一起,就没有"代码进了、注册忘了"的半吊子状态。这是 Day 17/18 的工程纪律。
L05

server.py:整个 agent 的入口只有 30 行

🤔 痛点重演:一个"能上生产"的 HTTP agent 通常要写多少行? 鉴权、限流、预算闸、请求脱敏、case 落库、Prometheus 三件套、SSE 流式、老路径兼容、错误转 500……随便写都是几百行,还每个 agent 重来。cookbook 的答案是:这些一行都别写,全交给工厂 build_v1_router

看 cookbook §7 给的最小可跑范例 echo-agentdocs/new-agent-cookbook.md:420 起,这是完整的 30 行 server.py,没省略):

"""echo-agent · 最小 v1 agent 示例 · 把 body.msg 回显。"""
from __future__ import annotations
from typing import Any
from ai_trust_toolkit.api import (
    AgentOutcome, CallContext, CaseStore, CostTracker,
    build_v1_router, make_case_store_from_env)
from fastapi import APIRouter

async def _invoke(body: dict[str, Any], ctx: CallContext, tracker: CostTracker) -> AgentOutcome:
    msg = body.get("msg", "")
    return AgentOutcome(
        status="success",
        result={"echo": msg, "caller": ctx.user_id, "msg_len": len(msg)})

def build_router(case_store: CaseStore | None = None) -> APIRouter:
    router = APIRouter(tags=["echo"])
    router.include_router(
        build_v1_router(
            agent_name="echo",
            invoke_fn=_invoke,
            case_store=case_store or make_case_store_from_env()))
    return router

逐块读: 你只写一个 _invoke——签名固定收三样:body(请求体)、ctx(调用上下文,里面有 user_id / anthropic_api_key)、tracker(成本记账器)。 干完活返回一个 AgentOutcome(status + result)。 build_router 里把 _invoke 交给工厂 build_v1_router(agent_name=..., invoke_fn=_invoke, ...),收工。真实业务 agent 只是把 _invoke 内部换成"校验 body → await graph.ainvoke(...) → 翻译成 result",其余一字不变。

cookbook 紧接着列了工厂自动替你干的活(第 240 行"工厂自动负责"),照抄一段:

✅ 鉴权(X-User-Id / PLATFORM_AUTH_MODE 3 档)
✅ envelope 构造(case_id ULID / metrics from tracker.finalize / trace_id)
✅ case 持久化(成功 + critic_end + agent 内部 failed 都写 · 422/401 不写)
✅ Prometheus 指标(agent_invoke_cost_usd_total / _tokens_total / _duration_seconds)
✅ 老路径 /agents/my-agent/run + Deprecation: true header
✅ 跨 agent / 跨 user 404(防 case_id 枚举泄漏)
⚖️ 设计取舍:为什么 _invoke 只让你返 AgentOutcome,异常直接往外抛,而不是自己 try/except 拼 500? cookbook 反模式表(第 525 行)明确点名:"自己拼 response BaseModel + raise HTTPException(500)" 是反模式,正确做法是 return AgentOutcome(status="failed", ...) 或直接让异常冒泡给工厂。为什么? 因为错误的 envelope 形状、状态码、落不落库这些"横切策略"必须全平台一致——只能有一个地方(工厂)说了算。每个 agent 自己拼,迟早拼得五花八门,监控和 case 查询就乱了。把"怎么响应错误"从业务手里收走,是保证平台一致性的代价,也是它的价值。
📝 举个例子:调一次 echo-agent 拿到的真实 envelope curl -X POST /v1/agent/echo/invoke -H "X-User-Id: dev" -d '{"msg":"hi"}' → 工厂返回:
{"case_id":"01HXY...", "agent":"echo", "status":"success", "result":{"echo":"hi","caller":"dev","msg_len":2}, "metrics":{...}, "trace_id":"01HZZ..."}
注意 case_id / trace_id / metrics 你一个字没写——全是工厂加的。你只贡献了 result 里那三个字段。
L06

注册表走读:一个 dict 就是全楼住户名册

Day 12 讲过"发现机制是显式注册表,不是自动扫描"。今天把这张表翻开看真身。它就是 gov_agents_server/main.py:62 一个普通 dict(裁剪,真有 18 条):

# main.py:62 —— Agent 注册表(名字 → (模块路径, 工厂函数名))
AGENT_REGISTRY: dict[str, tuple[str, str]] = {
    "sre-rca":        ("sre_rca.server",        "build_router"),
    "risk-reviewer":  ("risk_reviewer.server",  "build_router"),
    "doc-checker":    ("doc_checker.server",    "build_router"),
    "biz-link-gov":   ("biz_link_gov.server",   "build_router"),
    "agent-creator":  ("agent_creator.server",  "build_router"),
    "spec-author":    ("spec_author.server",    "build_router"),
    # ... 共 18 条 ...
    "my-agent":       ("my_agent.server",       "build_router"),   # ← 你加的这行
}
key(如 "my-agent")
agent 的对外注册名(kebab-case)。它决定 URL:/v1/agent/my-agent/invoke。也是 ENABLED_AGENTS 环境变量里写的名字。
value[0]("my_agent.server")
你的 server.py 模块导入路径(snake_case 包名 + .server)。平台稍后 importlib.import_module 它。
value[1]("build_router")
该模块里那个工厂函数名——就是 L05 写的 build_router。约定俗成都叫这名。

光有名册还不够,平台怎么用它才是关键。先看启动时怎么校验你要挂哪些(main.py:289):

def _parse_enabled(raw: str | None) -> list[str]:        # main.py:289
    if not raw or not raw.strip():
        return list(AGENT_REGISTRY)                      # 没设 → 默认全挂
    names = [a.strip() for a in raw.split(",") if a.strip()]
    unknown = [n for n in names if n not in AGENT_REGISTRY]
    if unknown:
        raise RuntimeError(f"未知 agent: {unknown!r}。合法值: {sorted(AGENT_REGISTRY)}")
    return names

逐行:读环境变量 ENABLED_AGENTS(逗号分隔);空 → 默认挂全部;只要你写的名字有一个不在注册表里,立刻 raise RuntimeError 并列出全部合法值——平台启动失败而不是带病运行。这就是"显式注册表"作为一道门槛的价值。

校验过了,真正把 agent 挂载靠 _load_routermain.py:299,裁剪核心)——这是一段漂亮的懒加载 + 依赖注入

def _load_router(name, *, case_store=None, pool=None, app=None) -> APIRouter:  # main.py:299
    mod_path, fn_name = AGENT_REGISTRY[name]          # ① 查名册拿(模块,函数名)
    mod = importlib.import_module(mod_path)           # ② 真正 import 你的 server.py
    fn = getattr(mod, fn_name)                        # ③ 取出 build_router
    if name in AGENTS_NEED_POOL:                      # ④ 要数据库的 agent 注入 pool
        return fn(pool)
    kwargs = {"case_store": case_store}               # ⑤ 普通 agent 注入共享 case_store
    sig = _inspect.signature(fn)                      # ⑥ 探测 build_router 要不要 app 参数
    if "app" in sig.parameters and app is not None:
        kwargs["app"] = app
    return fn(**kwargs)
💡 逐行讲:这段是"平台不认识你的 agent,也能把它挂起来" ①②③ 平台不 import 你的包——它只在真要挂你时,拿注册表里那个字符串 importlib.import_module 动态加载,再 getattr 拿到 build_router。这叫懒加载:ENABLED_AGENTS 没选你,你的代码根本不会被 import,某个 agent 有 import 错误也不会拖垮别人。④⑤依赖注入:需要数据库的 agent(AGENTS_NEED_POOL)给它 pool,普通 agent 给共享的 case_store 最妙——用 inspect.signature 探测你的 build_router 有没有 app 参数,有才注入。这样老 agent(不要 app)和新 agent(要 app 做 sandbox)用同一个加载器,谁都不破。
⚖️ 设计取舍:为什么用"显式 dict 注册表 + 懒加载",不用"自动扫描 apps/ 目录"? 自动扫描 = "谁往楼里放个门牌,前台就当合法住户"——一个半成品、import 就报错的 agent 会在启动时把整个平台拖崩。显式注册表把"能不能挂"变成一个需要人主动写一行、且 fail-fast 校验的动作:你得确认这户真能住人。代价是"多改一行",换来的是"坏 agent 进不来 + 出问题能被 ENABLED_AGENTS 一键摘掉"。
控制流:平台启动如何把 agent 挂上来(create_app) 读 ENABLED_AGENTS 环境变量 _parse_enabled 校验名字不在注册表 → raise 启动失败 for name in enabled: _load_router(name) importlib.import_module动态加载 my_agent.server(懒加载 · 没选不 import) getattr → build_router注入 case_store / pool / app(inspect.signature 探测) app.include_router/v1/agent/my-agent/*治理全白拿
图注:注册表(数据)→ 校验 → 懒加载 import → 依赖注入 → 挂路由。你只贡献名册里一行 + 一个 build_router。

👶 小白:注册表里明明有 18 个 agent,为什么我起服务只想跑我自己那个?

👨‍🏫 老师:用 ENABLED_AGENTS=my-agent uv run uvicorn gov_agents_server.main:app_parse_enabled 会只挑出 my-agent 挂载,其它 17 个的 server.py 压根不会被 import(懒加载的好处)——本地开发既快又不会被别的 agent 的依赖问题干扰。上生产才把该挂的都列上。

L07

准入门槛 + 反模式表(读 cookbook 原文)

回忆 Day 10 的硬规矩:新 Agent 必须先有评测样本 + Critic,才能合并。cookbook checklist(docs/new-agent-cookbook.md:539)把"接入完成前自检"逐条列了出来,照抄关键几条:

# cookbook §9 checklist(裁剪)
[ ] 顶层包名 snake_case · 跟其它 agent 不撞
[ ] server.py 没出现 @router.post("/agents/...") · 全走 build_v1_router
[ ] _invoke 抛 HTTPException(422/404) 透传 · 其它异常让 router 自动转 status=failed
[ ] 测试覆盖:v1 invoke 200 + 老路径 alias + 422 +(可选)stream
[ ] supervisor AGENT_REGISTRY 加一条
[ ] root pyproject.toml workspace members + testpaths + mypy ignore 加一条
[ ] OpenSpec change 跟代码同 commit

cookbook 还给了一张反模式表(第 525 行)——"看到就该警觉"。这是全天最值钱的一张表,因为它把"没用框架的人会怎么写错"一条条列了出来:

❌ 反模式✅ 正确做法
@router.post("/agents/my-agent/run") 手写路由build_v1_router(invoke_fn=_invoke) 工厂
自己写 SSE gen() + yield _sse(...) 字符串_stream(...) async generator + yield AgentOutcome
prev=os.environ.get("ANTHROPIC_API_KEY"); ... finally:with anthropic_key_override(ctx.anthropic_api_key):
自己拼 response BaseModel + raise HTTPException(500)return AgentOutcome(status="failed", result={...})
顶层包名用 agent / app / srcsnake_case + agent-specific(my_agent
为什么把"评测 + Critic"设成准入门槛? 因为它们是"可信"的最低保障——Critic 防这个新 agent 胡说,评测样本让 CI 能盯住它以后别退步(Day 18)。没有这两样,一个 agent 就是"不可信"的,不该进平台。这也是 AGENTS.md 收尾金句"保持评测样本同库、保持 Critic 兜底"的落地。

📎 没 monorepo 权限?走独立仓形态②——用 examples/standalone-agent-template 模板 + Nexus 一行 BOM 依赖,完整手把手 9 步(下模板 → Nexus 凭据 → 跑通 → 写业务图 → telemetry → CLI/MCP/pod → 测 → 安全自检 → 上线)见 番外篇 · 独立仓 Agent 开发 9 步 →

L08

L4 Skills:翻开 registry.json + SKILL.md

最后补上 Day 01 架构图最顶层的 L4 —— skills/。它是给 IDE 里的 AI(Claude Code / Cursor)用的一句话 SOP:把"该怎么调这些 agent"的操作流程沉淀成可触发的技能。别停在概念,直接读一个真 SKILL.md 的头部 frontmatter(skills/release-gate/SKILL.md:1):

---
name: release-gate
description: 给 PR diff / commit 区间 · 跑 risk-reviewer agent · 出 risk_score(0-100)·
  高于阈值阻断 + 列降险动作。触发词:上线闸 / release gate / 这个 PR 能合吗 / risk_score
tier: developer
tools:
  - agentctl invoke risk-review     # ← skill 向下只调 agentctl / MCP / git
  - agentctl case
  - git diff
requires:
  agentctl_min_version: "0.1.0"
  enabled_agents:
    - risk-review                    # ← 声明它依赖哪个 agent 挂着
---
name / description
skill 的名字 + 一句话干什么。description 里那串"触发词"很关键——IDE 的 AI 靠它判断"用户这句话该不该触发这个 skill"。
tier
三档:developer(开发者用)/ oncall(值班用)/ user-facing(业务方用)。决定给谁看。
tools
这个 SOP 会用到的命令——全是 agentctl / git / MCP,不含任何业务代码 import
requires.enabled_agents
声明依赖哪些 agent 挂着(这里是 risk-review)。缺了这个 agent,skill 跑不了。

这些 SKILL.md 被 agentctl skill index 汇总成一个 skills/registry.json(当前 12 个 skill:8 个 developer + 3 个 oncall + 1 个 user-facing)。平台首页会读它——看 gov_agents_server/main.py:347 的加载函数:

def _load_skill_registry(skills_dir: Path | None) -> list[dict]:   # main.py:347
    if skills_dir is None:
        return []
    f = skills_dir / "registry.json"
    if not f.exists():
        return []
    try:
        return json.loads(f.read_text(encoding="utf-8")).get("entries", []) or []
    except (OSError, json.JSONDecodeError) as e:
        log.warning("load skills registry failed: %s", e)
        return []                     # ← 读失败也只是首页少个板块 · 绝不拖垮平台
⚠️ 边界:为什么读 registry 失败要"返空 list"而不是抛异常? 注意最后那个 except ... return []。skills catalog 只是首页一个锦上添花的板块——registry.json 写坏了、文件没了,平台核心(那 18 个 agent 的 invoke)该照跑。所以这里吞掉异常、降级为"少显示一个板块",打个 warning 就算。这是 Day 08"优雅降级"哲学在一个不起眼角落的贯彻:非核心功能坏了,不许连累核心。
Skill 和 MCP 的区别? MCP(Day 13)是"给 IDE 的 AI 提供工具";Skill 是"给 IDE 的 AI 提供操作流程 SOP"——告诉它"遇到这类需求,按这个步骤、调这些工具"。一个是工具,一个是使用工具的说明书。L4 只向下调 MCP / agentctl / OpenAPI,L1-L3 绝不 import skills——又是 Day 01 的分层铁律:依赖只能从上往下。
L09

今日小结 + 动手

🧠 今天你应该能回答(都能指到真源码)

  • agentctl new agent--pattern 默认为什么是 supervisor--critic 为什么默认 both?(默认服务大多数 + 可信默认强制)
  • 6 个 pattern 的本质是什么?(_patterns.py 里 pattern→BOM extras 的推导表,core 强制补齐)
  • 一个 agent 的 server.py 为什么只有 30 行?(_invoke 交给 build_v1_router,鉴权/envelope/落库/指标全工厂给)
  • AGENT_REGISTRY 是什么、平台怎么用它挂 agent?(dict 名册 → _parse_enabled 校验 → _load_router 懒加载 + 依赖注入)
  • 为什么用显式注册表不用自动扫描?读 registry.json 失败为什么返空 list?(门槛 fail-fast + 非核心优雅降级)
  • L4 Skill 的 SKILL.md 里 tier / requires.enabled_agents 各是什么?和 MCP 什么关系?

✋ 动手:把今天的源码全翻一遍

# 1. 读 scaffold 命令的真实签名 + 6 pattern 推导表
sed -n '51,100p' packages/gov-agents-cli/src/gov_agents_cli/commands/new_agent.py
sed -n '1,55p'  packages/gov-agents-cli/src/gov_agents_cli/_patterns.py

# 2. 读最小 echo-agent(30 行 server.py 完整范例)+ 反模式表
sed -n '420,540p' docs/new-agent-cookbook.md

# 3. 读注册表 + 懒加载 + fail-fast 校验(今天核心)
sed -n '62,82p'   packages/gov-agents-server/src/gov_agents_server/main.py   # AGENT_REGISTRY
sed -n '289,332p' packages/gov-agents-server/src/gov_agents_server/main.py   # _parse_enabled + _load_router

# 4. 只挂自己的 agent 跑起来(懒加载:其它 17 个不 import)
ENABLED_AGENTS=echo uv run uvicorn gov_agents_server.main:app --port 8080

# 5. 读一个真 SKILL.md + registry
cat skills/release-gate/SKILL.md ; sed -n '1,40p' skills/registry.json
明天预告 · Day 20(收官):最后一天讲 OpenSpec 规格驱动开发(propose/apply/archive 三段流,读真 change 目录)+ 平台"自我开发"闭环的四个 agent 逐个走读(spec-author 的 repair loop、spec-executor 的 agent/* 分支闸、release-coordinator 的并行 gate)+ 20 天全框架回顾 + 继续深入的路线图。
← Day 18 CI Day 20 · OpenSpec & 收官串讲 →