先选择正确的放置位置¶
"要扩展 LoopX"不自动意味着"新建 Extension"。先判断用户结果、调用合同与生命周期,才能决定 能力应放在 Capability、Provider、Extension,还是项目内部 helper。
从一个坏的结局开始¶
一个团队要给 LoopX 接第二种行情数据源,于是新建了 loopx-market-connector package,写一份
manifest,声明一个 [[provides]],loopx capability list 里立刻多了一行 market-connector。
三周后回头看,这一行的状态是:
market-connector: declared=true, installed=true, enabled=true, ready=true
callers=0, resolver=none, domain policy=none
它可发现、可路由、能通过 doctor,但没有任何 caller 用它的结果,没有 resolver 归一化它的输出,
也没有一段 domain policy 规定它的 transition。控制面因此背上一份没有真实 caller 的合同:每个
新增的 Provider 都要考虑"是不是也该注册成 capability",而唯一能回答这个问题的人已经换项目了。
第二周埋下的是另一个版本。有人发现三个内部模块都要做同一种状态归一化,于是把那段代码提成
loopx/capabilities/state-normalization/,理由是"涉及多个文件"。半年后这个 capability 只有一个
内部 caller,却要求 manifest、doctor、版本兼容和 catalog registration 一路维护。
两个坏结局的共同点:放置决策在写代码前就做错了,而错的方式都是让抽象的边界宽于真实调用者的 需求。 一个多出没有 caller 的公共合同,一个多出没有外部消费者的安装与生命周期成本。
为什么"以后再补 caller"行不通¶
自然的反应是先注册、后接线。但顺序反了会在控制面留下具体的代价:
- catalog 是权威读模型。 一份声明会进入
loopx capability list,被 dashboard、agent 和 review 读取。读者无法从这一行区分"已发布的合同"和"先占位的名字",于是要么照着它写调用,要么逐渐 不再信任 catalog; - registration 决定 routing。 一旦有条目,决定"谁该处理这个结果"时它就会成为候选,而它没有 domain policy 可以裁决 transition;
- 安装成本立刻生效。 capability 落地即意味着一份要长期维护的版本兼容窗口,而它换来的收益 (被 caller 使用)此刻并不存在。
反方向的错误同样常见:把只服务当前项目的 helper 注册成 capability,期望"将来别人能复用"。将来 是否复用无法验证,安装成本却是现在就要付的。
放置决策的作用是在动手前暴露这两种错误。它产出的是一段 rationale,而非更多设计文档。
Capability 与 Extension 是两个维度¶
Capability 描述调用者可以获得什么结果,以及结果要满足什么合同。
Extension 描述一个实现如何被交付和管理,包括安装、启停、升级、回滚与兼容性。
真实仓库里这两个维度各自有归属路径:
loopx/capabilities/<capability>/ caller-facing contracts and core providers
loopx/extensions/ extension lifecycle and bundled providers
packages/<package-id>/ independently installable distributions
loopx/extensions/ 随 LoopX wheel 一起发布,packages/ 的子目录有自己的打包元数据,不进入
wheel;仓库根部刻意没有 extensions/ 目录,因为那个重名会掩盖"这是可导入的 LoopX 代码"还是
"这是可单独安装的产物"。
两个维度不合并,它们通过 capability/provider registry 组合:
Capability: caller-facing outcome contract
^
| implemented by
Provider
^
| delivered by
Extension: package and lifecycle
每个注册的 capability 声明三个 provider-facing 字段:origin(builtin 或 extension)、
visibility(public 或 internal)、provider_id(loopx-core 或 extension manifest id)。
重复的 capability 或 provider id 会 fail closed。
因此一个 Extension 可以:
- 提供一个新的 Capability;
- 实现一个已有 Capability;
- 只暴露自己的 bounded standalone command。
一个 Capability 也可以由 LoopX core 内置 Provider 实现,不需要 Extension。
案例:财经发现 Extension¶
当前 loopx-finance-value-discovery 是一个容易被名称误导的真实案例。它处理财经研究 packet,
但当前 manifest 没有 [[provides]] 或 [[implements]],也不会向 Capability catalog 注册
finance-value-discovery。
它确实声明了 [[presentation_surfaces]](investment-research,visibility = "public-safe"),
这属于 Extension 自己的呈现合同。呈现面与 capability 无关:把结果画成一张 dashboard,不产生
可被 routing 的 domain 结果。
官方 placement 指南让 Finance value discovery 保持 standalone extension 形态:公开市场、披露和新闻
采集可以留在该 extension 内,直到出现真实的跨 Provider LoopX 合同;value connector 协议也明确不允许
凭空发明 finance-value-discovery Capability。本章以下判断以当前 manifest、catalog readback 和
managed runtime 为准。
它的 placement rationale 是:
capability_id: none
provider_id: loopx-finance-value-discovery
origin: extension
placement: separately activated package
reason: deterministic reducer over caller-supplied frozen public-safe evidence;
independent package and lifecycle; no provider-neutral caller contract yet
为什么不是 Capability:
- 公开调用合同目前是这个 Extension 自己的
finance_value_discovery_extension_v0; - input 是调用方已经冻结的
finance_value_discovery_input_v0,并非"帮我发现投资机会"这类宽泛请求; - provider 只输出有界研究 packet;
- 没有多个可替换 Provider 共享的 caller outcome、resolver 和 domain policy;
- 当前 Capability catalog 不承诺
finance-value-discovery。
为什么适合作为 Extension:
- package、版本、doctor、启停和 upgrade 可以独立于 LoopX core 管理;
- manifest 与 runtime 的 permissions 都为空;
- reducer 不自动拉取行情,不读取账户或持仓,不发起交易,也不产生持续监控;
- 同一份 frozen public-safe evidence 可以得到确定性结果;
- generic
extension run不会绕过任何外部 effect authority。
它的当前边界可以画成:
public evidence collector or human review
-> frozen finance_value_discovery_input_v0
-> loopx-finance-value-discovery Extension
-> bounded finance_value_discovery_packet_v0
-> human / Goal decides whether a successor is justified
安装、启用和运行是三种不同证明:
# package entrypoint 进入当前 Python environment
python3 -m pip install ./packages/loopx-finance-value-discovery
# extension runtime 记录并激活经过 doctor 的 manifest revision
loopx extension install \
--manifest packages/loopx-finance-value-discovery/extension.toml \
--execute \
--format json
# managed runtime 处理一个已经冻结的公开证据输入
loopx extension run loopx-finance-value-discovery \
--input-json packages/loopx-finance-value-discovery/examples/paypal-debeta-discovery.json \
--execute \
--format json
这些命令要求 provider 源码包可用;它当前不是 bundled Extension,LoopX 不会替用户下载 package。
extension list 证明 activation state,executed doctor 证明当前 revision 的 readiness,示例 run
证明 request/response contract。三者不能互相替代。
package 还必须安装在运行 loopx 的同一 Python environment,并让
loopx-finance-value-discovery entrypoint 出现在当前 PATH。只调用某个 venv 里的 loopx
绝对路径、却没有让同一 venv 的 provider entrypoint 可解析时,doctor 会返回
entrypoint_missing;这是正确的 fail-closed 行为。
什么时候应升级成 Capability + Provider¶
如果将来 LoopX 要向多个财经数据或研究 Provider 暴露稳定、provider-neutral 的调用结果,就应先
定义 Capability contract,例如统一的 input、evidence freshness、authority、failure、readback
和 successor policy。之后这个 package 可以通过 [[implements]] 成为其中一个 Extension
Provider。
不能反过来因为 package 名称含有"财经发现",就先注册一个没有真实 caller 和 resolver 的 Capability。数据采集也不应偷偷进入这个零权限 reducer;公开市场、财报和新闻采集需要自己的 Provider 边界、来源 freshness、license 与 credential Gate。
四个候选位置¶
1. 项目内部 helper¶
如果代码只服务当前项目,没有独立调用合同、安装需求或生命周期,就放在最近的 owning module。
例如当前项目需要把两种内部状态转换成统一 dict,但没有外部调用者,也没有独立版本兼容要求。 把它注册成 Capability 或打包成 Extension 只会增加 manifest、doctor 和升级成本。共享代码说明 可能存在 helper,它并不说明需要独立安装和生命周期。
2. 现有 Capability 的 Provider¶
如果调用者需要的结果已经由一个 Capability 定义,新实现应进入该合同,而不是创建同义 Capability。
Provider 可以是:
- built-in:随 LoopX core 一起发布;
- extension-delivered:由独立 Extension 提供。
是否访问外部系统并非判断 Extension 的唯一条件。一个和 core 同生命周期、由 core 始终维护的 connector 仍可以是 built-in Provider。
3. 新 Capability¶
只有当 LoopX 调用者需要 provider-neutral 的稳定结果合同、catalog identity 和 routing surface 时,才创建新 Capability。
新 Capability 至少需要:
- 清晰的 caller outcome;
- 稳定 id 与 versioned protocol;
- 真实入口或调用点;
- domain validation 与 transition policy;
- focused validation;
- catalog registration。
仅仅"未来可能有多个 Provider"不足以让抽象提前进入产品。
4. Standalone Extension¶
如果能力:
- 有独立 package 与版本;
- 需要独立安装、启停、升级或回滚;
- 有一个有界 request/response command;
- 不需要进入现有 Capability;
- 直接调用不需要任何权限;
那么 standalone Extension 是合适的起点。
本书的 loopx-text-stats 就属于这一类:它根据 request 中的文本计算统计值,不读文件、不访问
网络、不修改外部系统,也没有跨 Provider 的产品合同。
按顺序做 placement decision¶
官方指南要求按顺序回答五个问题,本书把它展开成六个。顺序本身重要:先定结果,再找 owner,最后 才决定交付形态。
- 用户结果是什么?
不要用 connector、adapter、sink 这类实现机制代替结果名称。
- 最近的现有 owner 能否拥有它?
如果现有 Capability 已经定义相同结果,扩展它。
- LoopX core 是否必须始终发布这个实现?
是则考虑 built-in;否则考虑 Extension。
- 是否需要独立生命周期?
独立依赖、版本、启停、凭据或 provider ownership 通常指向 Extension。
- 是否只是内部 helper?
没有独立调用合同就留在 owning module。
- 是否有权限或外部 effect?
有则不能通过 generic standalone runner 绕过 Capability/domain policy。
平台级机制放哪里¶
在六个问题之外还有一条容易混淆的分支:如果待添加的只是所有 Extension 共享的注册与生命周期
机制,它属于 loopx/extensions/,不属于任何 provider package。它不构成 capability,也不需要
自己的 manifest。把这类机制塞进某个 provider,会让下一个 provider 被迫依赖前一个的私有结构。
记录最小 rationale¶
在实现前写一段短记录:
capability_id: none
provider_id: loopx-text-stats
origin: extension
placement: standalone package
reason: bounded deterministic command with an independent lifecycle;
no provider-neutral LoopX capability is needed
如果 Extension 实现已有 Capability:
capability_id: <existing-capability>
provider_id: <extension-id>
origin: extension
placement: independently packaged provider
reason: reuses the caller contract but needs independent dependencies
and activation lifecycle
记录的目的并非生产更多设计文档,而是在动手前暴露错误抽象。对于小改动,这段 rationale 可以直接 进入 Todo、PR 描述或 commit history。
代价与边界¶
放置决策并非免费的,它换来的是"抽象边界与真实 caller 需求对齐",代价需要讲清楚。
代价一:决定之后不能随手改交付形态。 一个 package 一旦有独立版本与安装步骤,回退到 core 内置就要处理已有用户的升级路径。
代价二:Extension 用户必须显式做事。 独立生命周期意味着用户要自己安装、enable 并通过
doctor;core 内置的能力随安装即用。一份 manifest 被声明进 catalog read 时状态是
declared=true, installed=false, enabled=false, ready=false,它不会因为存在就可用。
代价三:公开合同需要维护。 新 Capability 应有稳定的调用者 outcome、真实入口和验证;一个 Provider 也可以满足这些条件。独立安装需求本身不足以建立新 Capability,provider 数量也不是准入门槛。
边界一:这条决策管的是 LoopX 产品面的归属,不覆盖别人自己的仓库结构。 独立的 provider 发行 放在自己的 package 或 repository,LoopX 只要求 manifest 与生命周期合同。
边界二:value-connectors 属于兼容面。 它是既有的 CLI 与 protocol surface,不应作为新工作的
公共 capability owner。新 profile 应迁到 issue-fix、content-ops 这类结果 capability,或者迁到
loopx-finance-value-discovery 这类 standalone extension,再退役该兼容面。
边界三:这条决策不判断值不值得做。 它只回答"放在哪"。一个结果本身是否有价值,由 Goal 与 acceptance 回答。
反例¶
"这是外部 API,所以创建 connector Capability"¶
传输机制并非用户结果。先找谁需要这个 API 返回的结果,以及现有 Capability 是否已经拥有它。
"先生成 [[provides]],以后再接调用入口"¶
manifest 可发现但不可调用,会制造假的产品表面。先建立真实 caller contract、resolver、policy 和 validation,再声明 Capability。
"standalone runner 能启动进程,所以也可以发消息"¶
generic runner 要求 manifest 和 runtime 的权限都为空。发消息、写文件、发布或管理资源是 effect, 必须进入能检查 authority 与 scope 的 Capability/domain command。
"多个文件共用代码,所以抽成 Extension"¶
共享代码只说明可能存在 helper,不说明需要独立安装和生命周期。抽象应该跟随 change reason, 而不是文件数量。
本书示例的决定¶
loopx-text-stats 的 placement:
| 字段 | 决定 |
|---|---|
capability_id |
none |
provider_id |
loopx-text-stats |
| origin | extension |
| kind | standalone |
| permissions | [] |
| managed entrypoint | loopx extension run |
覆盖这条决策的可执行证据是 examples/capability-extension-placement-doc-smoke.py、
tests/capabilities/test_capability_extension_registry.py 与
tests/capabilities/test_capability_extension_registry.py::test_duplicate_capability_fails_closed:
第一项核对放置文档与目录归属;第二项核对内置 catalog 的来源,以及 extension 声明的 Capability 以
declared=true, installed=false 组合进 registry;第三项核对一个 id 被声明两次时 registry fail closed。
(examples/capability-extension-registry-smoke.py 当前在本机 main 上因内置 capability 清单写死而过期,
不要把它当成可依赖的证据入口。)
如果你要设计的不只是 standalone package,而是 Explore、Domain State、Capability Pack、 multi-agent preset、Provider 或 presentation 的组合,继续阅读 Control-Plane Course 第 11 讲。 它解释这些扩展面怎样复用 Kernel,而不是创建第二套 Goal、Todo、Quota 或 Scheduler。
不变式¶
六句可以自己核对的话。
- 一个 capability 的存在理由是它的 caller outcome,不是它的目录名。 找不到 caller、 resolver 和 domain policy,它就不该出现在 catalog 里。
- capability 与 extension 是两个维度,一次改动可以只落在其中一个上。 看到新 package 就直接推断新 capability,是放置错误最常见的形式。
- 共享代码并不构成升级成 capability 的理由。 判据是独立调用合同与生命周期,而非文件数量。
- manifest 可发现并不等于合同可调用。
[[provides]]与[[implements]]只有在真实入口、 resolver 和 validation 就位后才应写入。 - Extension 的独立生命周期是用户可见的成本。 声明它之前先确认这份成本换来了 caller 需要的独立版本或凭据边界。
- effect 不能经由 standalone runner 绕过 authority。 任何写文件、发消息、发布或管理资源的 能力都必须进入 Capability/domain command。
下一章会从官方 extension init 生成这套结构,再只修改 request/response domain contract。