给 Paperclip 自己写一个适配器:外部插件包的结构、执行契约与 UI 解析器

2026-08-17

如果你手上的 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.tsui/src/adapters/registry.tscli/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.adapterUiParserUI 解析器的契约版本号,当前是 "1.0.0"
files限制发布内容,只发 dist/

tsconfig 那边文档给的是 target: ES2022module: Node16moduleResolution: Node16strict: 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 强制下次唤醒开新会话
}

usagecostUsd 不填,成本报表那条链路就没有数据来源;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包根安全提取配置值

模板可用的变量,文档示例里给到了 agentIdagentNamecompanyIdrunIdtaskIdtaskTitle。要理解这些字段为什么长这样,得先明白心跳是怎么驱动一次运行的,见 Agent 在 Paperclip 里怎么被驱动

环境自检:三档诊断决定能不能跑

src/server/test.ts 在运行前校验配置,返回结构化诊断。级别只有三档,效果各不相同:

级别含义效果
error配置无效或不可用阻断执行
warn非阻断问题以黄色指示展示
info检查通过在测试结果里展示

整体状态是 "pass" | "warn" | "fail",文档示例里的算法很直白:只要有任何一条 error 就判 fail。每条检查可以带 messagehintcodehint 是给人看的修复建议(示例里是把相对路径的工作目录纠正成绝对路径)。这一层写扎实,比在 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 是一个判别联合,一共八种形状:assistantthinkingusertool_calltool_resultsystemstderrstdout。工具调用和结果靠 toolUseId 配对,tool_result 上的 isError: true 会显示红色标识。

约束这一节是硬的,文档列了六条:

  1. 零运行时 import。文件是在浏览器里用 URL.createObjectURL + 动态 import() 加载的,不能有 importrequire、顶层 await
  2. 不许用 DOM 和 Node.js API。跑在浏览器沙箱里,只能用原生 JS(ES2020+)。
  3. 无副作用。模块级代码不能改全局、不能碰 window、不能做 I/O,只声明和导出函数。
  4. 确定性。同样的 (line, ts) 必须产出同样的结果,这一条是为了日志回放。
  5. 绝不抛异常。解析不了的行返回 [{ kind: "stdout", ts, text: line }],而不是让整条转写崩掉。
  6. 文件大小控制在 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 上有一组可选字段,服务端和界面用它们决定给这个适配器开哪些功能:

开关类型默认控制什么
supportsLocalAgentJwtbooleanfalse心跳是否为该 Agent 生成本地 JWT
supportsInstructionsBundlebooleanfalse托管指令包(AGENTS.md)的服务端解析与编辑器
instructionsPathKeystring"instructionsFilePath"adapterConfig 里存指令文件路径的键名
requiresMaterializedRuntimeSkillsbooleanfalse运行时 skill 条目是否必须先落盘再执行

这些开关会通过 GET /api/adapters 暴露在一个 capabilities 对象里,同时还有一个派生的 supportsSkills——只要定义了 listSkillssyncSkills 就是 true。文档说得很清楚:设了这些开关,指令包编辑器、skills 管理页、工作目录字段会自动出现,不需要改 Paperclip 源码;不设的话,服务端对内置类型退回旧的硬编码清单,而外部适配器省略开关时所有能力一律按 false 处理。

可选件:会话、skills、模型探测

三块可选能力,按需实现:

会话持久化。从 execute() 返回 sessionParams(比如 { sessionId: "abc123" }),下次唤醒读 runtime.sessionParams 续上;还可以实现 sessionCodec,它有 deserialize / serialize / getDisplayId 三个方法,分别做校验、存储序列化和给人看的会话标签。

skills 同步。实现 listSkillssyncSkills,返回体里有 supportedmode(示例是 "ephemeral")、desiredSkillsentrieswarnings。至于怎么让运行时看见 Paperclip 的 skills,文档按优先级给了四种做法:最好是建临时目录 + 软链 skills + 用 CLI 参数传入 + 用完清理;其次是软链到运行时的全局插件目录;再次是用环境变量指向仓库的 skills/ 目录;提示词注入是最后手段

模型探测。如果运行时有本地配置文件写明默认模型,可以实现 detectModel(),返回 modelprovidersourcecandidates

跨运行的工作区:不许依赖 git remote

这一条是文档里明确称为「契约」的东西,写自研适配器时最容易踩。原文的表述是:本地执行工作区的 cwd 是跨运行的唯一持久化边界,任何适配器都不得依赖 git remote 来保存跨运行状态。

官方支持的往返路径是这样的:每次运行时,远端一侧由 prepareWorkspaceForSshExecution(在 packages/adapter-utils/src/ssh.ts)把本地 worktree 打成 git bundle 送到该次运行的远端目录,全程不设任何 git remote,bundle 本身就是传输载体;运行结束时,适配器在 finally 块里调用 restoreRemoteWorkspace,链路是 restoreWorkspaceFromSshExecutionexportGitWorkspaceFromSshintegrateImportedGitHead,远端运行期间产生的提交回落到本地 worktree,不需要 git push,也不需要配置 remote。

必须守住的三条不变式:

  • 永远不要 git push——适配器和运行时代码都不行。运维方可以自己配置选择加入,但默认契约就是不做远端操作。
  • 永远不要假设 remote 存在。运行之间,本地 cwd 就是唯一真相。
  • 同步回落失败必须冒泡。回落失败要作为运行级错误抛出,不能悄悄记个警告了事。心跳会围绕 adapter.execute 记一行 workspace_finalizesucceeded / 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 buildnpm publish,之后别人按包名就能装。

安全约束与什么时候别自己写

两篇文档的安全一节内容基本一致,五条:把 Agent 输出当作不可信来源,防御性解析、绝不执行(external-adapters 里写得更直白:绝不 eval() Agent 输出);密钥用环境变量注入,不要塞进提示词;运行时支持的话配好网络访问控制;超时和宽限期永远要强制执行;UI 解析器模块跑在浏览器沙箱里,必须零运行时 import、无副作用。

什么时候不该走自研这条路:

  • 你的运行时本来就是个命令行程序,那先看进程适配器够不够,见 进程适配器与本地 CLI
  • 你的运行时是个 HTTP 服务,先看 HTTP 适配器接入自建 Agent 能不能直接配出来。自研适配器的价值在于内置几类覆盖不到的调用方式、会话语义或输出格式,为了改个参数名去写一个包并不划算。

还有几件事这三篇文档没有交代,动手前要有心理准备:parse.ts 的输出解析没有给统一的接口约定,怎么从 stdout 里抠出 token 用量和成本要按各自运行时的格式自己定;AdapterInvocationMeta 的具体字段文档没有展开;能力开关里提到的”旧的硬编码清单”具体包含哪些内置类型也没有列出来。这些只能去读源码,或者在实现时先按最小可用集合走通一条链路再补。

延伸阅读


本文依据 Paperclip 官方仓库(github.com/paperclipai/paperclip,MIT 协议)的 docs/ 用户文档 与 doc/ 下的规范、运维与连接器手册整理,核对日 2026-08-17。 我们没有部署或运行过 Paperclip,因此不涉及界面外观与操作手感; 部分规范文档描述的是目标架构而非当前实现,文中已就地标注,不构成对实际行为的保证。 请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。