--- title: 事件与触发 description: Gem 的流程(SOP)在什么时候开始运行:手动调用、定时、平台事件三种触发方式的写法、输入、限制和排查方法。 section: dev order: 60 updated: 2026-11-05 --- # 事件与触发 触发器回答一个问题:gem 里的一段流程(SOP)**什么时候开始运行**。一共三种方式: | 方式 | `trigger` 的写法 | 什么时候运行 | 输入从哪来 | | --- | --- | --- | --- | | [手动](#手动触发) | `{ "type": "manual" }` | 有人把它当工具调用时 | 调用方传的参数 | | [定时](#定时触发) | `{ "type": "cron", "expr": "0 8 * * *" }` | 到了 cron 表达式指定的时间 | 写在触发器里的固定参数 `args` | | [平台事件](#事件触发) | `{ "type": "event", "source": "email", "name": "onNewMessage" }` | 平台发布了这个事件 | 事件的内容 | **gem 之间不能直接互相触发。** 事件只有平台自己发布的那几种(见[可用的事件](#可用的事件));gem 不能发布事件,也不能订阅另一个 gem 的活动。 ## 在哪里声明 触发器写在 SOP 的声明上,每个 SOP 有且只有一个。 在 Gem Studio 的草稿里,是 `manifest.json` 中 `kind` 为 `sop` 的那一项: ```json { "name": "digest", "kind": "sop", "entry": "workflows/digest.js", "description": "每天早上汇总新邮件。", "input_schema": { "type": "object", "properties": { "limit": { "type": "number" } } }, "trigger": { "type": "cron", "expr": "0 8 * * *", "args": { "limit": 20 } } } ``` 打包后的清单(`notochord.json`)里是 `workflows[]` 的一项,字段含义相同,只是 `input_schema` 写作 `inputSchema`: ```json "workflows": [ { "name": "digest", "entry": "workflows/digest.js", "description": "每天早上汇总新邮件。", "inputSchema": { "type": "object", "properties": { "limit": { "type": "number" } } }, "trigger": { "type": "cron", "expr": "0 8 * * *", "args": { "limit": 20 } } } ] ``` 不写 `trigger` 等于 `{ "type": "manual" }`。 不管声明的是哪一种,SOP 的写法都一样——一个函数,第一个参数就是这次触发带来的输入: ```js export default async function (input, ctx) { // input:手动调用的参数 / 定时触发的 args / 事件的内容 const mail = await ctx.call('tool.list_new_mail', { limit: input.limit ?? 20 }) return { processed: mail.messages.length } } ``` ## 手动触发 每个 SOP 都会被注册成一个工具,名字是 `gem.<gem id>.<SOP 名>`,参数就是它的 `input_schema`。`manual` 的意思是「只有这一种启动方式」;声明了定时或事件触发的 SOP 同样可以被手动调用。 - **谁能调用**:聊天里的 agent。这个工具不在会话的工具白名单里时,第一次调用会先问用户是否允许。 - **返回什么**:SOP 的返回值;运行留下了文件时是 `{ "output": <返回值>, "folder": "runs/…" }`,`folder` 是它在对话工作目录里的[运行文件夹](/zh/dev/permissions.md#运行文件夹)。如果流程中途挂起了(等待用户审批,或一次较长的等待),调用方拿到的是 `{ "status": "suspended", "run_id": "…", "folder": "runs/…" }`,流程之后在后台继续,结果仍然写进这个文件夹。失败或被取消时调用方收到错误。 - **文件和文件夹**:路径是普通字符串,工具用 `workspace:` 前缀在这次对话的文件夹里读写,见[权限模型](/zh/dev/permissions.md#对话文件夹)。 - **用谁的权限运行**:SOP 自己的——安装时记录下来的那一份,加上用户在 gem 页面上打开的。调用它的会话有什么权限,和它能做什么无关。 - 一个 SOP 不能调用另一个 SOP,也不能调用别的 gem 的工具。 ## 定时触发 ```json "trigger": { "type": "cron", "expr": "30 7 * * 1-5", "args": { "limit": 20 } } ``` `expr` 是标准的 5 段 cron 表达式,依次是:分、时、日、月、星期。 | 写法 | 含义 | 例子 | | --- | --- | --- | | `*` | 任意值 | `* * * * *` 每分钟 | | 数字 | 这个值 | `0 8 * * *` 每天 8:00 | | `a-b` | 范围 | `0 9-17 * * *` 9 点到 17 点的每个整点 | | `a,b,c` | 列表 | `0 0 1,15 * *` 每月 1 号和 15 号的 0:00 | | `*/n`、`a-b/n` | 步长 | `*/15 * * * *` 每 15 分钟 | 要注意的地方: - **按电脑的本地时间算**,不是 UTC。用户换了时区,触发时刻跟着变。 - **最小粒度是一分钟**。调度器每分钟检查一次,所以实际开始时间可能比表达式晚不到一分钟。 - **星期用 0–6,0 是星期日**。不支持 `7`,不支持 `MON` 这样的名字,也不支持 `@daily` 这类简写。 - **「日」和「星期」同时写了具体值时,两个都要满足**(`0 8 13 * 5` 是「既是 13 号又是星期五」的 8:00)。这一点和有些 cron 实现不同。 - 调度器读不懂的表达式,或者永远不会到点的表达式(比如 2 月 31 日),**在打包和安装时就会报错**。 **输入**:到点时没有人在场提供参数,所以输入是触发器里写死的 `args`(不写就是 `{}`)。`input_schema` 里的必填项必须都能在 `args` 里找到,否则打包和安装时报错。这种 SOP 没有对话:它的工具用 `workspace:` 读写的是用户在 gem 页面上给这个 gem 选的工作文件夹,没选就没有(见[权限模型](/zh/dev/permissions.md#定时和事件触发的-sop))。事件触发的 SOP 也一样。 **应用没开着的时候**不会运行。重新打开后: - 只错过了一次(下一次还没到点)——立刻补跑这一次; - 已经错过了不止一次——不补跑,等下一次到点。 所以「每天 8:00」的任务,电脑 10 点才开机,会在 10 点跑一次;关机三天再开,不会连跑三次,而是等到下一个 8:00。 ## 事件触发 ```json "trigger": { "type": "event", "source": "email", "name": "onNewMessage" } ``` 事件由平台发布,订阅了它的 SOP 各自开始一次运行,事件的内容就是 SOP 的 `input`。 ### 可用的事件 | `source` | `name` | 什么时候发布 | | --- | --- | --- | | `email` | `onNewMessage` | 用户的邮箱收到一封新邮件 | 这一版只有这一个。写了表里没有的事件——包括拼错的名字,和 `"source": "gem:<别的 gem>"` 这种指向另一个 gem 的写法——**打包和安装时会报错**,不会装上以后悄悄不触发。 这一版**不发布**的: - **知识库的变化**(`knowledge.document.changed`)。要处理用户新加的内容,用[定时触发](#定时触发),在运行时读:`ctx.knowledge.documents.list(collection)` 每项带 `updatedAt`,自己记下处理到哪里。见[知识库 API](/zh/dev/knowledge.md)。 - **gem 被卸载**(`gem.uninstalling`)。gem 之间不共享私有数据;要给别的 gem 用的信息,写进知识库。 ### email.onNewMessage 用户添加的邮箱账号每 5 分钟同步一次收件箱;每同步到一封新邮件,在它存好之后发布一次。 ```json { "accountId": "3f6c…", "accountEmail": "me@example.com", "id": "9d2e…", "messageId": "<CAF…@mail.example.com>", "fromAddr": "alice@example.com", "toAddrs": ["me@example.com"], "subject": "周五的会议", "date": 1760256000000 } ``` | 字段 | 说明 | | --- | --- | | `accountId` | 收到这封信的账号在应用内部的 id | | `accountEmail` | 收到这封信的账号自己的地址(不是从信的收件人里取的) | | `id` | 这封信在应用里的 id,读正文时和 `accountId` 一起交给 `ctx.email.getMessage` | | `messageId` | 邮件的 Message-ID;没有时是服务器上的 UID | | `fromAddr`、`toAddrs`、`subject` | 发件人、收件人列表、主题 | | `date` | 邮件的日期,毫秒时间戳 | 事件里**没有正文**,只有信封。 - **只有收件箱**(INBOX)里的新邮件会触发。 - **从收到到触发最多隔 5 分钟**(同步间隔)。应用没开着时到的邮件,下次打开同步到时再触发。 - **新添加账号时的历史邮件不触发**。账号第一次同步会回填一段历史,这期间一封也不发布。 - **只收到用户允许的账号**。用户在 gem 页面的「设置 › 它能读的邮箱」里选;没被选中的账号来的邮件不会触发这个 gem。**装好时一个账号都没有**,所以用户选之前这个触发不会发生。用户也可以选「所有账号」(以后新添加的也算)。改了以后,下一封邮件就按新的设置来。 - **要正文,由工具去读**。SOP 本身不能读邮件:给一个工具申请 `email` 权限(gem 级 `"email": true`),SOP 把事件里的 `accountId` 和 `id` 交给这个工具,工具调用 `ctx.email.getMessage({ accountId, id })` 拿到纯文本正文和附件列表。和别处一样,只能读用户在 gem 页面上允许的账号。见[工具的 `ctx.email`](/zh/dev/tools.md#ctxemail)。 一个完整的例子,每来一封新邮件,先由工具读出正文,再交给 gem 自己的 agent 处理: 清单里的相关部分(`agent.assistant` 是同一个 gem 里的一个 agent,这里省略了它的声明): ```json "permissions": { "email": { "read": true } }, "tools": [ { "name": "read_email", "entry": "tools/read_email.js", "description": "Read one email in full from the user's mailbox. Returns { subject, fromAddr, body }.", "inputSchema": { "type": "object", "required": ["accountId", "id"], "properties": { "accountId": { "type": "string" }, "id": { "type": "string", "description": "The message id the email.onNewMessage event gives." } } }, "permissions": { "email": { "read": true } }, "sideEffect": "read" } ], "workflows": [ { "name": "on_new_message", "entry": "workflows/on_new_message.js", "description": "Runs the assistant agent against every newly received email.", "inputSchema": { "type": "object", "required": ["accountId", "id", "messageId"], "properties": { "accountId": { "type": "string" }, "id": { "type": "string" }, "messageId": { "type": "string" }, "subject": { "type": "string" }, "fromAddr": { "type": "string" } } }, "trigger": { "type": "event", "source": "email", "name": "onNewMessage" } } ] ``` 在 Gem Studio 的草稿里,`permissions.email` 写成 `manifest.json` 顶层的 `"email": true`。 `tools/read_email.js`: ```js export default async function (input, ctx) { const message = await ctx.email.getMessage({ accountId: input.accountId, id: input.id }) return { subject: message.subject ?? '', fromAddr: message.fromAddr ?? '', body: message.body } } ``` `workflows/on_new_message.js`: ```js export default async function (input, ctx) { const email = await ctx.call('tool.read_email', { accountId: input.accountId, id: input.id }) await ctx.call('agent.assistant', { messageId: input.messageId, subject: email.subject, fromAddr: email.fromAddr, body: email.body }) return { ok: true, messageId: input.messageId } } ``` ## 触发之后:一次运行 三种方式启动的都是同一种东西——SOP 的一次运行。 - **每次触发是独立的一次运行**,有自己的记录。一封邮件一次、一个定时点一次;同一个事件有多个 gem 订阅时,各跑各的。 - **输入先过 `input_schema`**。不满足时这次运行不会开始:手动调用的调用方收到错误;定时和事件触发则只在日志里留一条记录。给事件触发的 SOP 写 `input_schema` 时,只把真正要用的字段列为必填。 - **有自己的文件夹**。从聊天里调用的,放在那个对话的工作目录里;定时、事件触发的,放在 gem 自己的数据文件夹里。这次运行调用的工具和 agent 都在这里读写,见[运行文件夹](/zh/dev/permissions.md#运行文件夹)。 - **数据通过工具读写**。SOP 没有 `ctx.data`:要读写 gem 的集合、Log、文档、Blob,调用 gem 自己的工具(`ctx.call('tool.save_note', …)`)。`storage.kv.get`、`storage.kv.set`、`storage.sql.query`、`storage.sql.insert` 可以直接调用,不用声明。见 [SOP 怎么访问数据](/zh/dev/storage.md#sop-怎么访问数据)。 - **能做什么在安装时就定了**。SOP 里每个 `ctx.call('…')` 的目标在安装时被扫描出来、记录下来(列在 gem 的页面上);运行时调用不在这份清单里的目标会被拒绝。 - **有上限**。默认一次运行最多活跃 10 分钟、最多 500 次 `ctx.call`,可以用 `limits` 调整:`"limits": { "maxDurationMs": 600000, "maxCalls": 500 }`。挂起等待的时间不算在内。 - **可以挂起**。`ctx.call('wait.approval', …)` 会停下来等用户批准,`ctx.call('wait.sleep', …)` 可以等一段时间;应用重启后挂起的运行会接着跑。 - **同时运行的 SOP 有上限**(目前是 20 个),超出的排队。 - **在哪看**:gem 页面的「运行记录」列出这个 gem 每次运行和它的每一步;应用首页的「运行记录」列出所有 gem 的运行。等待审批的在那里批准或拒绝,运行中的可以取消,失败或取消的可以重试——重试时已经成功的步骤不重跑,从失败的那一步接着做。 ## 送达 - **至少一次,不是恰好一次。** 有两种情况会重复: - 事件发布后先落盘再分发。应用在分发途中崩溃,重启后这个事件会被重新分发,订阅它的 SOP 可能为同一个事件**开始第二次运行**。要防这种重复,用事件里自带的标识自己去重(邮件用 `messageId`)。 - 一次运行中途崩溃,恢复时**正在执行的那一步会重跑**(已经完成的步骤不重跑,直接取用上次的结果)。有外部副作用的工具应该用 `ctx.idempotencyKey` 去重——同一次运行的同一步,重跑时这个值不变。 - **不保证顺序。** 两封几乎同时到的邮件,对应的两次运行谁先谁后没有保证。 - **不会连锁。** gem 之间不能互相触发,gem 的运行也不会引出新的事件。 ## 安装和升级时用户看到什么 - gem 的页面会逐个列出 SOP:它怎么被触发,以及会调用哪些工具、agent 和平台服务。 - 订阅了邮件事件(或有工具申请了 `email` 权限)时,卡片上多一个账号选择框。 - 升级时如果新版本多了定时或事件触发器,卡片会提示这次更新要的权限比已安装的版本多。 - 触发器在安装(或升级)完成时就生效,不需要重启应用。 ## 排查 **装不上,报 `E_WORKFLOW_INVALID`** | 报错里的话 | 原因 | | --- | --- | | `is not an event the platform publishes` | 事件的 `source` / `name` 不在[可用的事件](#可用的事件)里,多半是拼写 | | `a gem cannot be triggered by another gem` | 触发器指向了另一个 gem | | `has a cron trigger that cannot be scheduled` | cron 表达式写法不支持,或永远不会到点 | **报 `E_INPUT_INVALID`**:定时触发器的 `args` 不满足 `input_schema`。 **装上了但没有触发** 1. 在首页的「运行记录」里看有没有这次运行。有而且失败了,点开看是哪一步。 2. 邮件事件:这个账号是不是用户现在允许的账号之一;邮件是不是在收件箱里;是不是刚添加账号、还在回填历史。 3. 定时:按本地时间算;应用当时是否开着。 4. `input_schema` 是否把事件里没有的字段列成了必填。
Notochord
简体中文English