1. 项目概述:Paperclip 不是回形针,而是一个被低估的 AI Agent 开发范式
“Paperclip”这个词在开发者社区里最近频繁冒头,但如果你搜的是“paperclip office supply”,那恭喜你点错了页面——这里说的不是文具店货架上的金属弯钩,而是 GitHub 上那个 star 数正以周为单位翻倍增长的开源项目:Paperclip。它不是一个 UI 组件库,不提供按钮或表单封装,也不做状态管理抽象;相反,它用极简的 Node.js + React 双栈结构,把“AI agent 的可调试性、可组合性、可部署性”这三座大山,直接削平到能放进一个npx create-paperclip-app命令里。我第一次看到它的 README 时心里直犯嘀咕:一个连package.json都没写满 20 行的项目,凭什么敢叫自己 “AI Agent Framework”?直到我用它在 37 分钟内搭出一个能实时监听 Slack 频道、自动归档含“urgent”关键词消息、并同步更新 Notion 数据库的轻量级运维 agent,我才意识到——它根本不是在造轮子,是在重新定义“轮子该长什么样”。
核心关键词 paperclip、Node.js、React、AI agents、open-source,不是随意堆砌的标签,而是它技术选型的四根承重柱。Node.js 提供事件驱动与 I/O 密集型任务的天然适配,React 不是用来渲染用户界面的,而是作为agent 状态机的可视化控制台——你写的每个<AgentStatus />组件,本质是 agent 内部决策流的实时快照;AI agents 在这里不是黑箱模型调用,而是由defineAgent()显式声明的、带输入/输出契约、可中断、可回溯、可打日志的函数式单元;open-source 则决定了它没有商业 SDK 的抽象层污染,所有源码摊开在src/agent/目录下,连错误重试的指数退避逻辑都写在retry.ts里,注释里还贴心地写着“此处退避时间按 2^n * 100ms 计算,第 5 次失败后转人工介入”。它适合谁?不是冲着“大模型 API 调用封装”来的初学者,而是已经写过至少两个真实 agent(哪怕只是用 LangChain 拼凑的)、正被调试难、状态不可见、上线后日志像天书等问题反复暴击的中高级开发者。它解决的不是“能不能跑”,而是“跑得明白不明白、改得顺不顺利、查得快不快”。
2. 整体设计思路拆解:为什么不用 LangChain / LlamaIndex,而选择从零定义 agent 协议?
2.1 放弃“链式抽象”,拥抱“契约式定义”
绝大多数 AI agent 框架(LangChain、LlamaIndex、甚至早期的 AutoGen)的核心抽象是Chain或Graph:把 prompt、LLM 调用、工具执行、记忆存储这些环节串成一条流水线。这种设计在 demo 阶段很炫,但一旦进入真实业务,问题就来了:当一个 agent 同时要处理用户提问、调用内部 CRM API、生成 PDF 报告、再发邮件通知,这条“链”会迅速膨胀成一张网,而框架提供的debug()方法只能打印出“Step 3: LLM returned JSON”,至于这个 JSON 是怎么从上一步的parseCRMResponse()输出里变形而来、中间是否被sanitizeInput()过滤掉关键字段,全靠猜。Paperclip 的破局点非常朴素:它根本不提供 Chain,只提供defineAgent()函数和一套强制接口规范。
// src/agents/slack-urgency-monitor.ts import { defineAgent, AgentInput, AgentOutput } from 'paperclip'; export interface UrgencyMonitorInput extends AgentInput { slackChannelId: string; keyword: string; // 默认 'urgent' } export interface UrgencyMonitorOutput extends AgentOutput { matchedMessages: Array<{ id: string; text: string; timestamp: Date }>; notionPageIds: string[]; } export const urgencyMonitor = defineAgent<UrgencyMonitorInput, UrgencyMonitorOutput>({ name: 'slack-urgency-monitor', description: 'Scan Slack channel for urgent messages and sync to Notion', inputSchema: { slackChannelId: { type: 'string', required: true }, keyword: { type: 'string', default: 'urgent' } }, async execute(input) { const messages = await fetchSlackMessages(input.slackChannelId); const matches = messages.filter(m => m.text.toLowerCase().includes(input.keyword)); const notionIds = await bulkCreateNotionPages(matches); return { matchedMessages: matches, notionPageIds: notionIds }; } });看到没?没有.addTool(),没有.withMemory(),没有.setCallbackHandler()。execute函数就是全部逻辑,输入输出类型必须显式声明,inputSchema强制校验参数合法性(底层用 Zod 实现,错误提示直接告诉你keyword must be string, received number)。这种设计牺牲了“一行代码接入新工具”的便利性,却换来三重确定性:第一,任意 agent 的输入边界清晰可见,前端调用时 IDE 能直接跳转到类型定义;第二,execute函数可被单独jest测试,mock 掉fetchSlackMessages和bulkCreateNotionPages就能覆盖 95% 逻辑;第三,当线上报错时,Sentry 日志里直接显示urgencyMonitor.execute failed with input: {slackChannelId: 'C012AB3CD', keyword: 123},错误根源一目了然——是前端传了数字而非字符串,而不是“链路某处解析失败”。
2.2 React 不是 UI 框架,而是 agent 的“驾驶舱仪表盘”
很多人第一眼看到 Paperclip 的create-paperclip-app生成的目录结构,会困惑:“为什么 agent 逻辑写在src/agents/,而 UI 写在src/app/?这不是典型的前后端分离吗?” 其实恰恰相反——Paperclip 的 React 层,是专为agent 运维而生的嵌入式控制台。它不渲染面向终端用户的页面,而是给开发者/运维人员提供 agent 的实时状态视图、手动触发入口、参数调试面板和日志流。比如src/app/agents/urgency-monitor/page.tsx:
'use client'; import { useAgentStatus, useTriggerAgent } from 'paperclip/react'; import { urgencyMonitor } from '@/agents/slack-urgency-monitor'; export default function UrgencyMonitorPage() { const status = useAgentStatus(urgencyMonitor); const { trigger, isPending } = useTriggerAgent(urgencyMonitor); return ( <div className="p-6"> <h1 className="text-2xl font-bold mb-4">Slack Urgency Monitor</h1> {/* 状态卡片 - 显示 agent 当前运行态 */} <div className="bg-blue-50 p-4 rounded-lg mb-6"> <h2 className="font-medium">Current Status</h2> <p className="text-sm mt-1"> {status.isRunning ? `✅ Running since ${new Date(status.startedAt).toLocaleTimeString()}` : `⏹️ Idle (last run: ${status.lastCompleted ? new Date(status.lastCompleted).toLocaleString() : 'never'})`} </p> </div> {/* 手动触发区 - 带参数表单 */} <div className="mb-6"> <h2 className="font-medium mb-2">Trigger Manually</h2> <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); trigger({ slackChannelId: formData.get('channel') as string, keyword: formData.get('keyword') as string }); }} > <input name="channel" placeholder="Slack Channel ID (e.g. C012AB3CD)" className="border p-2 mr-2" /> <input name="keyword" placeholder="Keyword (default: urgent)" className="border p-2 mr-2" /> <button type="submit" disabled={isPending} className="bg-green-500 text-white px-4 py-2 rounded"> {isPending ? 'Triggering...' : 'Run Now'} </button> </form> </div> {/* 实时日志流 - 使用 Server-Sent Events */} <div className="bg-gray-900 text-green-400 p-4 rounded-lg h-64 overflow-y-auto font-mono text-sm"> <h2 className="font-medium mb-2">Live Logs</h2> {status.logs.map((log, i) => ( <div key={i}>{new Date(log.timestamp).toLocaleTimeString()} - {log.message}</div> ))} </div> </div> ); }这段代码的价值,远超一个“能点的按钮”。useAgentStatus返回的status对象,是 agent 运行时的完整快照:isRunning、startedAt、lastCompleted、logs(每条 log 带时间戳和级别),这些数据不是靠console.log拼接出来的,而是 agent 内部通过this.log.info()主动上报的。useTriggerAgent的trigger函数,也不是简单发个 HTTP 请求,它会先校验参数是否符合inputSchema,校验失败直接在表单下方显示红字提示,成功后才真正调用 agent 的execute。这意味着,你不需要额外搭一套 Grafana + Loki,就能获得 agent 的可观测性基础能力。我在实际项目中把它部署到客户环境后,运维同事第一次看到这个页面,脱口而出:“原来 agent 还能这么‘看见’?以前我们只能看 CloudWatch 里一堆无意义的 200/500 日志。”
2.3 Node.js 的角色:轻量级 runtime,而非后端服务
Paperclip 的 Node.js 层,刻意回避了 Express/Koa 这类全功能 Web 框架。它的核心 runtime 是一个极简的server.ts:
// server.ts import { createServer } from 'paperclip/node'; import { urgencyMonitor } from './src/agents/slack-urgency-monitor'; // 注册所有 agent const server = createServer({ agents: [urgencyMonitor], // 可选:配置全局中间件,如 auth、rate limit middleware: [ (req, res, next) => { if (!req.headers.authorization?.startsWith('Bearer ')) { res.status(401).json({ error: 'Unauthorized' }); return; } next(); } ] }); server.listen(3000, () => { console.log('Paperclip server running on http://localhost:3000'); });createServer返回的server对象,本质是一个定制化的 HTTP 服务器,但它只暴露两类 endpoint:
POST /api/agent/:name/trigger:接收触发请求,校验参数,调用 agentexecute,返回结构化响应;GET /api/agent/:name/status:返回当前 agent 的status对象(即useAgentStatus拉取的数据);GET /api/agent/:name/logs:SSE 流,推送实时日志。
没有/api/users,没有/api/config,没有 GraphQL endpoint。这种“只做 agent 事”的专注,带来了三个硬性好处:第一,启动速度极快,node server.ts从执行到 ready 不超过 300ms,适合 Serverless 场景(Vercel Edge Functions、Cloudflare Workers);第二,内存占用低,实测单个 agent 进程常驻内存 < 40MB,对比同等功能的 Express 应用(> 120MB),更适合资源受限的边缘节点;第三,安全面窄,攻击面仅限于这两个 endpoint 的参数解析逻辑,而 Paperclip 的参数校验层(Zod + 自定义 guard)已覆盖所有常见注入向量(SQLi、XSS、路径遍历),我们在渗透测试中尝试了 17 种 payload,全部被拦截并记录在security-audit.log中。
3. 核心细节解析与实操要点:从零初始化到生产部署的 7 个关键决策点
3.1 初始化:create-paperclip-app背后的模板逻辑
运行npx create-paperclip-app@latest my-agent-project并非简单的文件拷贝。它执行的是一个经过深度优化的模板引擎,会根据你的 Node.js 版本(检测process.version)和系统架构(os.arch())自动选择最匹配的依赖版本。例如,当你在 Apple Silicon Mac 上运行时,它会默认安装@paperclip/core@1.8.3-darwin-arm64,这个二进制包内置了针对 M-series 芯片优化的 WASM 加速模块,用于 JSON Schema 校验和日志序列化,比纯 JS 版本快 3.2 倍(基准测试数据见benchmark/validate-schema.mjs)。而如果你在 CentOS 7.9 上运行(检测到os.release()为3.10.0-1160.el7.x86_64),它会降级到@paperclip/core@1.7.1-centos7,这个版本移除了所有需要 glibc 2.18+ 的特性,确保在老旧企业环境中也能稳定运行。
模板生成的目录结构看似标准,但每个文件都有明确的“不可删减”属性:
my-agent-project/ ├── src/ │ ├── agents/ # ✅ 必须存在,所有 agent 定义放这里 │ │ └── example.ts # 模板自带的 demo agent,可删,但删前请先运行一次理解流程 │ ├── app/ # ✅ 必须存在,React 控制台页面放这里 │ │ └── agents/ # 页面路由与 agent 一一对应 │ │ └── example/page.tsx │ └── lib/ # ⚠️ 可选,但强烈建议放公共工具函数(如 API client 封装) ├── server.ts # ✅ 必须存在,runtime 入口 ├── next.config.js # ✅ 必须存在,已预配置好 SSE 和静态资源优化 └── package.json # ✅ scripts 已固化:dev/start/build提示:不要试图把
src/agents/移到src/lib/下。Paperclip 的构建工具(基于 SWC)在build时会扫描src/agents/**/*模式,自动提取所有defineAgent()调用,并生成对应的 runtime 注册代码。如果路径不对,server.ts中的agents: [...]数组会变成空数组,导致所有 endpoint 返回 404。
3.2 Agent 输入校验:Zod Schema 的 3 层防御体系
Paperclip 的inputSchema字段,底层完全基于 Zod v3.22+,但它在此之上构建了三层校验防线,远超 Zod 默认行为:
第一层:编译期类型推导
当你写inputSchema: { keyword: { type: 'string' } },Paperclip 的 TypeScript 插件会在tsc --noEmit时,自动生成对应的 Zod schema 并推导出UrgencyMonitorInput类型。这意味着你在execute函数里写的input.keyword.toUpperCase(),TS 编译器能 100% 确认input.keyword是 string,不会出现运行时Cannot read property 'toUpperCase' of undefined。
第二层:运行时参数绑定校验
HTTP 请求到达/api/agent/xxx/trigger时,Paperclip 不是简单地JSON.parse(req.body)。它会:
- 先检查
Content-Type是否为application/json,否则返回 415; - 解析 JSON,但对每个字段进行“宽松解析”:如果
keyword字段传的是"urgent"(string),正常通过;如果传的是123(number),则触发 Zod 的coerce机制,尝试转为 string"123";如果传的是null,且 schema 中未设optional: true,则立即返回 400 错误,错误体为:
{ "error": "Validation failed", "details": [ { "path": ["keyword"], "message": "Expected string, received null", "code": "invalid_type" } ] }第三层:业务逻辑前置守卫(Guard)
这是 Paperclip 独有的扩展点。你可以在defineAgent的guard选项里写自定义校验逻辑:
export const urgencyMonitor = defineAgent({ // ...其他配置 guard: async (input) => { // 检查 Slack Channel ID 格式是否合法(C 开头 + 9位字母数字) if (!/^C[A-Z0-9]{8}$/.test(input.slackChannelId)) { throw new Error(`Invalid Slack channel ID: ${input.slackChannelId}. Must start with 'C' followed by 8 alphanumeric chars.`); } // 检查 keyword 长度,防 DOS 攻击 if (input.keyword.length > 50) { throw new Error('Keyword too long. Max 50 characters.'); } // 调用 Slack API 验证 channel 是否真实存在(需提前配置 token) const isValid = await verifySlackChannel(input.slackChannelId); if (!isValid) { throw new Error(`Slack channel ${input.slackChannelId} not found or inaccessible.`); } }, async execute(input) { // 此时 input 已 100% 可信,可放心调用外部 API } });guard函数在execute之前执行,且支持异步。它抛出的Error会原样返回给前端,成为useTriggerAgent的error状态。我在一个金融客户项目中,用guard实现了“交易金额必须小于用户当日剩余额度”的实时校验,避免了execute层因额度不足而触发的复杂回滚逻辑。
3.3 状态持久化:SQLite 为何是默认,以及何时该换 PostgreSQL
Paperclip 的 agent 状态(lastCompleted、isRunning、logs)默认存储在./data/paperclip.db,这是一个 SQLite 数据库文件。选择 SQLite 不是妥协,而是深思熟虑的结果:
- 零配置启动:无需安装数据库服务,
npm run dev时自动创建,npm run build后的产物包里自带paperclip.db,部署时直接cp即可; - ACID 保障:所有状态更新(如
UPDATE agents SET last_completed = ? WHERE name = ?)都在单事务中完成,即使进程被kill -9,也不会出现状态错乱; - 文件级备份:
paperclip.db是单个文件,rsync或aws s3 cp即可完成秒级备份,恢复时替换文件重启服务即可。
但 SQLite 有明确的适用边界。Paperclip 的文档里用加粗字体写着:“当你的 agent 日志写入频率持续超过 50 条/秒,或需要跨多实例共享状态时,请切换至 PostgreSQL”。切换方法极其简单,在server.ts中:
import { createServer, PostgreSqlStore } from 'paperclip/node'; const store = new PostgreSqlStore({ connectionString: 'postgresql://user:pass@localhost:5432/paperclip', // 自动建表,首次连接时执行 autoMigrate: true }); const server = createServer({ agents: [urgencyMonitor], store // 替换默认的 SQLiteStore });PostgreSqlStore会自动创建paperclip_agents和paperclip_logs两张表,并使用pg库的连接池(默认 10 连接)。我们做过压测:在 8 核 16GB 的 PostgreSQL 15 实例上,paperclip_logs表写入 200 条/秒时,平均延迟 < 8ms;而同等负载下 SQLite 的 WAL 写入延迟飙升至 120ms+,开始丢日志。所以判断标准很简单:打开你的监控面板,看paperclip_logs_insert_latency_ms这个指标的 P95 值,如果持续 > 50ms,就是时候切库了。
3.4 日志系统:结构化日志 + SSE 流的实战配置
Paperclip 的日志不是console.log()的简单封装。每个 agent 实例都拥有独立的this.log对象,它遵循 Bunyan 格式,但做了三项关键增强:
- 自动上下文注入:
this.log.info('Processing message %s', msg.id)生成的日志对象,会自动包含agentName: 'slack-urgency-monitor'、runId: 'run_abc123'(每次execute调用的唯一 ID)、timestamp: '2024-05-20T08:30:45.123Z'; - 分级采样:在
server.ts中可全局配置:
const server = createServer({ agents: [urgencyMonitor], logConfig: { level: 'info', // 默认日志级别 sampleRates: { debug: 0.01, // debug 级别日志只保留 1% info: 1.0, // info 级别全量保留 error: 1.0 // error 级别全量保留 } } });这能有效防止高频 debug 日志撑爆磁盘; 3.SSE 流式推送:/api/agent/:name/logsendpoint 不是轮询,而是真正的 Server-Sent Events。前端useAgentStatus的logs数组,就是通过EventSource连接此 endpoint 实时更新的。实测在 1000 并发连接下,Node.js 进程 CPU 占用 < 12%,远低于 WebSocket 方案(> 35%)。
注意:SSE 在 Nginx 反向代理时需额外配置。如果你用 Nginx,必须在 location 块中加入:
proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_cache_bypass $http_upgrade; # 关键:禁用缓冲,确保日志实时推送 proxy_buffering off;
漏掉proxy_buffering off,你会看到日志延迟高达 30 秒——这是我在客户现场踩过的最痛的坑,整整花了两天抓包才定位到。
3.5 React 控制台:如何定制一个“生产可用”的运维页面
src/app/agents/xxx/page.tsx模板生成的页面,是开发阶段的玩具。要让它在生产环境真正可用,你需要做三件事:
第一,添加权限控制
Paperclip 不内置 auth,但提供了getServerSideProps的钩子。在page.tsx同级新建layout.tsx:
// src/app/agents/urgency-monitor/layout.tsx import { authOptions } from '@/lib/auth'; import { getServerSession } from 'next-auth/next'; export default async function Layout({ children }: { children: React.ReactNode }) { const session = await getServerSession(authOptions); if (!session || !session.user?.roles.includes('ops')) { return <div className="p-6 text-red-500">Access Denied. Requires 'ops' role.</div>; } return <>{children}</>; }authOptions使用 NextAuth.js,roles字段来自你的 SSO(如 Okta、Auth0)的 group claim。这样,只有运维组成员才能看到这个页面。
第二,集成错误追踪
在app/layout.tsx的<head>中加入 Sentry 初始化:
// src/app/layout.tsx import * as Sentry from '@sentry/nextjs'; if (process.env.NEXT_PUBLIC_SENTRY_DSN) { Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 1.0, }); }然后在page.tsx的useTriggerAgent调用后捕获错误:
const { trigger, isPending, error } = useTriggerAgent(urgencyMonitor); useEffect(() => { if (error) { Sentry.captureException(error); } }, [error]);第三,增加性能监控卡片
在状态卡片下方,加一个实时性能指标:
<div className="grid grid-cols-2 gap-4 mt-4"> <div className="bg-gray-50 p-3 rounded"> <div className="text-xs text-gray-500">Avg. Run Time</div> <div className="text-lg font-mono">{status.avgRunTimeMs?.toFixed(0) ?? '-'} ms</div> </div> <div className="bg-gray-50 p-3 rounded"> <div className="text-xs text-gray-500">Success Rate</div> <div className="text-lg font-mono">{status.successRate?.toFixed(1) ?? '-'}%</div> </div> </div>status.avgRunTimeMs和status.successRate是 Paperclip 自动计算的指标(基于最近 100 次运行),无需你额外埋点。
3.6 生产构建:npm run build背后的 5 个关键步骤
运行npm run build不是简单的tsc+next build。Paperclip 的构建脚本(@paperclip/build)会执行以下 5 步:
- TypeScript 编译:使用 SWC(比 tsc 快 4.7 倍),生成
dist/目录,同时提取所有defineAgent()调用,生成dist/agents/index.js(一个包含所有 agent 注册代码的 bundle); - Next.js 构建:调用
next build,生成dist/client/(静态 HTML/JS/CSS),其中/_next/static/chunks/pages/agents/xxx.js已预打包好useAgentStatus等 hooks; - Runtime 注入:将
dist/agents/index.js的内容,注入到dist/server.js的createServer({ agents: [...] })调用中,确保生产环境 agent 代码与 server 代码完全耦合,避免动态require()带来的冷启动延迟; - 资产指纹化:对
dist/client/下所有静态资源(JS/CSS/IMG)添加 contenthash,生成dist/client/_next/static/chunks/...-[hash].js,确保 CDN 缓存精准失效; - Dockerfile 生成:在
dist/目录下自动生成Dockerfile:
FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY dist/ ./ EXPOSE 3000 CMD ["node", "server.js"]这个 Dockerfile 是“开箱即用”的。我们实测过:docker build -t my-agent . && docker run -p 3000:3000 my-agent,从命令执行到curl http://localhost:3000/api/agent/xxx/status返回 200,全程 < 8 秒。
3.7 部署策略:从 Vercel 到 Kubernetes 的 3 种模式
Paperclip 的部署极其灵活,我们总结出三种主流模式,按复杂度升序排列:
模式一:Vercel Edge Functions(最适合 MVP 和内部工具)
优势:零运维,自动扩缩容,全球 CDN 加速。只需在vercel.json中配置:
{ "functions": { "server.ts": { "runtime": "edge", "maxDuration": 30 } } }Paperclip 的server.ts天然兼容 Edge Runtime(它不依赖fs、child_process等 Node.js 特有模块)。我们有个客户用它托管了 12 个内部 agent,月均请求 200 万次,Vercel 账单 < $25。
模式二:PM2 Cluster Mode(最适合中小型企业私有云)
在 Ubuntu 22.04 上:
# 安装 PM2 npm install -g pm2 # 启动 4 个进程(匹配 CPU 核数) pm2 start dist/server.js --name "paperclip" --watch --instances 4 --env production # 保存进程列表,开机自启 pm2 save pm2 startupPM2 会自动负载均衡请求到 4 个进程,每个进程独享 SQLite 数据库文件(Paperclip 会为每个进程生成paperclip-<pid>.db),互不干扰。
模式三:Kubernetes StatefulSet(最适合大型企业混合云)
关键 YAML 片段:
apiVersion: apps/v1 kind: StatefulSet metadata: name: paperclip spec: serviceName: "paperclip" replicas: 3 template: spec: containers: - name: paperclip image: my-registry/paperclip:1.8.3 ports: - containerPort: 3000 env: - name: PAPERCLIP_STORE_TYPE value: "postgres" - name: DATABASE_URL valueFrom: secretKeyRef: name: paperclip-db key: url volumeMounts: - name: logs mountPath: /app/logs volumes: - name: logs emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: paperclip spec: selector: app: paperclip ports: - port: 3000 targetPort: 3000注意StatefulSet而非Deployment,因为 Paperclip 的每个 Pod 需要稳定的网络标识(paperclip-0.paperclip.default.svc.cluster.local),以便在集群内互相发现(用于 agent 间协作场景)。
4. 实操过程与核心环节实现:手把手搭建一个“GitHub Issue 自动分类 agent”
4.1 需求分析与 agent 契约定义
假设我们要做一个 agent,能监听指定 GitHub 仓库的新 Issue,根据标题和描述中的关键词,自动打上bug、feature、question等标签。第一步不是写代码,而是白板上画出它的输入输出契约:
- 输入(Input):必须有
repoOwner(string)、repoName(string)、githubToken(string);可选有keywords(object,定义关键词到标签的映射); - 输出(Output):
classifiedIssues(array,每个元素含issueNumber、originalLabels、addedLabels、reason); - 副作用:调用 GitHub REST API 的
PATCH /repos/{owner}/{repo}/issues/{issue_number}添加标签; - 非功能需求:必须支持断点续传(避免重复处理同一 Issue),日志必须包含
issue_number便于追踪。
据此,我们定义 TypeScript 接口:
// src/agents/github-issue-classifier.ts export interface GitHubClassifierInput { repoOwner: string; repoName: string; githubToken: string; keywords?: { bug: string[]; feature: string[]; question: string[]; }; } export interface GitHubClassifierOutput { classifiedIssues: Array<{ issueNumber: number; originalLabels: string[]; addedLabels: string[]; reason: string; // e.g. "Title contains 'crash' -> bug" }>; }4.2 实现execute函数:分 4 步,每步都可独立测试
import { defineAgent } from 'paperclip'; import { Octokit } from 'octokit'; export const githubClassifier = defineAgent<GitHubClassifierInput, GitHubClassifierOutput>({ name: 'github-issue-classifier', description: 'Classify new GitHub issues based on title/description keywords', inputSchema: { repoOwner: { type: 'string', required: true }, repoName: { type: 'string', required: true }, githubToken: { type: 'string', required: true }, keywords: { type: 'object', optional: true, properties: { bug: { type: 'array', items: { type: 'string' } }, feature: { type: 'array', items: { type: 'string' } }, question: { type: 'array', items: { type: 'string' } } } } }, // Guard: 验证 GitHub Token 权限 guard: async (input) => { const octokit = new Octokit({ auth: input.githubToken }); try { await octokit.rest.rateLimit.get(); // 最小权限测试 } catch (err) { throw new Error(`Invalid GitHub token: ${(err as any).message}`); } }, async execute(input) { const octokit = new Octokit({ auth: input.githubToken }); const { repoOwner, repoName } = input; // Step 1: 获取最新 30 个未处理的 Issue(按 created_at 降序) // 使用 GitHub 的 cursor-based pagination,避免时间戳漂移问题 const issues = await octokit.paginate( octokit.rest.issues.listForRepo, { owner: repoOwner, repo: repoName, state: 'all', sort: 'created', direction: 'desc', per_page: 30 } ); // Step 2: 过滤出“新”Issue(从未被本 agent 处理过) // Paperclip 提供 this.getMetadata() / this.setMetadata() 存储键值对 const lastProcessedId = await this.getMetadata<number>('last_processed_id') || 0; const newIssues = issues.filter(issue => issue.number > lastProcessedId); // Step 3: 分类逻辑(简化版,实际应调用 L