Skip to content

插件 manifest 与加载

一份 manifest 是惰性数据:一个名字、一个版本范围、一份可选的配置 schema,以及一串贡献。读取它不会导入任何插件代码——正因如此,宿主才能在任何东西跑起来之前,把每一个已安装的插件列举出来并检查冲突。本页讲 manifest 的形状、它可以采用的两种形式、load_plugins 如何找到它们,以及一个插件如何打包。

源码:packages/noeta-sdk/noeta/client/{plugin_manifest,plugin_set,plugins}.py

分发形式:[tool.noeta]

一个已安装的包在 pyproject.toml[tool.noeta] 下声明它的 manifest,并把它镜像进 wheel 作为名为 noeta-plugin.toml 的包数据

toml
[tool.noeta]
name = "house-style"
requires-noeta = ">=0.4"

[[tool.noeta.contributions]]
surface = "prompt_fragment"
name    = "house-style"
text    = "Answer in at most three sentences."

[[tool.noeta.contributions]]
surface  = "reminder"
ref      = "house_style.reminders:stay_brief"
priority = 500

对常规安装,read_distribution_manifest 直接从磁盘上读这个文件;对可编辑安装,它回落到 importlib.util.find_spec——后者能在不导入的前提下定位一个包。零执行的保证在两种情况下都成立。

parse_manifest_text 按优先级接受三种 TOML 形状:[tool.noeta](一个同时携带插件的 pyproject.toml)、[noeta],以及裸的顶层键(那份镜像出来的 noeta-plugin.toml)。

manifest 字段

字段形状含义
namestr,必填这个插件的身份——加载时的去重键以及激活名
requires-noetastr | None一个版本范围——加载时会判定(告警;strict 下拒绝)
config-schematable | None面向运维配置的可选 schema
contributions表数组一个贡献一条

贡献字段

每一条都会变成一个 ManifestContribution

形状含义
surfacestr,必填一个已注册的 Surface 名——见 插件 Surface
namestr冲突 / 排序键以及列表里的标签;省略时从 refpath 推导
refstr | None一个 modulemodule:qualname 导入字符串,在执行边界处解析
pathstr | None一个资源路径,供 skills 这类纯资源 Surface 使用
params其余的键Surface 专属并原样保留:reminderpriorityreminder_providerseams、字面量 prompt_fragmenttext

省略 name 时,它从 ref 的最后一个属性(或模块的最后一段)推导,否则从 path 的 basename 推导。(surface, name) 在一份 manifest 内必须唯一;重复会抛出 PluginError 并指名两条条目。

单文件形式:PluginBuilder

一个本地 .py 插件声明一个模块级的 PluginBuilder 并用装饰器标注它的贡献。这个 builder 就是 manifest——这可以接受,因为本地文件本来就要过一道显式的信任门。

python
# brevity.py — a single-file plugin
from noeta.sdk import PluginBuilder

plugin = PluginBuilder("brevity", requires_noeta=">=0.4")

plugin.prompt_fragment("Answer in at most three sentences.", name="be-brief")

@plugin.reminder(priority=500)
def stay_brief(view):
    return None   # a real reminder returns str | None from the folded view

PluginBuilder(name, *, requires_noeta=None, config_schema=None) 为每个 Surface 暴露一个方法。每个方法都转发到通用的 contribute(surface, value, *, name=None, ref=None, path=None, **params),后者也覆盖那些没有专用方法的 Surface(agentcontent_kindmcp_serverskillsprovider)。

方法Surface额外参数
tool(fn=None, *, name=None)tool——
reminder(fn=None, *, name=None, priority=0)reminderpriority
reminder_provider(fn=None, *, name=None, seams=())reminder_providerseams
tool_result_transform(fn=None, *, name=None, priority=0)tool_result_transformpriority
guard(obj=None, *, name=None)guard——
observer(fn=None, *, name=None)observer——
prompt_fragment(text, *, name)prompt_fragmenttext
policy(factory=None, *, name=None)policy——
sandbox_provider(obj=None, *, name=None)sandbox_provider——
session_pack(factory=None, *, name=None, priority=0)session_packpriority
control_tool(factory=None, *, name=None, priority=0)control_toolpriority

manifest() 返回等价的 PluginManifest,而被装饰的对象会被缓存下来(resolved_objects),因此 loader 解析一个单文件插件的贡献时不需要第二次 import。

版本约束:requires-noeta

requires-noeta 记录这个插件是针对哪个 SDK 版本范围写的,loader 在加载时会拿它跟已安装的 noeta-sdk 版本做实际判定

结果默认load_plugins(strict=True)
范围被满足静默静默
范围不被满足抛出 PluginVersionWarning,指名插件、它声明的范围和已安装的版本;插件照常加载抛出 PluginError,加载失败
无法识别的版本表达式抛出 PluginVersionWarning:"unrecognized requires-noeta specifier … not enforced"同样只是告警;绝不转成拒绝
noeta-sdk 没有安装元数据(仓库内直接跑源码)视为满足,只记一条 debug 日志同上

默认只告警,是因为一个范围是作者对"我测过什么"的声明,而不是一把锁:拿它来拒绝,会在 SDK 第一次打出插件还没来得及重测的补丁版本时,直接搞坏一个本来能跑的部署。strict=True 面向的是把"针对本 SDK 测过"当作发布闸门的部署。

判定器刻意做得很小、不引入依赖——支持 >=><=<==!= 作用在点分版本号上,用逗号做 AND 连接,空格随便写。比这更复杂的东西(~=、extras、epoch、预发布标记)一律读作"无法识别"并如实报出来,而不是去猜——一个插件绝不会因为一种 loader 从未承诺理解的写法而被拒绝。

toml
requires-noeta = ">=0.6,<1.0"

从一个已加载的插件上读它:

python
print(pset.get("memory").manifest.requires_noeta)   # → '>=0.4'

来源与加载流水线

五个来源,各有自己的门。发现顺序从不影响结果——它只影响一条错误信息会指名哪个来源。

#来源load_plugins 参数
0内置插件(noeta.builtinsbuiltins=True(默认)默认开启;用 disabled_builtins 按名字关掉
1entry point(noeta.plugins 组)entry_points=Trueenabled 白名单,在任何 import 之前生效
2显式模块或文件路径modules=[...]调用方指定即视为已授权
3~/.noeta/plugins/user_dirs=[...]用户自己的机器,受信任
4工作区的 .noeta/plugins/workspace_dirs=[...]信任存储;未受信的目录会告警并被跳过

对每个候选者:读 manifest(对包形式和 .toml 形式是零代码执行)→ 在任何 import 之前过 enabled信任门(仅来源 4)→ 冲突检查确定性合并,按 (plugin, contribution) 排序。解析各个 ref 以及运行每个 Surface 的校验器,只发生在执行边界(PluginSet.resolve 及其同伴)。

load_plugins(...) -> PluginSet

python
load_plugins(
    *,
    builtins=True,               # bool | Iterable[PluginManifest]
    disabled_builtins=(),        # Iterable[str]
    entry_points=False,          # bool | Iterable[entry-point-like]
    modules=(),                  # Sequence[str] — dotted modules or file/dir/.toml paths
    user_dirs=(),                # Sequence[path] — scanned unconditionally
    workspace_dirs=(),           # Sequence[path] — scanned only when trusted
    enabled=None,                # Iterable[str] | None — allow-list of plugin names
    trust_store=None,            # Path | None — defaults to ~/.noeta/trust.json
    registry=None,               # SurfaceRegistry | None — defaults to standard_registry()
    entry_point_group="noeta.plugins",
    strict=False,                # bool —— 拒绝一个不被满足的 requires-noeta
) -> PluginSet
  • builtins=True 会发现内置目录;传一个 PluginManifest 的可迭代对象则注入一组自定义 manifest(那是测试用的接缝)。disabled_builtins 按名字丢掉内置项,而这次禁用会被记录在返回的集合上,好让宿主在没有任何贡献表达它的地方也能遵守它——禁用 skills 正是让 Client 彻底不提供 skills 套件的方式。缺席不等于禁用:builtins=False 限定的是被加载的集合,从不是 SDK 自身的能力。
  • react 不能被禁用——disabled_builtins=["react"] 会抛出 PluginError。它提供每个编译出的 AgentSpec 都要钉住的那个默认决策 policy。默认的大脑是可以通过 policy Surface 替换的,但不能被移除。
  • entry_points=True 通过 importlib.metadata 发现 noeta.plugins 组;传一个 entry-point 样式对象的可迭代对象(每个暴露 .name.dist)则改为注入它们。一个所属分发未随包提供 noeta-plugin.toml 的 entry point 会大声失败。
  • modules 的条目可以是一个点分模块、一个 .py 文件、一个目录(按来源 3 或来源 4 的方式扫描),或一份 .toml manifest。
  • user_dirs 无条件加载;workspace_dirs 只在该目录位于信任存储中时才加载。两者都会扫描携带 noeta-plugin.toml 的子目录(零执行)以及顶层的 *.py 单文件插件(会被执行——那是一个受信目录),并跳过以 _ 开头的文件。
  • strict=True 把一个不被满足的 requires-noeta 从告警变成 PluginError(见上面那张表)。无法解析的版本表达式仍然只是告警。

跨来源的重复插件 名字是一个错误,并会指名两个来源。

PluginSet

已加载的、宿主层的集合。它是冻结的;它持有被发现的那些插件以及它们所依据的 Surface registry。每个投影都会记忆自己的解析结果,因此一次构建对每个 ref 最多 import 一次。

成员返回会执行插件代码吗?
names() / __iter__ / __len__ / __contains__ / get(name)列举
contributions(surface=None)((plugin_name, ManifestContribution), …)
merged()MergedContributions —— 已查冲突、确定性排序
disabled_builtinsfrozenset[str]
resolve()每个贡献,其 ref 已 import 并已校验——这就是执行边界
identity_activations(only=None)每个外部 插件的身份面贡献
activation_transforms(only=None)tool_result_transform 阶段
activation_reminders(only=None)compose 时的 reminder 渲染
activation_reminder_providers(only=None)被记录的 reminder_provider
activation_session_packs(only=None)session_pack 工厂
activation_control_tools(only=None)control_tool 工厂
process_hooks()来自外部插件的 (guards, observers),按 (plugin, name) 顺序
host_skills_dirs()外部插件的 skills 路径,按 (plugin, name) 顺序
host_mcp_servers()外部插件的 ((alias, plugin, SdkMcpServer), …)

Client 在构建期间调用这些激活投影、process_hooks 以及那两个 host_* 投影,而绝不在某一轮里调用。host_* 这一对和 process_hooks接受 only=:它们的 Surface 是进程级或 host 接线的,加载本身就让它们生效。内置插件被排除在它们全部之外——内置项的效果搭乘的是 compile_options 按名字处理的那套激活词汇。only= 参数把解析限制在某个 agent 实际激活的那些名字上,因此一个被加载但未被激活的插件,它的模块体永远不会运行。

信任存储

工作区目录的信任存储是一个 JSON 文件——{"trusted": [absolute path, …]}——默认位于 ~/.noeta/trust.json。只有 workspace_dirs 会查它;user_dirs 总是被扫描。

函数行为
is_trusted(path, store=None) -> boolpath 的规范形式是否已被记录;存储不存在意味着 False,而不是一个错误
grant_trust(path, store=None) -> None记录 path 的规范形式(幂等);存储及其父目录不存在时会创建

两侧以相同方式规范化——展开 ~、取绝对路径、解析符号链接——因此一个路径的写法从不决定信任与否。格式损坏的存储在读取时会抛出 PluginError

python
from noeta.sdk import grant_trust, load_plugins

grant_trust("./workspace/.noeta/plugins")     # writes ~/.noeta/trust.json
pset = load_plugins(workspace_dirs=["./workspace/.noeta/plugins"])

失败语义

加载故障是大声的,并在启动时让 client 构建失败,绝不会落在会话中途的某一轮。

  • 一份糟糕或缺失的 manifest、一个损坏的文件、一个无法 import 的 ref、一个缺失的 ref 属性,或一个没通过其 Surface 校验器的值,都会抛出指名该插件的 PluginError
  • 任何冲突——两个插件声称同一个键、跨来源的重复插件名、第二个 policyprovider、一个配方已经占用的 mcp_server 别名——都会抛出 PluginError指名双方。不存在覆盖。
  • 一个写了但不是整数的 priority 会抛出 PluginError,指名插件和那条贡献。按 priority 排序的 Surface 是按整数排的,把它强行折成 0,会把这条贡献放到它作者本想让它待在末尾的那个档位的最前面;完全不写 priority 仍然是文档规定的默认值 0
  • 一个未知的激活名会在编译时抛出 ValueError

有三种不抛异常的跳过,每一种都带告警:

  • 一个未受信的 workspace_dirs 条目——UntrustedPluginDirWarning
  • enabled 白名单生效时,一个名字无法被静态读出的单文件插件——UnnamedPluginFileWarning。白名单授权的是名字,而一个只有跑起来才知道自己叫什么的文件,压根没有名字可供授权;为了搞清楚它叫什么而去执行它,等于把这道门自己拆了。加一行模块级的 noeta_plugin_name = "..."(或者一个 PluginBuilder("...") 字面量)就能让这个文件可被门控。没有白名单时,这个文件仍然像以前一样被执行并加载。
  • 一个不被满足或无法解析的 requires-noeta——PluginVersionWarning(见上;strict=True 会把"不被满足"这一种变成 PluginError)。
python
from noeta.sdk import PluginError, load_plugins

pset = load_plugins(builtins=[m_a, m_b])   # both contribute prompt_fragment "frag"
try:
    pset.merged()
except PluginError as exc:
    print(exc)
# → prompt_fragment 'frag' on surface 'prompt_fragment' is contributed by both
#   plugin 'a' and plugin 'b' — no override

打包

一个分发的插件会带上同一份数据的两个副本:pyproject.toml 里的 [tool.noeta],以及 wheel 内作为包数据的 noeta-plugin.toml。让两者保持一致——python -m noeta.sdk.plugin_check 会从一个 PluginBuilder 推导出 TOML 并在发布时校验它。没有控制台脚本。

Noeta 自己的内置项遵循同样的布局,每个能力一个目录,位于 packages/noeta-sdk/noeta/builtins/<name>/__init__.py 放零执行的 MANIFESTimpl/ 放代码,而 manifest 的各个 ref 指向同级的 impl 模块。manifest 这一层不 import impl 里的任何东西,因此列举一个内置项运行的能力代码为零。

下一步

Released under the Apache License 2.0.