关于 glove
glove 是面向 TypeScript 开发者的开源框架,用于构建由 Agent 驱动的 AI 应用;开发者定义工具和渲染器,框架负责 Agent 循环。其生态包含首选运行时 Glove Foundry,以及 MCP 桥接、记忆和工作环境等软件包。
主要功能
- 01提供 Agent 循环与工具执行运行时
- 02支持 React 集成和 Next.js 流式路由
- 03通过 glove-mcp 接入 MCP 服务
- 04提供记忆、事实和 Agent 协作软件包
- 05包含数据库、REPL 和持久工作环境接口
适用场景
- 01构建由对话驱动的 AI 应用
- 02在 Glove Agent 中调用 MCP 服务
- 03为 Agent 组织记忆与共享证据
- 04为工具使用提供 REPL 或隔离工作环境
MCP 相关
glove-mcp 可将 MCP 服务桥接到 Glove Agent,提供挂载、连接和工具桥接能力。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ porkytheblack/ glove --skill glove安装后在 Claude Code 中输入/glove确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
api-reference.mdexamples.mdworkflows.md详细介绍
Glove 框架 — 开发指南
你是 Glove 框架方面的专家。在编写、调试或审查 Glove 代码时,请运用这些知识。
Glove 是什么
Glove 是一个用于构建 AI 驱动应用的开源 TypeScript 框架。用户通过对话描述自己的需求,AI 则会决定调用哪些能力(工具)。开发者定义工具和渲染器;Glove 负责处理 Agent 循环。
Glove Foundry 是基于这些原语构建的首选应用框架和 Agent 运行时。对于基于文件路由的 Agent 系统、持久化实例、应用和传输、操作手册、计划、工作环境以及检查器,请使用专用的 glove-foundry 技能。Glovebox 已弃用,仅作为旧版兼容接口保留。
代码库:https://github.com/porkytheblack/glove 文档网站:https://glove.dterminal.net 许可证:MIT(dterminal)
目标、事实与表单准备
在添加动态目标、记录可复用的证据或根据已有事实准备表单时,请阅读 workflows.md。目标位于 glove-memory/goals;共享证据位于 glove-facts。该参考文档涵盖公共导入、工具挂载、生命周期与恢复规则,以及通过提供的 Glove Agent 进行准备并保留追踪信息。提供该 Agent 即可启用准备功能。
软件包概览
| 包 | 用途 | 安装 |
|---|---|---|
glove-core | 运行时引擎:Agent 循环、工具执行、显示管理器、模型适配器、默认的内存中 StoreAdapter MemoryStore(兼容浏览器——无原生依赖) | pnpm add glove-core |
glove-sqlite | 已弃用。 SqliteStore — 基于 SQLite 的持久化存储。原型开发优先使用 glove-core 中的 MemoryStore,生产环境则使用自定义的 StoreAdapter。 | pnpm add glove-sqlite |
glove-react | React hooks(useGlove)、GloveClient、GloveProvider、defineTool、<Render>、MemoryStore、带有就地渲染器的 ToolConfig | pnpm add glove-react |
glove-next | 用于流式传输 SSE 的单行 Next.js API 路由处理器(createChatHandler) | pnpm add glove-next |
glove-foundry | 首选的 Agent 应用框架和运行时。 Effect 原生文件路由、类型化定义、持久化实例和对话、动态安装和操作手册、计划任务、工作环境、多 Agent 组合以及关联检查。 | pnpm add glove-foundry |
glove-mcp | 将 MCP 服务桥接到 Glove Agent:mountMcp、connectMcp、bridgeMcpTool、McpAdapter、discovermcp 发现子 Agent。可通过 glove-mcp/oauth 选择启用 OAuth 辅助工具。 | pnpm add glove-mcp |
glove-memory | 实体、情节、资源和上下文记忆、对话表单以及动态目标(glove-memory/goals)。存储由用户提供;参考适配器仅限进程内使用。 | pnpm add glove-memory |
glove-facts | 共享的范围限定证据、更正和可复用链接;可选择通过调用方提供的 Glove Agent,为目标和表单进行准备。 | pnpm add glove-facts |
glove-image | Agent 式图像生成。 将图像处理作为一种工作流,而非单个 generate_image 工具:通过 PromptEnhancer[] 管线中的“中间步骤”构建每条提示词(逐字扩展角色/场景、风格、LLM 改写,然后强制调用 fitToModel(),将提示词限制在适配器能力范围内,并跟踪每项降级),在用户自有库中保存持久化的角色和场景,为参考图像指定角色(身份/风格/构图/内容/蒙版),在每个派生素材上记录 Recipe 血统信息(以便 regenerate 重放),通过可选的 sharp 确定性地执行 assemble,可选择启用视觉审核/描述,并在四个范围内跟踪 ImageUsage 成本。mountImage(glove, { adapter, assets, library, ... })。草案 v0.1。 | pnpm add glove-image |
glove-mesh | 基于收件箱原语实现 Agent 间通信:mountMesh、MeshAdapter(传输由用户提供)、MeshNetwork + InMemoryMeshAdapter 参考实现。提供四种工具——glove_mesh_send_message、glove_mesh_broadcast、glove_mesh_list_agents、glove_mesh_acknowledge。不提供身份验证(由使用方负责)。 | pnpm add glove-mesh |
glove-continuum-signal | 基于子进程的运行时基底,支持 Agent 跨时间协作。两种模式:触发式(冷启动,每次唤醒时创建新进程)和并发式(热运行,长期存活的子进程通过内联方式接收通知)。agent() 构建器、ContinuumRunner(发现 + 监督 + IPC)、ContinuumAdapter(持久化由用户提供;默认使用 MemoryAdapter)、ContinuumSubscriber(生命周期 + 转发 Glove 事件)。与 glove-mesh 搭配实现 Agent 间通信——基底提供受监督的子进程,mesh 提供消息传递。 | pnpm add glove-continuum-signal |
glovebox-core | 已弃用。 旧版 Glovebox 编写工具和构建 CLI。仅维护现有部署;新建 Agent 运行时请使用 glove-foundry。 | pnpm add glovebox-core |
glovebox-kit | 已弃用。 为保持兼容而保留的旧版 Glovebox 容器内运行时。 | (旧版传递依赖包) |
glovebox-client | 已弃用。 为现有部署保留的旧版 Glovebox 客户端 SDK。 | pnpm add glovebox-client |
glove-scratchpad | 用于工具调用的数据库模拟器。 将 Agent 的能力暴露为关系数据库,让 Agent 使用一个 execute_sql 工具进行查询,而不是加载数十个工具定义。资源会变成表(使用 Zod schema 的 defineResource,或 resourceFromTool);CRUD 动词映射到底层工具;WHERE 会下推参数;information_schema 用于发现;事务会暂存出站副作用。mountDatabase(glove, { db })。通过 glove-scratchpad/mcp 可将 MCP 服务转换为表。查询引擎为 glove-sql(随包提供);PGlite 为可选项。还提供供下方 REPL 使用的共享 glove-scratchpad/fns ToolFn 目录(defineFn、fnFromTool、fnsFromMcp、sampleResultShapes)。 | pnpm add glove-scratchpad |
glove-lisp | 用于工具调用的 REPL 界面(Clojure 风格)。将能力暴露为持久化、沙盒化 Lisp REPL 中的函数,并通过一个 execute_lisp 工具调用。在同一会话中支持两种模式:ResourceTable 模式(registerAll)和函数模式(registerFns,使用 glove-scratchpad/fns 目录)。LispSession.create() + mountLisp(glove, { session })。草案 v0.1。 | pnpm add glove-lisp |
glove-js | 用于工具调用的 REPL 界面(JavaScript)。通过 glove-scratchpad/fns 目录,能力会变成小型沙盒化 JS 解释器中的异步函数,并由一个 execute_js 工具驱动。在 REPL 内可通过短名称(search/servers/fns/describe)和原生工具名称别名(search_functions/list_servers/list_functions/describe_function)调用渐进式发现功能,因此使用工具名称预热的模型能够顺利发起调用。已强化至 A/B 测试的最高水平(97%)——参见 JS-EXPLORATION.md。草案 v0.1。 | pnpm add glove-js |
glove-python | 用于工具调用的 REPL 界面(Python)。能力会变成沙盒化 Python 子集中的函数,由一个 execute_python 工具驱动,并使用同一目录;工具调用使用关键字参数(github.list_pull_requests(state="open"))。支持推导式、f 字符串、切片、def/lambda;禁止使用双下划线属性。PySession.create() + mountPy(glove, { session })。参见 PY-EXPLORATION.md。草案 v0.1。 | pnpm add glove-python |
glove-working-environment | 用于工具调用的持久化工作环境。 scratchpad 是每次调用均无状态的 REPL,而此处的状态会持续累积:一个沙盒化的内存虚拟文件系统(/inbox、/scripts、/skills、/std、/tmp、/out、/.env),通过封闭的动词接口访问——write_file、edit_file、read_file、ls、grep、describe、rm、mv、cp、run_script、run_tests、checkpoint、undo、redo、history——当宿主接入 vision 模型时,还可使用 view_image(path, prompt, page?);这是唯一能发现视觉缺陷的动词(表格超出页面、图表没有柱形、标题重叠)。它会为你栅格化 PDF/演示文稿,page 从 1 开始,因此检查第 3 张幻灯片只需一次调用。所有执行都通过具名持久化脚本完成(export default async function (args)——写入时验证,基于 JSDoc 生成同目录 .d.ts,ls /scripts 即能力目录)。脚本仅导入注入的能力:env:fs、env:std、env:assert、已注册的适配器以及其他脚本——没有隐式网络/宿主文件系统/进程访问;外部能力必须通过宿主挂载的适配器提供。脚本在工作线程中运行(只有 worker.terminate() 能停止受计算限制的脚本)。启动时会填充 /skills:其中包含可直接使用的示例方案,以及每个模块的准确导入语句——错误猜测导入是测得最多的失败原因,/skills/imports.md 则是解决方法。截断并溢出到文件的机制可保留大型数据;按文件撤销/重做、运行历史记录和具名 checkpoint 分支可用于恢复。宿主接口:mount / export / snapshot / fromSnapshot。createWorkingEnvironment({ stdlib, limits }) + mountWorkingEnvironment(glove, { env })(结构化实现——不依赖 glove-core)。核心零依赖;体积较大的格式库位于适配器包(glove-env-*)。暴露能力有四种途径——根据能力类型选择(参见下方“工作环境”章节):I/O → defineAdapter,有状态的构建器 → defineBuilder/defineBuilders,纯计算 → definePureModule(同步),属于能力而非库的组件(MCP 服务、模型、HTTP API)→ defineTools。宿主端选项用于控制动词:vision 添加 view_image,onPresent 添加 present(交付 /out 中的文件),readOnlyPaths 则将子树设为 Agent 可读取但不可编辑。使用 glove-working-environment/testing 进行测试:createAdapterTestEnv(adapter) 会提供 { env, fs, script(), runScript(), audit() };如果 types 与实际绑定的定义不一致(无论哪个方向),audit() 都会导致构建失败。草案 v0.1。 | pnpm add glove-working-environment |
glove-env-fetch | 将 HTTP 请求以及 VFS 下载/上传作为 env:fetch 提供,并具备宿主域名/源策略、原生 DNS/IP 防护、取消机制和凭据别名。参见下文的 HTTP 文件和范围限定密钥。 | pnpm add glove-env-fetch |
glove-env-secret | 将宿主管理的密钥库作为 env:secret 提供:范围限定的元数据/引用、可选启用的查看/写入,以及位于 VFS 快照之外的可插拔存储。 | pnpm add glove-env-secret |
glove-env-documents | 标准库适配器:文档,供 glove-working-environment 使用 → env:documents。一份文档规范可同时渲染为 PDF(pdf-lib)和 DOCX(docx):{ heading }、{ text }、{ bullets }、{ table }、{ image }、{ pageBreak }——自动换行和分页。describe(path) 会根据字节内容识别格式。支持 pdf.merge/split/extractPages/setMetadata/stamp;pdf.extractText 需要可选的 pdfjs-dist peer 依赖(字形到字符的解码取决于字体/CMap;朴素扫描会返回看似合理的乱码,因此此处会直接拒绝)。docx.extractText 使用 node:zlib 读取 OOXML 压缩包,无需额外依赖。PDF 中的非 Latin-1 字符会转写(标准字体);要输出完整 Unicode 内容,请使用 DOCX。草案 v0.1。 | pnpm add glove-env-documents |
glove-env-spreadsheets | 标准库适配器:电子表格,供 glove-working-environment 使用 → env:spreadsheets(exceljs)。describe(path) 可用固定开销返回工作表、大小、表头和一行样本数据。read 会将记录按表头行命名为键,并以纯 JSON 返回——公式展平为计算结果、富文本转为字符串、日期转为 ISO 格式、错误单元格转为 #DIV/0!——并提供 totalRows,以支持通过 offset/limit 分页。空白表头会替换为列字母,重复表头会追加 _2,因此不会丢弃任何列;append 按工作表的表头顺序写入,而非按记录的键顺序。支持 writeSheets、toCsv/fromCsv。仅支持 .xlsx。草案 v0.1。 | pnpm add glove-env-spreadsheets |
glove-env-images | 标准库适配器:图像,供 glove-working-environment 使用 → env:images(sharp)。图像字节不会进入上下文窗口:describe(path) 会报告格式/尺寸/色彩空间/alpha/EXIF 方向而不解码像素,stats(path) 还会返回通道分布和主色(可回答“这个页面是空白的吗?”)。支持 resize/convert/crop/rotate/composite/thumbnail/contactSheet,以及用于绘制说明文字或水印的 text(input, output, {...})。动画 GIF/WebP 在 resize/convert/crop/thumbnail/composite 操作后仍保留动画——根据输出格式决定;.gif/.webp 保留所有帧,其他格式仅保留第一帧;对动画源执行 rotate 时会拒绝操作(libvips 无法原地旋转动画帧——旋转 180° 会反转动画),并将 { animated: false } 指定为退出方式。SVG 会使用 scale/density 栅格化;不允许输出 .svg。除非 { format } 覆盖,否则根据输出扩展名选择编码器。EXIF 方向会显示出来,而不会静默应用——rotate(in, out) 不传角度时会对其进行标准化。草案 v0.1。 | pnpm add glove-env-images |
glove-env-slides | 标准库适配器:幻灯片,供 glove-working-environment 使用 → env:slides(pptxgenjs)。create(spec, output) 是一步完成的方式——自动布局和调整尺寸。演示文稿也可作为输入:describe(path)、extract(path) → 提取每张幻灯片的文本、outline(path) → 生成易读的大纲,均通过解析 OOXML 压缩包完成。若需全面控制,可通过 defineBuilder 原样开放 pptxgenjs 自带的构建器 API——new Pptx()、addSlide()、addText()、链式调用以及参数中的节点引用均可跨越工作线程边界。仅支持 .pptx。草案 v0.1。 | pnpm add glove-env-slides |
glove-env-zip | 标准库适配器:归档文件,供 glove-working-environment 使用 → env:archives。支持 Zip、tar 和 tar.gz 的双向操作,无依赖——使用 node:zlib 和容器格式本身。describe(path) / list(path) 可报告条目和大小而不解压;extract(path, dir, { include? }) 和 create(dir, output, { glob?, format? })。解压前会根据环境的字节预算进行检查,因此超出容量的归档文件会被拒绝,而不会等到解压时才发现。草案 v0.1。 | pnpm add glove-env-zip |
glove-env-media | 标准库适配器:媒体,供 glove-working-environment 使用 → env:media(ffmpeg/ffprobe,通过 @ffmpeg-installer / @ffprobe-installer 随包提供)。处理音频和视频时无需将字节放入上下文:describe(path) 通过 ffprobe 报告容器/流/时长/比特率;另支持 thumbnail、frames(input, dir, { fps?, limit? })、clip、concat、transcode、extractAudio、slideshow。还支持 loudness(综合 LUFS/真峰值/响度范围)、normalize(双遍 loudnorm,视频直接复制)、waveform、extractSubtitles(.srt/.vtt/.ass/.ssa,可边处理边转换)和 burnSubtitles(libass,可使用内嵌轨道或树中的文件);extractAudio 接受 codec/bitrate/sampleRate/channels,describe 会报告每条流的 track/language/title。草案 v0.1。 | pnpm add glove-env-media |
glove-env-render | 标准库适配器:渲染,供 glove-working-environment 使用 → env:render。将文档栅格化为页面 PNG,让 Agent 能查看生成的内容:render(input, outDir, { pages?, scale?, maxWidth? }) → { pages, format, totalPages }。PDF 和图像无需额外依赖(pdfjs-dist 使用 @napi-rs/canvas);.docx/.xlsx 需要无头 LibreOffice,而 .pptx 会回退到根据自身 OOXML 几何信息绘制的布局示意图(包含实际框架和文本,不包含主题/字体/图表——结果带有 approximate: true,适合检查内容是否超出幻灯片、元素是否重叠或页面是否为空,但不适合判断外观)——仅安装 libreoffice-core 并不足够:它不包含导入筛选器,且每次转换都会失败但仍以 0 退出,适配器会检测到这一情况并明确指出。先根据魔数判断格式,再检查扩展名,因此名为 .pptx 的 PDF 不会交给 LibreOffice。最长边限制为 1600px(视觉模型按像素收费)。与 view_image 动词搭配使用。草案 v0.1。 | pnpm add glove-env-render |
glove-env-motion | 标准库适配器:动态效果,供 glove-working-environment 使用 → env:motion。Agent 编写 React 组件;render(scene, out, opts) 会生成 .mp4/.webm/.gif/PNG 帧,still(scene, out, { frame }) 则生成单张 PNG——因此同一项能力可用于视频、标题卡和图表图像。时间由程序控制,不实时测量:requestAnimationFrame 会变为仅由渲染器推进的队列,performance.now()/Date.now() 会返回渲染器设定的值,因此两次运行 60 帧场景会得到逐字节相同的结果。场景可以使用 useFrame()(通过注入的 glove/motion 运行时提供的帧纯函数),或使用未经修改的 React Native Reanimated——两种信号都会逐帧推进,且每种信号对另一种场景类型均不起作用,因此无需选择模式。React、Babel/worklets 工具链和 ffmpeg 均随包提供;宿主只需提供浏览器(自动检测 macOS/Windows/Linux 上的 Chrome/Edge/Chromium),Reanimated 则需要可选的 react-native-reanimated + react-native-web peer 依赖。请传入 limits: MOTION_LIMITS——如果 runTimeoutMs 不足以覆盖渲染时间,会提前拒绝执行并指出确切的修复方法。doctor() / glove-motion-doctor 可执行文件 / capabilities() / 自动生成的 /std/motion/README.md 均可逐项回答“此宿主能否渲染”并提供修复命令。草案 v0.1。 | pnpm add glove-env-motion |
根据应用形态选择合适的方案。 使用 glove-react + glove-next 构建专注于对话的 Web 界面,使用 glove-core 构建单个无头 Agent,使用 glove-foundry 构建完整的 Agent 应用,支持文件路由、运行时实例、入站/出站集成、自动化、多 Agent 协作和检查。若要通过 Model Context Protocol 使用第三方工具,请参阅 MCP 集成。 |
框架包含的内容
glove-core— Agent 循环、工具、显示栈、存储/模型/订阅器适配器、上下文压缩、收件箱、钩子/技能/子 Agent,以及默认使用内存中StoreAdapter的MemoryStore。glove-react— 通过defineTool、<Render>、useGlove、MemoryStore、createRemoteStore、createEndpointModel、createRemoteModel实现就近渲染。glove-next—createChatHandler(一行即可创建 SSE 路由)、语音令牌处理器。glove-foundry— 首选的原生 Effect 应用框架和运行时,适用于基于文件路由、类型化且可观测的 Agent 系统。请参阅专门的glove-foundry技能。glove-sqlite— 已弃用;用于持久化的SqliteStore(仅限服务端)。glove-voice— 全双工语音流水线:STT/TTS/VAD 适配器、GloveVoice、useGloveVoice、useGlovePTT、<VoicePTTButton>。glove-voice-s2s— 让 Glove Agent 直接运行在实时语音到语音模型上(OpenAI Realtimegpt-realtime、Gemini Live):RealtimeAgent(服务端;从 Agent 的模型槽派生其适配器)、s2sDrivenModel(携带配置的模型槽:提供商/模型/语音/轮次控制参数)、createS2SAdapter(参数 →S2S_*环境变量回退)、类型化的OpenAITurnDetection/GeminiRealtimeInputConfig,以及带截断同步的无条件插话抢占(模型会获知来电者实际听到了什么)。glove-voice-avatar— 将实时虚拟形象作为 S2S 栈上的渲染层:AvatarAdapter契约(连接 → 客户端可附加到的view;sendAudio/endUtterance/interrupt,并强制符合性检查)+runAvatarConformance、TavusEchoAdapter(echo PAL、Daily 房间、sendInteraction信使——交互仅通过数据通道传送)、AnamPassthroughAdapter(音频直通 persona,浏览器 SDK 负责音频输入 →sendCommand信使;低于 Growth 层级的 Anam 计划上限会每隔几分钟强制结束会话——通过disconnect()+connect()续接)、attachAvatar(rt, avatar)一次调用即可完成桥接。glove-voice-livekit— 将 LiveKit 用作适配器:LiveKitTransport(加入房间、发布节奏受控的 Agent 音轨、将远程麦克风输出为 PCM 事件、JSON 数据通道、clear()= 服务端权威的插话抢占刷新)+attachRealtime(rt, transport);LiveKit 虚拟形象TavusLiveKitAvatar/AnamLiveKitAvatar实现相同的AvatarAdapter契约——提供商的 worker 加入你自己的房间(令牌类型为agent,lk.publish_on_behalf),并通过lk.audio_stream字节流 +lk.clear_bufferRPC 驱动;使用mintParticipantToken/mintAvatarToken生成令牌。glove-mcp— 将 MCP 服务作为一等工具:mountMcp、connectMcp、bridgeMcpTool、McpAdapter(由使用方为每段对话提供的接入点)。discovermcp发现子 Agent(通过glove.defineSubAgent(discoverySubAgent({...}))注册)。可选择启用glove-mcp/oauth中的 OAuth 辅助工具(runMcpOAuth、FsOAuthStore、MemoryOAuthStore、McpOAuthProvider)。glove-memory— 记忆层,包含五个同级子系统(实体图谱/情景时间线/资源文件系统/环境上下文/表单),以及对应的useMemoryReader/useMemoryCurator、useEpisodicReader/useEpisodicCurator、useResourcesReader/useResourcesCurator、useContext和useFormRunner/useFormReader辅助函数系列。与存储无关的适配器契约,以及用于开发/测试的参考InMemory*适配器。glove-image— Agent 式图像生成。mountImage(glove, { adapter, assets, library, pipeline?, model?, review?, usage?, onUsage?, curate?, candidates?, requirePermission? })集成glove_image_generate/_edit/_regenerate/_import/_describe/_asset_list/_assemble/_usage以及_character_*/_scene_*CRUD 工具。提示词流水线(expandCharacters、expandScenes、styleDirective、negativeDefaults、llmEnhance、终端阶段的fitToModel)会将意图转化为最终请求,并记录各阶段的追踪信息。参考适配器:来自glove-image/openrouter的openrouterImages()。glove-mesh— 基于收件箱原语实现 Agent 间消息传递:mountMesh(glove, { adapter, identity })注册 Agent,并集成glove_mesh_send_message/_broadcast/_list_agents/_acknowledge。MeshAdapter是由使用方提供的传输层(自备);内置InMemoryMeshAdapter+MeshNetwork,用于进程内开发/测试。每个 Agent 保留自己的收件箱;收到的消息会作为已解析的InboxItem写入,因此现有的收件箱注入路径会在下一次ask()时将其呈现出来。不进行身份验证——发送方 ID 未经验证。glove-continuum-signal— 以station-signal为模型、针对 Agent 形态设计的子进程运行时基底。agent("name").input(zod).triggered()|.concurrent()构建器会生成带品牌标记的 Agent;ContinuumRunner从目录中发现 Agent,预热并发型 Agent,从适配器队列派发触发型运行(每个生成的进程相互隔离),并将notifyIPC 信封内联路由到已预热的子进程。ContinuumAdapter是持久化契约(内置MemoryAdapter;使用方可自备 SQLite/Postgres 等适配器)。单一的onAgentEvent(envelope)订阅器会将任何子进程发出的每个 GloveSubscriberEvent转发到上游,并附上 Agent 身份。通过在工厂中为每个 Agent 挂载 Mesh 实现集成——无需特殊 IPC 机制。glove-scratchpad— 用于 LLM 工具调用的数据库模拟器。无需加载大量工具定义,只需将能力作为关系数据库公开,并通过一个execute_sql工具查询。资源(github_pr、emails、time、images等)会变成表;CRUD 动词映射到(可能不同的)底层工具。Database.create()、defineResource({ name, schema, keys?, volatility, select?, insert?, update?, delete? })(以 Zod 优先——该 schema 同时定义列和端到端行类型)或resourceFromTool(tool, { name, volatility, schema })、mountDatabase(glove, { db })会集成execute_sql+explain_sql并预置提示词。通过information_schema进行发现;WHERE等值条件会作为参数下推(采用 Steampipe 的必需键模型);写入操作会在BEGIN … COMMIT中暂存以供预览;EXPLAIN会报告查询会调用哪些工具,但不会调用解析器。每条语句都会在任何工具运行前完成解析和安全检查。通过glove-scratchpad/mcp(mcpResources/mountMcpDatabase)可将 MCP 服务转换为表。后端为glove-sql(内置、零依赖)或PgliteBackend(glove-scratchpad/pglite)。glove-working-environment— 持久化文件系统 + 脚本运行时,模型可针对它编写代码,而不是从固定工具菜单中选择。createWorkingEnvironment({ stdlib, limits })+mountWorkingEnvironment(glove, { env })。脚本在 worker 线程中运行,只能导入主机注入的内容。库通过四种按形态选择的路径提供给脚本——defineAdapter(I/O)、defineBuilder/defineBuilders(有状态的构建器 API,保持不变)、definePureModule(同步纯计算)和defineTools(一种能力,而非库:MCP 服务、Glove 工具、普通异步函数)。格式适配器以独立的glove-env-*包提供。请参阅 Working Environment 章节。
架构概览
User message → Agent Loop → Model decides tool calls → Execute tools → Feed results back → Loop until done
↓
Display Stack (pushAndWait / pushAndForget)
↓
React renders UI slots
核心概念
- Agent — AI 协调器,取代路由/导航逻辑。读取工具并决定调用哪些工具。
- 工具 — 一项能力:名称、描述、inputSchema(Zod)、
do函数,以及可选的render+renderResult。 - 显示栈 — 工具可将 UI 槽位压入其中的栈。
pushAndWait会阻塞工具;pushAndForget不会。 - 显示策略 — 控制槽位的可见性生命周期:
"stay"、"hide-on-complete"、"hide-on-new"。 - renderData —
do()返回的仅客户端数据,不会发送给 AI 模型。供renderResult用于渲染历史记录。 - 适配器 — 用于 Model、Store、DisplayManager 和 Subscriber 的可插拔接口。无需更改应用代码即可切换提供方。
- 上下文压缩 — 自动总结较长对话,使其保持在上下文窗口限制内。存储会保留完整的消息历史(因此前端可以显示完整聊天记录),而
Context.getMessages()会从最后一条压缩摘要处分割消息,使模型只看到压缩后的上下文。摘要消息会标记为is_compaction: true。 - 收件箱 — 用于跨实例通信的持久异步邮箱。Agent 会发布一个当前无法解决的请求(文本),由外部服务稍后解决(文本回复)。已解决的条目会在下一次
ask()调用时自动注入 Agent 的上下文。条目可以是阻塞式(Agent 应等待),也可以是非阻塞式。如果存储支持收件箱方法,则会自动注册内置的glove_post_to_inbox工具。 - 扩展(钩子、技能、子 Agent) —
/hookname会运行由构建器定义的处理程序,并提供完整的 Agent 控制能力(强制压缩、切换模型、提前结束一轮)。/skillname会在真实消息前生成一条合成用户消息(标记为is_skill_injection: true)。defineSubAgent({ name, factory })会注册一个子 Agent,主 Agent 可通过自动注册的glove_invoke_subagent工具将任务路由给它——用户的@name文本不会由 glove 解析,而是原样传递给模型并作为路由信号(与 Claude Code 的子 Agent 约定相同)。/token 会替换为不可触发的占位符([invoked_extension__hook_<name>]/[invoked_extension__skill_<name>]),让模型知道扩展已触发,同时避免占位符在未来解析时再次绑定;未绑定的/token 保持原样(因此/usr/local不受影响)。可将技能开放给 Agent(exposeToAgent: true),让 Agent 通过自动注册的glove_invoke_skill工具调用技能。 - MCP 目录 + 适配器 —
glove-mcp引入两部分:描述应用支持的服务的静态McpCatalogueEntry[],以及保存活动 ID 并解析访问令牌的每对话McpAdapter。mountMcp会重新加载之前处于活动状态的服务,并注册一个discovermcp发现子 Agent(通过glove.defineSubAgent(discoverySubAgent({...})))——模型通过glove_invoke_subagent({ name: "discovermcp", prompt: "..." })调用它,以便在对话过程中查找并激活服务。 - 网状网络 —
glove-mesh允许 Glove Agent 通过使用方提供的MeshAdapter(传输层)互相发送消息。每个 Agent 都有自己的StoreAdapter和收件箱;当 A 向 B 发送消息时,框架会将一个status: "resolved"的InboxItem放入 B 的存储,使现有的收件箱注入路径能在 B 下一次ask()时显示该消息。阻塞式发送会插入一个待处理的blocking: true条目,该条目会在收到确认或in_reply_to回复时解决。v1 不包含身份验证。 - Continuum(子进程运行时) —
glove-continuum-signal将 Glove Agent 作为 Node 子进程进行管理。触发式 Agent 处于冷态,每次唤醒都会重新启动(每次从持久化的StoreAdapter恢复);并发式 Agent 处于热态,并通过内联通知接收通知。agent()构建器会在调用.triggered()/.concurrent()后分化为特定模式的类型,因此模式专属设置器(.retries()、.every())在类型层面受到约束。通过.store(name => StoreAdapter)配置持久化存储,使同一存储实现可注入整个 Agent 集群。父进程是运行状态的唯一真实来源(子进程只发送 IPC)。Mesh 集成:在工厂中挂载——底层架构不会进入 Agent 间协议。
快速入门(Next.js)
1. 安装
pnpm add glove-core glove-react glove-next zod
2. 服务端路由
// app/api/chat/route.ts
import { createChatHandler } from "glove-next";
export const POST = createChatHandler({
provider: "anthropic", // or "openai", "openrouter", "gemini", etc.
model: "claude-sonnet-4-20250514",
});
在 .env.local 中设置 ANTHROPIC_API_KEY(或 OPENAI_API_KEY 等)。
3. 使用 defineTool 定义工具
// lib/glove.tsx
import { GloveClient, defineTool } from "glove-react";
import type { ToolConfig } from "glove-react";
import { z } from "zod";
const inputSchema = z.object({
question: z.string().describe("The question to display"),
options: z.array(z.object({
label: z.string().describe("Display text"),
value: z.string().describe("Value returned when selected"),
})),
});
const askPreferenceTool = defineTool({
name: "ask_preference",
description: "Present options for the user to choose from.",
inputSchema,
displayPropsSchema: inputSchema, // Zod schema for display props
resolveSchema: z.string(), // Zod schema for resolve value
displayStrategy: "hide-on-complete", // Hide slot after user responds
async do(input, display) {
const selected = await display.pushAndWait(input); // typed!
return {
status: "success" as const,
data: `User selected: ${selected}`, // sent to AI
renderData: { question: input.question, selected }, // client-only
};
},
render({ props, resolve }) { // typed props, typed resolve
return (
<div>
<p>{props.question}</p>
{props.options.map(opt => (
<button key={opt.value} onClick={() => resolve(opt.value)}>
{opt.label}
</button>
))}
</div>
);
},
renderResult({ data }) { // renders from history
const { question, selected } = data as { question: string; selected: string };
return <div><p>{question}</p><span>Selected: {selected}</span></div>;
},
});
// Tools without display stay as raw ToolConfig
const getDateTool: ToolConfig = {
name: "get_date",
description: "Get today's date",
inputSchema: z.object({}),
async do() { return { status: "success", data: new Date().toLocaleDateString() }; },
};
export const gloveClient = new GloveClient({
endpoint: "/api/chat",
systemPrompt: "You are a helpful assistant.",
tools: [askPreferenceTool, getDateTool],
// getSessionId: () => fetch("/api/session").then(r => r.json()).then(d => d.id),
});
4. Provider + 渲染
// app/providers.tsx
"use client";
import { GloveProvider } from "glove-react";
import { gloveClient } from "@/lib/glove";
export function Providers({ children }: { children: React.ReactNode }) {
return <GloveProvider client={gloveClient}>{children}</GloveProvider>;
}
// app/page.tsx — using <Render> component
"use client";
import { useGlove, Render } from "glove-react";
export default function Chat() {
const glove = useGlove();
return (
<Render
glove={glove}
strategy="interleaved"
renderMessage={({ entry }) => (
<div><strong>{entry.kind === "user" ? "You" : "AI"}:</strong> {entry.text}</div>
)}
renderStreaming={({ text }) => <div style={{ opacity: 0.7 }}>{text}</div>}
/>
);
}
也可以直接使用 useGlove(),进行完全的手动控制:
// app/page.tsx — manual rendering
"use client";
import { useState } from "react";
import { useGlove } from "glove-react";
export default function Chat() {
const { timeline, streamingText, busy, slots, sendMessage, renderSlot, renderToolResult } = useGlove();
const [input, setInput] = useState("");
return (
<div>
{timeline.map((entry, i) => (
<div key={i}>
{entry.kind === "user" && <p><strong>You:</strong> {entry.text}</p>}
{entry.kind === "agent_text" && <p><strong>AI:</strong> {entry.text}</p>}
{entry.kind === "tool" && (
<>
<p>Tool: {entry.name} — {entry.status}</p>
{entry.renderData !== undefined && renderToolResult(entry)}
</>
)}
</div>
))}
{streamingText && <p style={{ opacity: 0.7 }}>{streamingText}</p>}
{slots.map(renderSlot)}
<form onSubmit={(e) => { e.preventDefault(); sendMessage(input.trim()); setInput(""); }}>
<input value={input} onChange={(e) => setInput(e.target.value)} disabled={busy} />
<button type="submit" disabled={busy}>Send</button>
</form>
</div>
);
}
服务端 Agent
对于 CLI 工具、后端服务、WebSocket 服务或任何非浏览器环境,请直接使用 glove-core。无需 React、Next.js 或浏览器。
最简设置
import { Glove, Displaymanager, MemoryStore, createAdapter } from "glove-core";
import z from "zod";
// MemoryStore from glove-core is the default. Omit `store` from the
// Glove config and Glove constructs one for you with a generated identifier.
// For persistence implement your own StoreAdapter.
const store = new MemoryStore("my-session");
const agent = new Glove({
store,
model: createAdapter({ provider: "anthropic", stream: true }),
displayManager: new Displaymanager(), // required but can be empty
systemPrompt: "You are a helpful assistant.",
serverMode: true, // canonical "I am headless" flag — drives default permission gating + MCP discovery policy
compaction_config: {
compaction_instructions: "Summarize the conversation.",
},
})
.fold({
name: "search",
description: "Search the database.",
inputSchema: z.object({ query: z.string() }),
async do(input) {
const results = await db.search(input.query);
return { status: "success", data: results };
},
})
.build();
const result = await agent.processRequest("Find recent orders");
console.log(result.messages[0]?.text);
glove-core 中的 MemoryStore
MemoryStore 是在构造 Glove 时未提供 store 的情况下使用的默认 StoreAdapter。它实现了所有可选接口——消息、令牌、轮次、任务、权限、收件箱和 createSubAgentStore——因此子 Agent 开箱即用。
import { MemoryStore } from "glove-core";
const store = new MemoryStore("my-session");
// Sub-stores: durable: false (default) returns a fresh per-call store.
// durable: true caches the same instance per namespace so a subagent can
// carry message history across invocations.
const childStore = await store.createSubAgentStore("researcher", false);
MemoryStore 仅在进程内有效——重启后数据会丢失。要实现持久化,请针对你自己的后端实现 StoreAdapter。glove-sqlite 已弃用;新项目应优先使用 MemoryStore 进行原型开发,或在生产环境中使用自带的 StoreAdapter。
与 React 的主要区别
React(glove-react) | 服务端(glove-core) |
|---|---|
使用 render/renderResult 的 defineTool | 只需 do 的 .fold()——无需渲染器 |
useGlove() Hook 管理状态 | 直接调用 agent.processRequest() |
GloveClient + GloveProvider | new Glove({...}).build() |
createEndpointModel(SSE 客户端) | createAdapter() 或直接使用适配器(例如 new AnthropicAdapter()) |
来自 glove-react 的 MemoryStore | 来自 glove-core 的 MemoryStore(默认)——或实现 StoreAdapter 以实现持久化 |
无需显示的工具
大多数服务端工具会忽略显示管理器——只需返回结果:
gloveBuilder.fold({
name: "get_weather",
description: "Get weather for a city.",
inputSchema: z.object({ city: z.string() }),
async do(input) {
const res = await fetch(`https://wttr.in/${input.city}?format=j1`);
return { status: "success", data: await res.json() };
},
});
也可以返回普通字符串——它会自动包装为 { status: "success", data: yourString }。
交互式工具(pushAndWait)
当工具调用 display.pushAndWait() 时,Agent 循环会阻塞,直到调用 dm.resolve(slotId, value)。将其连接到你的 UI 层(WebSocket、终端、Slack 等):
// Tool side
async do(input, display) {
const confirmed = await display.pushAndWait({
renderer: "confirm",
input: { message: `Delete ${input.file}?` },
});
if (!confirmed) return { status: "error", data: null, message: "Cancelled" };
// proceed...
}
// Server side — resolve when user responds
dm.resolve(slotId, true);
订阅者(日志记录、转发)
import type { SubscriberAdapter } from "glove-core";
const logger: SubscriberAdapter = {
async record(event_type, data) {
if (event_type === "text_delta") process.stdout.write((data as any).text);
if (event_type === "tool_use") console.log(`\n[tool] ${(data as any).name}`);
},
};
gloveBuilder.addSubscriber(logger);
常见模式
- CLI 脚本:构建 Agent,调用
processRequest(),打印结果 - 多轮 REPL:使用 readline 循环,每次
processRequest()都会在存储中累积内容 - WebSocket 服务:每个连接使用隔离的 store/dm/subscriber 会话,通过
record()转发事件 - 后台工作进程:为每个作业构建 Agent,从队列中处理任务,无需显示
- 热切换模型:在运行时调用
agent.setModel(newAdapter) - 基于 MCP 的 Agent:设置
serverMode: true,在build()之前调用mountMcp(glove, { adapter, entries })。请参阅 MCP 集成。
可选的 Store 功能
- 任务(
getTasks、addTasks、updateTask):自动注册glove_update_tasks工具 - 权限(
getPermission(name, input?)、setPermission(name, status, input?)):对于设置了requiresPermission: true(或(input) => boolean门控函数)的工具,会检查用户是否同意。Executor 会在每次门控调用时传递模型提供的输入,因此存储可以按输入范围限定决策。默认的MemoryStore使用导出的permissionKey(name, input)辅助函数进行精确匹配键控。 - 收件箱(
getInboxItems、addInboxItem、updateInboxItem、getResolvedInboxItems):自动注册glove_post_to_inbox工具。支持跨实例异步通信。
如果你的存储未实现这些功能,它们会被静默禁用。
收件箱(异步邮箱)
收件箱支持 Agent 发布请求,并由外部服务稍后处理——即使跨会话和实例也能保留。
工作原理
- Agent 调用
glove_post_to_inbox,并传入标签、请求文本和阻塞标志 - 条目以
pending状态持久化到存储中 - 外部服务解析该条目(通过
glove-sqlite中的SqliteStore.resolveInboxItem()或存储 API) - 下次运行
agent.ask()时,已解析的条目会作为文本消息注入,并标记为consumed - 待处理的阻塞条目会作为临时提醒显示(不持久化)
- 压缩会在摘要块中保留待处理的收件箱条目
内置工具:glove_post_to_inbox
当存储实现了收件箱方法时,会自动注册。输入架构:
{
tag: string, // Category label, e.g. "restock_watch"
request: string, // Natural language description of what needs to happen
blocking: boolean, // Default false. If true, agent should wait for resolution
}
外部解析
// From a background job, webhook handler, or cron:
import { SqliteStore } from "glove-sqlite";
SqliteStore.resolveInboxItem(
"path/to/db.db",
"inbox_item_id",
"The item you requested is now available." // text response
);
如果已设置收件箱 API 路由,也可以通过 REST 进行解析(参见 coffee 示例)。
InboxItem 类型
interface InboxItem {
id: string;
tag: string;
request: string;
response: string | null;
status: "pending" | "resolved" | "consumed";
blocking: boolean;
created_at: string;
resolved_at: string | null;
}
存储方法(可选)
// Add to StoreAdapter to enable inbox:
getInboxItems?(): Promise<InboxItem[]>
addInboxItem?(item: InboxItem): Promise<void>
updateInboxItem?(itemId: string, updates: Partial<Pick<InboxItem, "status" | "response" | "resolved_at">>): Promise<void>
getResolvedInboxItems?(): Promise<InboxItem[]>
所有存储实现(glove-sqlite 中的 SqliteStore、MemoryStore、createRemoteStore)都支持收件箱。
React 集成
useGlove() 除了返回 tasks,还会返回 inbox: InboxItem[]:
const { inbox, tasks, timeline, sendMessage } = useGlove({ tools, sessionId });
// Show pending watches in UI
{inbox.filter(i => i.status === "pending").map(item => (
<div key={item.id}>{item.tag}: {item.request}</div>
))}
远程存储操作
使用 createRemoteStore 时,添加收件箱操作以持久化到后端:
const storeActions: RemoteStoreActions = {
// ...existing getMessages, appendMessages...
getInboxItems: (sid) => fetch(`/api/sessions/${sid}/inbox`).then(r => r.json()),
addInboxItem: (sid, item) => fetch(`/api/sessions/${sid}/inbox`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ item }) }),
updateInboxItem: (sid, itemId, updates) => fetch(`/api/sessions/${sid}/inbox/update`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemId, updates }) }),
getResolvedInboxItems: (sid) => fetch(`/api/sessions/${sid}/inbox/resolved`).then(r => r.json()),
};
扩展:Hooks、Skills 与子 Agent
processRequest 会从用户文本中解析两种内联指令,并在调用模型前进行分发。子 Agent 是第三种扩展方式,但它们不会从用户文本中解析——而是由模型通过分发工具进行路由。构建器通过三种构建器方法注册处理器(可链式调用,并且像 fold 一样可在 build() 后调用):
| 令牌/机制 | 用途 | 构建器方法 |
|---|---|---|
/hookname | 修改 Agent 状态、强制压缩、切换模型或提前结束一轮 | defineHook(name, handler) |
/skillname | 将上下文作为合成用户消息注入,并标记为 is_skill_injection: true | defineSkill({ name, handler, description?, exposeToAgent? }) |
glove_invoke_subagent({ name, prompt })(模型端工具) | 注册一个子 Glove,供主 Agent 将独立任务路由给它。用户的 @name 文本会原样传递给模型——它是路由信号,而不是经过解析的指令。这与 Claude Code 的子 Agent 惯例一致。 | defineSubAgent({ name, factory, description? }) |
/ 令牌只有在名称与已注册的 hook 或 skill 匹配时才会绑定——/usr/local/bin 会保持原样。已绑定的 / 令牌不会被移除,而是会被替换为无法触发的占位符,形式为 [invoked_extension__hook_<name>] 或 [invoked_extension__skill_<name>]。占位符不会在后续解析时再次绑定,但能确保持久化的用户消息在结构上如实反映扩展已触发,模型也能看到这一点。glove 完全不会解析 @ 令牌,因此像 [email protected] 这样的电子邮件地址会原样传递给模型。
快速示例
import { Glove, MemoryStore } from "glove-core";
const agent = new Glove({ /* ... */ })
.defineHook("compact", async ({ controls }) => {
await controls.forceCompaction();
})
.defineHook("stop", async () => ({
shortCircuit: { message: { sender: "agent", text: "Cancelled." } },
}))
.defineSkill({
name: "concise",
description: "Tighter, snappier responses",
exposeToAgent: true,
handler: async ({ source, args }) =>
`Be terse. (source=${source}, hint=${args ?? "none"})`,
})
.defineSubAgent({
name: "weather",
description: "Run the weather subagent. Use for weather questions.",
factory: async ({ parentStore, parentControls, prompt }) => {
const subStore = await parentStore.createSubAgentStore?.("weather", false)
?? new MemoryStore(`weather_${Date.now()}`);
return new Glove({
store: subStore,
model: parentControls.glove.model,
displayManager: parentControls.displayManager,
systemPrompt: "You are a weather assistant. Answer the prompt and return.",
compaction_config: { compaction_instructions: "Summarise weather lookups." },
})
.fold(weatherTool)
.build();
},
})
.build();
await agent.processRequest("/concise tell me about Rust"); // user-invoked skill
await agent.processRequest("/compact what's next?"); // hook → forceCompaction
// "@weather" reaches the model verbatim. The agent calls
// glove_invoke_subagent({ name: "weather", prompt: "NYC" }).
await agent.processRequest("@weather NYC");
Hooks
type HookHandler = (ctx: HookContext) => Promise<HookResult | void>;
interface HookContext {
name: string;
rawText: string;
parsedText: string; // text with bound tokens replaced by [invoked_extension__<type>_<name>] placeholders
controls: AgentControls;
signal?: AbortSignal;
}
interface HookResult {
rewriteText?: string; // override parsedText for downstream skills + the user message
shortCircuit?:
| { message: Message }
| { result: ModelPromptResult };
}
interface AgentControls {
context: Context;
observer: Observer;
promptMachine: PromptMachine;
executor: Executor;
glove: IGloveRunnable;
store: StoreAdapter; // direct access to the agent's StoreAdapter
displayManager: DisplayManagerAdapter; // direct access to the agent's display stack
forceCompaction: () => Promise<void>;
}
forceCompaction 会调用 Observer.runCompactionNow()——执行的内容与 tryCompaction 相同,只是不进行令牌阈值检查。订阅者仍会收到 compaction_start/compaction_end。
Hooks 按文档顺序运行。rewriteText 会覆盖工作文本,并将其传递给后续 hooks、skills 和最终用户消息。shortCircuit 会持久化用户消息并立即返回——不会调用模型。Glove 会在每个 hook 处理器运行前向订阅者发出 hook_invoked({ name })事件。
Skills
type SkillHandler = (ctx: SkillContext) => Promise<string | ContentPart[]>;
interface SkillContext {
name: string;
parsedText: string; // when source = "user": user text with bound directives replaced by their placeholders. when source = "agent": same as args ?? "".
args?: string; // model-supplied free-form args (only when source = "agent")
source: "user" | "agent";
controls: AgentControls;
}
interface SkillOptions {
description?: string; // shown to the agent in the invoke-skill tool listing
exposeToAgent?: boolean; // default false
}
interface DefineSkillArgs extends SkillOptions {
name: string;
handler: SkillHandler;
}
interface RegisteredSkill {
handler: SkillHandler;
description?: string;
exposeToAgent: boolean;
}
Skill 注入的消息会在 Message 上设置 is_skill_injection: true,与现有的 is_compaction 和 is_compaction_request 标记并列。可在对话记录渲染器中使用此标记,以便将注入的上下文与真实用户轮次区分开来。Glove 会向订阅者发出 skill_invoked({ name, source: "user" | "agent", args? })事件——对于用户端指令,分发在 Glove.processRequest 中触发;对于 Agent 端调用,则在 glove_invoke_skill 工具内部触发。
向 Agent 暴露技能
设置 exposeToAgent: true 后,Glove 会在执行器上自动注册一个 glove_invoke_skill 工具。其描述会列出每项已暴露技能(- name — description),并会在每次定义新的已暴露技能时就地重建,因此 build() 之后注册的技能也会立即被识别。
agent.defineSkill({
name: "research-mode",
description: "Switch to long-form research mode with citations",
exposeToAgent: true,
handler: async ({ source, args, parsedText }) => {
if (source === "agent") {
// Agent invoked via glove_invoke_skill — `args` is the model-supplied string.
return `Switch into research mode. Focus: ${args ?? "general"}.`;
}
// source === "user" — `parsedText` is the user message after directive substitution
// (e.g. "[invoked_extension__skill_research-mode] tell me about ribosomes").
return `Switch into research mode. User said: ${parsedText}`;
},
});
// User: "/research-mode tell me about ribosomes"
// → source = "user", parsedText = "[invoked_extension__skill_research-mode] tell me about ribosomes"
// Agent: glove_invoke_skill({ name: "research-mode", args: "ribosome assembly" })
// → source = "agent", args = "ribosome assembly"
成功时,该工具会返回 { status: "success", data: { skill, content } };对于未知或未暴露的名称,则会返回 { status: "error", message: 'Skill "..." is not available', data: null }。当技能返回 ContentPart[] 时,文本部分会拼接到 data.content 中(模型可见),完整的部分列表则会保留在 renderData 上(客户端渲染器可见,与 MCP-bridge 约定一致)。
| 方面 | 用户 /skill | Agent glove_invoke_skill |
|---|---|---|
| 消息进入位置 | 实际轮次之前的合成用户消息(is_skill_injection: true) | Agent 的 tool_use 上的工具结果 |
SkillContext.source | "user" | "agent" |
SkillContext.args | undefined | 模型提供的自由格式字符串 |
是否受 exposeToAgent 限制 | 否 — 用户始终可以调用 | 是 — 只能调用已暴露的技能 |
子 Agent
该设计参考了 Claude Code 的子 Agent 约定。定义子 Agent 会自动注册一个 glove_invoke_subagent 工具(从核心模块导出的常量 SUBAGENT_DISPATCH_TOOL_NAME),主 Agent 会使用 { name, prompt } 调用该工具。glove 不会解析用户原始消息中的 @name 文本 — 它会原样传给模型,并作为路由信号。工厂会为每次调用创建一个全新的子 Glove,并由分发器运行它。
interface SubAgentFactoryContext {
/** Subagent name as registered with `defineSubAgent`. */
name: string;
/** The task prompt the parent agent supplied when calling `glove_invoke_subagent`. */
prompt: string;
/** The parent agent's store. Use `createSubAgentStore(name, durable)` to derive a child store. */
parentStore: StoreAdapter;
/** Full parent agent controls (context, observer, promptMachine, executor, glove, store, displayManager, forceCompaction). */
parentControls: AgentControls;
}
type SubAgentFactory = (
ctx: SubAgentFactoryContext,
) => Promise<IGloveRunnable> | IGloveRunnable;
interface SubAgentOptions {
/** Short description shown to the agent in the invoke-subagent tool listing. */
description?: string;
}
interface DefineSubAgentArgs extends SubAgentOptions {
name: string;
factory: SubAgentFactory;
}
interface RegisteredSubAgent {
factory: SubAgentFactory;
description?: string;
}
规范工厂
import { Glove, MemoryStore } from "glove-core";
glove.defineSubAgent({
name: "researcher",
description: "Deep research subagent",
factory: async ({ parentStore, parentControls }) => {
// Sub-store: durable false → fresh per-call; durable true → cached for the namespace.
const subStore = await parentStore.createSubAgentStore?.("researcher", false)
?? new MemoryStore(`researcher_${Date.now()}`);
return new Glove({
store: subStore,
model: parentControls.glove.model, // inherit the parent's model
displayManager: parentControls.displayManager, // share the parent's display stack
systemPrompt: "You are a researcher.",
compaction_config: { compaction_instructions: "Summarize research progress." },
})
.fold(searchTool)
.fold(fetchTool)
.build(); // build() can take a store too; passing it here is equivalent to constructor `store`
},
});
分发器会在运行期间将父级的订阅者附加到子级,调用 child.processRequest(prompt, signal)(转发父级的中止信号),然后将其分离。子级最终的 Agent 文本会作为工具结果返回。
工具结果格式
与 glove_invoke_skill 对称:
// Tool input
{ name: string, prompt: string }
// Success
{ status: "success", data: { subagent: string, content: string } }
// Unknown name
{ status: "error", message: 'Subagent "..." is not registered. Use one of: ...', data: null }
// Factory threw
{ status: "error", message: 'Subagent "..." factory threw: ...', data: null }
// Child run threw
{ status: "error", message: 'Subagent "..." failed: ...', data: null }
成对事件 — 保证严格 1:1 对称
Executor(而非分发器)会在每次 glove_invoke_subagent 调用前触发 subagent_invoked({ name, prompt }),并在调用后触发 subagent_completed({ name, status: "success" | "error", message? }),形成事件括号。即使父级中止导致分发器的 Promise 链提前结束,这一事件括号仍保持对称:执行器的中止处理程序仍会触发结束事件。子 Glove 在这两个事件之间发出的事件都属于该子 Agent — 在运行期间,父级订阅者会附加到子级。
常见模式
- 每次调用都创建全新的子级:工厂在每次调用时都会创建一个新的
Glove,并为其配置独立的MemoryStore(或非持久化子存储)。这是默认且推荐的做法。 - 持久化子级:
parentStore.createSubAgentStore("name", true)会为同一命名空间返回同一个存储,因此子 Agent 会在多次调用间保留消息历史记录。工厂仍会创建一个全新的Glove;只有存储会复用。 - 一条消息中有多个子 Agent(
"@reviewer @architect please discuss this design"):两个名称都会传给模型,模型可以为每个子 Agent 调用一次glove_invoke_subagent(通过多次工具调用按顺序或并行执行 — 由模型决定)。
子存储:createSubAgentStore
StoreAdapter 提供一个可选的 createSubAgentStore(namespace: string, durable?: boolean): Promise<StoreAdapter> 工厂:
durable: false(默认)→ 每次调用都会创建一个全新且隔离的存储。子 Agent 每次调用时都从零上下文开始。durable: true→ 对于同一命名空间,始终返回同一个实例,因此子 Agent 会在父级生命周期内的多次调用间保留消息、任务、令牌和计数器。
glove-core 中的 MemoryStore 实现了这两种模式。如果你的存储未实现 createSubAgentStore,可在工厂内回退到 new MemoryStore(...) — 这样可以保留“每次调用都创建全新存储”的行为。
setDisplayManager(可链式调用)
Glove(构建器和可运行形式)都提供 setDisplayManager(displayManager)。子 Agent 工厂通常会将 parentControls.displayManager 传入子对象的构造函数,也可以在构建后调用 child.setDisplayManager(parentControls.displayManager),在运行过程中启用该功能。返回 this,以便链式调用。
processRequest 中的分发顺序
- 从原始文本中解析
/指令(正则表达式(^|\s)\/([A-Za-z][\w-]*)(?=\s|$))。已绑定的标记会被替换为[invoked_extension__<type>_<name>]占位符;未绑定的标记保留原样。完全不解析@标记。 - 按文档顺序运行钩子。Glove 会针对每个钩子发出
hook_invoked。应用所有rewriteText;遇到第一个shortCircuit时予以遵从并返回。 - 实例化技能(
source: "user")——每个技能都会成为一条合成的用户消息,并在真实用户消息之前通过context.appendMessages持久化。Glove 会针对每个技能发出skill_invoked。 - 根据替换了占位符的文本(其中仍包含未经处理的
@mention)以及调用方传入的所有非文本ContentPart,构建真实的用户Message。 - 将请求交给
Agent.ask。子 Agent 调用发生在 Agent 循环中,通过glove_invoke_subagent工具调用触发;执行器会为每次调用分别发送subagent_invoked和subagent_completed事件。
is_skill_injection 标记
技能实例化的用户消息会在 Message 上设置 is_skill_injection: true。在转录渲染时,可将其与 is_compaction 配合使用——折叠、静音或过滤注入的消息,使其在视觉上区别于真实的用户回合。
Message 上的 pre_modified_text
当钩子通过 rewriteText 重写用户消息时,原始文本会保存在 Message.pre_modified_text 中,以便前端显示用户实际输入的内容,以及模型收到的重写版本。
公共 API
import {
// Builder
Glove, // .defineHook(), .defineSkill(), .defineSubAgent()
// Types
HookHandler, HookContext, HookResult,
SkillHandler, SkillContext, SkillOptions, DefineSkillArgs, RegisteredSkill,
SubAgentFactory, SubAgentFactoryContext, SubAgentOptions, DefineSubAgentArgs, RegisteredSubAgent,
AgentControls,
// Constants
SUBAGENT_DISPATCH_TOOL_NAME, // "glove_invoke_subagent"
// Helpers
parseTokens, formatSkillMessage,
createSkillInvokeTool, renderSkillToolDescription,
createSubAgentInvokeTool, renderSubAgentToolDescription,
} from "glove-core";
可通过主入口和 glove-core/extensions 子路径使用。
MCP 集成(glove-mcp)
glove-mcp 可将模型上下文协议服务器(Notion、Gmail、Linear、Slack、围绕你自己的 API 构建的内部 MCP 封装等)接入 Glove Agent,使其工具像普通的 Glove 工具一样出现在模型的工具列表中。v1 仅支持 Streamable HTTP 传输。
适用场景
- 你需要供应商已通过 MCP 提供的第三方功能,例如 Notion、Gmail、Linear、Slack、Zapier-MCP 等。
- 你有多个内部服务,希望以统一的集成方式接入。
- 你希望 Agent 能在对话过程中发现并启用功能,而不是在启动时接入所有工具。
如果两端都由你控制,而且只需要少量一方工具,手动编写 glove.fold(...) 仍然更简单。目录规模较大或服务器并非由你掌控时,MCP 才更有优势。
心智模型:目录 + 适配器
刻意拆分为两个部分:
-
McpCatalogueEntry[]— 在应用层定义的静态列表。每个 MCP 服务对应一个条目,包含:id、name、description、url、tags?、excludeTools?、metadata?。所有用户看到的内容都相同。id同时用作工具命名空间前缀和激活键。excludeTools?: string[]按未命名空间限定的精确名称排除工具——在连接时进行过滤,因此无论通过哪条路径(启动时重新加载、激活discovermcp,或基于同一连接的glove-scratchpad桥接),被排除的工具都不会被挂载。 -
McpAdapter— 由使用方实现的会话级接口(类似于StoreAdapter)。它持有会话已激活的服务 ID,并解析访问令牌。interface McpAdapter { identifier: string; // for log correlation getActive(): Promise<string[]>; // ids active in this conversation activate(id: string): Promise<void>; // called by the discovery subagent deactivate(id: string): Promise<void>; // for the consumer's UI; v1 limitation: doesn't unload tools getAccessToken?(id: string): Promise<string>; // bearer seam — return a token string getAuthHeaders?(id: string): Promise<Record<string, string>>; // custom-header seam — e.g. { "x-api-key": ... }; wins over getAccessToken }
getAccessToken / getAuthHeaders 是仅有的认证接入点。常见情况是使用 getAccessToken——框架会将返回的字符串封装在 Authorization: Bearer ... 中。对于不接受 Bearer 令牌的服务(例如 Composio 的 x-api-key),请实现 getAuthHeaders 并返回完整的标头映射;如果两者都有定义,则优先使用 getAuthHeaders;如果两者都未定义,则连接时不带认证标头。凭据的获取、刷新和持久化完全由使用方负责——环境变量、保险库、自行实现 OAuth 流程,以及选择使用的 runMcpOAuth 助手,都是有效方式。
mountMcp — 规范入口点
在 new Glove(...) 之后、glove.build() 之前:
import { mountMcp } from "glove-mcp";
const glove = new Glove({ /* ... */ , serverMode: true });
await mountMcp(glove, {
adapter, // McpAdapter
entries, // McpCatalogueEntry[]
ambiguityPolicy: { type: "auto-pick-best" }, // optional
subagentModel: undefined, // optional — defaults to glove.model
subagentSystemPrompt: undefined, // optional — defaults to per-policy prompt
clientInfo: { name: "My App", version: "1.0.0" }, // optional
filterTools: (tool, entry) => !tool.annotations?.destructiveHint, // optional — catalogue-wide drop
});
glove.build();
其执行顺序如下:
- 读取
adapter.getActive(),为每个已激活的 ID 建立 MCP 连接(使用getAccessToken),列出工具,并通过bridgeMcpTool将每个工具添加到主 Agent。各服务重新加载失败时会记录日志并跳过——暂时性故障不会导致 Agent 停止工作。 - 通过
glove.defineSubAgent(discoverySubAgent({...}))注册discovermcp发现子 Agent,使模型能够在对话过程中请求激活更多服务。模型通过glove_invoke_subagent({ name: "discovermcp", prompt: "..." })调用它。
排除工具。 若要阻止模型使用特定工具,可在 McpCatalogueEntry 上设置 excludeTools: string[](未命名空间限定的精确名称),也可以将 filterTools: (tool, entry) => boolean 传给 mountMcp,以应用于整个目录的规则(在每个条目的 excludeTools 规则基础上执行)。两种方式都会在连接时应用(connectMcp 也直接支持 excludeTools / filterTools),因此可在一个位置设置排除规则,并应用于所有挂载路径——启动时重新加载、激活 discovermcp,以及基于同一连接构建的任何 glove-scratchpad 桥接(mcpResources / fnsFromMcp)。过滤仅作用于列表;conn.raw / conn.callTool 保持不变。导出的纯谓词函数是 includeTool(tool, { excludeTools, filterTools })。
重新加载和子 Agent 注册完成后,mountMcp 即返回。为确保初始化顺序最简洁,请在 build() 之前调用它,但在 build() 之后调用 fold() / defineSubAgent() 也可行。
桥接工具的形态
bridgeMcpTool(connection, tool, serverMode) 会按以下约定生成 GloveFoldArgs:
- 名称:
${entry.id}__${tool.name}(例如notion__search)。分隔符__(导出为MCP_NAMESPACE_SEP)在所有模型提供商中都可安全用于正则表达式。 - 输入架构:来自 MCP 服务的原始 JSON Schema,通过
jsonSchema传入(不使用 Zod)。MCP 服务是唯一可信来源。 - 结果形态:当服务声明了
outputSchema(MCP 2025-06-18+)时,会将精简的Returns: …形态(通过导出的jsonSchemaToShape)追加到工具描述中——模型工具调用的传输格式仅包含输入,因此在普通桥接路径中,描述是展示返回形态的唯一渠道。低于该修订版本的服务,或未声明架构的服务,不会附加此内容。 requiresPermission:在serverMode下始终为false;否则,除非 MCP 工具标注了readOnlyHint: true,否则为true。- 结果:当服务提供
structuredContent时(MCP 2025-06-18+),模型会看到该内容(JSON 字符串化后放入data);否则会看到拼接后的content[]文本。完整的content[]始终会作为renderData传递,以供 React 渲染器使用。 - 身份验证过期约定:
callTool中任何形似 401 的错误都会映射为{ status: "error", message: "auth_expired", data: null }。请从对话日志中检测此情况,刷新令牌;下次调用会通过getAccessToken获取新值。
发现(discovermcp)与歧义策略
mountMcp 会注册一个模型可路由至的子 Agent:discovermcp。模型通过 glove_invoke_subagent({ name: "discovermcp", prompt: "send an email" }) 调用它。工厂会创建一个子 Glove(使用自己的子存储,来自 parentStore.createSubAgentStore("discovermcp", false);如果不支持子存储,则回退到私有的 DiscoveryMemoryStore;并继承主 Agent 的模型、displayManager 和 serverMode),然后将以下工具添加到该子 Agent:
list_capabilities(query?, tags?)— 在目录中进行子串搜索。activate(id)— 连接并将工具桥接到主 Agent,同时持久化激活状态。工具会在主模型的下一轮中可用。deactivate(id)— 切换持久化状态。v1 的限制:工具会一直保持加载状态,直到会话刷新。ask_user(question, options)— 仅在interactive策略下注册。通过主 displayManager 上的mcp_picker渲染器呈现。
歧义策略用于控制子 Agent 找到多个可能匹配项时的处理方式:
| 策略 | 行为 | 适用场景 |
|---|---|---|
{ type: "interactive" } | 子 Agent 通过 pushAndWait 调用 ask_user。需要 displayManager 上配置 mcp_picker 渲染器。 | 配置了渲染器的浏览器 UI/聊天应用。serverMode: false 时的默认策略。 |
{ type: "auto-pick-best" } | 子 Agent 始终选择排名最高的匹配项。无需人工介入。 | 无头环境/服务端/CLI。serverMode: true 时的默认策略。 |
{ type: "defer-to-main" } | 子 Agent 以文本形式返回候选列表,由主 Agent 决定要激活什么。 | 多 MCP 发现流程中,主模型的对话上下文比子 Agent 更丰富的场景。 |
Glove 配置中的 serverMode: true 是表示“我处于无头模式”的标准标志——它既控制默认歧义策略,也控制桥接工具的默认 requiresPermission(绝不设限)。
Auth 模型 — 静态请求头(Bearer 或自定义)
框架只知道每个连接的静态请求头。connectMcp 提供了两个辅助工具:auth: bearer(token | () => token) 用于设置 Authorization: Bearer ...,以及 auth: headers(map | () => map),供需要自定义请求头的服务使用(例如 Composio 的 x-api-key)。两者都接受一个 thunk,每次连接时都会重新解析。mountMcp 和发现工具 activate 通过 adapterAuth(adapter, id) 解析 auth:如果定义了 getAuthHeaders,则使用它;否则,将 getAccessToken 包装为 bearer;如果两者都没有,则不设置 auth 请求头。
import { bearer, headers, connectMcp } from "glove-mcp";
const conn = await connectMcp({
namespace: "notion",
url: "https://mcp.notion.com/mcp",
auth: bearer(() => adapter.getAccessToken!("notion")),
clientInfo: { name: "My App", version: "1.0.0" },
});
const composio = await connectMcp({
namespace: "composio",
url: "https://mcp.composio.dev/...",
auth: headers({ "x-api-key": process.env.COMPOSIO_API_KEY! }),
});
auth_expired 契约
调用过程中,如果令牌过期,桥接工具的结果会显示为 { status: "error", message: "auth_expired" }。框架不会刷新令牌。你的应用必须:
- 在对话日志中检测
auth_expired(通过订阅者的tool_use_result事件,或事后检测)。 - 通过负责管理凭据的机制刷新凭据或重新进行身份验证。
- 更新存储;下一次桥接调用会从
getAccessToken获取新令牌。
对于 UI 使用者,通常会显示“重新连接 Notion”的提示。对于 CLI,提示用户重新运行身份验证命令很常见。
glove-mcp/oauth — 可选的 OAuth 工具
如果你还没有 OAuth 流程,glove-mcp/oauth 子路径提供了一个基于 MCP 授权规范构建的小型参考实现:
runMcpOAuth(opts)— 一次调用即可完成端到端流程。它会在http://localhost:53683/callback启动本地监听器(可配置),通过 SDK 执行 DCR(也可通过preRegisteredClient跳过),打开用户的浏览器,使用授权码换取令牌,并通过listTools(或你选择的callTool)进行验证。examples/mcp-cli/*-mcp-auth.ts脚本使用了它。FsOAuthStore/MemoryOAuthStore—OAuthStore的实现。FsOAuthStore会写入一个模式为0600的 JSON 文件,并通过临时文件加原子重命名确保写入安全。生产环境中可替换为你的数据库。McpOAuthProvider— 面向高级使用者的底层OAuthClientProvider,用于直接通过 SDK 驱动auth()。buildClientMetadata、MCP_DEFAULT_CLIENT_INFO、emptyOAuthState— 一些辅助工具。
已有令牌的使用者(例如令牌来自环境变量、内部集成、保管库或现有 OAuth 配置)可以完全忽略此子路径——getAccessToken 只需返回 bearer 即可。
完整类型签名请参阅 api-reference.md — glove-mcp/oauth,示例请参阅 examples.md — 模式:MCP OAuth 流程。
生产环境迁移
参考示例 examples/mcp-cli 是一个面向单用户的 Node CLI;生产环境通常需要:
- 多用户存储 — 将
FsOAuthStore替换为由数据库支持的按用户划分的OAuthStore。该接口包含三个方法(get、set、delete)。 - 在路由处理器中执行 OAuth 流程 —
GET /oauth/<id>/start调用runMcpOAuth(或直接调用底层 SDK 的auth()),GET /oauth/<id>/callback完成流程。机制相同,只是调用方式不同。runMcpOAuth的本地监听器方式适用于 CLI,但不适合放在负载均衡器之后。 - 后台刷新 — 按照你的技术栈所采用的方式刷新过期令牌;
getAccessToken只负责读取最新的 bearer 字符串。 - 持久化活动状态 — 示例中的
McpAdapter使用内存中的Set存储活动 ID。生产环境中,应按对话持久化活动 ID(与消息一并存储),这样重启后重新加载才会实际生效。
Agent 代码本身无需更改——唯一需要调整的接口是 McpAdapter.getAccessToken。
快速参考 — 内容位置
| 需求 | 符号 |
|---|---|
| 将 MCP 挂载到 Agent 上 | mountMcp(glove, { adapter, entries, ... }) |
| 实现消费者适配器 | McpAdapter 接口 |
| 编写目录条目 | McpCatalogueEntry |
| 单次连接(预检、自定义流程) | connectMcp({ namespace, url, auth }) |
| 手动桥接工具 | bridgeMcpTool(connection, tool, serverMode) |
| Bearer 标头辅助函数 | `bearer(token |
自定义标头辅助函数(非 Bearer,例如 x-api-key) | `headers(map |
| 适配器 → 身份验证解析器 | adapterAuth(adapter, id) |
| 发现子 Agent 工厂 | discoverySubAgent({ adapter, entries, ambiguityPolicy })(返回 DefineSubAgentArgs;传入 glove.defineSubAgent(...)) |
| 工具命名空间分隔符 | MCP_NAMESPACE_SEP("__") |
| 检测原始连接中的 401 | UnauthorizedError |
| 运行 OAuth 流程 | 来自 glove-mcp/oauth 的 runMcpOAuth(opts) |
| 持久化 OAuth 状态 | 来自 glove-mcp/oauth 的 FsOAuthStore、MemoryOAuthStore |
| 构建客户端元数据 | 来自 glove-mcp/oauth 的 buildClientMetadata(opts) |
Memory(glove-memory)
以架构为先的记忆层,包含五个并列子系统。存储无关的适配器契约;随附用于开发/测试的参考内存适配器。状态:v0.1 草案;配套存储后端(glove-memory-sqlite、glove-memory-postgres)尚未发布。
五个子系统
| 子系统 | 适配器 | 用途 |
|---|---|---|
| 实体 | EntityMemoryAdapter | 图结构、以架构为先、确定性的身份解析。节点(人员、组织、项目)及其间的类型化边。由整理者编写,由 Agent 读取。 |
| 情景 | EpisodicMemoryAdapter | 与时间线绑定、仅追加的事件。会议、决策、观察。时间是一等字段;语义搜索为可选功能(通过 supportsSemanticSearch 声明)。 |
| 资源 | ResourceFsAdapter | Agent 使用 ls / read / grep / glob / edit 导航的 POSIX 风格虚拟文件系统。存放研究笔记、转录文本、链接集合。仅限文本;仅支持绝对路径(不支持 . / ..)。 |
| 上下文 | ContextAdapter | 用户配置的环境上下文,在每次模型迭代前作为临时运行时上下文追加。形态不同:并非从整理者提取,也没有读取者/整理者分工——一次注册即可为 Agent 同时提供读写工具。 |
| 表单 | FormAdapter | 在对话中进行结构化收集。定义以代码编写(Zod + 闭包),每次加载一个层级,因此 60 个字段的表单与 6 个字段的表单占用相同长度的常驻提示词行。 |
架构建议:不要把记忆工具一股脑地添加到主 Glove 上
如果你正在构建一个需要访问记忆的 Agent,不要将实体/情景/资源工具直接挂载到主 Glove 上。应构建子 Agent——每种检索任务各一个——并将它们注册到主 Agent 上。每个子 Agent 只挂载所需的适配器切片。
原因:
- 限制提示词范围。 工具描述只会呈现该角色所需的架构切片——令牌成本随角色而非本体总规模增长。
- 路由更精准。
lookup/recall/find-notes这样的子 Agent 名称本身就是推理界面。与“你有八个记忆工具,决定用哪个”相比,信号更明确。 - 变更范围由结构限定。 挂载了
useMemoryReader的子 Agent 无法写入;它根本不具备这种能力。还可以更精细——某个文件夹可读但不可写,某个整理器可以归档但永不删除——这可通过{ tools: { allow, deny } }配合withResourceAccess实现。 - 适配器保持共享。 所有子 Agent 都读写同一底层图谱/时间线/文件系统。拆分工具不会拆分数据。
整理器也同样适用这条建议:由一个父级整理器路由到专门负责写入的子 Agent(实体链接器、情景记录器、资源归档器),胜过给单个整理器挂载所有写入工具。
例外是 useContext。上下文很小(4 个工具),由用户驱动(“记住……”),并且随运行时上下文提供程序一起提供,而该提供程序必须位于用户实际交互的 Agent 上。将 useContext 保留在主 Agent 上。
参阅 examples.md — Memory: subagent-delegated reader / curator composition / context flow,了解完整示例模式。
辅助函数系列
每个辅助函数都会将相关工具集成到 Glove 上。它们都会返回同一个 G,以便链式调用;并且都可作用于 IGloveBuilder 或 IGloveRunnable(任何公开 fold 的对象)。
| 辅助函数 | 集成内容 | 备注 |
|---|---|---|
useMemoryReader(glove, adapter) | glove_memory_find、_get、_query | 只读实体图谱访问。 |
useMemoryCurator(glove, adapter) | 读取工具 + _add_node、_update_node、_connect、_disconnect、_merge_nodes | 完整的实体写入权限。 |
useEpisodicReader(glove, adapter) | glove_episodic_find、_timeline、_search | 仅当 adapter.supportsSemanticSearch === true 时才会注册 _search。 |
useEpisodicCurator(glove, adapter) | 读取工具 + _record、_update、_delete | |
useResourcesReader(glove, adapter) | glove_resources_ls、_read、_stat、_grep、_glob、_search、_links_for | 仅当 supportsSemanticSearch 为真时才会注册 _search。 |
useResourcesCurator(glove, adapter) | 读取工具 + _write、_edit、_mkdir、_move、_remove、_set_metadata | |
useContext(glove, adapter) | glove_context_get、_set、_update、_unset | 每次模型迭代前都会调用 adapter.render(),并在输入末尾追加一份临时的用户角色快照;系统提示词和已保存的历史记录保持不变。 |
每个辅助函数都接受一个可选的末尾参数 { tools?: { allow?, deny? } }(对于 useFormRunner,这是其配置上的一个字段),用于缩小实际集成的工具范围——见下文。
分层记忆——在同一视图中包含共享层和私有层
记忆由多个层组成:有些由其他人编写并共享(例如组织手册、通用本体、已发布事件、常设指令),Agent 可以读取但绝不能修改;有些则属于 Agent 自己。它们位于不同的存储中——共享语料库无法复制到每个私有存储中——而 Agent 不应被迫了解这一点。每个 layer* 函数都接收一个堆栈,并返回一个符合普通契约的适配器,因此现有的 use* 辅助函数可以照常将工具集成到其上。
const resources = layerResources([
{ name: "handbook", adapter: sharedFs, access: "read", paths: ["/handbook"] },
{ name: "notes", adapter: privateFs, access: "write" },
]);
useResourcesCurator(glove, resources);
四个子系统都支持分层:layerEntity、layerEpisodic、layerResources、layerContext(来自 glove-memory/layered)。各处通用的规则:每个栈中恰好有一个层的 access: "write"(零个会让每个写入工具都变成陷阱,两个会导致路由模糊——这两种情况都会在构造时抛出错误);读取按层顺序合并(遇到冲突时前面的层优先,因此顺序就是遮蔽规则);写入会路由到拥有目标的层,并以 MemoryLayerError 拒绝(code: "layer_read_only",并指出层名);limit/offset 应用于合并结果(每个层都会收到 limit + offset,且不带 offset,因为较早层跳过的行并不是合并视图要跳过的行);允许对只读层调用 setEmbedding,因为索引是宿主的职责,而非 Agent 的职责;layerOf(id|path) 会告知宿主某项内容来自哪个层。
资源——层顺序决定优先级,paths 将某个层限定在一个子树中;同一套机制既适用于挂载(前缀互不重叠),也适用于联合(两者都覆盖 /,私有层列在前面并遮蔽共享层)。路径不会被转换:限定在 /handbook 的层会把同一个绝对路径传给其适配器,因此共享存储必须以该前缀作为路径——转换路径会悄然导致所有已存储的 metadata.links 目标失效。某个层无法泄漏到其前缀之外(结果会筛选为它所声明拥有的内容),但通往已声明前缀的目录仍可列出,以便访问挂载点。写入会路由到已经持有该路径的层;如果是新路径,则回退到前缀所属的层——这样的顺序可以让 remove("/handbook/pay.md") 报告只读层,而不是先尝试私有存储后得到的“未找到”。不支持写时复制;跨层移动,以及会触及共享层的递归删除,都会被拒绝。
实体——有两点需要注意。addNode 会在写入之前,使用该类的 identityKeys 在只读层中解析身份,并返回共享节点的 id,且 created: false;否则,每个 Agent 首次提及每个共享实体时都会创建一个私有副本,共享图也会悄然分叉。这样一来,返回的 id 可能不可变,因此后续的 updateNode 会被拒绝。边不能跨层——connect 会在单个适配器内验证两个端点,而私有存储中没有共享节点的记录,因此跨层 connect 会以 code: "cross_layer_unsupported" 拒绝。可以采用一种实际可行的变通方式:episodic 的 participants 和 resource 的 metadata.links 都是未经验证的普通 id,因此可以自由跨层。这是对图进行分层时唯一真正会丢失信息的部分。
Episodic 会将各层交错合并成一条真实的时间线;只要任意一个层支持语义搜索,supportsSemanticSearch 就为 true,而 searchEpisodes 只会查询支持语义搜索的层(合并后的排名只是尽力交错——分别构建的两个索引,其分数无法直接比较)。Context 会合并读取结果,并按“共享层优先”的顺序拼接各层的 render 块;setSection 只会替换可写层中的条目。
分层与 withResourceAccess 是组合使用,而非相互替代:分层回答“内容存在哪个存储中”,路径策略回答“在单个存储中可以执行什么操作”。可以包装某个层的适配器,进一步限制其访问;也可以包装分层后的适配器,限制合并视图。一个栈中的所有层都应共用同一个 MemorySchema。
限制 Agent 可以执行的操作
两个可独立配置、应配合使用的选项。一个移除可用入口——工具不会传递给模型。另一个移除能力——无论调用以何种方式到达,适配器都会拒绝。
工具允许列表。 useResourcesCurator(glove, resources, { tools: { deny: ["remove", "move"] } }) 会创建一个只能归档笔记、无法删除或移动内容的 curator;{ tools: { allow: ["ls", "read", "grep"] } } 则从无到有,仅指定允许的工具。选择器可使用完整名称("glove_resources_remove")或短名称("remove")解析;先由 allow 缩小范围,再由 deny 排除。若选择器没有匹配任何工具,则会抛出 MemoryToolSelectionError——否则,deny 条目中的拼写错误可能导致工具仍被注册。你可以使用导出的 selectTools,为自己构建的界面选择工具。这只能控制提示词界面;适配器仍保有完整能力。
按路径范围控制访问策略。 withResourceAccess(adapter, policy) 会包装一个 ResourceFsAdapter,按路径检查每次调用:
const resources = withResourceAccess(new InMemoryResourcesAdapter({ schema }), {
default: "none",
rules: [
{ path: "/research", access: "read", note: "curated by the research team" },
{ path: "/notes", access: "write" },
{ path: "/**/*.locked.md", access: "read" },
],
});
useResourcesCurator(glove, resources);
"write" 表示可读取和修改;"read" 会通过 ResourceAccessError(code: "access_denied")拒绝所有修改;"none" 会拒绝读取,并从 ls / grep / glob / search / links_for 的结果中过滤掉相应内容。path 是绝对目录前缀或 glob(* / ** / ?,与 glove_resources_glob 使用相同的模式);规则按最后匹配优先的原则覆盖 default(未设置时为 "write")。访问控制在适配器上执行,而非工具列表中,因此无论哪个工具发起调用,写入只读文件夹都会被拒绝——并且这两个选项可以组合使用。
以下细节容易引发问题:多路径读取会进行过滤,而非直接失败(显式指定隐藏路径仍会被拒绝;exists 会返回 false,而非抛出错误);通向已授权子树的目录仍可列出,因此允许列表策略可以导航,但目录遍历不等于读取权限;如果递归 remove 或目录 move 会触及任何受保护路径,就会被拒绝;每个资源工具的描述中都会呈现该策略(describe: false 只会隐藏说明,不会禁用访问控制);任何限制性策略下,replaceLinkTarget 都会被拒绝(请在未包装的适配器上运行协调);findFilesNeedingEmbedding / setEmbedding 不经过过滤,因为嵌入循环由宿主负责,而非 Agent。
工具清单
实体(useMemoryReader / useMemoryCurator)
| 工具 | 用途 |
|---|---|
glove_memory_find | 按类别和筛选条件查找节点,可选模糊匹配 |
glove_memory_get | 按 ID 获取节点及其一跳邻域 |
glove_memory_query | 通过查询 DSL 执行完整的结构化查询 |
glove_memory_add_node | 按身份键创建或更新节点 (curator) |
glove_memory_update_node | 修改节点的属性 (curator) |
glove_memory_connect | 创建或更新边 (curator) |
glove_memory_disconnect | 移除边 (curator) |
glove_memory_merge_nodes | 将一个节点合并到另一个节点中 (curator) |
情景记忆(useEpisodicReader / useEpisodicCurator)
| 工具 | 用途 |
|---|---|
glove_episodic_search | 搜索情景记录的内容——根据适配器不同,使用基于嵌入的语义搜索,或进程内模糊/词法搜索 (仅在 supportsSemanticSearch 时可用) |
glove_episodic_find | 结构化筛选——按类型、参与者、时间范围和属性筛选 |
glove_episodic_timeline | 按时间顺序列出某个实体或时间范围的记录 |
glove_episodic_record | 追加新的情景记录 (curator) |
glove_episodic_update | 修改现有情景记录 (curator) |
glove_episodic_delete | 移除情景记录 (curator) |
资源(useResourcesReader / useResourcesCurator)
| 工具 | 用途 |
|---|---|
glove_resources_ls | 列出目录内容 |
glove_resources_read | 读取文件正文,可选行范围 |
glove_resources_stat | 获取单个路径的元数据 |
glove_resources_grep | 在整个目录树中进行文本/正则表达式搜索 |
glove_resources_glob | 按名称模式查找路径 |
glove_resources_search | 语义搜索(仅当 supportsSemanticSearch 时可用) |
glove_resources_links_for | 反向查找:查找链接到目标的资源 |
glove_resources_write | 创建或覆盖文件(curator) |
glove_resources_edit | 替换唯一子字符串(curator) |
glove_resources_mkdir | 创建空目录(curator) |
glove_resources_move | 重命名或移动(curator) |
glove_resources_remove | 删除文件或目录(curator) |
glove_resources_set_metadata | 修补元数据,不重写正文(curator) |
上下文(useContext)
| 工具 | 用途 |
|---|---|
glove_context_get | 按分区读取条目,或列出全部条目 |
glove_context_set | 添加新条目 |
glove_context_update | 就地修补现有条目 |
glove_context_unset | 移除条目或清空整个分区 |
表单(useFormRunner / useFormReader)
| 工具 | 用途 |
|---|---|
glove_form_list | 已注册的表单,名称 + 描述 — 不加载模块 |
glove_form_start | 启动一个实例,可选提供初始值 |
glove_form_status | 完整显示当前打开的步骤(第 1 层) |
glove_form_inspect | 查看任意步骤、单个字段或完整大纲(第 2 层) |
glove_form_fill | 一次修补多个字段 ID;返回重新评估后的状态 |
glove_form_revise | action:set / retract / undo / redo |
glove_form_abandon | 说明原因并关闭 |
glove_form_history | 读取过往填写记录(reader 注册) |
MemorySchema — 共享本体
同一个架构对象会传递给每个适配器。它存在于代码中;该包不会持久化它、不会在不同部署之间验证它,也不会提供迁移原语 — 这些都由使用者负责。
import { MemorySchema } from "glove-memory/core";
import { z } from "zod";
const schema = new MemorySchema()
.defineNodeClass({
name: "Person",
schema: z.object({ name: z.string(), email: z.string().optional() }),
identityKeys: [["email"], ["name"]], // multi-set: any matching set folds the write
searchableProperties: ["name", "email"], // indexed for fuzzy / contains
})
.defineRelationship({
type: "worksAt",
from: "Person",
to: "Organization",
propertiesSchema: z.object({ since: z.string().optional() }).optional(),
multi: false, // default — re-connect updates rather than duplicating
})
.defineEpisodeKind({
name: "meeting",
description: "A scheduled gathering.",
propertiesSchema: z.object({ duration_min: z.number() }).optional(),
})
.defineResourceRoot({
path: "/research",
description: "External research artifacts.",
semanticSearch: true, // default true; false skips embedding lifecycle for this root
});
运行时安全的操作:
- 添加新的节点类、关系或事件类型始终安全。
- 添加可选属性始终安全。
- 添加必需属性不会影响读取;未提供该属性的新写入会验证失败。
- 删除或重命名属性需要由使用者管理重写 — 适配器不会察觉。
- 更改身份键可能会在后续写入时静默地合并或拆分节点。
来源信息 — 必需、仅追加、每次写入都要提供
每次适配器写入都需要一个 Provenance。它会在每个节点、边、事件、资源和上下文条目上仅追加。面向读取者的工具会从结果中过滤掉 provenance;只有直接调用适配器才会返回它。
interface Provenance {
source: string; // "conversation:<id>/turn:<n>", "manual", "import:<kind>:<id>"
actor: string; // "curator-run-xyz", "user:don", "system"
timestamp: string; // ISO 8601
note?: string; // free-form rationale (identity-merge decisions, conflict notes)
}
Link 是共享的交叉引用词汇 — 事件指向人物、资源指向事件、上下文条目指向项目。
interface Link {
kind: "entity" | "episode" | "resource";
id: string; // entity / episode id, or resource path
relation?: string; // free-form, e.g. "primary-contact", "source-transcript"
}
该包不会验证链接目标是否存在 — 适配器彼此解耦。交叉验证由 curator / orchestrator 负责。
嵌入生命周期 — 异步处理,由你提供适配器
情景记忆和资源使用相同的生命周期。写入会将记录标记为 embeddingStatus: "missing"(初始状态)或 "stale"(内容变更),并立即返回。单独的进程(通常是 Station 信号)负责执行嵌入处理:
interface EmbeddingAdapter {
dimensions: number;
embed(texts: string[]): Promise<number[][]>;
}
刷新循环:
const pending = await episodic.findEpisodesNeedingEmbedding({ limit: 50 });
const vectors = await embedder.embed(pending.map((p) => p.content));
for (let i = 0; i < pending.length; i++) {
await episodic.setEmbedding(pending[i].id, vectors[i]);
}
资源使用 findFilesNeedingEmbedding / setEmbedding(两者在 ResourceFsAdapter 上均为可选方法,仅当 supportsSemanticSearch === true 时存在)。在内存适配器中,情景记忆的过时标记仅针对内容——对 kind / participants / properties / occurredAt 的更新不会触发重新嵌入;如果使用者希望采用不同的行为,可以先删除再重新记录。searchEpisodes 中的近期性权重默认 recencyWeight = 0.2,半衰期为 30 天。
使用后台索引的自定义情景记忆适配器(自带搜索)
embeddingStatus + findEpisodesNeedingEmbedding + setEmbedding 生命周期是一个通用的后台索引接口,并非专用于嵌入——索引可以是向量、SQLite FTS5、Postgres tsvector、BM25、Meilisearch、Tantivy 等。若要让 glove_episodic_search 使用自定义索引,请实现 EpisodicMemoryAdapter,并设置 supportsSemanticSearch: true,同时实现 searchEpisodes 方法。该方法的调用方各司其职:
- 写入(
recordEpisode/updateEpisode/deleteEpisode)会持久化到主存储,将行标记为missing(新增)/stale(内容变更),并快速返回——不会在热路径中进行索引。 - 结构化读取(
findEpisodes/episodesForEntity/episodesBetween)直接访问主存储,始终读取最新数据——不会访问索引。 - 索引生命周期(
findEpisodesNeedingEmbedding→ 构建索引产物 →setEmbedding)构成后台工作进程的队列与提交过程。setEmbedding(id, vector)会提交索引产物,并将行状态切换为fresh。 searchEpisodes(query, opts)是工具调用的方法:查询索引,应用opts.filter(kind/participantIds/timeRange),并按score从高到低返回EpisodeSearchResult[]({ episode, score, distance })。
// Out-of-band worker (Station signal / cron / queue). Index type is your choice.
const pending = await adapter.findEpisodesNeedingEmbedding({ limit: 100 });
const artifacts = await buildIndex(pending.map((p) => p.content)); // vectors | FTS docs | BM25 postings
for (let i = 0; i < pending.length; i++) await adapter.setEmbedding(pending[i].id, artifacts[i]);
契约规则(与参考适配器一致):supportsSemanticSearch: true 是启用搜索工具的开关——没有它,searchEpisodes 就是死代码;刚写入的情景记忆只有在工作进程追上进度后才可搜索(最终一致性——find/timeline 会立即看到它);searchEpisodes 必须遵循 opts.filter、按 score 降序排序,并移除 provenance;在应用近期性融合前,将相关性归一化到 [0,1]((1 - recencyWeight) * relevance + recencyWeight * recencyScore,默认 recencyWeight = 0.2,半衰期 30 天),因为 BM25 分数没有上界;仅在内容变更时重新标记;setEmbedding 的 vector 参数仅对向量索引有意义——对于词法索引/外部索引,应忽略它,并将 setEmbedding 视为“写入文档 + 标记为最新”(该方法仍是接口要求)。
协调原语
该包的契约刻意保持精简:存储、查询、写入、搜索。它不会跨适配器级联处理。合并或删除实体时,引用其旧 ID 的情景记忆不会自动更新。编排器会使用以下原语:
| 操作 | 原语 |
|---|---|
| 实体已合并 | episodic.replaceParticipantId(oldId, newId, prov)、resources.replaceLinkTarget("entity", oldId, newId, prov) |
| 实体已删除 | episodic.findEpisodes({ where: { participantIds: [id] } })、resources.linksFor("entity", id),然后由编排器决定如何处理 |
| 资源已移动 | resources.replaceLinkTarget("resource", fromPath, toPath, prov) |
| 情景记忆已删除 | resources.linksFor("episode", id),然后由编排器决定如何处理 |
| 嵌入已过时 | findEpisodesNeedingEmbedding / findFilesNeedingEmbedding → embed → setEmbedding |
参考内存适配器
适用于开发、测试和快速原型。全部从 glove-memory/in-memory 导出(并由总入口重新导出)。
import {
InMemoryEntityAdapter,
InMemoryEpisodicAdapter,
InMemoryResourcesAdapter,
InMemoryContextAdapter,
} from "glove-memory";
const entity = new InMemoryEntityAdapter({ schema });
const episodic = new InMemoryEpisodicAdapter({ schema, embedder }); // omit embedder → supportsSemanticSearch = false
// Embedding-free content search: `{ schema, fuzzySearch: true }` runs in-process
// lexical/fuzzy search over episode content — no embedder, no external service,
// supportsSemanticSearch = true. `embedder` wins if both are supplied.
const resources = new InMemoryResourcesAdapter({ schema, embedder }); // ditto
const context = new InMemoryContextAdapter({ schema });
仅限进程内使用——重启后数据会丢失。生产项目可换用配套包或自带适配器。
Forms — 在对话中进行结构化收集
定义是代码。Zod 模式、门控闭包和执行器集中在一个类型贯穿的构建器链中,而 Agent 永远不会读取定义——它读取的是评估后状态的投影。
import { z } from "zod";
import { defineForm } from "glove-memory/forms";
export const travelClaim = defineForm({
id: "travel-claim",
version: 1,
name: "Travel reimbursement claim",
description: "Claimant, trip, travel and approval details.",
conduct: "Conversational — one or two questions at a time. Don't read the field list aloud.",
})
.step("claimant", { title: "Claimant", preview: "name, staff id, email" }, (s) =>
s
.field("fullName", { schema: z.string().min(2), label: "Full name" })
.field("email", { schema: z.string().email(), label: "Work email" }),
)
.step(
"travel",
{ title: "Travel", preview: "mode, mileage or ticket", when: (v, s) => s.stepComplete("claimant") },
(s) =>
s
.field("mode", { schema: z.enum(["car", "rail", "air"]), label: "Mode" })
.field("mileage", {
schema: z.number().int().min(1).optional(),
label: "Miles driven",
when: (v) => v.mode === "car", // applicability, not ask-order
}),
)
.checkpoint("policy-cap", {
when: (v) => typeof v.total === "number" && v.total > 750,
blocking: true,
run: () => ({ fail: "Over the limit — needs Finance pre-approval." }),
})
.onComplete(async (ctx) => {
await ctx.memory.upsertNode("Person", { name: ctx.values.fullName });
})
.build();
每个 .field() 都会扩展累积值类型,因此在每个下游调用点,ctx.values.mode 都会收窄为其枚举联合类型,而 ctx.values.mileage 的类型为 number | undefined。
可选性和类型都是推导得出的,绝不需要声明。 没有 required 选项——当且仅当字段的模式接受 undefined 时,该字段才是可选的;推断值类型的构建也采用同一判定条件。Agent 读取的 type 字符串来自 z.toJSONSchema 和一个渲染器("email address"、"one of: car | rail | air"、"integer >= 1")。两者结合,便无需维护字段类型词汇表:没有类型联合、没有注册表,也没有需要扩展的内容。
写入从不受门控限制。 没有锁,也没有字段锁定错误。glove_form_fill 接受包含任意字段 ID 的补丁,逐个独立验证,因此一个无效值不会导致其余值被拒绝;暂时不适用的值会作为暂存条目保留,而不是丢弃。用户在被询问第二题时回答了第六题,那他就回答了第六题。字段 ID 也很宽容——full_name、Full name 和 fullName 都会通过编译时别名索引解析到同一字段;仍然无法匹配的 ID 会返回 did_you_mean。
序列分成两件互不相关的事:when 决定在当前答案下某字段是否有意义;步骤则决定要引导至何处。
任何内容都不会丢失。 entries 会将每个字段映射到一个只追加的修订日志,并用游标指明当前生效的修订。更正会追加记录,而非覆盖原值。撤回本身也是一个修订,因此 retract / undo / redo 只是对不会丢失答案的日志进行纯游标移动——而且每种操作都可撤销。
await runner.retract("ticketReference"); // withdraw, keeping the answer
await runner.undo(); // last answer anywhere on the form
await runner.redo("mileage"); // or on one field
await runner.history("mileage"); // every answer ever given
Agent 通过 glove_form_revise 的 action(set / retract / undo / redo)调用这四种操作,而非使用四个动词——每次模型调用都会重新发送工具模式,一项 Agentic 评估发现,这些模式约占该界面整体上下文成本的 75%。
执行器在四个位置集中使用同一个签名——field.onFill、step.onComplete、checkpoint.run、form.onComplete——仅在上升沿以先提交后运行的方式分发,并为每次触发提供幂等键。已验证的单次提交内触发顺序:field → step → checkpoint → form。执行器返回 { patch }、{ fail }、{ jump } 或 { complete };ctx.memory 则通过引擎提供的来源信息连接其他四个子系统。
触发器会引导对话。 检查点是一种触发器——它是一个基于值的条件,在上升沿触发。返回 { jump } 会移动当前开放步骤,可以向前,也可以返回到已完成的步骤:
.checkpoint("verify-identity", {
when: (v) => v.claimValue > 10_000,
run: () => [{ patch: { verificationRequired: true } }, { jump: "claimant" }],
})
执行器可以返回单个效果或效果数组,因此路由器可以在一次触发中既写入值,又移动步骤。向后跳转属于重访:该步骤的答案仍保持 filled,但会重新标记为 ask: true;第 0 层会提示 back at step 1/3 "Claimant" — go through it again(即使表单其他部分已经完成)。在该步骤中进行下一次写入后,覆盖状态就会解除,因此跳转只是轻推,不会固定位置;跳转到不存在的步骤会被忽略。
根据状态路由,而不只是根据值。 when 和 run 都会接收一个 FormState——其中包含 stepComplete(id)、checkpointFired(id)、complete——以及类型化的值,因此路由器可以根据对话进行到何处来分支:
.checkpoint("route", {
when: (v, s) => Boolean(v.kind) && s.stepComplete("triage"),
run: (ctx) => ({ jump: ctx.values.kind === "complex" ? "complex" : "simple" }),
})
checkpointFired 读取门控条件看到的计数器,因此在检查点自身的 run 中调用它,会报告该检查点之前是否已触发,而不是正在进行的这次触发。
终止收集。 { terminate: reason } 会停止表单——适用于不符合条件、重复或已撤回等情况,此时继续收集是错误的,而不只是尚未完成。这与已有的另外两种操作不同:fail 记录拒绝并继续;complete 声明成功。terminate 会通过 closedReason 关闭实例,停止所有字段提问,拒绝后续写入,将实例从第 0 层移除,并优先于同一次提交中生效的完成操作。
延迟加载,分为三层。 第 0 层是在每次迭代前追加到模型输入末尾的一行临时内容——当前打开的步骤、该步骤待填写字段的标签,以及剩余每个步骤的一行 preview。第 1 层(glove_form_status)是完整的当前打开步骤。第 2 层(glove_form_inspect)可以检查其他步骤、字段或整个大纲。只有在表单模块启动后才会导入,因此 glove_form_list 只需加载名称和描述。
[form: travel-claim] step 2/4 "Trip" · pending: Destination, Departure date
later: Travel (mode, mileage or ticket) · Approval (cost centre, manager)
接线:
const registry = new FormRegistry().register("travel-claim", {
name: "Travel reimbursement claim",
description: "Claimant, trip, travel and approval details.",
load: () => import("./forms/travel-claim").then((m) => m.travelClaim),
});
const { runner } = useFormRunner(glove, new InMemoryFormAdapter({ schema }), {
registry,
subject: conversationId,
memory: { entity, episodic, resources, context },
});
useFormRunner 整合七个工具,并包装 processRequest 以注入第 0 层内容,然后返回 runner,供宿主启动实例并处理检查点,无需经过模型。useFormReader 为另一个 Agent 提供对过去填写内容的只读访问。
编写 FormAdapter——仅负责存储和检索;所有语义均由引擎负责。有四项不变量:
entries只追加,绝不替换。commitInstance接收每个字段对应的{ append?, cursor? },而不是替换历史记录。覆盖日志会销毁设计保证保留的答案。applyEntryCommit已导出,可供你复用完全相同的语义。version用于比较并设置。 如果ifVersion已过期,则拒绝并抛出FormConflictError;每次写入成功后都递增版本。- 提交必须全部成功或全部失败——条目、出现次数计数器、分发日志和状态必须一并处理。
- 读取操作返回快照。
其他事项均由实现者决定:存储引擎、架构、索引、保留策略、如何实现原子性、来源追踪深度、多租户和加密。FormAdapter 的方法注释中提供了各方法的详细说明。
值得了解的运行注意事项: 已记录的执行器失败不会重试(至少一次保证仅涵盖记录结果之前发生的崩溃);挂起的阻塞检查点会让实例一直处于 awaiting 状态,直到调用 resolveCheckpoint,且没有超时机制;recordDispatch 的写入不在 CAS 事务范围内;所有字段均为可选的步骤会在启动时立即完成,因此其 onComplete 会立即触发;处于 complete 状态的实例仍可访问以便更正——只有 abandon 会关闭表单。
不在范围内
- 触发、调度或流水线编排(属于 Station 的职责)。
- 策展逻辑本身(由使用方配置)。
- 嵌入生成——使用方接入自己的
EmbeddingAdapter。 - 架构持久化或迁移——架构仅存在于代码中。
- 实体合并、剧集删除或资源重命名时跨适配器级联处理——这属于协调工作,由编排器负责。
- 用户侧的上下文写入路径:适配器提供
set/update/unset;用户编辑偏好的 UI / API / 表单或其他入口会直接调用这些方法。 - 二进制资源。资源仅支持文本。
.和..路径解析。所有资源路径均为绝对路径。
快速参考 — 各项内容的位置
| 需求 | 符号 |
|---|---|
| 定义本体 | MemorySchema from glove-memory/core |
| 必需的写入元数据 | Provenance from glove-memory/core |
| 子系统之间的交叉引用 | Link from glove-memory/core |
| 嵌入契约 | EmbeddingAdapter from glove-memory/core |
| 实体契约 | EntityMemoryAdapter from glove-memory/entity |
| 情景记忆契约 | EpisodicMemoryAdapter from glove-memory/episodic |
| 资源契约 | ResourceFsAdapter from glove-memory/resources |
| 上下文契约 | ContextAdapter from glove-memory/context |
| 表单契约 | FormAdapter from glove-memory/forms |
| 定义表单 | defineForm from glove-memory/forms |
| 表单注册表(延迟加载模块) | FormRegistry from glove-memory/forms |
| 表单引擎(宿主端) | FormRunner from glove-memory/forms |
| 在适配器中复用追加与限幅逻辑 | applyEntryCommit from glove-memory/forms |
| 读取者 / 管理者辅助工具 | useMemoryReader / useMemoryCurator、useEpisodicReader / useEpisodicCurator、useResourcesReader / useResourcesCurator、useContext、useFormRunner / useFormReader from glove-memory/tools |
| 收窄折叠后的工具接口 | 任意 use* 辅助工具上的 { tools: { allow, deny } };selectTools、ToolSelection from glove-memory/tools |
| 按路径限制资源访问 | withResourceAccess、ResourceAccessControl、getResourceAccessControl、ResourceAccessPolicy from glove-memory/resources |
| 将共享存储与私有存储合并为一个视图 | layerEntity / layerEpisodic / layerResources / layerContext、MemoryLayer from glove-memory/layered |
| 引用进程内适配器 | InMemoryEntityAdapter、InMemoryEpisodicAdapter、InMemoryResourcesAdapter、InMemoryContextAdapter、InMemoryFormAdapter from glove-memory/in-memory |
| 错误类 | MemoryError、MemoryNotFoundError、MemorySchemaError、MemoryQueryError、MemoryWriteError、MemoryToolSelectionError、MemoryLayerError、EpisodicMemoryError、ResourceFsError、ResourceAccessError、ContextError、FormError / FormConflictError / FormStaleError / FormBlockedError / FormDefinitionError from glove-memory/core |
完整类型签名请参阅 api-reference.md — glove-memory;实际示例(模式定义、委派给子 Agent 的读取者、管理者组合、上下文流、表单定义)请参阅 examples.md — Memory。
图像工作流(glove-image)
Agent 式图像生成。手写的 generate_image(prompt) 工具只能完成一次调用,而 glove-image 覆盖的是整个工作流:提示词由流水线构建,主体和设置是持久的,现有图像可以作为输入,结果带有溯源信息,并且每次模型调用都会计量。
适用场景
- 图像中反复出现相同的主体、风格或设置(角色艺术图、分镜、产品照片、品牌素材)。
- 提示词构建过程应当可检查、可组合,而不是一个模板字符串。
- 用户会提供图像,输出也会作为参考图像反馈回来。
- 你需要了解实际的生成成本。
只需要一个“给我画张图”的工具?直接使用 glove.fold(...) 仍然更简单。
mountImage — 标准入口
import {
mountImage, InMemoryImageAssetStore, InMemoryImageLibrary,
expandCharacters, expandScenes, styleDirective, llmEnhance, UsageMeter,
} from "glove-image";
import { openrouterImages } from "glove-image/openrouter";
const meter = new UsageMeter();
await mountImage(glove, {
adapter: openrouterImages(), // ImageModelAdapter (required)
assets: new InMemoryImageAssetStore(), // ImageAssetStore (required)
library: new InMemoryImageLibrary(), // ImageLibraryAdapter (required)
model: createAdapter({ provider: "openrouter", model: "openai/gpt-4o-mini", stream: false }),
pipeline: [expandCharacters(), expandScenes(), styleDirective("gouache"), llmEnhance()],
review: { vision: visionAdapter, rounds: 1, rubric: "..." }, // opt-in vision
usage: meter,
onUsage: (source, usage) => billing.record(source, usage),
curate: true, // default — false folds read-only library tools
candidates: 1, // default; clamped to capabilities.maxCandidates
requirePermission: false, // true gates generate/edit/regenerate
});
异步、不可链式调用,可在 build() 之前或之后调用(与 mountMcp / mountMesh 的约定相同)。它会验证流水线(增强器名称必须唯一——重复时会抛出异常),追加 fitToModel(),并折叠这些工具。
提示词流水线(核心思路)
生成绝不会将原始模型文本发送给图像模型。它会构建一个 PromptDraft,并按顺序运行增强器,每个阶段追加一个 TraceEntry:
interface PromptDraft {
intent: string; // the original ask — NEVER mutated
positive: string; // the working prompt
negative?: string;
refs: RefImage[];
params: GenerationParams; // { size?, seed?, candidates?, extra? }
requested: { characters: string[]; scene?: string }; // names from the call
characters: CharacterDef[]; // resolved by expandCharacters()
scene?: SceneDef; // resolved by expandScenes()
trace: TraceEntry[]; // { enhancer, note?, positive_after }
}
interface PromptEnhancer {
name: string; // must be unique across the pipeline
run(draft: PromptDraft, ctx: EnhancerContext): Promise<PromptDraft | void>; // void = keep mutated draft
}
interface EnhancerContext {
library: ImageLibraryReader;
assets: Pick<ImageAssetStore, "get" | "list">;
model?: ModelAdapter; // the mount's `model` slot
capabilities: ImageModelCapabilities;
note(message: string): void; // → this stage's trace entry
recordUsage(usage: Partial<ImageUsage>): void;
signal?: AbortSignal;
}
| 中间环节 | 行为 |
|---|---|
expandCharacters() | 原样拼接每个具名角色的 appearance,合并其 negative,并将其参考图像附加为 identity 参考图。找不到名称时,抛出 ImageCharacterNotFoundError,并指出当前可用的名称。 |
expandScenes() | 对场景的 setting 执行相同操作,并附加 style/composition 参考图。 |
styleDirective(text) | 追加固定的品牌风格说明。 |
negativeDefaults(list) | 合并常驻负面提示,不重复添加或覆盖单次调用的内容。 |
llmEnhance({ model?, instructions? }) | 执行一次 LLM 改写,并要求原样保留角色措辞。没有模型时跳过,并添加一条跟踪说明。报告其令牌用量。 |
fitToModel() | 终端阶段,始终追加。 如果模型没有负面提示槽位,则将 negative 合并到提示词中,格式为 Avoid: …;移除不支持的参考图角色;将参考图数量限制在 maxRefs 以内(优先保留 identity);调整 size 以符合要求;限制 candidates;移除不支持的 seed。每次调整都会添加一条跟踪说明。 |
未传入流水线时的默认值:[expandCharacters(), expandScenes()]。传入的流水线会替换中间阶段;无论如何,最后都会运行 fitToModel()。
角色与场景
interface CharacterDef {
name: string; // kebab-case library key
display_name?: string;
appearance: string; // ONE paragraph, spliced VERBATIM — write it prompt-ready
notes?: string; // non-visual; NEVER sent to the image model
negative?: string;
ref_images?: Array<{ asset: string; label?: string }>; // identity anchors
tags?: string[];
created_at: string; updated_at: string;
}
interface SceneDef {
name: string; display_name?: string;
setting: string; // location/era/palette/lighting/mood, spliced verbatim
negative?: string;
ref_images?: Array<{ asset: string; role: "style" | "composition"; label?: string }>;
tags?: string[]; created_at: string; updated_at: string;
}
一致性来自重复,而非记忆——措辞由资料库管理,并会在每次调用时重新拼接。将一次满意的生成结果提升为角色参考图(ref_images: [{ asset: "img_..." }]),就是“锁定此形象”的做法。
资源、沿袭关系与重新生成
interface Recipe {
kind: "generated" | "edited" | "assembled";
intent?: string; finalPrompt?: string; negative?: string;
params?: GenerationParams; adapter?: string;
characters?: string[]; scene?: string;
refs?: Array<{ asset: string; role: RefRole }>;
trace?: TraceEntry[];
parent?: string; // "edited": source asset
spec?: AssemblySpec; // "assembled"
usage?: ImageUsage; // what this asset cost
}
glove_image_regenerate({ asset, tweak? }) 会通过当前流水线重新执行 generated 配方(会应用之后对资料库的编辑),并将 tweak 追加到意图中。重新生成导入或编辑的内容会被拒绝,并返回明确的错误。
用量与成本跟踪
interface ImageUsage { requests: number; tokens_in: number; tokens_out: number; cost_usd?: number }
type UsageSource = "generate" | "edit" | "enhance" | "review" | "describe";
共有四种范围:每次调用(data.usage)、每个资源(Recipe.usage)、每个会话(UsageMeter.report() → { total, by_source },Agent 也可通过 glove_image_usage 读取),以及每个宿主(onUsage(source, usage))。适配器可通过在 ImageModelResult 中返回 usage 来报告用量;未报告用量的适配器也会计为 { requests: 1 }。OpenRouter 会报告实际美元费用(usage: { include: true })。
视觉能力(选择启用)
review: { vision, rounds?, rubric? }——任意具备视觉能力的 ModelAdapter。可实现两项功能:glove_image_describe 会增加 visual_description;当 rounds > 0 时,每次生成都会经过评审(PASS → 完成;FAIL → 将评审意见追加为修订备注,作为 review 阶段进行跟踪,再进行一轮)。默认关闭,因为每次查看一张图像约需 25k 输入令牌。
集成的工具
glove_image_generate · _edit · _regenerate · _import · _describe · _asset_list · _assemble · _usage · _character_save/_get/_list/_remove · _scene_save/_get/_list/_remove(写入操作由 curate 控制)。
面向模型的 data 包含资源 ID、尺寸、降级信息和用量;renderData 包含缩略图数据 URL。字节数据不会进入 data——无论会话涉及多少图像,上下文成本都保持不变。
ImageModelAdapter(自带适配器)
interface ImageModelCapabilities {
modes: Array<"generate" | "edit" | "variation">;
maxRefs: number; refRoles: RefRole[];
sizes: string[] | "flexible";
negativePrompt: boolean; seed: boolean; maxCandidates: number;
}
interface ImageModelAdapter {
name: string;
capabilities: ImageModelCapabilities;
generate(req: ImageGenerateRequest, signal?: AbortSignal): Promise<ImageModelResult>;
edit?(req: ImageEditRequest, signal?: AbortSignal): Promise<ImageModelResult>;
}
挂载层会在调用前解析参考图字节——适配器接收的是已物化的字节,不会接触存储,并可假定请求已符合模型能力范围(这由 fitToModel 负责)。项目提供 glove-image/openrouter 中的 openrouterImages({ apiKey?, model?, baseUrl?, referer?, title? })(使用原生 fetch,默认模型为 google/gemini-2.5-flash-image,支持生成、编辑和候选结果分发)。
快速参考 — 内容位置
| 需求 | 符号 |
|---|---|
| 挂载表面 | mountImage(glove, { adapter, assets, library, … }) |
| 模型契约 | ImageModelAdapter, ImageModelCapabilities |
| 参考模型适配器 | 来自 glove-image/openrouter 的 openrouterImages |
| 资源存储契约 | ImageAssetStore;参考实现 InMemoryImageAssetStore |
| 角色/场景存储 | ImageLibraryAdapter;参考实现 InMemoryImageLibrary |
| 编写过渡步骤 | 来自 glove-image/pipeline 的 PromptEnhancer |
| 内置过渡步骤 | expandCharacters, expandScenes, styleDirective, negativeDefaults, llmEnhance, fitToModel |
| 手动运行流水线 | createDraft, runPipeline, defaultPipeline |
| 费用核算 | ImageUsage, UsageMeter, addUsage, emptyUsage |
| 来源链 | ImageAsset.recipe 上的 Recipe |
| 组装 | AssemblySpec(需要可选的 sharp 对等依赖) |
| 字节辅助函数 | sniffImage, toDataUrl, fromDataUrl |
| 错误类 | ImageError, ImageAssetNotFoundError, ImageCharacterNotFoundError, ImageSceneNotFoundError, ImageCapabilityError |
Mesh 网络(glove-mesh)
glove-mesh 让多个 Glove Agent 能够相互通信——发送直接消息、广播和确认——并构建在现有的 glove-inbox 基础功能之上。除了一项小型运行时 API 增补外,该软件包在行为上对 glove-core 保持增量兼容:IGloveRunnable.store(一个 readonly 访问器,mountMesh 会读取它,以便直接写入已解析的收件箱项目)。Agent 循环语义没有变化。该软件包本身不提供身份验证;传输以及任何签名/验证均由使用方的 MeshAdapter 负责。
适用场景
- 同一进程(或不同进程/主机)中有两个或更多 Glove Agent 异步或并行运行,需要相互协调。
- 你希望 Agent 循环将同伴消息作为模型可读取的纯上下文呈现,而不必编写自定义订阅者/存储集成。
如果你只需要让一个 Agent 将工作委派给另一个隔离的子任务,请改用 defineSubAgent——子 Agent 嵌套运行于父 Agent 之下。Mesh 用于同级 Agent。
概念模型
每个 Agent 都拥有自己的 StoreAdapter 和收件箱。MeshAdapter 是网络的逐 Agent 视图(与 McpAdapter 的逐会话模式一致)。当 Agent A 调用 glove_mesh_send_message({ to: "b", content: ... }) 时,框架会将一个带有 status: "resolved" 和标签 mesh:from:a 的 InboxItem 写入 B 的存储。B 现有的 Agent 注入已解析收件箱项目的路径(glove-core 中的 Agent.injectResolvedInboxItems)会在 B 下一次调用 ask() 时,将其作为合成用户消息呈现——与已由外部解决的收件箱项目完全相同。
“共享收件箱”是概念上的:共享的是 Mesh;收件箱仍由各 Agent 独立持有。
mountMesh — 标准入口
调用 new Glove(...).build() 后:
import { mountMesh, MeshNetwork, InMemoryMeshAdapter } from "glove-mesh";
const network = new MeshNetwork(); // in-process bus; for distributed, BYO
await mountMesh(glove, {
adapter: new InMemoryMeshAdapter(network, "agent-a"),
identity: {
id: "agent-a",
name: "Agent A",
description: "Plans tasks for the team.",
capabilities: ["chat", "planning"],
},
});
其作用:
- 验证
glove.store是否实现了四种收件箱方法(getInboxItems、addInboxItem、updateInboxItem、getResolvedInboxItems)——否则抛出MeshStoreUnsupportedError。 - 等待
adapter.register(identity)完成,以便其他 Agent 能发现此 Agent。 - 调用一次
adapter.subscribe(handler)。该处理程序会将传入消息转换为已解析的收件箱项目(或在收到确认时解决待处理的阻塞项目)。 - 将四个工具合并到运行中的 Glove 上:
glove_mesh_send_message、glove_mesh_broadcast、glove_mesh_list_agents、glove_mesh_acknowledge。
返回 Promise<void>(不可链式调用)。这与 mountMcp 的异步设置约定一致。
四个工具
| 工具 | 输入 | 行为 |
|---|---|---|
glove_mesh_send_message | { to, content, in_reply_to?, blocking? } | 调用 adapter.send。阻塞模式会插入一个标记为 mesh:waiting:<msg_id> 的待处理阻塞收件箱条目,并在收到确认或回复时解决。 |
glove_mesh_broadcast | { content, blocking? } | 调用 adapter.broadcast。阻塞模式会在收到第一个确认时解决。 |
glove_mesh_list_agents | { filter?: { capability?, name_contains? } } | 调用 adapter.listAgents;进行筛选;排除自身。 |
glove_mesh_acknowledge | { message_id, note? } | 调用 adapter.acknowledge。用于轻量确认;如需实质性回复,请改用带有 in_reply_to 的 glove_mesh_send_message。 |
MeshAdapter 合约
每个 Agent 实现一个。由使用方提供 — 此软件包不会了解你的传输方式。
interface MeshAdapter {
identifier: string;
// Identity / registration
register(identity: AgentIdentity): Promise<void>;
unregister(): Promise<void>;
listAgents(): Promise<AgentIdentity[]>;
getAgent(id: string): Promise<AgentIdentity | null>;
// Outbound
send(message: MeshMessage): Promise<void>;
broadcast(message: Omit<MeshMessage, "to">): Promise<void>;
acknowledge(messageId: string, note?: string): Promise<void>;
// Inbound — framework registers ONE handler per agent
subscribe(handler: (msg: IncomingMeshMessage) => Promise<void>): () => void;
}
框架依赖的适配器保证:
send会在传输层接受消息时返回,而不是等接收方处理完消息。broadcast会在扇出时排除发送方。- 处理程序错误不得向外传播 — 记录错误并继续,以确保向其他 Agent 的扇出不受影响。
acknowledge会将kind: "ack"的IncomingMeshMessage路由回messageId的原始发送方。
参考实现 InMemoryMeshAdapter
适用于开发、测试和单主机设置。MeshNetwork 是共享总线;构造一次,并将同一个实例交给进程中的每个 InMemoryMeshAdapter。
import { MeshNetwork, InMemoryMeshAdapter } from "glove-mesh";
const net = new MeshNetwork();
const a = new InMemoryMeshAdapter(net, "agent-a");
const b = new InMemoryMeshAdapter(net, "agent-b");
MeshNetwork 会维护一个有界 LRU(默认 1024),记录 message_id → sender_id,以便 acknowledge 无需模型在每次确认时都传递发送方即可进行回传路由。
阻塞发送
| 工具调用 | 待处理条目? | 在何时解决 |
|---|---|---|
glove_mesh_send_message({ blocking: false }) | 否 | 不适用 |
glove_mesh_send_message({ blocking: true }) | 是 — 标记为 mesh:waiting:<msg_id> | 收到 ack_of === msg_id 的确认,或收到回复(带有 in_reply_to === msg_id 的 glove_mesh_send_message) |
glove_mesh_broadcast({ blocking: true }) | 是 | 收到任意对等方发出的第一个确认 |
glove_mesh_acknowledge(此 Agent 确认入站消息) | 否 | 不适用 — 工具本身 |
在每个轮次中,待处理的阻塞条目都会通过 Agent.buildPendingBlockingMessage 生成一条临时提醒,直到条目解决。收到确认或回复后,已解决的条目会通过标准的 [Inbox: N item(s) resolved] 注入显示。
回复即表示确认。 收到带有 in_reply_to: X 的直接入站消息会同时执行两件事:插入一条包含回复正文的新已解决收件箱条目,并解决 X 对应的待处理条目。这样接收方就少调用一次工具。
标签约定
Mesh 条目使用带命名空间的标签,以便使用方从收件箱历史记录中过滤掉 Mesh 流量:
| 标签前缀 | 方向 | 含义 |
|---|---|---|
mesh:from:<sender> | 入站 | 直接消息 |
mesh:broadcast:from:<sender> | 入站 | 广播 |
mesh:waiting:<msg_id> | 本地 | 出站发送的待处理阻塞条目 |
身份验证模型 — 没有身份验证
MeshMessage.from 是由发送方自行声明且未经验证的。如果需要经过身份验证的消息,请在调用 send/broadcast 前对消息签名,并在 subscribe 处理程序中进行验证。这与 McpAdapter.getAccessToken 的设计一致:身份验证由使用方负责。
快速参考 — 各项内容的位置
| 需求 | 符号 |
|---|---|
| 在 Agent 上挂载 Mesh | 来自 glove-mesh 的 mountMesh(glove, { adapter, identity }) |
| 适配器合约 | 来自 glove-mesh/core 的 MeshAdapter |
| 消息类型 | 来自 glove-mesh/core 的 AgentIdentity、MeshMessage、IncomingMeshMessage |
| 进程内总线 | 来自 glove-mesh/in-memory 的 MeshNetwork、InMemoryMeshAdapter |
| 单个工具构建器 | 来自 glove-mesh/tools 的 buildMeshSendTool、buildMeshBroadcastTool、buildMeshListAgentsTool、buildMeshAcknowledgeTool |
| 错误类 | 来自 glove-mesh/core 的 MeshError、MeshNotRegisteredError、MeshUnknownAgentError、MeshUnknownMessageError、MeshStoreUnsupportedError |
与 glove_post_to_inbox 的区别
glove_post_to_inbox—“我稍后会在对话外自行处理”(外部服务、webhook、cron)。glove_mesh_send_message—“我正在通过 mesh 与另一个 Glove Agent 通信”(点对点)。
两者都会写入同一个 StoreAdapter 收件箱接口;标签前缀可以区分它们。
限制(v1)
InMemoryMeshAdapter仅限进程内使用;重启会清除状态。实际传输由使用方负责。- 发送方表的 LRU 上限为 1024——对于非常旧的消息,确认尽力而为。
- 广播阻塞会在收到第一个确认时解除,而不是等所有对端确认。
- 不会新增
SubscriberEvent类型;四种工具的可观测性通过tool_use_result和收件箱状态写入实现。 - 没有群组/主题概念。广播会发送给每个已注册的 Agent。
Continuum(glove-continuum-signal)
基于子进程的运行时基底,负责监管 Glove Agent,类似 station-signal 监管后台作业。支持两种执行模式:
- 触发式(异步) — Agent 默认处于冷态。外部触发(
.trigger(input)、计划任务触发、收到 mesh 消息)会唤醒它们。它们会恢复持久化存储,运行一轮,然后返回冷态。每次唤醒都会启动一个新的子进程。 - 并发式(同步) — Agent 在长期运行的子进程中保持热态。运行器会让它们持续运行,并通过
runner.notify(name, input)内联推送通知;循环中途即可立即接收,无需等待启动。
该基底不是 Agent 间协议——那是 glove-mesh。Continuum 为 mesh 提供稳定的每 Agent 身份、支持收件箱的持久化存储,以及供热态 Agent 使用的长期运行子进程;mesh 完全运行在该子进程内,并使用使用方提供的 MeshAdapter 传输方式。
适用场景
- 在一次部署中运行多个长期运行的 Agent,每个 Agent 都有隔离的子进程,但由中心统一观测。
- 需要在多次唤醒之间保持状态的 Agent(触发式 Agent 的上下文连续性)。
- 从 HTTP 处理程序/cron/webhook 触发 Agent 工作并异步处理——类似后台作业,但作业本身是一个完整的 Glove Agent。
- 在同一主机上的 Agent 之间使用 mesh 通信,无需外部代理(可配合示例
FilesystemMeshAdapter使用)。
如果是在 Next.js 处理程序中运行单个进程内 Agent,则无需使用 continuum——继续使用 createChatHandler 即可。
agent() 构建器——按模式分叉
import { agent, z } from "glove-continuum-signal";
import { Glove, Displaymanager } from "glove-core";
import { createAdapter } from "glove-core/models/providers";
export const pizzaBaker = agent("pizza-baker")
.input(z.object({ orderId: z.string() }))
.output(z.object({ ready: z.boolean() }))
.triggered() // forks into TriggeredAgentBuilder
.timeout(60_000)
.retries(2)
.every("5m").withInput({ orderId: "tick" })
.env({ OVEN: "hot" })
.store((name) => new MyPersistentStore(`./agents/${name}.db`))
.onComplete(async (out, in_) => audit(out, in_))
.factory(async (ctx) =>
new Glove({
store: ctx.store ?? undefined,
model: createAdapter({ provider: "anthropic" }),
displayManager: new Displaymanager(),
systemPrompt: "You bake pizzas.",
compaction_config: { compaction_instructions: "..." },
})
.fold(checkOrderTool)
.build(ctx.store ?? undefined),
);
// Fire-and-forget. Returns a run id immediately.
const runId = await pizzaBaker.trigger({ orderId: "abc-123" });
.triggered() 返回 TriggeredAgentBuilder<TInput>——.retries()/.every()/.withInput() 仅在此处可用。.concurrent() 返回 ConcurrentAgentBuilder<TInput>——构建出的 Agent 除了 .trigger(input) 之外,还会有一个 .notify(input) 实例方法(两者都会将 kind: "notify" 的运行加入队列,并路由到热态子进程;确定对端处于热态时,notify() 是更清晰的名称)。对触发式 Agent 调用 .notify() 会产生类型错误,而不是运行时错误。
工厂上下文(AgentFactoryContext):
name— 已注册的 Agent 名称runId— 触发式模式下为每次唤醒分配的 ID;并发式工厂设置期间为"warmup"mode—"triggered" | "concurrent"store— 运行时通过.store(factory)创建的StoreAdapter(如果未调用.store(...),则为null)subscriber— 转发 IPC 的SubscriberAdapter,引导程序会在工厂返回后再次进行防御性挂接controls.emit({ type, data })— 向运行器的订阅者发送自定义事件,并将其封装为agent:event信封controls.signal— 在优雅停止/重启/终止失败时触发的AbortSignal;可用于卸载 mesh、关闭数据库连接池等
ContinuumRunner
import {
ContinuumRunner,
MemoryAdapter,
ConsoleSubscriber,
} from "glove-continuum-signal";
const runner = new ContinuumRunner({
agentsDir: "./agents", // auto-discover branded agents (recursive)
adapter: new MemoryAdapter(), // or your own ContinuumAdapter
subscribers: [new ConsoleSubscriber()],
pollIntervalMs: 1_000,
maxConcurrent: 5, // triggered-run budget
warmRestartPolicy: { maxRestarts: 5, backoffMs: 1_000 },
});
await runner.start();
// Triggered: spawn-per-wakeup. Returns a run id immediately.
const runId = await pizzaBaker.trigger({ orderId: "abc-123" });
const final = await runner.waitForRun(runId);
// Concurrent: routes to the warm subprocess inline.
const notifyId = await runner.notify("pizza-watcher", { event: "oven_ready" });
await runner.stop({ graceful: true, timeoutMs: 10_000 });
runner.notify(name, input) 是与运行器绑定的 concurrentAgent.notify(input) 等效方法——当你有运行器引用但没有 Agent 引用时很方便(例如,通过持有运行器的包装器调用)。
持久化存储
触发式 Agent 需要一个能够跨唤醒持续存在的 StoreAdapter,否则每次都会重置上下文。通过 .store(name => …) 进行配置——运行时会在每次启动时使用 Agent 的名称调用它,并通过 ctx.store 将生成的存储传递给工厂。对于未配置 .store(...) 的触发式 Agent,发现阶段会发出警告。并发 Agent 通常使用内存存储即可,因为它们的子进程会长期运行(不过,如果运行器可能重启,仍需要持久化存储)。
Mesh 集成
在工厂内为每个 Agent 挂载 mesh:
import { mountMesh } from "glove-mesh";
import { makeRedisMeshAdapter } from "./infra/mesh.js";
agent("pizza-watcher")
.input(z.object({ event: z.string() }))
.concurrent()
.store((name) => new MyInboxCapableStore(`./agents/${name}.db`))
.factory(async (ctx) => {
const glove = new Glove({ store: ctx.store ?? undefined, /* ... */ }).build();
await mountMesh(glove, {
adapter: makeRedisMeshAdapter(ctx.name),
identity: { id: ctx.name, name: ctx.name, description: "..." },
});
return glove;
});
mountMesh 要求存储实现收件箱方法(getInboxItems / addInboxItem / updateInboxItem / getResolvedInboxItems)。Glove 的默认 MemoryStore 已实现这些方法;自定义存储也必须实现。glove-mesh 中的 InMemoryMeshAdapter 仅适用于单进程;如需跨子进程 Agent 间传输,请选择真实的适配器(Redis、NATS、HTTP Webhook 等),或使用示例 glove-continuum-signal/tests/fixtures/fs-mesh-adapter.ts 中的 FilesystemMeshAdapter(通过原子临时文件加重命名进行写入,以约 100ms 的间隔轮询订阅,并针对每条消息查询发送方,以支持跨进程 acknowledge())。
适配器契约(ContinuumAdapter)
该契约与 station-signal 的 SignalQueueAdapter 类似,但有三处差异:所有位置都使用 agentName(而非 signalName),RunKind 包含 "notify",并且不再包含步骤(Glove 轮次本身就是工作单位;细粒度可观测性由转发的订阅者事件流提供,而非关系型 Step 行)。默认提供 MemoryAdapter;使用者可自行选择 SQLite、Postgres 等。HttpTriggerAdapter 通过 configure({ endpoint, apiKey }) 支持远程 .trigger()。
订阅者模型
ContinuumSubscriber 提供生命周期回调(onAgentDiscovered、onAgentSpawned、onAgentReady、onAgentTerminated、onAgentRestarted、onRunDispatched、onRunStarted、onRunCompleted、onRunFailed、onRunTimeout、onRunRetry、onRunCancelled、onRunSkipped、onRunRescheduled、onNotifyDelivered、onCompleteError、onLogOutput),以及一个统一的 onAgentEvent(envelope),用于将任意子进程发出的每个 Glove SubscriberEvent 转发到上游:
interface AgentEventEnvelope<T extends SubscriberEvent["type"]> {
agentName: string;
runId: string | null; // null for ambient warm-agent events between notifies
mode: AgentMode;
event_type: T;
data: SubscriberEventDataMap[T];
timestamp: string;
}
采用单一信封(而不是按类型分别设置回调),因为 Glove 的 SubscriberEvent 联合类型包含 14 种以上的类型,且会独立扩展。包装器可以轻松地按类型分发。
信任模型
- 发现阶段会通过
await import()加载注册的 Agent 文件,并在具有父进程环境变量的子进程中运行。agentsDir绝不能指向受用户影响的内容。 - 转发父进程环境变量前,会移除
NODE_OPTIONS、LD_PRELOAD、LD_LIBRARY_PATH和DYLD_INSERT_LIBRARIES,且 Agent 的.env({...})无法覆盖这些变量。 - 对于预热子进程,父进程会验证
notify:*信封中的runId是否属于发送方子进程(检查pendingNotifies的所有权),以防行为异常的预热子进程伪造其他 Agent 的运行完成事件。 - 预热子进程按名称拥有独立的重启额度(
warmRestartPolicy.maxRestarts,默认值为 5);在ready后稳定运行 60s,额度会重置。因此,长期运行的部署不会因偶发故障而永久失去预热 Agent。崩溃循环仍会耗尽额度,届时将停止重试。
与 station-signal 的区别
| 方面 | station-signal | glove-continuum-signal |
|---|---|---|
| 工作单元 | 无状态任务(运行处理程序或分步流水线) | 有状态的 Glove Agent 轮次 |
| 构建器终结方式 | .run(fn) / .step().build() | .factory(ctx => Glove) |
| 模式 | 仅按运行生成子进程 | 触发式(每次唤醒生成子进程)+ 并发式(常驻子进程 + 通知 IPC) |
| 持久化 | 适配器中的步骤记录 | 每个 Agent 的 StoreAdapter,通过 .store(name => …) 在唤醒期间延续 |
| 子进程会操作适配器吗? | 会(写入 Step 行) | 不会(父进程是唯一事实来源) |
| 可观测性 | 按类型设置的订阅者回调 | 生命周期回调 + 一个用于转发 Glove 事件的完整 onAgentEvent(envelope) |
| 单元间消息传递 | 不适用 | 委托给 glove-mesh(在工厂中挂载) |
限制(v1)
- 仅支持单运行器。多运行器常驻池分片以及周期性计划的分布式声明租约,将推迟到未来的包装器包中实现。
configure()是模块级单例;同一进程中的多个运行器会争用它。需要指定某个运行器的适配器时,请使用runner.notify()。- 常驻子进程中的通知若卡住,超时后只会使其自身运行失败,不会终止该子进程;后续通知会在引导程序的 Promise 链上排队。如果发现持续饥饿,请重启常驻 Agent。
- 通知取消尽力而为——父进程会将状态切换为
cancelled,但常驻子进程的 Promise 链仍会继续运行。对于对变更至关重要的工作,请提前规划。
快速参考 — 各项内容的位置
| 需求 | 符号 |
|---|---|
| 定义 Agent | agent("name").input(zod).triggered() 或 .concurrent(),然后调用 .factory(ctx => glove),来自 glove-continuum-signal |
| 运行 Agent | 使用 glove-continuum-signal 中的 new ContinuumRunner({ agentsDir, adapter, subscribers, ... }) |
| 向常驻 Agent 发送内容 | runner.notify(name, input) 或 concurrentAgent.notify(input) |
| 持久化契约 | glove-continuum-signal 中的 ContinuumAdapter |
| 默认进程内适配器 | glove-continuum-signal 中的 MemoryAdapter |
| 远程触发 | configure({ endpoint, apiKey }) + glove-continuum-signal 中的 HttpTriggerAdapter |
| 可观测性 | glove-continuum-signal 中的 ContinuumSubscriber、ConsoleSubscriber、AgentEventEnvelope |
| 品牌符号 | AGENT_BRAND = Symbol.for("glove-continuum-agent")、glove-continuum-signal 中的 isAgent(v) |
| 在工厂内部挂载 mesh | glove-mesh 中的 mountMesh(glove, { adapter, identity })(参见 Mesh 部分) |
Glovebox — 旧版沙箱运行时(已弃用)
Glovebox 已弃用,建议改用 Glove Foundry。以下内容仅用于维护或迁移现有的 Glovebox 部署;请勿在新的 Agent 应用中选择 Glovebox。新的运行时、工作区、VFS 和产物工作流请使用 glove-foundry 技能和 Foundry 工作环境。
旧版 Glovebox 会将构建好的 Glove Agent 打包为隔离且可通过网络访问的服务。开发者编写 Glove Agent,调用 glovebox.wrap(runnable, config),运行 glovebox build,即可获得可部署产物(Dockerfile + nixpacks.toml + esbuild 服务器包 + 清单 + 身份验证密钥)。部署后的服务器会为每个会话公开一个经过身份验证的 WebSocket 端点,并通过该单一套接字使用 id 对提示词进行多路复用。
适用场景
- 你有一个 Glove Agent,需要使用宿主进程无法安全(或跨平台)提供的系统工具——ffmpeg、pandoc、无头 Chromium、ImageMagick、qpdf、libreoffice——并且希望将这些工具隔离在容器中,而不是安装到每个部署目标上。
- 你想为 Glove Agent 提供可通过网络调用的接口,让 React 前端、Node 服务或其他 Agent 无需重新实现 Agent 循环即可调用它。
- 你需要稳定的输入/输出契约:客户端发送提示词和文件,收到最终消息和输出文件,无需关心中间调用了哪些适配器或工具。
对于专用聊天端点,请使用 glove-next 的 createChatHandler。对于 Agent 运行时、动态工作环境、持久化实例,或多个客户端和 Agent,请使用 Foundry。仅在迁移不属于当前任务时保留 Glovebox。
三个软件包
glovebox— 编写功能 +glovebox buildCLI。公共 API 位于packages/glovebox/src/index.ts(glovebox.wrap、重新导出的config、protocol、storage)。存储 DSL(rule.inline、rule.url、rule.localServer、rule.s3、composite)。线路协议类型(FileRef、ClientMessage、ServerMessage、Manifest、StoragePolicyEncoded)。glovebox-kit— 容器内运行时。startGlovebox(opts)位于packages/glovebox-kit/src/server.ts。自动注入两个技能(environment、workspace)、两个钩子(/output、/clear-workspace),并在启动时将静态环境块(由buildEnvironmentBlock构建)添加到 Agent 现有的系统提示词前。除 WS 升级端点外,还提供/health(公开)、/environment(需 Bearer 认证)、/files/:id(需 Bearer 认证)HTTP 路由。glovebox-client— 客户端 SDK。GloveboxClient.make({ endpoints })注册命名端点;client.box(name)返回延迟构造的Box。box.prompt(text, { files })返回一个PromptResult,其中包含可异步迭代的events/display、message/outputsPromise,以及read(name)辅助方法。
基础镜像
五个预构建基础镜像位于 docker/ 下,发布至 ghcr.io/porkytheblack/glovebox/<name>:<tag>(使用 GLOVEBOX_REGISTRY 环境变量可覆盖注册表)。标签固定在 packages/glovebox/src/build/dockerfile.ts 的 KNOWN_BASE_TAGS 中:
| 基础镜像 | 标签 | 内容 |
|---|---|---|
glovebox/base | 1.0 | Node 20、glovebox 用户(uid 10001)、/work + /input + /output + 位于 /opt/glovebox-prebuilt/node_modules 的预构建 better-sqlite3 |
glovebox/media | 1.4 | base + ffmpeg、imagemagick、sox、yt-dlp |
glovebox/docs | 1.2 | base + pandoc、qpdf、pdftk-java、ghostscript、无头 libreoffice |
glovebox/python | 1.3 | base + uv、numpy、pandas、pillow、scipy、matplotlib |
glovebox/browser | 1.1 | base + 带 Chromium 的 Playwright |
标准基础镜像会跳过生成的 Dockerfile 中的用户/目录结构设置,并使用 ln -sfn 将预构建的 better-sqlite3 链接到服务器包的 node_modules。自定义基础镜像则会根据生成的 package.json 执行常规的 npm install --omit=dev。
编写:glovebox.wrap
像往常一样构建 Glove Agent,然后将 GloveboxApp 作为默认导出:
// glovebox.ts
import { glovebox, rule, composite } from "glovebox-core"
import { agent } from "./my-agent" // your built IGloveRunnable
export default glovebox.wrap(agent, {
name: "pdf-extractor",
base: "glovebox/docs",
packages: { apt: ["poppler-utils"] },
storage: {
inputs: composite([rule.url(), rule.inline()]),
outputs: composite([
rule.inline({ below: "1MB" }),
rule.localServer({ ttl: "1h" }),
]),
},
env: {
ANTHROPIC_API_KEY: { required: true, secret: true },
},
limits: { memory: "2GB", timeout: "10m" },
})
默认值(来自 packages/glovebox/src/config.ts):
base:"glovebox/base"。fs:{ work: "/work" (rw), input: "/input" (ro), output: "/output" (rw) }—DEFAULT_FS。storage.inputs:DEFAULT_INPUTS_POLICY— 始终尝试url,失败时回退到inline。storage.outputs:DEFAULT_OUTPUTS_POLICY— 小于 1MB 时使用inline,否则使用 TTL 为 1h 的localServer。
存储规则的顺序很重要:composite([...]) 会保留调用方的顺序,而 pickAdapter 会按规则顺序查找,第一个匹配项优先。always: true 在任何位置都表示终止规则;default: true 是没有其他规则匹配时使用的回退规则。validateOutputsPolicy 会拒绝指向只读 url 适配器或缺少终止规则的输出策略——容器会在启动时快速失败。
构建:glovebox build
pnpm add -D glovebox
npx glovebox build ./glovebox.ts --out ./dist --name pdf-extractor
在 dist/ 下生成:
Dockerfile # FROM <resolved base>, optional apt/pip/npm, COPY server, CMD ["node", "index.js"]
nixpacks.toml # Same recipe for Railway / Render / nixpacks-aware platforms
glovebox.json # The Manifest (name, version, base, fs, env, limits, key_fingerprint, storage_policy, packages, protocol_version: 1)
glovebox.key # 32-byte hex random — the bearer token. KEEP SECRET. Re-runs reuse if present.
.env.example # Generated from `env` config — required vars first
server/
├── index.js # esbuild ESM bundle: glovebox-kit + glovebox + glove-core + your wrap module
├── package.json # only declares "better-sqlite3" — the one native dep
└── glovebox.json # Manifest copy (the runtime resolves it via `new URL("./glovebox.json", import.meta.url)`)
构建生成的合成 ESM 入口会从环境中读取 GLOVEBOX_KEY(必需)、GLOVEBOX_PORT(默认值为 8080)和 GLOVEBOX_PUBLIC_URL(可选),获取 wrap 模块的默认导出,并调用 startGlovebox(...)。在 esbuild 调用中,better-sqlite3 是唯一的 external:(server-bundle.ts 中的 NATIVE_EXTERNALS)。
运行时注入
在容器内,startGlovebox 会在启动时运行一次 applyInjections(runnable, config, getExfilState)(参见 packages/glovebox-kit/src/injection.ts)。这会添加:
environment技能 — 可由 Agent 调用;以 JSON 形式返回已解析的配置(name、version、base、fs、packages、limits)。可供 Agent 自行检查环境。workspace技能 — 可由 Agent 调用;列出每个 fs 挂载点(work、input、output)中的实时内容。/output钩子 —parseTokens指令:当 Agent 写入/output /tmp/some/extra/file.png时,会将绝对路径添加到每个请求对应的extraOutputs: Set<string>中。轮次完成后,该集合中的所有内容都会与 Agent 写入/output的内容一并上传。/clear-workspace钩子 — 对/work挂载点执行rm -rf。如果未配置work,则不执行任何操作。
buildEnvironmentBlock(config) 也会在启动时添加到现有系统提示词的前面,让 Agent 了解 /work、/input、/output、可用的 apt 软件包和限制,无需你手动配置。每个请求的输入文件列表不会放在环境块中 — Agent 会按需调用 workspace 技能来读取 /input。
线协议结构
每个会话使用一个 WebSocket,并在升级时通过 Authorization: Bearer <key> 进行身份验证。提示词通过 id 多路复用。在 v1 中,服务端会按顺序处理会话内的提示词(server.ts 中的 promptChain.then(...))— Glove 的 PromptMachine + Context 不支持并发调用。该协议采用多路复用结构,因为 v2 将取消这一限制。
消息类型定义在 packages/glovebox/src/protocol.ts 中:
- 客户端 → 服务端:
prompt、abort、display_resolve、display_reject、ping。 - 服务端 → 客户端:
event(与SubscriberEvent一一对应)、display_push/display_clear(通过attachDisplayBridge将 Glove 的显示堆栈桥接到线上)、complete(最终助手消息 + 输出映射)、error、pong。
FileRef 是文件在任一方向跨越边界时使用的线上结构:
type FileRef =
| { kind: "inline"; name; mime; data } // base64
| { kind: "url"; name; mime?; url; headers? }
| { kind: "server"; name; mime; size; id; url } // /files/:id on the same server
| { kind: "s3"; name; mime?; bucket; key; region? }
| { kind: "gcs"; name; mime?; bucket; object } // v2-deferred adapter
服务端会通过 pickAdapter(policy, { size }, registry) 为每个输出选择适配器。输入则根据 ref 的 kind,通过 pickAdapterForRef(ref, registry) 读取 — 服务端默认始终包含 inline、url 和 localServer;只有通过 adapters 注册适配器后,s3 才能解析(见下文)。
身份验证模型
仅支持 Bearer;每次部署使用一个密钥。构建 CLI 会将一个 32 字节的十六进制密钥写入 dist/glovebox.key,并将其 SHA-256 指纹存入清单。启动时,verifyAgainstManifest 会检查已配置的 GLOVEBOX_KEY 是否与清单中的指纹匹配,以及提供的 bearer 是否与已配置的密钥匹配 — verifyBearer 会通过 timingSafeEqual 执行恒定时间比较。清单中的指纹不会泄露任何信息;验证仍然需要原始密钥。
同一个密钥用于验证 WS 升级、/environment 和 /files/:id。JWT 和每会话令牌推迟到 V2。
通过 adapters 导出自定义存储适配器
wrap 模块可以在默认导出之外导出一个 adapters 函数(或值)。合成构建入口会等待该函数执行,并将结果传入 startGlovebox({ adapters })。适配器会按名称合并到默认注册表中,因此 s3 会成为策略可以指定的真实适配器:
// glovebox.ts
import { glovebox, rule, composite } from "glovebox-core"
import { S3Storage } from "glovebox-kit"
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
import { agent } from "./my-agent"
const s3 = new S3Client({ region: process.env.AWS_REGION })
export const adapters = () => ({
s3: new S3Storage({
bucket: process.env.OUTPUTS_BUCKET!,
region: process.env.AWS_REGION,
uploadObject: async ({ bucket, key, body, contentType }) => {
await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType }))
},
downloadObject: async ({ bucket, key }) => {
const out = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
return new Uint8Array(await out.Body!.transformToByteArray())
},
}),
})
export default glovebox.wrap(agent, {
base: "glovebox/media",
storage: {
outputs: composite([
rule.inline({ below: "1MB" }),
rule.s3({ bucket: process.env.OUTPUTS_BUCKET! }),
]),
},
})
该套件将 S3Storage 作为“延迟”适配器提供——运行时镜像中没有具体的 SDK 依赖。你需要从自己的代码库传入 uploadObject / downloadObject thunk。如果 outputs 策略指定了 s3,但未注册适配器,该套件会在启动时抛出错误(每个生效的策略都会运行 validateOutputsPolicy,包括每个请求的覆盖策略)。
部署
该构件与平台无关。两种常见方式:
- Docker — 执行
docker build -t my-app dist/,然后使用-p 8080:8080 -e GLOVEBOX_KEY=$(cat dist/glovebox.key) -e GLOVEBOX_PUBLIC_URL=https://my-app.example.com my-app运行。设置.env.example中的所有必需环境变量。 - Railway / Render / nixpacks — 将
dist/推送到仓库;平台会读取nixpacks.toml。在平台 UI 中设置GLOVEBOX_KEY和所有必需环境变量;GLOVEBOX_PUBLIC_URL应设为部署的公开 URL,这样客户端才能访问server类型的 FileRef。
GLOVEBOX_PORT 默认为 8080;Dockerfile 中通过 EXPOSE 和 ENV 设置了该变量。GLOVEBOX_PUBLIC_URL 默认为 http://localhost:<port>——本地使用没问题,但容器外的客户端无法访问。
从客户端调用
import { GloveboxClient } from "glovebox-client"
const client = GloveboxClient.make({
endpoints: {
pdf: { url: "wss://pdf.example.com/", key: process.env.GLOVEBOX_PDF_KEY! },
},
})
const box = client.box("pdf")
const env = await box.environment() // Bearer-auth'd GET /environment, cached after first call
console.log(env.base, env.packages.apt)
const result = box.prompt("extract tables from invoice.pdf", {
files: { "invoice.pdf": { mime: "application/pdf", bytes: pdfBytes } },
})
for await (const event of result.events) {
if (event.event_type === "text_delta") {
process.stdout.write((event.data as { text: string }).text)
}
}
const message = await result.message
const outputs = await result.outputs // Record<string, FileRef>
const csvBytes = await result.read("tables.csv") // routes via ClientStorage based on FileRef.kind
PromptResult 将 events 和 display 都作为异步可迭代对象公开。显示事件的作用域是会话(而非请求),因此会分发到每个活动提示词的显示流。result.resolve(slot_id, value) / result.reject(slot_id, error) 会将显示答案发回;result.abort() 会向上游发送 { type: "abort", id }。
DefaultClientStorage 仅处理 inline 和 url(以及通过 Bearer 身份验证 HTTP 处理的 server)——如果你的输入需要先存入 S3,请将自定义 ClientStorage 传给 GloveboxClient.make({ storage })。
调试
GET /health无需身份验证,会返回{ ok: true, name, version }。可用于存活探针。GET /environment携带Authorization: Bearer <key>时,会返回清单中的name、version、base、fs、packages、limits、protocol_version。客户端 SDK 会在首次调用后缓存这些信息。如果box.environment()返回 401,说明密钥有误;如果它返回的base与预期不同,则说明部署使用的是过期清单构建的。- 清单指纹不匹配 — 启动失败,并提示
Configured GLOVEBOX_KEY does not match the manifest fingerprint。可能是密钥轮换后未重新构建,也可能是你指向了错误的密钥文件。 Outputs policy references unregistered adapter: s3—validateOutputsPolicy拒绝了该策略,因为 wrap 模块的adapters导出未提供s3条目。请添加适配器,或从 outputs 中移除rule.s3(...)。- 完成消息为空 —
processRequest返回的结果中,最终消息没有text。请检查 Agent 的正常完成路径——该套件只会原样读取result.messages[last].text(或result.text)。 - 提示词卡住 — v1 通过
promptChain.then(...)按会话串行处理提示词。如果某个提示词挂起(例如,等待pushAndWait解析器),同一会话中的所有后续提示词都会一并等待。请确保客户端发送display_resolve,或关闭 WS 以清除链。
V2 延后事项
v1 工具包和协议的设计范围刻意保持狭窄。请根据以下限制进行规划:
- 多路复用执行 — 线路支持多路复用,但服务端不支持。不要在同一会话中流水线发送多个提示词并期待并行执行。
- JWT 身份验证 — 每次部署使用一个共享的 Bearer 密钥。每用户 JWT / 作用域令牌已延后。
- 热重载 — 不支持。每次更改封装配置后都需要重新构建并重新部署。
- GCS / Azure 适配器 — 仅支持
inline、url、localServer,以及(通过你的adapters导出)s3。FileRef中存在gcs,但如果未注册适配器,运行时会抛出错误。 - 按基础工具包预注册的子 Agent —
@transcoder(媒体)、@pdfwright(文档)、@analyst(python)、@scraper(浏览器)将在 v2 中加入;在 v1 中,如果需要这些功能,请自行连接。
如需完整类型签名,请参阅 api-reference.md — glovebox / glovebox-kit / glovebox-client;如需完整示例,请参阅 examples.md — Pattern: Glovebox PDF Extractor。
Scratchpad(glove-scratchpad)
用于大模型工具调用的数据库模拟器。 无需将数十个工具定义加载到上下文窗口,只需将 Agent 的能力作为关系数据库公开,供其通过单个 execute_sql 工具进行查询。无论模型规模如何,它都已能熟练使用 SQL,因此可以通过编写查询来发现、调用和组合能力。资源会成为表——资源是一种实体 / 数据类型(github_pr、linear_issue、emails、time、images),其 CRUD 动词会映射到(可能不同的)底层工具。
从本质上说,它是一个 SQL 解释器:每条语句都会在任何工具运行前进行解析和检查。这让你无需额外付出就能获得发现能力(information_schema)、组合能力(JOIN / INSERT … SELECT)、预览能力(EXPLAIN、事务)以及真正的安全边界(可拒绝语法树)——因为数据库数十年前就已解决了这些问题。
适用场景
- Agent 拥有许多能力,而加载每个工具定义会让上下文膨胀。一个
execute_sql工具即可取代整个目录;模型通过information_schema发现能力。 - 你想在一条语句中跨服务组合操作(
INSERT INTO notion_page SELECT … FROM github_pr WHERE merged),避免中间行往返传回上下文。 - 你想使用
BEGIN … COMMIT暂存并预览外发操作(需审批的写入),或使用ROLLBACK执行试运行。 - 你正在连接规模庞大的 MCP 集群,并希望以统一、可查询的形式访问它们(
glove-scratchpad/mcp)。
如果你只需要少量一方工具,直接使用 glove.fold(...) 会更简单。Scratchpad 在工具目录庞大或你需要组合 / 预览能力时才更有价值。
快速入门
import { Database, resourceFromTool, defineResource, mountDatabase } from "glove-scratchpad";
import { z } from "zod";
const db = await Database.create({ policy: { writes: true } }); // writes off by default
// A read-only tool → a one-row `time` table. Columns come from a Zod schema.
db.register(resourceFromTool(getTimeTool, {
name: "time", volatility: "stable",
schema: z.object({ now: z.date(), tomorrow: z.string() }),
}));
// A search tool → a volatile `web` table; the tool's required `query` input
// becomes a required-key column pushed down through WHERE.
db.register(resourceFromTool(searchTool, {
name: "web", volatility: "volatile",
schema: z.object({ title: z.string(), url: z.string() }),
}));
mountDatabase(agent, { db }); // folds execute_sql (+ explain_sql) and primes the prompt
之后,模型完全通过 SQL 工作:SELECT table_name FROM information_schema.tables、SELECT title, url FROM web WHERE query = 'sql engines' 等。
将资源用作表(以 Zod 为先)
资源是一个包含列和任意子集 CRUD 操作的实体,每种操作都可独立接入。使用 Zod schema 定义资源,一个对象就是列以及端到端的行类型——该 schema 会流入每个解析器,因此 select 返回这种类型的行,insert 接收这种类型的行,update 的 set 是部分类型,而 bindings.one("col") 会自动补全 schema 的列名。列、键或写入负载中的拼写错误都会导致编译错误,而不是悄无声息的运行时错误。
import { z } from "zod";
const githubPr = defineResource({
name: "github_pr",
volatility: "stable",
schema: z.object({
number: z.number().int().describe("PR number"), // an API argument (see keys)
title: z.string(),
merged: z.boolean(),
}),
keys: ["number"], // required WHERE-pushdown key(s), typed to the schema
select: (b) => listPrs({ number: b.one("number") }), // SELECT → a list/get tool (b.one autocompletes columns)
insert: (rows) => createPr(rows[0]), // INSERT → a create tool (rows typed to the schema)
update: (set, b) => updatePr(b.one("number"), set), // UPDATE → an update tool (set: Partial<row>)
delete: (b) => closePr(b.one("number")), // DELETE → a close tool
});
每种操作都是可选的,并且可以独立接入:只读的 time 只有 select;emails(发送)资源只有 insert;images 生成器是 select 形状但不稳定的关系函数(SELECT url FROM images WHERE prompt = '…'——prompt 是参数)。操作是否存在决定了功能是否可用:对只写资源执行 SELECT,或写入没有写入器的资源,都会明确报错。必需键列会从下推的 WHERE 自动填入,因此 select 可以省略这些列。
Zod → Postgres 类型映射(通过独立导出的 columnsFromZod 实现):
| Zod | Postgres 列类型 |
|---|---|
z.number().int()、z.bigint() | bigint |
z.number() | double precision |
z.boolean() | boolean |
z.string() | text |
z.date()、z.iso.datetime() | timestamptz |
z.object(...)、z.array(...)、无法表示的类型 | jsonb(可通过 -> / ->> 访问) |
任意字段 .meta({ pgType: "…" }) | 对应的确切类型(覆盖优先) |
字段上的 .describe(...) 会成为列描述——作者可在此填写枚举/允许值提示(status: unresolved | resolved | ignored),模型会通过 information_schema 和预加载的目录读取这些提示。建议使用 schema;如果你更愿意手动编写 pg 类型,也仍可使用原始 columns: [{ name, type, requiredKey? }] 列表。
对于简单的单操作场景,resourceFromTool(tool, spec) 是一种便捷方式(将一个工具转换为单操作资源)。它会从工具自身的输入 schema 推导必需键列(必需的 Zod 字段或 jsonSchema.required 条目),并通过 schema(或 columns)获取输出列;op 用于选择操作(默认为 "select")。不稳定的 SELECT 必须声明其列(无法根据首次返回的零行推断出稳定的 schema)。
易变性
每个资源都声明 Postgres 的 immutable | stable | volatile 属性。无论如何,解释器在每次 execute 中对一个资源只解析一次(因此有副作用的读取不会被规划器调用 N 次);易变性还会影响缓存:
- immutable — 在数据库的整个生命周期内缓存(纯查找)。
- stable — 在一次
execute中缓存(在单次执行期间保持稳定的读取,例如time)。 - volatile — 每条语句都会重新解析,绝不缓存(有副作用/非确定性的读取以及所有写入)。
查询的运行方式(预解析)
glove-sql 是同步的;资源则是异步且有副作用的,因此 Database.execute 无法在引擎内部接入解析流程——它会预解析:解析 → 安全门控(语句类型白名单,默认只读,拒绝 CREATE/DROP,多语句仅允许作为 BEGIN … COMMIT 脚本)→ 收集所有被引用的关系 → 将 WHERE/JOIN-ON 等式下推为参数(缺少必需键时会明确报错)→ 每个资源只解析一次 → 将其行物化到引擎中 → 运行此时已同步的查询 → 拆除临时表。预先解析一次正是易变性保证得以成立的原因。
读己之写:policy.readYourWrites(默认值为 true)会将本会话中已触发的写入合并到对同一张表的后续读取中,因此即使上游实时视图尚未更新,你 INSERT 的行也会出现在后续 SELECT 中。
发现、事务、EXPLAIN
- 通过
information_schema发现资源。 无需单独步骤——资源会通过目录回调公布在information_schema.tables/.columns中,因此 Agent 进入陌生数据库后,可以列出表、检查列,并自行了解可用功能。 - 事务 = 预览与暂存。 对资源执行写入会调用有副作用的工具。在
BEGIN … COMMIT内,写入会被暂存而非执行——记录确切的解析器和参数。db.preview()(以及结果中的staged字段)提供审批界面;COMMIT按顺序执行这些操作;ROLLBACK则丢弃它们——实现真正的试运行。事务外的单次写入会立即执行。除非创建Database时设置policy: { writes: true },否则写入功能默认关闭。 - EXPLAIN(
db.explain(sql)、explain_sql工具,或通过execute_sql执行EXPLAIN <stmt>)只运行预处理阶段——不会调用解析器——并报告语句将访问哪些资源、每个资源的易变性、读写权限以及解析后的参数。解释generate_image查询不会产生任何费用。
MCP 服务 → 表(glove-scratchpad/mcp)
大多数 MCP 工具都是针对某种资源类型执行 CRUD 操作,因此可以将服务拆分为资源,并为每个资源创建一张表。glove-mcp 是可选的对等依赖。若要避免工具出现在可用界面中,可在 connectMcp 上设置 excludeTools / filterTools——mcpResources / mountMcpDatabase 会直接桥接 conn.listTools(),因此被排除的工具不会成为表(与 mountMcp 使用相同的配置项)。更精细的逐桥接跳过方式是使用 table(tool) → null 钩子。
import { connectMcp } from "glove-mcp";
import { mountMcpDatabase } from "glove-scratchpad/mcp";
const conn = await connectMcp({ namespace: "github", url, excludeTools: ["delete_repository"] });
await mountMcpDatabase(db, conn, {
table: (t) => t.name === "list_pull_requests"
? { name: "github_pr", op: "select", volatility: "stable",
schema: z.object({ title: z.string(), merged: z.boolean() }),
rows: (d) => JSON.parse(d as string) }
: null, // skip the rest, or map them too
});
// → INSERT INTO linear_issue SELECT … FROM github_pr WHERE merged composes two servers in one statement.
只读工具(readOnlyHint)默认映射为 select 资源;其他工具默认映射为易变的 insert 资源。MCP 结果很少包含规范的列清单,因此可通过 table(tool) 声明 schema/columns(以及 rows 提取器),让服务中的数据真正可查询,而不只是使用默认的单个 result 列。
后端
数据操作界面采用定义明确的 Postgres 子集;后端可以替换(ScratchpadBackend)。
glove-sql(默认)——零依赖的纯 JS Postgres 子集引擎:连接、GROUP BY/HAVING、CTE、集合操作、相关子查询、窗口函数、jsonb访问、标量函数、information_schema、INSERT … SELECT、UPDATE。对于子集之外的操作,会抛出明确错误,而不会给出错误答案。PgliteBackend(glove-scratchpad/pglite)——用于完整方言的嵌入式 Postgres(WASM)。@electric-sql/pglite是可选的对等依赖。- 自带后端——基于真实的 Postgres、SQLite 或远程服务实现
ScratchpadBackend。
快速参考——各项功能的位置
| 需求 | 符号 |
|---|---|
| 创建解释器 | 来自 glove-scratchpad 的 Database.create({ policy?, backend?, actor? }) |
| 定义资源(优先使用 Zod) | defineResource({ name, schema, keys?, volatility, select?, insert?, update?, delete? }) |
| 定义资源(显式指定列) | defineResource({ name, columns, volatility, … }) |
| 一个工具 → 一个资源 | resourceFromTool(tool, { name, volatility, schema | columns, op? }) |
| 将 Zod 对象转换为列(独立使用) | columnsFromZod(schema, keys?) |
| 在解析器中使用类型化的下推参数 | TypedBindings<Row>(b.one("col") 提供自动补全) |
| 执行 / 预览 SQL | db.execute(sql, opts) / db.explain(sql) / db.preview() |
| 整合单个 Agent 工具与初始提示 | mountDatabase(glove, { db, prime?, explain?, allowWrites? }) |
| MCP 服务 → 表 | 来自 glove-scratchpad/mcp 的 mcpResources / mountMcpDatabase |
| 完整 Postgres 方言后端 | 来自 glove-scratchpad/pglite 的 PgliteBackend |
| 自带后端的接口约定 | ScratchpadBackend |
如需无需 API 密钥即可运行的演示,请参阅 examples/scratchpad-agent(pnpm scratchpad:db)。
显示栈模式
pushAndForget — 显示结果(非阻塞)
async do(input, display) {
const data = await fetchData(input);
await display.pushAndForget({ input: data }); // Shows UI, tool continues
return { status: "success", data: "Displayed results", renderData: data };
},
render({ data }) {
return <Card>{data.title}</Card>;
},
renderResult({ data }) {
return <Card>{(data as any).title}</Card>; // Same card from history
},
pushAndWait — 收集用户输入(阻塞)
async do(input, display) {
const confirmed = await display.pushAndWait({ input }); // Pauses until user responds
return {
status: "success",
data: confirmed ? "Confirmed" : "Cancelled",
renderData: { confirmed },
};
},
render({ data, resolve }) {
return (
<div>
<p>{data.message}</p>
<button onClick={() => resolve(true)}>Yes</button>
<button onClick={() => resolve(false)}>No</button>
</div>
);
},
renderResult({ data }) {
const { confirmed } = data as { confirmed: boolean };
return <div>{confirmed ? "Confirmed" : "Cancelled"}</div>;
},
显示策略
| 策略 | 行为 | 适用场景 |
|---|---|---|
"stay"(默认) | 插槽始终可见 | 信息卡片、结果 |
"hide-on-complete" | 插槽解析后隐藏 | 表单、确认、选择器 |
"hide-on-new" | 同一工具出现更新的插槽时隐藏 | 购物车摘要、状态面板 |
SlotRenderProps
| 属性 | 类型 | 描述 |
|---|---|---|
data | T | 传递给 pushAndWait/pushAndForget 的输入 |
resolve | (value: unknown) => void | 解析插槽。对于 pushAndWait,值会返回给 do。对于 pushAndForget,使用 resolve() 或 removeSlot(id) 关闭插槽。 |
reject | (reason?: string) => void | 拒绝插槽。对于 pushAndWait,这会导致 Promise 被拒绝。用于取消流程。 |
工具定义
defineTool(推荐用于带 UI 的工具)
import { defineTool } from "glove-react";
const tool = defineTool({
name: string,
description: string,
inputSchema: z.ZodType, // Zod schema for tool input
displayPropsSchema?: z.ZodType, // Zod schema for display props (recommended for tools with UI)
resolveSchema?: z.ZodType, // Zod schema for resolve value (omit for pushAndForget-only)
displayStrategy?: SlotDisplayStrategy,
requiresPermission?: boolean | ((input: z.infer<I>) => boolean), // function form gates per-input (e.g. only writes, not reads)
unAbortable?: boolean, // Tool runs to completion even if abort signal fires (e.g. voice barge-in)
do(input, display): Promise<ToolResultData>, // display is TypedDisplay<D, R>
render?({ props, resolve, reject }): ReactNode,
renderResult?({ data, output, status }): ReactNode,
});
要点:
do()应返回{ status, data, renderData }—data会传给模型,renderData仅保留在客户端render()会接收有类型约束的props(与 displayPropsSchema 匹配)和有类型约束的resolve(与 resolveSchema 匹配)renderResult()会接收renderData,用于显示历史记录中的只读视图displayPropsSchema是可选的,但建议提供 — 不需要显示内容的工具应使用原始ToolConfig
ToolConfig(适用于不带 UI 或需要手动控制的工具)
interface ToolConfig<I = any> {
name: string;
description: string;
inputSchema?: z.ZodType<I>; // Optional now — tools may use jsonSchema instead
jsonSchema?: Record<string, unknown>; // Raw JSON Schema alternative (used by MCP-bridged tools)
do: (input: I, display: ToolDisplay) => Promise<ToolResultData>;
render?: (props: SlotRenderProps) => ReactNode;
renderResult?: (props: ToolResultRenderProps) => ReactNode;
displayStrategy?: SlotDisplayStrategy;
requiresPermission?: boolean | ((input: I) => boolean); // function form gates per-input
unAbortable?: boolean;
}
jsonSchema 与 inputSchema: 两者只能传入一个。inputSchema(Zod)会在运行 do() 前进行本地验证。jsonSchema(原始 JSON Schema)会原样转发给模型,执行器会跳过 Zod 验证 — 真实数据源位于其他地方。bridgeMcpTool 会使用此方式,因为 MCP 服务定义了该架构;但在封装任何外部工具目录时,你也可以直接使用它。
build() 之后的 glove.fold
在任何时候,都可以对 IGloveRunnable 调用 fold(),包括 build() 之后。发现子 Agent 的 activate 工具依赖此功能 — 它会在对话过程中折入新桥接的 MCP 工具,使这些工具在下一轮可用。这对任何“动态注册工具”的模式都很有用。
const agent = new Glove({...}).build();
// ...later, mid-conversation:
agent.fold({ name: "new_tool", description: "...", inputSchema: z.object({}), async do() { ... } });
do(input, display, glove, signal?) — 第三个和第四个参数
工具的 do 函数会将运行中的 IGloveRunnable 作为第三个参数接收,并将当前请求的 AbortSignal 作为可选的第四个参数接收。glove 参数让 discovermcp 子 Agent 的 activate 工具能够折入桥接的 MCP 工具,使其加入主 Agent,并继承主 Agent 的模型和 displayManager。signal 参数应传递给耗时较长的内部工作(嵌套 Agent 运行、fetch),以便中止操作能够传播;忽略它的工具仍可免费获得执行器可中止 Promise 的展开机制,而标记为 unAbortable: true 的工具应完全忽略 signal。大多数工具会忽略这两个参数。
ToolResultData
interface ToolResultData {
status: "success" | "error";
data: unknown; // Sent to the AI model
message?: string; // Error message (for status: "error")
renderData?: unknown; // Client-only — NOT sent to model, used by renderResult
summary?: string; // Populated by the Executor from tool.generateSummary; swapped in for data in older context when enableToolResultSummary is on
generateSummaryArgs?: unknown; // Opaque payload do() returns to drive the tool's generateSummary handler
}
重要: 模型适配器会在发送给 AI 之前明确剥离 renderData。因此,将敏感的仅客户端数据(例如电子邮件地址、UI 状态)存储在 renderData 中是安全的。
工具结果摘要(可选)
针对会使上下文膨胀的工具负载(文件读取、网页抓取、大型查询结果)进行 Token 效率优化。默认关闭;在 GloveConfig 上设置 enableToolResultSummary: true 以启用,并为每个要压缩的工具添加 generateToolSummary。
const agent = new Glove({
store,
model: createAdapter({ provider: "anthropic" }),
displayManager: new Displaymanager(),
systemPrompt: "...",
compaction_config: { compaction_instructions: "Summarize so far." },
enableToolResultSummary: true, // turn on the pruner
})
.fold({
name: "read_file",
description: "Read a slice of a file.",
inputSchema: z.object({ path: z.string(), from: z.number().optional(), to: z.number().optional() }),
async do(input) {
const slice = await readSlice(input);
return {
status: "success",
data: slice,
generateSummaryArgs: { path: input.path, from: input.from, to: input.to, lineCount: slice.split("\n").length },
};
},
async generateToolSummary(args) {
const { path, from, to, lineCount } = args as any;
const range = from != null || to != null ? ` lines ${from ?? 1}-${to ?? "EOF"}` : "";
return `Read ${path}${range} (${lineCount} lines).`;
},
})
.build();
工作原理:
do()返回generateSummaryArgs——摘要处理器所需的任意参数(路径 + 行范围、URL、查询、行数)。- 执行器在
do()完成后调用generateToolSummary(args),并将结果存储在ToolResultData.summary中。结果中同时包含data和summary。 PromptMachine.summarizeOlderToolResults在每次调用模型之前运行(当enableToolResultSummary: true时):找到最新的非工具用户消息,并将该索引及之前每个工具结果中的data替换为summary。当前轮次的工具结果不受影响。
存储始终同时保留 data 和 summary。只有传递给模型适配器的消息会被改写——对话记录渲染器、历史快照和分析仍会看到完整记录。
没有 generateToolSummary 的工具,或未提供 generateSummaryArgs 的调用,其 summary 会保持未设置,清理器也不会处理它们。只对部分工具进行检测的工具目录也能正常工作。
可与压缩配合使用:工具摘要会延后 Observer 需要压缩的时机;当经过检测的上下文超过 CONTEXT_COMPACTION_LIMIT 时,仍会触发压缩。
<Render> 组件
用于替代手动时间线渲染的无头渲染组件:
import { Render } from "glove-react";
<Render
glove={gloveHandle} // return value of useGlove()
strategy="interleaved" // "interleaved" | "slots-before" | "slots-after" | "slots-only"
renderMessage={({ entry, index, isLast }) => ...}
renderToolStatus={({ entry, index, hasSlot }) => ...}
renderStreaming={({ text }) => ...}
renderInput={({ send, busy, abort }) => ...}
renderSlotContainer={({ slots, renderSlot }) => ...}
as="div" // wrapper element
className="chat"
/>
功能:
- 根据
displayStrategy自动控制插槽可见性 - 自动为已完成且带有
renderData的工具渲染renderResult - 交错显示:插槽会内联显示在对应工具调用旁边
- 为所有渲染属性提供合理的默认值
GloveHandle 接口
<Render> 使用的接口,由 useGlove() 返回:
interface GloveHandle {
timeline: TimelineEntry[];
streamingText: string;
busy: boolean;
sessionReady: boolean;
sessionId: string;
slots: EnhancedSlot[];
sendMessage: (text: string, images?: { data: string; media_type: string }[]) => void;
abort: () => void;
renderSlot: (slot: EnhancedSlot) => ReactNode;
renderToolResult: (entry: ToolEntry) => ReactNode;
resolveSlot: (slotId: string, value: unknown) => void;
rejectSlot: (slotId: string, reason?: string) => void;
}
useGlove Hook 返回值
| 属性 | 类型 | 描述 |
|---|---|---|
timeline | TimelineEntry[] | 消息 + 工具调用 |
streamingText | string | 当前流式传输缓冲区 |
busy | boolean | Agent 正在处理 |
sessionReady | boolean | 异步 getSessionId 解析期间为 false;未配置时始终为 true |
sessionId | string | 已解析的会话 ID(未配置时自动生成 glove_<uuid> ——零配置会话) |
newConversation(id?) | Promise<string> | 原地开始新对话:生成一个 ID(参数 → GloveClient.createSessionId → 自动生成),中止正在进行的任务,重置时间线,并重建存储和 Agent。无需重新挂载。 |
switchConversation(id) | void | 原地切换到现有会话——替换存储并重新加载时间线。sessionId 配置属性也是响应式的(更改后会执行相同操作)。 |
isCompacting | boolean | 上下文压缩进行中(由 compaction_start/compaction_end 事件驱动) |
slots | EnhancedSlot[] | 带有元数据的活动显示堆栈 |
tasks | Task[] | Agent 任务列表 |
inbox | InboxItem[] | 收件箱条目(待处理、已解决、已消费) |
stats | GloveStats | { turns, tokens_in, tokens_out } |
sendMessage(text, images?) | void | 发送用户消息 |
abort() | void | 取消当前请求 |
renderSlot(slot) | ReactNode | 渲染显示插槽 |
renderToolResult(entry) | ReactNode | 渲染历史记录中的工具结果 |
resolveSlot(id, value) | void | 解决一个 pushAndWait 插槽 |
rejectSlot(id, reason?) | void | 拒绝一个 pushAndWait 插槽 |
TimelineEntry
type TimelineEntry =
| { kind: "user"; text: string; images?: string[] }
| { kind: "agent_text"; text: string }
| { kind: "tool"; id: string; name: string; input: unknown; status: "running" | "success" | "error"; output?: string; renderData?: unknown };
type ToolEntry = Extract<TimelineEntry, { kind: "tool" }>;
支持的提供商
| 提供商 | 环境变量 | 默认模型 | SDK 格式 |
|---|---|---|---|
openai | OPENAI_API_KEY | gpt-4.1 | openai |
anthropic | ANTHROPIC_API_KEY | claude-sonnet-4-20250514 | anthropic |
openrouter | OPENROUTER_API_KEY | anthropic/claude-sonnet-4 | openai |
gemini | GEMINI_API_KEY | gemini-2.5-flash | openai |
minimax | MINIMAX_API_KEY | MiniMax-M2.5 | openai |
kimi | MOONSHOT_API_KEY | kimi-k2.5 | openai |
glm | ZHIPUAI_API_KEY | glm-4-plus | openai |
mimo | MIMO_API_KEY(+ 可选的 MIMO_BASE_URL) | mimo-v2.5 | mimo |
ollama | (无) | (用户指定) | openai |
lmstudio | (无) | (用户指定) | openai |
bedrock | AWS_ACCESS_KEY_ID | anthropic.claude-3-5-sonnet-20241022-v2:0 | bedrock |
推理模型
OpenAI 兼容适配器会捕获由提供商输出的推理轨迹(reasoning_content / reasoning 字段),适用于 DeepSeek-R1 / V4、Qwen3-Thinking、GLM-4.5 / 4.6、Kimi K2、MiniMax M2.5、OpenRouter、GPT-5 / o 系列,以及其他遵循此约定的 OpenAI 格式端点。捕获的轨迹会写入 Message.reasoning_content(类型化字符串字段),并在后续调用工具的助手轮次中回传(否则 DeepSeek V4 和 MiMo 会拒绝请求)。
| 用例 | 配置 |
|---|---|
| 默认捕获并回传 | createAdapter({ provider, reasoning: true }) |
| 指定思考深度(GPT-5 / GLM / MiniMax / Kimi / DeepSeek V4) | createAdapter({ provider, reasoning: { effort: "high" } }) — "minimal"/"low"/"medium"/"high" |
| OpenRouter 统一推理对象 | createAdapter({ provider: "openrouter", reasoning: { reasoningObject: { effort: "high", max_tokens: 2000 } } }) |
Anthropic 风格的 thinking(用于 OpenAI 垫片) | createAdapter({ provider, reasoning: { thinking: { type: "enabled", budget_tokens: 4000 } } }) |
Qwen3 dashscope enable_thinking | createAdapter({ provider, reasoning: { extraBody: { enable_thinking: true, thinking_budget: 1024 } } }) |
| 在可见文本中显示轨迹(用 `` 包裹) | createAdapter({ provider, reasoning: { includeInText: true } }) |
| 禁用回传(专用于 DeepSeek-R1) | createAdapter({ provider, reasoning: { echo: false } }) |
OpenAICompatReasoningOptions 从
glove-core/models/openai-compat 导出。createAdapter 和
createChatHandler 上旧版的 reasoningEffort /
includeReasoningInText 字段会整合到新结构中——现有 MiMo
调用方无需修改即可继续使用。MiMo 提供商会继续使用
专用适配器(MimoAdapter 已内置该字段)。
预构建工具注册表
访问 https://glove.dterminal.net/tools — 复制粘贴到你的项目中:
confirm_action— 是/否确认对话框collect_form— 多字段表单ask_preference— 单选偏好选择器text_input— 自由文本输入show_info_card— 信息/成功/警告卡片(pushAndForget)suggest_options— 多选建议approve_plan— 分步计划审批
语音集成(glove-voice)
包概览
| 软件包 | 用途 | 安装 |
|---|---|---|
glove-voice | 语音管线:GloveVoice、适配器(STT/TTS/VAD)、AudioCapture、AudioPlayer、SpeechGate、AudioIO 平台接口层 | pnpm add glove-voice |
glove-voice-native | React Native / Expo 音频后端:NativeAudioCapture、NativeAudioPlayer、createNativeAudioIO、SileroVADNativeAdapter(子路径 /silero-vad)。原生模块:react-native-audio-api(必需的 peer 依赖)、onnxruntime-react-native + expo-file-system(可选,用于神经网络 VAD)。仅适用于开发客户端 / prebuild,不适用于 Expo Go。 | pnpm add glove-voice-native |
glove-react/voice | React Hooks:useGloveVoice、useGlovePTT、VoicePTTButton(不依赖 DOM,也可用于 React Native) | 包含在 glove-react 中 |
glove-next | 令牌处理器:createVoiceTokenHandler(已包含在 glove-next 中,无需单独导入) | 包含在 glove-next 中 |
glove-voice-s2s | 语音到语音:RealtimeAgent、s2sDrivenModel、createS2SAdapter、类型化的轮次交互参数(见下文“语音到语音、虚拟形象与 LiveKit”) | 工作区 |
glove-voice-avatar | 基于 S2S 栈的实时虚拟形象:AvatarAdapter、TavusEchoAdapter、AnamPassthroughAdapter、attachAvatar | 工作区 |
glove-voice-livekit | LiveKit 传输 + LiveKit 虚拟形象:LiveKitTransport、attachRealtime、TavusLiveKitAvatar、AnamLiveKitAvatar、令牌生成 | 工作区 |
架构
Mic → VAD → STTAdapter → glove.processRequest() → TTSAdapter → Speaker
GloveVoice 将 Glove 封装为全双工语音管线。Glove 仍是智能层——所有工具、显示栈和上下文管理都能正常工作。STT 和 TTS 可通过适配器替换。文本令牌会经由 SentenceBuffer 实时流入 TTS。
快速开始(Next.js + ElevenLabs)
步骤 1:令牌路由 —— 在服务器端使用处理器将你的 API 密钥换成短期令牌
// app/api/voice/stt-token/route.ts
import { createVoiceTokenHandler } from "glove-next";
export const GET = createVoiceTokenHandler({ provider: "elevenlabs", type: "stt" });
// app/api/voice/tts-token/route.ts
import { createVoiceTokenHandler } from "glove-next";
export const GET = createVoiceTokenHandler({ provider: "elevenlabs", type: "tts" });
在 .env.local 中设置 ELEVENLABS_API_KEY。
步骤 2:客户端语音配置
// app/lib/voice.ts
import { createElevenLabsAdapters } from "glove-voice";
async function fetchToken(path: string): Promise<string> {
const res = await fetch(path);
const data = await res.json();
return data.token;
}
export const { stt, createTTS } = createElevenLabsAdapters({
getSTTToken: () => fetchToken("/api/voice/stt-token"),
getTTSToken: () => fetchToken("/api/voice/tts-token"),
voiceId: "JBFqnCBsd6RMkjVDRZzb",
});
步骤 3:SileroVAD —— 使用动态导入确保 SSR 安全
export async function createSileroVAD() {
const { SileroVADAdapter } = await import("glove-voice/silero-vad");
const vad = new SileroVADAdapter({
positiveSpeechThreshold: 0.5,
negativeSpeechThreshold: 0.35,
wasm: { type: "cdn" },
});
await vad.init();
return vad;
}
步骤 4:React Hook
const { runnable } = useGlove({ tools, sessionId });
const voice = useGloveVoice({ runnable, voice: { stt, createTTS, vad } });
// voice.mode, voice.isActive, voice.isMuted, voice.error, voice.transcript
// voice.start(), voice.stop(), voice.interrupt(), voice.commitTurn()
// voice.mute(), voice.unmute() — gate mic audio to STT/VAD
// voice.narrate("text") — speak text via TTS without model (returns Promise)
startMuted 配置选项
在手动(按住说话)模式下,管线现在默认以静音状态启动——无需在调用 start() 后立即调用 mute()。这样可以消除音频在这段间隙泄漏的竞态条件。
// Manual mode auto-mutes — just works
await voice.start(); // already muted in manual mode
// Explicit override
const voice = useGloveVoice({
runnable,
voice: { stt, createTTS, turnMode: "manual", startMuted: false }, // opt out
});
useGloveVoice 中的 enabled 状态
该 Hook 现在会公开 voice.enabled,用于追踪用户意图(调用 start() 后为 true,调用 stop() 或管线终止后为 false)。可替代手动使用 useState + 同步 useEffect 的模式:
// Before — consumer tracks + syncs
const [voiceEnabled, setVoiceEnabled] = useState(false);
useEffect(() => {
if (voiceEnabled && !voice.isActive) setVoiceEnabled(false);
}, [voiceEnabled, voice.isActive]);
// After — hook tracks it
voice.enabled // auto-resets on pipeline death
useGlovePTT Hook(按住说话)
封装完整 PTT 生命周期的高级 Hook。可将约 80 行样板代码缩减至约 5 行:
import { useGlovePTT } from "glove-react/voice";
const glove = useGlove({ endpoint: "/api/chat", tools });
const ptt = useGlovePTT({
runnable: glove.runnable,
voice: { stt, createTTS }, // turnMode forced to "manual"
hotkey: "Space", // default, auto-guards INPUT/TEXTAREA
holdThreshold: 300, // click-vs-hold discrimination (ms)
minRecordingMs: 350, // min audio before committing
});
// ptt.enabled — is the pipeline active
// ptt.recording — is the user currently holding
// ptt.processing — is STT finalizing
// ptt.mode — voice mode (idle/listening/thinking/speaking)
// ptt.transcript — partial transcript while recording
// ptt.error — last error
// ptt.toggle() — enable/disable the pipeline
// ptt.interrupt() — barge-in
// ptt.bind — { onPointerDown, onPointerUp, onPointerLeave }
<button {...ptt.bind}><MicIcon /></button>
<VoicePTTButton> 组件
无头(无样式)组件,通过 render prop 渲染麦克风按钮:
import { VoicePTTButton } from "glove-react/voice";
<VoicePTTButton ptt={ptt}>
{({ enabled, recording, mode }) => (
<button className={recording ? "active" : ""}>
<MicIcon />
{enabled && <StatusDot />}
</button>
)}
</VoicePTTButton>
支持区分点击与按住、指针移出安全处理以及 aria 属性。
<Render> 语音支持
<Render> 接受可选的 voice 属性,以自动渲染转录文本和语音状态:
<Render
glove={glove}
voice={ptt} // or useGloveVoice() return
renderTranscript={...} // optional custom renderer
renderVoiceStatus={...} // optional custom renderer
renderInput={() => null}
/>
轮次模式
| 模式 | 行为 | 适用场景 |
|---|---|---|
"vad"(默认) | 自动语音检测 + 打断 | 免手动操作、语音优先的应用 |
"manual" | 按住说话,显式调用 commitTurn() | 嘈杂环境、需要精准控制 |
噪声鲁棒性(语音门控)
在 "vad" 模式下,麦克风音频默认启用语音门控(speechGating: true):音频会暂存于滚动预录缓冲区(speechGatePrerollMs,默认 800ms),只有 VAD 确认检测到语音片段后,才会将音频发送给 STT 提供方——背景噪声永远不会传到 STT,因此无法被转录或幻觉成文字。使用 SileroVADAdapter(其声明了 supportsRealStart: true)时:
- 门控会在
speech_real_start时开启(语音持续时间达到minSpeechMs,默认 250ms)——短促的噪声会被报告为vad_misfire,其音频会被彻底丢弃。 - 插话检测也会在
speech_real_start时触发,因此关门声不会打断 Agent 说话。 - 所有 VAD 适配器都会为每一帧发送
speech_prob(模型概率,或内置VAD的能量代理值)——可用于电平表和阈值调优。
内置能量型 VAD 采用基于时间的检测(silenceMs、minSpeechMs——与数据块大小无关),并启用自适应噪声底限(默认 adaptive: true),将有效阈值提高到持续背景噪声之上。AudioCapture 除标准约束外还会请求 voiceIsolation;可通过 GloveVoiceConfig.micConstraints 覆盖任意约束。设置 speechGating: false 可恢复连续 STT 流式传输。
React Native / Expo(glove-voice-native)
该管线与平台无关;麦克风采集和扬声器播放是仅有的平台相关环节,通过 GloveVoiceConfig.audio(一个 AudioIO,默认为浏览器实现)注入。在 React Native / Expo 中:
import { createNativeAudioIO, withNativeAudio } from "glove-voice-native";
import { SileroVADNativeAdapter } from "glove-voice-native/silero-vad";
const vad = new SileroVADNativeAdapter(); // Silero v5 on onnxruntime-react-native
await vad.init(); // downloads + caches model (expo-file-system)
const voice = useGloveVoice({
runnable,
voice: withNativeAudio({ stt, createTTS, vad }), // or { ..., audio: createNativeAudioIO() }
});
- 基于
react-native-audio-api(Software Mansion):使用AudioRecorder采集音频,使用 Web Audio 实现播放。其 Expo 配置插件会处理麦克风权限(iosMicrophonePermission、androidPermissions: ["android.permission.RECORD_AUDIO", ...])。 NativeAudioCapture会将 iOS 会话配置为playAndRecord+voiceChat模式 → 使用操作系统回声消除(相当于浏览器的echoCancellation约束)。- ElevenLabs STT/TTS 适配器在 RN 上可直接运行(WebSocket + 纯 JS base64——无需
btoa/atob)。 - 原生模块 → 需要 Expo 开发客户端 /
expo prebuild,不能使用 Expo Go。 - 如果不想使用
onnxruntime-react-native,可将glove-voice中的能量型 VAD 作为零原生依赖的备用方案。
旁白播报与麦克风控制
voice.narrate(text)— 通过 TTS 播报任意文本,无需模型参与。音频播放完毕后返回。播报期间自动静音麦克风。可通过interrupt()中止。可安全地从pushAndWait工具处理程序中调用。voice.mute()/voice.unmute()— 控制是否将麦克风音频转发给 STT/VAD。静音时仍会触发audio_chunk事件(用于可视化)。audio_chunk事件 — 来自麦克风的原始Int16ArrayPCM,即使静音也会发送。可用于波形/电平可视化。- 压缩期间静音 — 上下文压缩期间,语音功能会自动忽略
text_delta,避免播报摘要。
语音优先的工具设计
- 使用
pushAndForget代替pushAndWait— 需要等待点击的阻塞式工具在语音模式下无法使用 - 在
data中返回描述性文本 — LLM 会读取这些内容并据此组织口头回复 - 添加语音专用系统提示词 — 指示 Agent 简洁地口述结果
- 使用
narrate()播报槽位内容 — 在工具处理程序中朗读显示内容
支持的语音提供方
| 提供方 | 令牌处理程序配置 | 环境变量 |
|---|---|---|
| ElevenLabs | { provider: "elevenlabs", type: "stt" | "tts" } | ELEVENLABS_API_KEY |
| Deepgram | { provider: "deepgram" } | DEEPGRAM_API_KEY |
| Cartesia | { provider: "cartesia" } | CARTESIA_API_KEY |
语音到语音、虚拟形象与 LiveKit
上面的级联管线(glove-voice)是两种语音路径之一。另一种会直接让 Agent 运行在实时语音到语音模型上——无需 STT/TTS,模型本身就是语音:
import { RealtimeAgent, s2sDrivenModel } from "glove-voice-s2s";
const agent = new Glove({
store,
// The model slot CARRIES the realtime config — the agent definition is the
// single source of truth; RealtimeAgent derives the provider session from it.
model: s2sDrivenModel({
label: "s2s-front",
provider: "openai", // or "gemini"
apiKey: process.env.OPENAI_API_KEY!,
// model/voice optional — defaults: gpt-realtime + marin / gemini-live-2.5-flash-preview + Puck
turnDetection: { type: "server_vad", silence_duration_ms: 450, prefix_padding_ms: 300 }, // snappy barge-in
}),
displayManager: new Displaymanager(),
systemPrompt: "...",
serverMode: true,
}).fold({ /* tools work normally */ });
const rt = new RealtimeAgent({ agent }); // derives the adapter from the model slot
await rt.start();
rt.sendAudio(pcm); // caller mic in (Int16Array at rt.adapter.inputFormat.sampleRate)
rt.adapter.on("audio", (pcm, fmt) => {}); // agent speech out (24 kHz)
rt.adapter.on("interrupted", () => {}); // barge-in — flush your playback
rt.inject("<text>", { respond: true }); // typed lines / worker results into the live session
经过验证的分层方式(从 examples/s2s-rooms 开始):S2S 模型是一个轻量的前端 Agent;繁重的查询会通过 glove-mesh 委托给能力更强的工作 Agent,每次调用都是一次站点信号运行。
虚拟形象是这套架构之上的渲染层——输入 PCM,在 WebRTC 界面上呈现会说话的面孔。AvatarAdapter 实现:TavusEchoAdapter(Tavus echo PAL + Daily 房间;需要 sendInteraction 信使——Tavus 交互只能通过 Daily 数据通道进行)、AnamPassthroughAdapter(Anam 直通角色;浏览器 SDK 管理音频输入,因此需要 sendCommand 信使;低于 Growth 层级的方案有会话时长上限,每隔几分钟就会强制结束会话——主机通过 disconnect()+connect() 续期并重新附加客户端)。一次调用即可完成接线:const detach = await attachAvatar(rt, avatar)(音频 → sendAudio,说话停止 → endUtterance,受到打断 → interrupt)。
LiveKit(glove-voice-livekit)用双向 WebRTC 取代手动搭建的音频管道:语音链路使用 LiveKitTransport + attachRealtime(rt, transport)(浏览器只需调用 Room.connect + setMicrophoneEnabled;抢话处理在服务端通过 clear() 完成),面部则使用 TavusLiveKitAvatar/AnamLiveKitAvatar——提供商的工作进程会以参与者身份加入你的房间(使用 mintAvatarToken 为其生成令牌,身份为 TAVUS_AVATAR_IDENTITY/ANAM_AVATAR_IDENTITY,并以 Agent 的名义加入),并通过 lk.audio_stream 字节流向其提供音频;附加虚拟形象时,设置 publishAgentAudio: false(工作进程会代表 Agent 发布语音——不要重复发布)。
完整 API 见 api-reference.md(“glove-voice-s2s”、“glove-voice-avatar”、“glove-voice-livekit”);可运行的渐进示例见 examples/layered-voice → server-voice → s2s-rooms → avatar-rooms → livekit-rooms(每个示例都保留,端口并列)。
工作环境(glove-working-environment)
一种文件系统和脚本运行时,模型可以针对它编写代码,而不是使用固定的工具菜单。适用于围绕文件开展的开放式工作——文档、电子表格、演示文稿、归档文件——此时更有用的下一步是编写脚本,而非进行调用。
import { createWorkingEnvironment, mountWorkingEnvironment } from "glove-working-environment";
import { documents } from "glove-env-documents";
import { spreadsheets } from "glove-env-spreadsheets";
const env = await createWorkingEnvironment({
stdlib: [documents(), spreadsheets()],
limits: { runTimeoutMs: 60_000 },
execution: { size: 1 },
});
mountWorkingEnvironment(agent, { env }); // structural — no glove-core dependency
目录树
/inbox(由主机挂载的输入) · /scripts(模型的持久化库,包含生成的 .d.ts 同级文件) · /skills(实用配方,在启动时实例化) · /std(每个模块各有一个目录,包含其类型和 README) · /tmp · /out(交付物) · /.env(记录信息)。
主机接口:使用 env.mount(bytes|path|{text}, dest) 挂载输入,使用 env.export(glob) 导出,使用 env.snapshot() / fromSnapshot 持久化,使用 env.close() 释放工作线程。
移交工作。 接入 onPresent({ path, name, bytes, mediaType, caption }) 后,就会出现 present 动作;未接入时则不会出现。在 /out 中写入会创建文件,present 则会交付文件——任务结束时,/out 中也会存有草稿和中间文件,只有 Agent 知道哪个才是答案。若路径位于 /out 之外,则会拒绝操作并提供修正方法。mediaType 根据扩展名确定(仅对没有扩展名的文件检查魔数),因此主机可以选择播放器,而不是显示下载提示。
只读区域。 设置 readOnlyPaths: ["/corpus"] 后,子树可以读取、搜索和描述,但无法写入。强制检查位于唯一的核心变更入口,因此只需一次检查,就能约束所有操作途径:模型动作、通过 env:fs 调用的脚本、适配器句柄,以及 undo。env.mount() 则特意绕过此检查——这样才能预先放入 Agent 只能读取的内容;而 env.fs 这个经过保护的主机句柄遵循与模型相同的规则。启动时会在环境说明文件中列出区域并创建相应目录,让 Agent 通过阅读了解边界,而不是因操作被拒绝才知晓。可与 hostDirectory 搭配,用于“这是一个真实项目,/src 仅供参考”的场景。只读区域不会存入快照——主机每次都要重新提供,stdlib 也是如此。
目录树的存储位置。 filesystem 接受任意 Vfs。内置三种实现:inMemoryFs()(默认)、hostDirectory(dir)(基于真实目录进行写时复制——在调用 commit() 前,Agent 无法损坏源目录),以及 cachedRemote(store, { prefix })(对象存储;由你提供 get/put/delete/list,因此无需依赖 SDK)。若要在重启后实现简单持久化,优先使用 snapshot() 并存入单个对象——每个会话只需往返一次,而非每个文件往返一次,且操作是原子的。cachedRemote 会将结构索引(路径、大小、修改时间、目录)保留在内存中,因为每次写入都会调用 totalSize(),而 files() 则用于 glob/grep/rm -r;只有文件内容会经过网络。没有分布式锁——每个会话使用一个独立的前缀。
暴露能力的四种方式——根据形态选择
这是实现者最容易选错的地方。应根据事物的形态选择方式:
| 形态 | 方式 | 调用风格 |
|---|---|---|
| 执行 I/O——读取/写入文件、发起外部调用 | defineAdapter | async |
有状态的构建器——new X()、链式修改、最终保存 | defineBuilder / defineBuilders | async |
| 纯计算——无 I/O、无状态 | definePureModule | 同步 |
| 一种能力,而非库——MCP 服务、模型、HTTP API | defineTools | async |
defineTools({ name, description, fns, docs }) 会将一组 ToolFn 挂载为 env: 模块——其结构与 glove-scratchpad/fns 目录一致,因此 fnsFromMcp(conn) 会直接生成该列表,MCP 服务无需编写适配器即可导入。选择它的理由在于循环调用的经济性:若将其作为动词,每个答案都会占用上下文窗口,因此检查 40 个渲染页面需要往返 40 次;将同一能力作为函数,则可让脚本循环处理并返回一行结果。模块形态相同,导入语句相同,也无需手写 types。
// Pure computation: imported INSIDE the worker, bound into the vm context.
// Calls never cross a thread, so they stay synchronous.
definePureModule({
name: "lodash",
from: "lodash",
description: "Lodash utilities for shaping data.",
pick: ["groupBy", "sumBy", "orderBy", "uniqBy", "camelCase"],
})
pick 是沙箱边界,而非便利功能——被选取的函数会在工作进程的 realm 中运行,位于 vm 之外。绝不要选取从字符串生成代码的成员(_.template 会在宿主侧运行 Function(source))。定义时会拒绝原型成员。创建环境时会将每个名称与真实模块逐一核对,因此拼写错误会在此时报错,而不是在脚本中变成 undefined。
同步为何重要:对普通值使用 await 不会产生影响,但对 Promise 漏掉 await 会悄无声息地生成无效结果——通过异步绑定调用 sumBy(rows, 'n') 会返回字符串化的 Promise,而运行却报告成功。同步是更宽容的方向,因此同步库必须以同步方式暴露。
defineBuilder 会原样暴露构建器 API——调用会在上下文中记录为扁平操作列表,并在最终调用时于宿主侧重放,因此 new Pptx()、链式方法,以及将一个节点传入另一个节点等操作都能跨越工作进程边界正常运行。glove-env-slides 和 glove-env-spreadsheets 使用它原样暴露 pptxgenjs 和 exceljs。
HTTP 文件和受限范围的密钥
使用 glove-env-fetch 中的 fetchFiles() 处理 env:fetch.request/download/upload,包括 JSON、表单和 multipart 请求体。使用 glove-env-secret 中的 secret() 获取由宿主提供的密钥元数据和引用。阅读HTTP 文件和宿主密钥,了解宿主设置、脚本 API、安全限制、取消和持久化;不要自行构造 shell/curl 桥接,也不要将凭据放入 VFS 文件。
将网络授权和凭据别名保存在宿主配置中。原生 fetch 会阻止访问非公共 DNS/IP 目标;访问私有服务需要精确设置 privateNetworkOrigins opt-in。替代传输方式必须自行执行等效的 DNS/IP/TLS 检查。将持久化的 SecretStore 限定在租户范围内,并在恢复时重新提供;引用本身不会授予访问权限,且读取明文和写入默认关闭。适配器调用应放在脚本的默认导出中。在绑定中读取 ctx.signal,以便运行结束时中止宿主 I/O。
编写和测试适配器
defineAdapter({ name, description, types, docs, skills?, create(vfs, ctx) })。create 是能力边界,并且会被调用两次——一次用于只读的脚本写入时验证。每个暴露的函数都会经过包装,使错误显示为 env:<name>.<fn>: …,参数则以经过深拷贝的宿主 realm 值形式传入。
使用 glove-working-environment/testing 进行测试:createAdapterTestEnv(adapter) 会返回 { env, fs, script(), runScript(), audit() }。如果 types 与真实绑定在任一方向上不一致,audit() 都会使构建失败——包括 Promise 规则;对于纯模块,这条规则会反转(在同步绑定上声明 Promise<…>,就是此处的错误声明)。
在 Next.js 中托管
有两个陷阱,而且它们暴露出来的位置都离根因很远:
- 每个环境包都必须列在
serverExternalPackages中。 工作池会根据自身的模块 URL 定位入口;打包后,该 URL 指向 Next 的代码块,而 worker 并不在其旁边。问题会在首次调用run_script时出现,而不是构建时。sharp(通过env:images使用)出于自身原因也必须设为外部依赖。 - 在 pnpm monorepo 中,这样还不够。 Next 会将
serverExternalPackages与解析后的路径匹配,并且将symlinks: true硬编码为解析选项;工作区链接会解析到../../packages/<name>,其中没有node_modules路径段,因此匹配永远不会触发。症状是:Can't resolve './worker-dev.mjs'。添加显式 webpack 外部依赖,以绕过此启发式规则。
Agent 必须在服务端运行——createChatHandler 是面向客户端工具的模型代理,在这里并不合适。请在路由中运行 processRequest,并将 Agent 自己的事件流作为 SSE 转发。参见 examples/document-desk。
渲染动画(glove-env-motion)
挂载 motion() 时必须同时设置 limits: MOTION_LIMITS——二者必须配套使用。一帧是浏览器截图(约 330ms),因此,如果环境的 runTimeoutMs 无法满足渲染需求,系统会提前拒绝;拒绝信息会指出要设置的确切 limits: { runTimeoutMs: N },但这仍然是拒绝。在 Next 中,将 glove-env-motion 和 playwright-core 都加入 serverExternalPackages:适配器会在运行时从自己的包根目录解析工具链,而打包后的副本没有包根目录。
开发期间,有五种故障模式会各自耗掉一轮诊断,而且它们全都会静默失败——第 1 帧能渲染,之后没有任何动作,也不报错。它们现在都由包内部处理,因此不要再为了“好心”配置而重新引入这些问题:worklet 需要 Babel 插件(esbuild 不会运行 Babel,因此 useAnimatedStyle(() => …) 会变成一个永远不会调用的闭包);该插件的预设会调用 api.assertVersion(7),因此 Babel 8 会造成误导性报错;.web.js 必须先于 .js 解析,否则会打包原生运行时;时钟 shim 必须在打包前安装;而且**page.setContent() 不会运行 addInitScript——只有导航会运行**,因此页面会先写入磁盘,再通过 goto() 访问。
有两件事应该告知用户,而不是花时间调试:渲染需要浏览器(glove-motion-doctor 会逐行打印修复建议);不含专有编解码器的 Chromium(包括 playwright-core install 安装的版本)无法解码 H.264,会显示一个控件可用但没有报错的黑色矩形。mp4 文件本身没有问题;Chrome/Edge/Safari/Firefox 都能播放。要在不含所需编解码器的 Chromium 中使用,请渲染 .webm。
真正让模型成功的因素
对三个开源模型进行 90 次运行的测量表明:猜测导入项是最常见的失败原因。这就是存在 /skills/imports.md 的原因,也是环境会在写入时纠正错误导入名、避免浪费一次运行机会的原因。首先让系统提示词指向 /skills/README.md。让模型 describe 文件,而不是读取它——一份 80 页的 PDF 有约 200KB 文本,而响应上限是 8KB。开放库的真实 API,比使用简化封装效果更好:在套件中,需要使用真正 pptxgenjs API 的场景得分最高。
支持文件
详细的 API 参考请参见 api-reference.md。 真实实现中的示例模式请参见 examples.md。
常见注意事项
- model_response_complete 与 model_response:流式适配器会发出
model_response_complete,而不是model_response。订阅者必须同时处理两者。 - React hooks 中的闭包捕获:重新为会话设置键时,使用可变的
let currentKey = key,以避免闭包过期。 - React useEffect 时序:状态更新不会在同一渲染周期内生效,因此要使用提前返回进行保护。
- 浏览器安全的导入:
glove-core现在可在浏览器中安全使用(无原生依赖)。带有原生依赖better-sqlite3的SqliteStore位于独立的glove-sqlite包中,仅供服务端使用。子路径导入(glove-core/core、glove-core/glove等)仍然可用,但为了浏览器安全已不再必需。 Displaymanager大小写:具体类名是Displaymanager(小写的 “m”),而不是DisplayManager。按以下方式导入:import { Displaymanager } from "glove-core/display-manager"。createAdapter的流式默认值:stream默认为true,而不是false。如果需要同步响应,请显式传入stream: false。- 工具返回值:
do函数应返回包含{ status, data, renderData? }的ToolResultData。data会传给 AI;renderData仅保留在客户端。 - Zod .describe():始终为 schema 字段添加
.describe(),AI 会读取这些描述,以了解需要提供哪些内容。 - displayPropsSchema 可选,但建议使用:
defineTool的displayPropsSchema是可选的,但对于带有显示 UI 的工具,建议使用;没有显示 UI 的工具应改用原始ToolConfig。 - 模型适配器会剥离 renderData:模型适配器在为 AI 格式化工具结果时会明确排除
renderData,因此将其用于客户端专属数据是安全的。 - SileroVAD 必须动态导入:在 Next.js/SSR 中,绝不要在模块顶层导入
glove-voice/silero-vad。请使用await import("glove-voice/silero-vad"),避免将 WASM 打包进服务端 bundle。 - Next.js transpilePackages:在
next.config.ts的transpilePackages中添加"glove-voice",以便 Next.js 处理该 ES 模块。 - createTTS 必须是工厂函数:
GloveVoice会在每轮调用一次,以获取全新的 TTS 适配器。传入() => new ElevenLabsTTSAdapter(...),不要传入单个实例。 - 打断保护需要
unAbortable:pushAndWait的 resolver 会在触发层抑制语音打断(当resolverStore.size > 0时,GloveVoice 会跳过interrupt())。但这本身并不能保护工具——如果通过其他方式调用了interrupt(),只有在工具上设置unAbortable: true,才能保证它不受中止信号影响并运行至完成。对于 checkout 这类对变更至关重要的工具,两者要一起使用。对于语音优先的工具,请使用pushAndForget。 - 已提交的转录文本为空:对于较短的语音内容,ElevenLabs Scribe 可能会返回空的已提交转录文本。适配器会自动回退到最后一条部分转录文本。
- TTS 空闲超时:ElevenLabs TTS WebSocket 在空闲约 20 秒后会断开连接。GloveVoice 会在每次
model_response_complete后关闭 TTS,并在下一个text_delta到来时开启新会话,以处理这种情况。 - onnxruntime-web 构建警告:onnxruntime-web 发出的
Critical dependency: require function is used in a way...警告是预期的,无害。 - 音频采样率:所有适配器必须统一使用 16kHz 单声道 PCM(默认值)。除非你的提供商明确要求其他采样率,否则不要更改。
narrate()会自动静音麦克风:voice.narrate()会在播放期间自动静音麦克风,以防 TTS 音频反馈到 STT/VAD。播放完成后会恢复之前的静音状态。narrate()需要已启动的管线:在voice.start()之前调用narrate()会抛出错误。必须先初始化 TTS 工厂和 AudioPlayer。- 语音会在压缩期间自动静音:触发上下文压缩时,语音管线会忽略
compaction_start与compaction_end之间的所有text_delta事件。压缩摘要永远不会被朗读。 - 用于 React UI 反馈的
isCompacting:压缩进行期间,GloveState.isCompacting为true。可用它显示加载指示器,或在压缩期间禁用输入。 <Render>自带默认输入框:如果你有自定义输入表单,务必传入renderInput={() => null}以隐藏内置输入框,否则会出现重复的输入框。- 工具在 React 之外执行:工具的
do()函数在组件树之外运行。要访问 React context(例如useWallet()),请使用与 React 组件同步的可变单例 ref(桥接模式)。 - 手动模式不需要 SileroVAD:使用
turnMode: "manual"(按住说话)时,请跳过 SileroVAD 导入及其 WASM 开销。只有turnMode: "vad"才需要 VAD。 - 系统提示词:明确说明工具:尽管工具已有描述和 schema,但在系统提示词中列出每个工具及其参数,可以显著提高工具选择准确率。
- 收件箱条目需要远程存储接线:使用
createRemoteStore时,如果不提供getInboxItems/addInboxItem/updateInboxItem/getResolvedInboxItems操作,收件箱会回退到内存存储。重新加载后,条目会消失。 - 收件箱已处理条目是纯文本消息:已处理的收件箱条目会作为用户文本消息注入,而不是作为工具结果。这样可以避免 Anthropic API 因不匹配的 tool_use/tool_result 对而产生验证错误。
- 阻塞式收件箱提醒是临时的:待处理的阻塞条目提醒会包含在提示词中,但不会持久化到存储中,以防上下文在多轮对话中不断膨胀。
- MCP 工具名称使用
__:桥接的 MCP 工具名称为${entry.id}__${tool.name}——分隔符__(MCP_NAMESPACE_SEP)在所有模型提供商的正则表达式中都安全。Notion 的search工具会显示为notion__search。 auth_expired是一种约定,不是异常:MCPcallTool期间出现形似 401 的错误时,会转换为{ status: "error", message: "auth_expired" }。框架不会刷新令牌——由你的应用刷新令牌、将其写回存储,然后下一次调用会通过getAccessToken获取新令牌。McpAdapter.deactivate不会卸载工具(v1):它会切换持久化状态,但桥接的工具仍会保留在运行中的 Agent 上,直到会话刷新。请据此安排操作。mountMcp失败时会继续运行:如果活动服务重新加载失败(临时中断、令牌过期),会通过console.warn记录错误,Agent 会继续使用目录中的其余内容。不要依赖mountMcp抛出异常。serverMode会设置默认发现策略:serverMode: true→auto-pick-best,桥接的工具永远不会受权限限制。serverMode: false(默认值)→interactive策略,读写 MCP 工具需要权限。传入ambiguityPolicy可显式覆盖。- 交互式发现需要
mcp_picker渲染器:interactive歧义策略通过 displayManager 上的mcp_picker渲染器进行渲染。如果你在浏览器中使用该策略,请为它注册渲染器;否则pushAndWait会一直挂起。 - 只解析
/指令:parseTokens只查找/name。@nametoken 会原样传给模型。像/usr/local这样的路径会保留(注册表中不会有usr这个名称);像[email protected]这样的电子邮件则完全不会被处理。如果用户的有效消息中包含/compact,且你有同名 hook,它就会触发——选择 hook/skill 名称时,请避免与普通文本冲突。 - 已绑定的
/指令会替换为占位符,而不是被剥离:在模型看到的解析文本中,/compact会变成[invoked_extension__hook_compact]。该占位符不会触发(它不匹配指令正则表达式),因此对同一文本重新解析时不会再次触发扩展。Hook 和 skill 处理器收到的parsedText中包含占位符,而不是单独的指令——进行匹配时请注意这一点。 - Skill 注入的消息会标记为
is_skill_injection: true:由/skill调用生成的合成用户消息会设置此标记。可在转录渲染器中使用它,将这类消息与真实的用户轮次区分开。它们会像其他消息一样持久化到存储中,并在压缩后保留(与其他所有内容一样,受splitAtLastCompaction影响)。 glove_invoke_skill每次调用都会读取实时注册表:自动注册的工具会在运行时检查this.skills,因此在build()之后定义并设置了exposeToAgent: true的 skill 可以立即调用。注册新的公开 skill 时,工具描述也会在原处重建,因此模型看到的列表始终是最新的。glove_invoke_subagent和 subagent 注册表也一样。@mention是模型侧的路由信号,而不是已解析的指令:遵循 Claude Code 的 subagent 约定,glove 永远不会解析@nametoken。完整的用户消息会传给模型,由模型根据glove_invoke_subagent的描述决定是否调用该工具。这意味着调用并不保证发生(模型可能会忽略@mention),但一条消息中有多个@mention也能正常工作——模型可以针对每个 subagent 各调用一次分派工具。- Subagent 看不到父级上下文:subagent 在隔离环境中运行,唯一的输入是 Agent 提供的
prompt字符串。如果 subagent 需要上下文,父级 Agent 必须将其写入提示词中(类似 Claude Code)。工厂会创建一个拥有独立存储的新子级Glove;如果要继承父级设置,请传入parentControls.glove.model和parentControls.displayManager。 subagent_invoked/subagent_completed始终成对触发:每次调用glove_invoke_subagent工具前后,都会由 Executor(而非分派器)触发这两个括起事件。即使父级中止导致分派器的 promise 链被短路,Executor 的中止分支仍会触发subagent_completed,并设置status: "error"和message: "Subagent run aborted by the user."。订阅者可以依赖这两个事件一一对应。- Hook 的
shortCircuit仍会持久化用户消息:即使 hook 短路了这一轮,用户消息(重写后)仍会先追加到上下文中,确保转录内容一致。只是这一轮不会调用模型。 - Token 消耗事件:每轮模型对话后,Observer 会向订阅者触发
token_consumption({ consumption: { tokens_in, tokens_out } })。StoreAdapter.addTokens接受相同的TokenConsumptionCounter结构;getTokenCount()仍会返回单个总和。 - 推理捕获默认关闭:
OpenAICompatAdapter默认会忽略reasoning_content。在createAdapter/new OpenAICompatAdapter中传入reasoning: true(或对象),即可将轨迹捕获到Message.reasoning_content。MiMo 适配器会无条件捕获。 reasoning_content与reasoning字段:适配器会从响应中读取这两个字段中的任意一个。DeepSeek / Qwen3 / GLM / Kimi / MiniMax / MiMo 会发出reasoning_content;OpenRouter 会发出reasoning(并将reasoning_content作为文档中说明的别名)。捕获的字符串始终存入Message.reasoning_content,这是 Glove 的规范字段。- 工具结果摘要默认关闭:
GloveConfig中的enableToolResultSummary默认为false。单独将其设为true不会产生任何效果——你还必须为每个要缩减结果的工具添加generateToolSummary,并让do()返回generateSummaryArgs。只有处理器和参数都存在时,Executor 才会填充result.summary。 summary只会替换较早上下文中的data:PromptMachine.summarizeOlderToolResults会找到最新的非工具用户消息,并且只重写位于该消息索引处或之前的工具结果。当前轮次的工具结果始终会以完整的data传给模型。存储会保留每个结果中的data和summary;只有传给模型适配器的消息会被重写。summarizeOlderToolResults会跳过空摘要:只有当result.summary为真值时才会进行替换。部分工具目录接入摘要功能也是安全的——已接入的工具会缩减较早上下文中的结果,未接入的工具则保留原始data。除非不为某个工具填充summary,否则没有其他方式可以让它不参与重写。- 字符串错误数据不再被重复 JSON 序列化:所有模型适配器(
anthropic、bedrock、mimo、openai-compat、openrouter)现在都会在对错误结果数据执行JSON.stringify前检查typeof data === "string"。如果你的工具返回{ status: "error", data: "some message" },模型看到的会是Error: ...\nsome message,而不是Error: ...\n"some message"。这是一个 bug 修复——消费者无需采取任何操作,但如果你在模型侧解析错误字符串,值得了解这一行为变化。 - DeepSeek V4 / MiMo 的工具轮次需要回显:启用
reasoning时,对于产生tool_calls的 assistant 轮次,适配器会回显Message.reasoning_content——否则 DeepSeek V4 和 MiMo 会拒绝请求。较早的模型 DeepSeek-R1 会完全拒绝此字段;如果你的目标是 R1,请设置reasoning: { echo: false }。 reasoning_effort的"minimal"仅适用于 GPT-5:完整的 effort 枚举为"minimal" | "low" | "medium" | "high",但"minimal"仅适用于 GPT-5 / o-series。MiMo 分支会静默丢弃此值。其他提供商可能会拒绝它——使用前请查阅提供商文档。- 自适应推理模型可能会在
"low"时抑制思考:对于mimo-v2.5-pro等类似的自适应模型,传入effort: "low"或"medium"可能会抑制推理,而不是限制其程度。若要稳定获得深入推理,请传入"high";也可以不设置,让模型自行决定。 - 通过
extraBody传入提供商专属的推理扩展参数:对于 Qwen3 dashscope 的enable_thinking/thinking_budget,或其他非标准请求字段,请使用reasoning: { extraBody: { ... } }——这些字段会直接合并到请求正文中。常见情况则可使用结构化选项(effort、reasoningObject、thinking)。 - 权限根据(工具,输入)组合确定,而不仅仅根据工具确定:每次受权限限制的调用中,
Executor.checkPermission都会使用模型提供的输入调用store.getPermission(name, input)。默认的MemoryStore会通过permissionKey(name, input)→"${name}::${JSON.stringify(input ?? null)}"对输入进行精确匹配,因此不同输入的调用会分别请求授权,而输入相同的调用会命中缓存的决定。自定义存储可以实现更宽松的匹配方式(例如对命令使用正则表达式、对路径使用前缀匹配),方法是自行忽略或规范化input。 requiresPermission接受函数:boolean | ((input: I) => boolean)。当权限门槛本身取决于输入时,请使用函数形式——例如使用单个bash工具,限制写入但不限制读取:requiresPermission: (i) => !i.cmd.startsWith("ls")。返回false会完全跳过此次调用的存储查询;返回true则会执行常规的getPermission(name, input)流程。- 升级时迁移 SqliteStore:现有数据库会在
permissions表中新增input_hash列(主键变为(session_id, tool_name, input_hash))。旧数据行会保留,并将input_hash设为''(这个哨兵值不会匹配任何真实调用),因此升级后每个工具都会要求用户重新授权一次——这符合新的精确匹配语义。glove-sqlite仍已弃用;建议优先使用glove-core中的MemoryStore或自带的StoreAdapter。 mountMesh需要支持收件箱的存储:如果glove.store未实现全部四个收件箱方法(getInboxItems、addInboxItem、updateInboxItem、getResolvedInboxItems),就会抛出MeshStoreUnsupportedError。MemoryStore和SqliteStore均符合要求;自带的存储需要显式选择支持。框架在此处不再静默地什么都不做——配置错误会立即失败。- Mesh 阻塞式发送使用现有的收件箱注入路径:
glove_mesh_send_message({ blocking: true })创建的待处理阻塞条目,会在每一轮通过Agent.buildPendingBlockingMessage生成临时提醒,直到条目被处理。收到确认或回复后,已处理条目会通过标准的[Inbox: N item(s) resolved]注入方式显示。glove-core中没有新增机制——mesh 只是纯增量功能。 - Mesh 发送者 ID 未经验证:
MeshMessage.from由发送者自行声明。框架不会验证发送者是否与已注册身份匹配。如果需要经过身份验证的消息,请在调用adapter.send/broadcast前为消息签名,并在subscribe处理器中验证——这与McpAdapter.getAccessToken将身份验证交由消费者负责的方式一致。 - Mesh 标签前缀是
mesh:,而不是glove_mesh::工具名称为glove_mesh_*(符合框架的glove_<package>_*约定),但收件箱标签使用较短的前缀:mesh:from:<sender>、mesh:broadcast:from:<sender>、mesh:waiting:<msg_id>。这种不一致是有意为之——标签用于筛选,工具名称面向模型——但使用 grep 检索收件箱历史时值得注意。 - Mesh 回复意味着确认:收到带有
in_reply_to: X的直接消息时,会同时执行两项操作:将回复正文作为新的已处理收件箱条目显示,并处理X对应的待处理阻塞条目(如果存在)。回复时,接收方无需另行调用glove_mesh_acknowledge。 InMemoryMeshAdapter仅限进程内使用:每个进程只构建一个MeshNetwork,并在所有InMemoryMeshAdapter之间共享。对于跨进程或分布式消息传递,请基于你的传输方式(Redis pub/sub、NATS、HTTP webhooks)直接实现MeshAdapter。用于确认路由、将message_id → sender_id映射起来的MeshNetworkLRU 默认容量为 1024——对非常旧的消息进行确认只能尽力而为。- Continuum
agent()构建器会根据模式分叉:调用.input(zod)后,调用.triggered()会返回TriggeredAgentBuilder<TInput>,调用.concurrent()会返回ConcurrentAgentBuilder<TInput>。.retries(n)/.every("5m")/.withInput(default)仅存在于 triggered 构建器中;在 concurrent 构建器上调用会产生类型错误,而不是运行时错误。终端方法.factory(ctx => Glove)会返回构建好的 Agent(带有AGENT_BRAND标记)。这与 station-signal 中.run()和.step().build()的分叉方式类似。 .notify()仅适用于 concurrent(类型级别):ConcurrentAgent<T>同时提供.trigger(input)和.notify(input)(两者都会写入kind: "notify"的运行记录);TriggeredAgent<T>仅提供.trigger(input)(会写入kind: "trigger")。运行时根据运行类型进行区分:trigger/recurring 会启动新的子进程,notify 会通过 IPC 路由到热运行实例。在 triggered Agent 上调用agent.notify()无法通过编译。- Triggered Agent 必须使用
.store(name => StoreAdapter):如果不设置,每次唤醒都会获得一个新的MemoryStore,并丢失之前唤醒的所有上下文,违背 continuum 的用途。Triggered Agent 未设置.store(...)时,发现流程会发出控制台警告。Concurrent Agent 通常无需设置,因为其子进程会长期运行;但如果运行器可能重启,持久化仍有帮助。 - 在 continuum 工厂中挂载
mountMesh即可开箱即用:挂载 mesh 时,continuum 的存储必须已支持收件箱(mesh 需要前述四个收件箱方法)。Glove 的MemoryStore符合要求;自带的StoreAdapter也需要实现收件箱方法。continuum 底层架构不为 mesh 提供特殊 IPC——工厂只需在构建 glove 后调用await mountMesh(glove, { adapter, identity }),适配器就会完全在子进程中运行。 - Continuum 订阅者收到的是
onAgentEvent(envelope),而不是按类型区分的 Glove 事件:运行器会将任何子进程发出的每个 GloveSubscriberEvent转发为一个AgentEventEnvelope({ agentName, runId, mode, event_type, data, timestamp })。订阅者通过envelope.event_type进行筛选,以处理特定类型。对于在任何 notify 之外发出的常驻热运行 Agent 事件(例如工厂设置期间的事件),runId可以为空。包装器可以轻松地在此基础上实现按类型分发。 - Continuum 父级是运行状态的唯一真实来源:子级只会发出 IPC 信封;父级运行器会将它们转换为
adapter.updateRun(…)调用。子级永远不会重建适配器,也不会直接与适配器通信(放弃步骤机制后,这相比 station-signal 确实简化了不少)。在终止 IPC 的同步关键路径中、任何await之前,都会设置resolved标志并减少活动计数,因此即使 200ms 的退出宽限期与待处理的状态更新重叠,响应较慢的适配器后端也不会导致重复递减。 - Continuum 热重启预算会在稳定运行 60 秒后重置:
warmRestartPolicy.maxRestarts(默认值为 5)限制 Agent 在崩溃循环中可重启的次数,但只要热运行子进程在未退出的情况下就绪并稳定运行 60 秒,计数器就会归零。因此,健康运行数小时后偶尔发生一次崩溃,不会导致 Agent 永久退出;持续崩溃仍会耗尽预算。退出处理器会清除重置计时器,因此快速再次崩溃不会获得新的预算。 - Continuum 运行器的进程生成环境变量黑名单:
NODE_OPTIONS、LD_PRELOAD、LD_LIBRARY_PATH和DYLD_INSERT_LIBRARIES会从转发给子进程的父进程环境变量,以及 Agent 提供的任何.env({...})覆盖项中剥离。这是针对加载器注入的纵深防御——信任模型仍是“我信任自己的 Agent 代码”,但拦截影响最大的逃逸途径成本很低。特意保留了PATH,以便spawn("node", …)能够解析。 runner.notify(name, input)与agent.notify(input):两者都会写入一个将路由到热运行子进程的kind: "notify"运行记录。Agent 的实例方法会经过getAdapter()(全局configure({adapter})单例);运行器方法则会经过运行器自身的适配器。如果同一进程中有多个运行器(全局单例会产生竞争),或者你想明确指定运行记录由哪个运行器处理,请使用运行器方法。- Continuum 引导路径为
dist/bootstrap.js(生产环境),否则回退到src/bootstrap.ts(开发环境):运行器使用existsSync选择存在的文件。在tsx环境下运行时,即使删除dist/,运行器也仍然可以使用源文件。如果两者都不存在,spawn会在运行时因ENOENT失败——启动运行器前,请确保已构建此包(或正在tsx环境下运行)。 tool_use_result包含tool_name/call_id,而不是name/id:工具调用事件(tool_use)包含{ id, name, input };其结果包含{ call_id, tool_name, result }。两者使用的键不同。如果用错误的键将结果与调用匹配,UI 中的所有工具都会一直转圈,永远无法完成。- 工作环境包必须从打包器中排除:
glove-working-environment及所有glove-env-*包都必须列入 Next 的serverExternalPackages。工作进程池会根据自身的模块 URL 解析入口;打包后,该 URL 会指向某个 chunk,而工作进程文件不在其旁边。问题不会在构建时出现,而是在第一次run_script时失败。env:motion还需要将playwright-core排除在打包之外——它会从自己的包根目录解析工具链,而打包后的副本没有该目录。 serverExternalPackages会静默漏掉 pnpm 工作区链接:Next 会针对解析后的路径进行匹配,并且硬编码使用symlinks: true解析路径。工作区依赖会解析为../../packages/<name>——没有node_modules片段,因此不会匹配,仍会被打包。症状是Can't resolve './worker-dev.mjs'。请添加显式的 webpack external 设置。此问题仅影响 monorepo;从 npm 安装的应用不受影响。- 同步库必须通过
definePureModule使用,而不是defineAdapter:适配器绑定会跨越线程,因此是异步的。通过适配器调用时,如果不使用await执行sumBy(rows, 'n'),会返回字符串化的 promise,并且运行会报告成功;而在同步回调中(keys.map(k => camelCase(k))),根本不存在正确的写法。definePureModule会在工作进程中导入该包,并将其绑定到 vm 上下文中,从而保持调用同步。 - 纯模块上的
pick是沙箱边界:被选中的函数会在工作进程的 realm 中执行,位于 vm 之外。绝不要选中将字符串转换为代码的成员——_.template会通过Function(source)编译,因此会在沙箱外执行任意代码。原型成员会在定义时被拒绝;创建环境时,则会针对实际模块验证其他所有名称。