给 Paperclip 自己写一个适配器:外部插件包的结构、执行契约与 UI 解析器
如果你手上的 Agent 运行时不在 Paperclip 内置的那几类里——比如公司内部自研的一套跑在 Kubernetes 上的执行器,或者一个只有私有 HTTP 接口的推理服务——那”接进 Paperclip”这件事最终要落到写一个适配器上。适配器在 Paperclip 里承担的角色很明确:把一次 Agent 唤醒翻译成对某个运行时的具体调用,再把运行时吐出来的 stdout 翻译回结构化的结果(退出码、token 用量、成本、会话状态)。
官方文档把这件事拆成了两篇:creating-an-adapter 讲通用内部机制,external-adapters 讲怎么把适配器做成独立 npm 包,再加一篇 adapter-ui-parser 讲运行日志在界面里怎么被解析。这三份文档的信息其实是交叉的,容易看着看着就不知道哪一段适用于自己。下面按”先选路线、再搭骨架、最后处理可选能力”的顺序把它捋一遍。
需要先说清楚:本文所有结论都来自 Paperclip 官方文档的这三篇,我们没有安装和运行过它,所以不会讲任何实测表现。
先选路:内置还是外部插件
文档开门见山给了一张两条路的对照表:
| 维度 | 内置(Built-in) | 外部插件(External) |
|---|---|---|
| 源码位置 | 在 paperclip-fork 内部(packages/adapters/) | 独立 npm 包或本地目录 |
| 分发方式 | 随 Paperclip 一起发布 | 独立发 npm,或用 file: 链接 |
| UI 解析器 | 构建期静态 import | 运行时从 API 动态加载 |
| 注册 | 手工改三处 registry | 启动时由插件加载器自动装载 |
| 更新 | 要等 Paperclip 发版 | 自己独立版本号 |
| 适合谁 | 核心适配器、向上游贡献者 | 第三方适配器、企业内部工具 |
文档自己的建议是:大多数情况下应该做外部适配器插件——更干净、可独立版本化,而且不用动 Paperclip 的源码。如果你是要往上游贡献一个大家都会用的适配器,才走内置那条路,代价是得同时在 server/src/adapters/registry.ts、ui/src/adapters/registry.ts、cli/src/adapters/registry.ts 三个注册表里各加一笔。外部插件这一步是自动的。
顺带一提,文档里还有个兼容性说明值得记一笔:Hermes 是内置的,有两个稳定的类型键,hermes_local 启动本地 Hermes CLI,hermes_gateway 调用已经跑起来的 Hermes API 服务;旧的 @paperclipai/adapter-hermes-gateway 包被标为「弃用的兼容 shim,只保留一个版本」,新的外部覆盖包应当依赖 @paperclipai/hermes-paperclip-adapter 并声明自己覆盖哪个类型键。类型键本身没有变。想先看清楚内置几类怎么用,可以对照 五类适配器分别怎么接。
包骨架和 package.json 里那几个字段
外部插件的最小结构文档写得很具体:
my-adapter/
package.json
tsconfig.json
src/
index.ts # 共享元数据(type、label、models)
server/
index.ts # createServerAdapter() 工厂
execute.ts # 核心执行逻辑
parse.ts # 输出解析
test.ts # 环境诊断
ui-parser.ts # 自包含的 UI 转写解析器
src/index.ts 是三个消费方(server / ui / cli)都会 import 的文件,文档特意强调保持它零依赖:
export const type = "my_adapter"; // snake_case,全局唯一
export const label = "My Agent";
export const models = [
{ id: "model-a", label: "Model A" },
];
export const agentConfigurationDoc = `# my_adapter configuration
Use when: ...
Don't use when: ...
`;
// plugin-loader 约定要求
export { createServerAdapter } from "./server/index.js";
package.json 里有四个字段是契约的一部分,漏一个就会出现”装上了但不生效”这类问题:
| 字段 | 作用 |
|---|---|
exports["."] | 入口,必须导出 createServerAdapter |
exports["./ui-parser"] | 自包含 UI 解析器模块(可选,但推荐) |
paperclip.adapterUiParser | UI 解析器的契约版本号,当前是 "1.0.0" |
files | 限制发布内容,只发 dist/ |
tsconfig 那边文档给的是 target: ES2022、module: Node16、moduleResolution: Node16、strict: true,输出到 dist。
execute 的输入输出:契约在这两个 interface 里
适配器的核心是 src/server/execute.ts。它拿到一个 AdapterExecutionContext,返回一个 AdapterExecutionResult。文档把上下文的形状写全了:
interface AdapterExecutionContext {
runId: string;
agent: { id: string; companyId: string; name: string; adapterConfig: unknown };
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
config: Record<string, unknown>; // agent 的 adapterConfig
context: Record<string, unknown>; // 任务、唤醒原因等
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}
返回值这一侧更值得逐字段看,因为 Paperclip 的成本、会话、错误几条链路都靠它:
interface AdapterExecutionResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
errorMessage?: string | null;
usage?: { inputTokens: number; outputTokens: number };
sessionParams?: Record<string, unknown> | null; // 跨心跳持久化
sessionDisplayId?: string | null;
provider?: string | null;
model?: string | null;
costUsd?: number | null;
clearSession?: boolean; // 置 true 强制下次唤醒开新会话
}
usage 和 costUsd 不填,成本报表那条链路就没有数据来源;clearSession 是文档在”处理未知会话错误”里点名的用法——碰到会话已失效,重试一次全新会话并把这个标志置上。
文档列出的执行职责是七步:用安全取值助手读配置、用 buildPaperclipEnv(agent) 构建环境、从 runtime.sessionParams 恢复会话状态、用 renderTemplate(template, data) 渲染提示词、用 runChildProcess() 或 fetch() 发起调用、解析输出拿用量/成本/会话/错误、处理未知会话错误。
配套的助手都在 @paperclipai/adapter-utils 里:
| 助手 | 来源 | 用途 |
|---|---|---|
runChildProcess(cmd, opts) | /server-utils | 带超时、宽限期、流式回调地起子进程 |
buildPaperclipEnv(agent) | /server-utils | 注入 PAPERCLIP_* 环境变量 |
renderTemplate(tpl, data) | /server-utils | {{variable}} 模板替换 |
asString / asNumber / asBoolean | 包根 | 安全提取配置值 |
模板可用的变量,文档示例里给到了 agentId、agentName、companyId、runId、taskId、taskTitle。要理解这些字段为什么长这样,得先明白心跳是怎么驱动一次运行的,见 Agent 在 Paperclip 里怎么被驱动。
环境自检:三档诊断决定能不能跑
src/server/test.ts 在运行前校验配置,返回结构化诊断。级别只有三档,效果各不相同:
| 级别 | 含义 | 效果 |
|---|---|---|
error | 配置无效或不可用 | 阻断执行 |
warn | 非阻断问题 | 以黄色指示展示 |
info | 检查通过 | 在测试结果里展示 |
整体状态是 "pass" | "warn" | "fail",文档示例里的算法很直白:只要有任何一条 error 就判 fail。每条检查可以带 message、hint、code,hint 是给人看的修复建议(示例里是把相对路径的工作目录纠正成绝对路径)。这一层写扎实,比在 execute 里做防御性判断划算得多——配置错了应该在跑起来之前就拦住。
UI 解析器:不写会怎样,写了要守什么
这是三篇文档里最容易被跳过、又最影响使用体验的一块。Paperclip 把 Agent 的 stdout 实时推给界面,界面需要一个解析器把裸行转成结构化的转写条目。没有自定义解析器时会退回通用 shell 解析器,把每一行非系统行都当成 assistant 输出——按文档的说法,工具命令会以纯文本泄漏出来、耗时丢失、错误不可见。
解析器模块要从 dist/ui-parser.js 导出下面两个里的至少一个:
parseStdoutLine(line: string, ts: string): TranscriptEntry[]——无状态,逐行调用;createStdoutParser(): { parseLine(line, ts): TranscriptEntry[]; reset(): void }——有状态工厂,需要跟踪多行续行、命令嵌套等跨调用状态时用。
两个都导出时,createStdoutParser 优先。
TranscriptEntry 是一个判别联合,一共八种形状:assistant、thinking、user、tool_call、tool_result、system、stderr、stdout。工具调用和结果靠 toolUseId 配对,tool_result 上的 isError: true 会显示红色标识。
约束这一节是硬的,文档列了六条:
- 零运行时 import。文件是在浏览器里用
URL.createObjectURL+ 动态import()加载的,不能有import、require、顶层await。 - 不许用 DOM 和 Node.js API。跑在浏览器沙箱里,只能用原生 JS(ES2020+)。
- 无副作用。模块级代码不能改全局、不能碰
window、不能做 I/O,只声明和导出函数。 - 确定性。同样的
(line, ts)必须产出同样的结果,这一条是为了日志回放。 - 绝不抛异常。解析不了的行返回
[{ kind: "stdout", ts, text: line }],而不是让整条转写崩掉。 - 文件大小控制在 50 KB 以内,它是按请求下发并在浏览器里 eval 的。
版本协商也写死了:宿主检查 paperclip.adapterUiParser,声明 1.0.0 且宿主期望 1.x 时加载;声明 2.0.0 而宿主期望 1.x 会记一条警告并退回通用解析器;字段缺失目前仍会加载(文档称为宽限期,未来版本可能强制要求)。
失败路径同样是”降级而不是崩”:模块语法错误、404、契约版本不匹配都会记警告并用通用解析器,且失败的类型会被缓存、不再重试;运行时抛错是按行捕获的,那一行退回通用解析,解析器本身仍然保持注册。
编译命令文档给的是:
tsc src/ui-parser.ts --outDir dist --target ES2020 --module ES2020 --declaration false
最后一点:如果你的适配器 stdout 本来就很简单——纯文本回复、只打印结果的自定义脚本、没有结构化输出的 CLI——文档明确说可以完全跳过 UI 解析器,办法就是不在 package.json 里写 exports["./ui-parser"]。
能力开关:让界面自动长出对应功能
ServerAdapterModule 上有一组可选字段,服务端和界面用它们决定给这个适配器开哪些功能:
| 开关 | 类型 | 默认 | 控制什么 |
|---|---|---|---|
supportsLocalAgentJwt | boolean | false | 心跳是否为该 Agent 生成本地 JWT |
supportsInstructionsBundle | boolean | false | 托管指令包(AGENTS.md)的服务端解析与编辑器 |
instructionsPathKey | string | "instructionsFilePath" | adapterConfig 里存指令文件路径的键名 |
requiresMaterializedRuntimeSkills | boolean | false | 运行时 skill 条目是否必须先落盘再执行 |
这些开关会通过 GET /api/adapters 暴露在一个 capabilities 对象里,同时还有一个派生的 supportsSkills——只要定义了 listSkills 或 syncSkills 就是 true。文档说得很清楚:设了这些开关,指令包编辑器、skills 管理页、工作目录字段会自动出现,不需要改 Paperclip 源码;不设的话,服务端对内置类型退回旧的硬编码清单,而外部适配器省略开关时所有能力一律按 false 处理。
可选件:会话、skills、模型探测
三块可选能力,按需实现:
会话持久化。从 execute() 返回 sessionParams(比如 { sessionId: "abc123" }),下次唤醒读 runtime.sessionParams 续上;还可以实现 sessionCodec,它有 deserialize / serialize / getDisplayId 三个方法,分别做校验、存储序列化和给人看的会话标签。
skills 同步。实现 listSkills 和 syncSkills,返回体里有 supported、mode(示例是 "ephemeral")、desiredSkills、entries、warnings。至于怎么让运行时看见 Paperclip 的 skills,文档按优先级给了四种做法:最好是建临时目录 + 软链 skills + 用 CLI 参数传入 + 用完清理;其次是软链到运行时的全局插件目录;再次是用环境变量指向仓库的 skills/ 目录;提示词注入是最后手段。
模型探测。如果运行时有本地配置文件写明默认模型,可以实现 detectModel(),返回 model、provider、source、candidates。
跨运行的工作区:不许依赖 git remote
这一条是文档里明确称为「契约」的东西,写自研适配器时最容易踩。原文的表述是:本地执行工作区的 cwd 是跨运行的唯一持久化边界,任何适配器都不得依赖 git remote 来保存跨运行状态。
官方支持的往返路径是这样的:每次运行时,远端一侧由 prepareWorkspaceForSshExecution(在 packages/adapter-utils/src/ssh.ts)把本地 worktree 打成 git bundle 送到该次运行的远端目录,全程不设任何 git remote,bundle 本身就是传输载体;运行结束时,适配器在 finally 块里调用 restoreRemoteWorkspace,链路是 restoreWorkspaceFromSshExecution → exportGitWorkspaceFromSsh → integrateImportedGitHead,远端运行期间产生的提交回落到本地 worktree,不需要 git push,也不需要配置 remote。
必须守住的三条不变式:
- 永远不要
git push——适配器和运行时代码都不行。运维方可以自己配置选择加入,但默认契约就是不做远端操作。 - 永远不要假设 remote 存在。运行之间,本地 cwd 就是唯一真相。
- 同步回落失败必须冒泡。回落失败要作为运行级错误抛出,不能悄悄记个警告了事。心跳会围绕
adapter.execute记一行workspace_finalize(succeeded/failed),这样依赖该运行的 issue 就不会在一个陈旧的 worktree 上被唤醒。
这条不变式在仓库里由 packages/adapter-utils/src/ssh-fixture.test.ts 的 “no-remote-git contract” 用例钉住,断言往返前后 git remote 都为空,且只靠 restore 就能把仅存在于远端的提交带回本地。
装上去:三种方式
文档给了三条安装路径。界面里的入口是 Settings → Adapters → Install from npm;API 走这个:
curl -X POST http://localhost:3102/api/adapters \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"packageName": "my-paperclip-adapter"}'
本地目录同一个端点,换个字段:
curl -X POST http://localhost:3102/api/adapters \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"localPath": "/home/user/my-adapter"}'
本地适配器会被软链进 Paperclip 的适配器目录,源码改动要重启服务端才会生效。开发期也可以直接改 ~/.paperclip/adapter-plugins.json,里面每一项是 packageName / localPath / type / installedAt。发布就是 npm run build 加 npm publish,之后别人按包名就能装。
安全约束与什么时候别自己写
两篇文档的安全一节内容基本一致,五条:把 Agent 输出当作不可信来源,防御性解析、绝不执行(external-adapters 里写得更直白:绝不 eval() Agent 输出);密钥用环境变量注入,不要塞进提示词;运行时支持的话配好网络访问控制;超时和宽限期永远要强制执行;UI 解析器模块跑在浏览器沙箱里,必须零运行时 import、无副作用。
什么时候不该走自研这条路:
- 你的运行时本来就是个命令行程序,那先看进程适配器够不够,见 进程适配器与本地 CLI;
- 你的运行时是个 HTTP 服务,先看 HTTP 适配器接入自建 Agent 能不能直接配出来。自研适配器的价值在于内置几类覆盖不到的调用方式、会话语义或输出格式,为了改个参数名去写一个包并不划算。
还有几件事这三篇文档没有交代,动手前要有心理准备:parse.ts 的输出解析没有给统一的接口约定,怎么从 stdout 里抠出 token 用量和成本要按各自运行时的格式自己定;AdapterInvocationMeta 的具体字段文档没有展开;能力开关里提到的”旧的硬编码清单”具体包含哪些内置类型也没有列出来。这些只能去读源码,或者在实现时先按最小可用集合走通一条链路再补。
延伸阅读
- 从头读起:Paperclip 是什么:一个自己不跑 Agent 的控制平面,怎么管住一整家 AI 公司
- 本专题共 40 篇,完整分组目录见专题页
- Paperclip 里的 Agent 不是常驻进程:心跳、唤醒与一次运行链路
- Paperclip 执行语义:一次运行到底保证了什么(幂等、锁、终态与超时的定义)
本文依据 Paperclip 官方仓库(github.com/paperclipai/paperclip,MIT 协议)的 docs/ 用户文档
与 doc/ 下的规范、运维与连接器手册整理,核对日 2026-08-17。
我们没有部署或运行过 Paperclip,因此不涉及界面外观与操作手感;
部分规范文档描述的是目标架构而非当前实现,文中已就地标注,不构成对实际行为的保证。
请以仓库最新内容为准。