426 lines
29 KiB
Markdown
426 lines
29 KiB
Markdown
# 008 手册
|
||
|
||
这本手册展示如何扩展 dsh-superagent。它使用 [001 领域模型](001-领域模型.md) 与 [002 授权](002-授权.md) 里的术语,以及 [007 架构](007-架构.md) 里的构件。
|
||
|
||
## 添加一个 Source
|
||
|
||
这份食谱实现一个名为 `webhook` 的 Source kind。它通过一个虚构的 CLI `hookq` 读取一个假想的消息队列,并把消息转换为 Triggers。其他所有接口都是 dsh-superagent 已有的部分。
|
||
|
||
实现有四项职责:翻译事件、定义接收与授权规则、提供回复与上下文、维护轮询循环。下面编号的步骤按顺序构建这些部件。
|
||
|
||
示例假定这样的 CLI 契约:
|
||
|
||
- `hookq pull --after <n> --json` 打印位置 `n` 之后的消息,最旧的在前:`{"me": "<the agent's id on the queue>", "items": […], "next": <position>}`。一个 item 有 `id`、`conversation`、`thread`(开启其 Thread 的消息的 id,该消息本身没有此字段)、带 `id` 与 `bot` 的 `sender`、`mentions_me`、`text`、`at` 和 `url`。
|
||
- `hookq reply --conversation <id> --to <message id> --key <key> --text <text>` 在某条消息下回复,每个 key 一次。对已被删除的 conversation,它失败并在标准错误上输出 `{"error": {"code": 410, "message": "…"}}`。
|
||
- `hookq thread --conversation <id> --root <message id> --json`,用 `--after <cursor>` 取后一页,打印一个 Thread 的一页,最旧的在前:`{"items": […], "next": "<cursor>"}`,最后一页没有 `next`。
|
||
|
||
Kind 及其插件条目放在 `src/sources/webhook.ts`,仿照 `src/sources/github.ts`。若这个 Source 变大,就像 Feishu Source 那样把翻译、Channel 与轮询代码拆成独立模块。
|
||
|
||
### 1. 把事件映射到领域
|
||
|
||
写代码之前先定下稳定的映射。Trigger ids、Thread ids 和 Channel 地址都会被持久化,之后改动它们可能让既有记录失联。
|
||
|
||
| 概念 | 对 webhook 队列而言 |
|
||
| --- | --- |
|
||
| Trigger | 一条消息 |
|
||
| Thread | 一个 conversation 里的一条消息及其回复:`<conversation>:<id of the first message>` |
|
||
| Actor | 发送者在队列上的 id,按允许名单写它的方式 |
|
||
| Channel 地址 | conversation,以及回复要挂在下面的那条消息 |
|
||
| 接收范围 | 一个 conversation,在准入表里有自己的一行 |
|
||
|
||
### 2. 配置:schema、字段、事实与健康
|
||
|
||
模块 import 后面各步骤要用到的东西:
|
||
|
||
```ts
|
||
import type { Context } from '@deepseek-ai/cordis'
|
||
import { setTimeout as delay } from 'node:timers/promises'
|
||
import { z } from 'zod'
|
||
import { key, threadKey, type OutboundMessage, type Trigger } from '../core/index.ts'
|
||
import { CliError } from '../infra/commands.ts'
|
||
import { KV } from '../kv.ts'
|
||
import { health, sourceContext, type SourceContext } from './host.ts'
|
||
import type { SourceKind } from './kind.ts'
|
||
```
|
||
|
||
```ts
|
||
const ConfigSchema = z.object({
|
||
conversations: z.array(z.object({
|
||
id: z.string().regex(/^[a-z0-9-]+$/),
|
||
listen: z.enum(['mentions', 'all']),
|
||
}).strict()).default([]),
|
||
pollSeconds: z.number().int().min(10).max(3600).default(30),
|
||
}).strict()
|
||
export type WebhookConfig = z.infer<typeof ConfigSchema>
|
||
|
||
export const webhookKind: SourceKind<WebhookConfig> = {
|
||
kind: 'webhook',
|
||
title: { zh: 'Webhook 队列', en: 'Webhook queue' },
|
||
description: { zh: '接收 webhook 队列里下列对话的消息。', en: 'Receives the messages of these conversations from the webhook queue.' },
|
||
schema: ConfigSchema,
|
||
fields: [
|
||
{ key: 'conversations', type: 'rows', label: { zh: '接收的对话', en: 'Conversations' }, columns: [
|
||
{ key: 'id', type: 'text', label: { zh: '对话', en: 'Conversation' }, pattern: '[a-z0-9-]+' },
|
||
{ key: 'listen', type: 'select', label: { zh: '接收', en: 'Receive' }, options: [
|
||
{ value: 'mentions', label: { zh: '仅 @', en: '@ only' } }, { value: 'all', label: { zh: '全部消息', en: 'All' } }] },
|
||
] },
|
||
{ key: 'pollSeconds', type: 'number', advanced: true, min: 10, max: 3600,
|
||
label: { zh: '轮询间隔 / 秒', en: 'Polling interval / seconds' } },
|
||
],
|
||
health: ['webhook'],
|
||
// find, scope, channel, threadContext, policy and environment follow in the next steps.
|
||
}
|
||
```
|
||
|
||
- `kind` 是稳定的 Source id。它出现在 Trigger 的 `source`、Thread-key 前缀、`source:webhook` 设置 block 和 Source Policy 里。
|
||
- `schema` 校验已保存的配置并提供默认值。在操作者保存一个实例之前,registry 会用那些默认值创建一个启用状态的实例。默认值应当什么都不接收;本示例默认为没有 conversation。
|
||
- `fields` 无需自定义面板代码就生成 Source 设置表单。一个 `rows` 字段编辑以其第一列作键的条目;`advanced` 隐藏不常用的设置。若 Source 能把 ids 解析成显示名,就实现 `refreshLabels`。把这些名字存到 `KV.sources.labels(kind)` 下,带 `choose` 与 `refresh` 的列随即可提供它们。
|
||
- 可选的 `facts(deployment)` 展示由 setup 推导的值,例如某个 CLI 背后的账号。目前 setup 只检查 `SETUP_FIELDS` 里列出的 GitHub 与 Feishu 字段,所以本示例不提供 facts。
|
||
- `health` 声明这个 Source 的健康条目,初始为 `starting`。注册会认领每个名字,并拒绝与 `runtime`、`reply`、`worker` 或 effect 种类等条目的冲突。用 kind 的名字,并把 `sync:<id>` 留给 Policy 同步。一个 `error` 会让状态脚本以 2 退出、让每小时检查通知操作者,所以只在可能需要人来处理时上报它。
|
||
|
||
### 3. 把一条消息翻译成 Trigger
|
||
|
||
转换之前先校验 CLI 的应答:
|
||
|
||
```ts
|
||
/** One message as `hookq pull` and `hookq thread` print it. */
|
||
const MessageSchema = z.looseObject({
|
||
id: z.string().min(1), conversation: z.string().min(1), thread: z.string().optional(),
|
||
sender: z.looseObject({ id: z.string(), bot: z.boolean().default(false) }), mentions_me: z.boolean().default(false),
|
||
text: z.string(), at: z.string(), url: z.string().optional(),
|
||
})
|
||
type QueueMessage = z.infer<typeof MessageSchema>
|
||
/** The answer of `hookq pull`, whose items are read one by one, and of `hookq thread`. */
|
||
const PageSchema = z.looseObject({ me: z.string(), items: z.array(z.unknown()), next: z.number().int() })
|
||
const ThreadPageSchema = z.looseObject({ items: z.array(MessageSchema), next: z.string().optional() })
|
||
```
|
||
|
||
```ts
|
||
/** The Trigger of one queue message; the agent's own messages produce none. */
|
||
export function webhookTrigger(message: QueueMessage, me: string): Trigger | undefined {
|
||
if (message.sender.id === me) return undefined
|
||
const thread = `${message.conversation}:${message.thread ?? message.id}`
|
||
return {
|
||
id: key('webhook', message.id),
|
||
source: 'webhook',
|
||
threads: [{ source: 'webhook', id: thread }],
|
||
actor: { source: 'webhook', id: message.sender.id },
|
||
human: !message.sender.bot,
|
||
...(message.mentions_me ? { addressed: 'mention' as const } : {}),
|
||
at: message.at,
|
||
text: message.text,
|
||
channel: { source: 'webhook', thread: `webhook:${thread}`,
|
||
address: { conversation: message.conversation, message: message.id, ...(message.url ? { url: message.url } : {}) } },
|
||
...(message.url ? { url: message.url } : {}),
|
||
meta: { conversation: message.conversation, messageId: message.id, root: message.thread ?? message.id },
|
||
}
|
||
}
|
||
```
|
||
|
||
- **稳定的 ids。** 通过 `key` 从队列 id 派生 Trigger id。`Engine.receive` 对已存储的 id 返回 `duplicate`,重复的 accept 也只创建一个 Ticket。因此重读消息是安全的。要把编辑当作新事件,就在 id 里包含编辑版本,如同 GitHub 对评论用 `updated_at` 那样。
|
||
- **Threads。** 第一个 Thread 是 primary,拥有那个 Eval。额外的 Threads 增加 Correlation 上下文。id 由 Source 选定;它的完整 key 是 `webhook:<id>`。
|
||
- **发送者。** 在 `actor` 里使用 Source 原生的 id。bot 与 CI 设 `human: false`。名单里的 bot 只能成为 inferred 候选。空的 actor id——如 CI 的情形——使用针对未列出、未被 at 的发送者的准入单元格。只有消息确实 at 了智能体时才设 `addressed` 为 `mention` 或 `direct`;这让名单中者的消息获得 explicit 强度。绝不为智能体自己的消息产出 Trigger。
|
||
- **回复去向与元数据。** `channel` 指明回复去向;`url` 链接到源消息。`channel.thread` 为有序投递给消息分组:一个 Ticket 的较晚消息排在较早消息之后,直到某条较早消息失败三次。其他 Tickets 不排队。Source 专属字段留在 `meta`,比如接收它的 conversation 与 Thread root。`meta` 与 `text` 都是不可信数据,不是授权。
|
||
|
||
Agents 可以通过 `orchestrator_read trigger/<id>` 与 `thread/<key>` 读取新 kind。它们暴露消息文本、处置结果与相邻 Triggers。新 kind 不会自动获得内建技能、原始服务记录的资源或 pull-request 绑定。
|
||
|
||
文本搜索无需额外代码即可工作。当人们使用 Source 专属的指称——如消息 id、URL 或 `#12`——时才实现 `find(query)`。返回那些指称所标识的 Thread keys 与搜索词:
|
||
|
||
```ts
|
||
find: query => ({ threads: [], terms: [...new Set(query.match(/\bm[0-9]+\b/g) ?? [])] }),
|
||
```
|
||
|
||
这里,查询里的队列消息 id(如 `m42`)会去搜索具有它的 Triggers。
|
||
|
||
### 4. Source 接收什么:scope
|
||
|
||
```ts
|
||
scope({ meta }, config) {
|
||
const conversation = config.conversations.find(item => item.id === meta.conversation)
|
||
if (!conversation) return { listened: false, reason: 'conversation_not_listed' }
|
||
return { listened: true, observed: conversation.listen === 'all', row: conversation.id }
|
||
},
|
||
```
|
||
|
||
Engine 对每个进来的 Trigger 经由 `SourceRegistry` 调用 `scope`。范围之外的消息作为 filtered 存储并带上原因,不创建 Thread。把消息交给 `receive`,而不是在轮询循环里悄悄丢弃,这样面板才能解释过滤。
|
||
|
||
`observed: true` 表示 Source 接收该范围内未被 at 的消息,允许对它们做准入表判断。`row` 标识对应的准入行。
|
||
|
||
重新校验会用 Mandate 存储的元数据调用 `scope`。让它只依赖 `meta` 与配置。恢复要求的是 `listened`,不是 `observed`。没有 `scope` 时,一个 kind 接收它产出的每个 Trigger,但不 observe 任何消息。
|
||
|
||
### 5. 回复:Channel
|
||
|
||
```ts
|
||
channel: {
|
||
allowed: (address, config) => config.conversations.some(item => item.id === address.conversation),
|
||
send: sendWebhook,
|
||
link: address => ({ url: address.url ?? '',
|
||
label: { zh: `Webhook 对话 ${address.conversation}`, en: `Webhook conversation ${address.conversation}` } }),
|
||
},
|
||
```
|
||
|
||
```ts
|
||
export async function sendWebhook(host: Pick<SourceContext, 'commands'>, message: OutboundMessage): Promise<'sent' | 'gone'> {
|
||
const { conversation, message: to } = message.channel.address
|
||
if (!conversation || !to) throw new Error('Missing webhook reply target')
|
||
try {
|
||
await host.commands.run('hookq', ['reply', '--conversation', conversation, '--to', to, '--key', message.id,
|
||
'--text', message.text])
|
||
return 'sent'
|
||
} catch (error) {
|
||
if (error instanceof CliError && error.code === 410) return 'gone'
|
||
throw error
|
||
}
|
||
}
|
||
```
|
||
|
||
- **幂等的发送。** Outbox 调用 `SourceRegistry.send`,后者调用 `channel.send`。Source 插件被卸载期间,消息等待。发送可能在远端服务已收到之后才失败,所以重试不得重复发消息。用 `message.id` 作幂等键:`hookq` 支持 `--key`;GitHub 存一个隐藏的评论标记;Feishu 接受一个幂等键。
|
||
- **永久失败与可重试失败。** 成功返回 `sent`,只有永久无法投递时才返回 `gone`,例如示例中的 410。一条 gone 消息作为 dropped 保留,不再排队;它出现在 `GET sources` 和 Source 页面上。其他失败要抛出,让 Outbox 在 5 秒后重试,倍增到 300 秒。`CliError.code` 是 CLI 输出报告的代码。
|
||
- **允许的目的地。** `allowed(address, config)` 只检查已保存的地址与当前配置。把每个必需的事实放进地址;例如 Feishu 记录 chat 是否 direct。一个不再被监听的目的地会被丢弃,而被停用的 Source 让消息保持等待。没有 `allowed` 时,Source 启用期间所有地址都允许。
|
||
- **链接。** `link(address, links)` 返回 `SourceRegistry.link` 供面板展示的 URL 与标签。
|
||
|
||
### 6. 供研判使用的 Thread 上下文
|
||
|
||
```ts
|
||
threadContext: {
|
||
messageId: trigger => String(trigger.meta.messageId ?? ''),
|
||
// A queue thread is flat: its root, and the message a reply answers, are on its first page, as on GitHub.
|
||
read: async (commands, trigger, _part, cursor) => {
|
||
const page = ThreadPageSchema.parse(JSON.parse(await commands.run('hookq', ['thread', '--conversation',
|
||
String(trigger.meta.conversation), '--root', String(trigger.meta.root), '--json', ...(cursor ? ['--after', cursor] : [])])))
|
||
const messages = page.items.map(item => ({ id: item.id, from: item.sender.id, at: item.at, text: item.text,
|
||
...(item.sender.bot ? { bot: true as const } : {}), ...(item.url ? { url: item.url } : {}) }))
|
||
return { messages, ...(page.next ? { next: page.next } : {}), body: { messages } }
|
||
},
|
||
},
|
||
```
|
||
|
||
主动研判需要 Trigger 周围的对话。`orchestrator_eval` 读一个初始窗口;`orchestrator_eval_read` 展开分页、root 或父消息。实现可选的 `threadContext` 接口来提供这些读取:
|
||
|
||
- `read(commands, trigger, part, cursor, config)` 返回一个 `ContextPart`。它的 `messages` 使用 `ContextMessage`:`{id, from, at, text}` 加上适用的 `bot`、`deleted`、`reply_to`、`url`、`path` 与 `line` 字段。最后一页省略 `next`。`body` 是展示给研判 Session 的内容,这里是同样的消息。可选的 `status` 可描述特殊结果,如 Feishu 的 `chat_neighbours`。`part` 是 `thread`、`root` 或 `parent`。
|
||
- `messageId(trigger)` 在这些消息中找到 Trigger 自己的那条。
|
||
- `images(text)` 与 `image(…)` 供消息带图片的 kind 使用,如 Feishu;示例略去了它们。
|
||
|
||
没有 `threadContext` 的 kind 不给研判提供任何 Thread 上下文;Trigger 自己的文本仍可读。
|
||
|
||
若消息会回复更早的消息,就加 `repliesTo(trigger)`。它返回对应的 Trigger id,让跟进通知与 worker Inputs 能在已存储时展示被回复的消息。例如,若 `hookq` 提供 `reply_to` 且翻译把它存为 `meta.replyTo`:
|
||
|
||
```ts
|
||
repliesTo: trigger => typeof trigger.meta.replyTo === 'string' ? key('webhook', trigger.meta.replyTo) : undefined,
|
||
```
|
||
|
||
### 7. 谁可以提需求:Source 的 Policy 部分
|
||
|
||
```ts
|
||
policy: {
|
||
schema: z.object({ allowed: z.array(z.object({ id: z.string().min(1), name: z.string().trim().max(100).optional() }).strict())
|
||
.default([]) }).strict(),
|
||
fields: [{ key: 'allowed', type: 'rows', label: { zh: '能发起工作的发送者', en: 'Senders who may originate work' }, columns: [
|
||
{ key: 'id', type: 'text', label: { zh: '发送者', en: 'Sender' } },
|
||
{ key: 'name', type: 'text', label: { zh: '名字', en: 'Name' }, optional: true }] }],
|
||
explicit: { zh: '名单里的人在消息里 @ 智能体。', en: 'Someone on the list mentions the agent in a message.' },
|
||
rows: stored => ConfigSchema.parse(stored).conversations.map(conversation => {
|
||
const all = conversation.listen === 'all'
|
||
return { id: conversation.id, label: conversation.id, note: all ? { zh: '全部消息', en: 'All' } : { zh: '仅 @', en: '@ only' },
|
||
cells: { listedUnaddressed: all, unlistedAddressed: true, unlistedUnaddressed: all } }
|
||
}),
|
||
},
|
||
```
|
||
|
||
- `schema` 校验 Source Policy,包括 `allowed` 发送者 ids。id 比较不区分大小写。准入表单独校验、跨 kinds 共享。这部分 Policy 存于 `policy.sources.webhook`,并参与 Policy 版本。
|
||
- `fields` 生成 **Eval → On each Source** 下的表单,从 `allowed` 开始。`explicit` 解释如何 at 智能体。可选的 `unnamed: {person, bot}` 为 Feishu 的 `ou_…` 这类不透明 ids 提供显示标签;示例省略它,因为队列 id 是可读的名字。
|
||
- `rows(config)` 列出准入范围及其可用单元格。每行的 id 必须匹配 `scope` 返回的 `row`。未被 at 的消息的单元格,只在 Source observe 那些消息的地方可用。没有 `rows` 的 kind 使用默认单元格。
|
||
- 可选的 `bots` 列出有 inferred 候选资格的 bots,使用与 `allowed` 相同的条目形状。没有它,任何 bot 都不被准入。
|
||
- 可选的 `sync: {label, ready(part), run(host, part, signal)}` 在启动与 **Sync now** 时刷新成员名单。Feishu 用 chat 成员资格。失败出现在 `sync:<id>` 下,一分钟后重试,倍增到一小时直到成功。
|
||
|
||
### 8. 给 Executor 的变量
|
||
|
||
```ts
|
||
environment: {
|
||
variables: [{ key: 'DSH_SUPERAGENT_WEBHOOK_CONVERSATION', description: {
|
||
zh: '接下工单的 webhook 对话;工单不是从这个来源来的时为空',
|
||
en: 'The webhook conversation that started the ticket; empty when the ticket did not start on this Source' } }],
|
||
values: ({ trigger }) => ({
|
||
DSH_SUPERAGENT_WEBHOOK_CONVERSATION: trigger?.source === 'webhook' ? String(trigger.meta.conversation ?? '') : '' }),
|
||
},
|
||
```
|
||
|
||
Execution 设置即使在 Source 停用时,也会在 prepare command 旁展示声明的 `variables`。启用期间,`values` 用 deployment、配置与发起的 Trigger 提供 Ticket 专属的值。
|
||
|
||
prepare command 读取环境变量;worker 在其指令中读取这些值。使用 `DSH_SUPERAGENT_<KIND>_…` 前缀,对来自其他 Source 的 Tickets 返回空值,且永不包含凭据。
|
||
|
||
### 9. 插件条目及其循环
|
||
|
||
```ts
|
||
export const name = 'dsh-superagent-webhook'
|
||
export const inject = ['orchestrator']
|
||
export function apply(ctx: Context): void {
|
||
ctx.effect(() => ctx.orchestrator.sources.register(webhookKind), 'webhook: source kind')
|
||
ctx.effect(() => {
|
||
const source = sourceContext(ctx.orchestrator, webhookKind)
|
||
const controller = new AbortController()
|
||
// DSH ends its process on an unhandled rejection, so a failure that escapes the loop becomes the Source's health.
|
||
const running = watch(source, controller.signal)
|
||
.catch((error: unknown) => { source.report('webhook', { state: 'error', detail: String(error) }) })
|
||
return async () => { controller.abort(); await running }
|
||
})
|
||
}
|
||
```
|
||
|
||
插件注入 `orchestrator`。它的第一个 effect 注册 kind,并在销毁时注销。第二个用 `SourceContext` 运行轮询循环。销毁会 abort 循环并等待其完成,包括应用停止或重启期间。
|
||
|
||
```ts
|
||
async function watch(ctx: SourceContext, signal: AbortSignal): Promise<void> {
|
||
await ctx.ready
|
||
// Setup restarts dsh-superagent once it resolves the accounts; until then the Source reads nothing.
|
||
if (!ctx.configured()) { ctx.report('webhook', health('paused', 'setupPending')); return }
|
||
let failures = 0
|
||
while (!signal.aborted) {
|
||
const config = ctx.config<WebhookConfig>('webhook')
|
||
if (!config) ctx.report('webhook', health('paused', 'sourceOff'))
|
||
else {
|
||
const at = new Date().toISOString()
|
||
try {
|
||
const { read, unreadable } = await pollQueue(ctx, signal)
|
||
failures = 0
|
||
ctx.report('webhook', { state: 'ready', detail: `${read} messages, ${unreadable} unreadable`, at })
|
||
} catch (error) {
|
||
failures += 1
|
||
// One network failure is retried on the next round; three in a row need a person.
|
||
if (!signal.aborted && failures >= 3) {
|
||
ctx.report('webhook', health('error', 'pollFailures', { count: String(failures), error: String(error) }))
|
||
}
|
||
}
|
||
}
|
||
await delay((config?.pollSeconds ?? 30) * 1000, undefined, { signal }).catch(() => {})
|
||
}
|
||
}
|
||
|
||
/** Read the queue after the cursor, take in each message, then move the cursor past them. */
|
||
export async function pollQueue(ctx: SourceContext, signal: AbortSignal): Promise<{ read: number; unreadable: number }> {
|
||
const after = ctx.state.get(KV.webhook.cursor) ?? 0
|
||
const output = await ctx.commands.withSignal(signal).run('hookq', ['pull', '--after', String(after), '--json'])
|
||
const page = PageSchema.parse(JSON.parse(output))
|
||
let unreadable = 0
|
||
for (const item of page.items) {
|
||
if (signal.aborted) throw new Error('The webhook Source stopped during the read')
|
||
const message = MessageSchema.safeParse(item)
|
||
if (!message.success) { unreadable++; continue }
|
||
const trigger = webhookTrigger(message.data, page.me)
|
||
if (!trigger) continue
|
||
await ctx.receive(trigger)
|
||
const conversation = { label: { zh: '对话', en: 'Conversation' }, value: message.data.conversation }
|
||
for (const ref of trigger.threads) ctx.threads.describe(threadKey(ref), { facts: { conversation } })
|
||
}
|
||
ctx.state.set(KV.webhook.cursor, page.next)
|
||
return { read: page.items.length, unreadable }
|
||
}
|
||
```
|
||
|
||
|
||
- **Setup 与开关。** 等待 `ctx.ready`,在 `ctx.configured()` 为 false 时什么都不读。每一轮都读 `ctx.config(kind)`:Source 停用时它是 undefined。
|
||
- **游标。** 用 `KV`(`src/kv.ts`)里有类型的条目把读取位置存进 `ctx.state`,例如 `webhook: {cursor: record<number>('webhook-cursor')}`。`tests/store.spec.ts` 要求 key 字面量只出现在那个 registry 里。外部包可以用导出的 `record` 与 `family` 定义 keys。把 record 设为 `undefined` 会移除它;`ctx.atomic` 把写入归组进一个事务。
|
||
|
||
只有在每个被覆盖的 Trigger 都已存储之后才推进游标。被打断的一轮保持游标不变;稳定的 ids 让重放安全。对时间戳游标,`src/sources/poll.ts` 的 `cursorAt(started)` 会减去五秒,以容纳远端的迟到写入。
|
||
- **Thread 元数据。** 收到一个 Trigger 后,调用 `ctx.threads.describe(key, {label, url, facts})` 填好它的展示信息。`ctx.threads.patch(key, meta)` 委托给 `threads.patchMeta`,设置 Source 拥有的键,或在值为 `undefined` 时移除它们。两者都会忽略不存在的 Threads,包括 filtered Triggers 的。
|
||
- **逐条错误。** 计数畸形 item 并继续,而不是让整批失败。`src/infra/commands.ts` 里的 `validItems(item, list, tally)` 支持这种模式。对独立轮询的 Threads,用 `src/sources/poll.ts` 的 `pollThreads`;它把每次失败记为 `pollError`。它的 `polled` 谓词保留 live 订阅,以及较晚的 Trigger 与 Ticket 结束之后两周的窗口。
|
||
- **健康。** 只通过 `ctx.report` 写声明过的条目。`health(state, code, values)` 提供固定消息,如 `setupPending`、`sourceOff` 与 `pollFailures`。上报 `error` 前先数连续失败次数。
|
||
- **Abort。** 把 signal 传给 `ctx.commands.withSignal(signal)` 与 `delay`,并在条目之间检查它,这样停止或重启能立刻结束循环。
|
||
|
||
本示例不发送 Source 发起的通知。需要它们的 Source 调用 `ctx.notify`;Outbox 像投递其他回复一样投递它们。
|
||
|
||
它也不把 Threads 绑定到 Ticket 完成。只在 Source 的 Thread 生命周期决定工作是否完成时才用 `ctx.tickets.bindThread` 与 `threadClosed`,如 GitHub pull requests 那样。Ticket 何时结束仍由 Engine 决定。
|
||
|
||
### 10. 注册插件
|
||
|
||
在 `tsdown.config.ts` 里把这个模块构建为它自己的 entry:
|
||
|
||
```ts
|
||
entry: { index: 'src/index.ts', 'github-watch': 'src/sources/github.ts', 'feishu-watch': 'src/sources/feishu.ts',
|
||
'webhook-watch': 'src/sources/webhook.ts' },
|
||
```
|
||
|
||
然后在 `package.json` 里导出构建产物:往 `exports` 加 `"./webhook-watch": "./lib/webhook-watch.js"`;再把该插件加进 bundle 的 `cordis.patch.yml` 的 `insert` 列表,处于禁用状态,就像 Feishu:
|
||
|
||
```yaml
|
||
- id: dsh-superagent-webhook
|
||
name: dsh-superagent/webhook-watch
|
||
disabled: true
|
||
```
|
||
|
||
在 profile 的 `cordis.patch.yml` 里用 `id: dsh-superagent-webhook` 与 `disabled: false` 启用该插件。构建并重启。新 Source 会出现在 Sources 设置和 **Eval → On each Source** 下。
|
||
|
||
把它的模块与 spec 加进 [007 架构](007-架构.md) 的 module 表和 test 表。`tests/docs.spec.ts` 检查两个索引是否完整。
|
||
|
||
### 11. 测试
|
||
|
||
仿照 `tests/sources.spec.ts` 的模式创建 `tests/webhook.spec.ts`:一个临时目录里的真实 Store、带 `HealthBoard` 的 `SourceRegistry`、用替身端口的 Engine,以及来自 `sourceContextOf` 的 `SourceContext`。
|
||
|
||
通过 spy `Commands.run` 来 mock CLI 调用。也要 spy `withSignal`,因为它通常返回一个新的 `Commands` 实例。
|
||
|
||
```ts
|
||
function host(overrides: Partial<SourceContextDeps> = {}) {
|
||
const store = new Store(join(mkdtempSync(join(tmpdir(), 'webhook-')), 'db.sqlite'))
|
||
store.saveSettings(SettingsSchema.parse({
|
||
sources: { webhook: { enabled: true, config: { conversations: [{ id: 'ops', listen: 'mentions' }] } } },
|
||
policy: { sources: { webhook: { allowed: [{ id: 'alice' }] } } },
|
||
}))
|
||
// The registry claims the kind's health entries on the board, where the test reads them.
|
||
const board = new HealthBoard()
|
||
const sources = new SourceRegistry(() => store.settings(), board)
|
||
sources.register(webhookKind)
|
||
const engine = new Engine({ repo: store, scope: sources, policy: () => settingsPolicy(store.settings(), sources),
|
||
assessor: { request: vi.fn() }, executor: { start: vi.fn(), deliver: vi.fn(), stop: vi.fn() },
|
||
outbox: { send: vi.fn(), status: () => undefined, withdraw: vi.fn() } })
|
||
const commands = new Commands(() => testDeployment(), {}, new AbortController().signal)
|
||
const receive = vi.fn((trigger: Trigger) => { const result = engine.receive(trigger); engine.evaluateDue(); return result })
|
||
// The Source's own notices go to the outbox, which keeps their receipts and sends nothing here.
|
||
const deps: SourceContextDeps = { store, engine, outbox: { send: message => { store.receipts.add(message) } }, commands, sources,
|
||
deployment: testDeployment(), ready: Promise.resolve(), configured: () => true, receive, ...overrides }
|
||
return { ...deps, store, engine, commands, receive, health: board.all, ctx: sourceContextOf(deps, webhookKind) }
|
||
}
|
||
/** The queue as `hookq` answers it. */
|
||
function queue(h: ReturnType<typeof host>, answer: () => unknown) {
|
||
vi.spyOn(h.commands, 'withSignal').mockReturnValue(h.commands)
|
||
return vi.spyOn(h.commands, 'run').mockImplementation(async () => JSON.stringify(answer()))
|
||
}
|
||
|
||
it('takes in what the queue holds after the cursor, once, and moves the cursor after it', async () => {
|
||
const h = host()
|
||
queue(h, () => ({ me: 'agent', items: [message('m1'), { id: 'broken' }, message('m2', { conversation: 'elsewhere' })], next: 3 }))
|
||
expect(await pollQueue(h.ctx, new AbortController().signal)).toEqual({ read: 3, unreadable: 1 })
|
||
expect(await h.receive.mock.results[0]!.value).toBe('stored')
|
||
expect(await h.receive.mock.results[1]!.value).toBe('filtered')
|
||
expect(h.store.get(KV.webhook.cursor)).toBe(3)
|
||
// An allowed sender who mentions the agent makes an explicit request.
|
||
expect(h.engine.candidate(key('webhook', 'm1'))?.strength).toBe('explicit')
|
||
await pollQueue(h.ctx, new AbortController().signal)
|
||
expect(await h.receive.mock.results[2]!.value).toBe('duplicate')
|
||
h.store.close()
|
||
})
|
||
```
|
||
|
||
`message(id, fields)` 构造一个队列 item。至少覆盖:
|
||
|
||
- 翻译:稳定的 id、Thread、actor、`human` 与 `addressed`,且智能体自己的消息不产生 Trigger;
|
||
- 一轮:Triggers 只存储一次、一个范围之外的被 `filtered`、游标在其之后移动、名单中者的 mention 被研判为 `explicit`;
|
||
- 被打断的一轮,其游标保持不变;
|
||
- `send`:`sent`、仅对 410 返回 `gone`、任何其他失败抛出,CLI 的错误用 `commandError` 构造;
|
||
- Thread 上下文:一页的消息与下一页的游标,如同 `tests/registry.spec.ts` 通过 `SourceRegistry.threadContext` 读取一个 kind 的 Threads;
|
||
- 插件:`apply` 注册 kind,并在 setup 等待时上报 `setupPending`;它的 disposers 注销 kind;逃出循环的失败变成该 Source 的健康状态,而不是 unhandled rejection;
|
||
- Source 的 Policy 部分及其准入行,如同 `tests/registry.spec.ts` 与 `tests/policy.spec.ts` 检查 Feishu Source 的那样。
|
||
|
||
### 检查清单
|
||
|
||
1. 映射 Triggers、Threads、actors、Channel 地址与接收范围。
|
||
2. 写出 kind:`schema`、`fields`、`health`,若 setup 推导了任何值再加 `facts`。
|
||
3. 把事件翻译成带稳定 id 的 Triggers,并丢掉智能体自己的。
|
||
4. 在 `scope` 里决定 Source 接收什么,只依据 `meta`。
|
||
5. 给它一个 Channel,其 `send` 每条消息只发一次,并且只在 conversation 永远无法接收时才回答 `gone`。
|
||
6. 若能读取 Thread,给研判提供它的 Thread 上下文。
|
||
7. 给它一个带 `allowed` 与准入行的 Policy 部分。
|
||
8. 注册它的执行环境变量。
|
||
9. 写好插件条目和一个循环:等待 setup、跟随开关、在 `receive` 之后移动游标、把错误保持在逐条层面、上报健康、abort 时停止;游标名字写进 `KV`。
|
||
10. 添加构建 entry、导出和 `cordis.patch.yml` insert,并把模块及其 spec 列进 007 的表格。
|
||
11. 像 `tests/sources.spec.ts` 测试其他 Sources 那样测试它。
|