用 Flue + Chat SDK 构建 GitHub Issue 评论双向对话 Agent:dispatch-only 架构实战
用 Flue + Chat SDK 构建 GitHub Issue 评论双向对话 Agent:dispatch-only 架构实战
导读
本文基于开源仓库 flue 中的 examples/chat-sdk 示例,完整讲解如何将 Chat SDK 的通道能力(GitHub Issue 评论的双向收发)与 Flue 的 Agent 执行引擎解耦组合:Webhook 只负责把消息送进队列,Agent 通过 dispatch() 无 HTTP 路由地被唤醒,再用一个工具函数把回复写回原线程。读完本文,你将掌握 Chat SDK 适配器的配置方法、'use agent' 指令与 dispatch-only Agent 的注册原理、以及一套完全本地化、可确定性复现的端到端测试方案。
一、示例定位:谁负责收发,谁负责思考
官方 README(examples/chat-sdk/README.md)开门见山给出了本示例的分工原则:
This example uses Chat SDK for bidirectional GitHub issue-comment messaging while Flue owns agent execution.
一句话概括:Chat SDK 管"嘴"(收发消息),Flue 管"脑"(Agent 执行)。两者通过 dispatch() 桥接,形成如下的数据流(原文流程图):
signed GitHub issue_comment webhook
-> Chat SDK GitHub adapter
-> dispatch(Assistant, ...)
-> Flue agent tool
-> bot.thread(threadId).post(...)
-> fake local GitHub comment API
这是 flue 官方示例中典型的"外部渠道集成"范式,与仓库中 examples/github-channel、examples/slack-channel 等通道示例并列,但它的特殊之处在于刻意不把 Agent 挂载成 HTTP 路由——Agent 是"私有"的,只通过消息派发被唤醒。
二、整体架构与目录结构
示例目录结构如下(见 examples/chat-sdk):
examples/chat-sdk/
├── README.md # 官方说明(本文核心骨架)
├── package.json # dev / build / test:e2e 脚本
├── vite.config.ts # flue() 插件 + 端口固定 3585
├── tsconfig.json # 继承根 tsconfig.base.json,含 workers 类型
└── src/
├── app.ts # Hono 入口:webhook + 伪造 GitHub 评论 API
├── chat.ts # Chat SDK 实例:适配器、内存状态、onNewMention
└── agents/
└── assistant.ts # 'use agent' 指令 + dispatch-only Agent
三个核心文件职责清晰:
| 文件 | 职责 | 关键导出 |
|---|---|---|
src/app.ts |
HTTP 入口,Hono 路由:接 Webhook、伪造评论 API、暴露测试端点 | default app |
src/chat.ts |
Chat SDK 机器人装配与消息→dispatch() 的桥接 |
bot、registerChatHandlers |
src/agents/assistant.ts |
Flue Agent 定义(含假模型 Provider 与回帖工具) | Assistant |
依赖关系
从 examples/chat-sdk/package.json 可以看到本示例的关键依赖:
@chat-adapter/github、@chat-adapter/state-memory:Chat SDK 的 GitHub 适配器与内存状态适配器;chat:Chat SDK 本体;@flue/runtime(workspace:*):Flue 运行时,提供dispatch、defineTool、useModel、useTool、setProvider等 API;@flue/vite(workspace:*):Vite 插件,负责构建期扫描'use agent'模块并注册 Agent;@flue/cli(workspace:*):CLI(如flue run);hono4.12.32:HTTP 框架;@earendil-works/pi-ai:提供脚本化的假模型 Provider(faux provider)与假消息构造工具;valibot:工具入参 schema 校验。
三、Chat SDK 装配:适配器、状态与并发策略
src/chat.ts(examples/chat-sdk/src/chat.ts)是整条管线的装配中心,代码量不大但信息密度很高:
import { createGitHubAdapter } from '@chat-adapter/github';
import { createMemoryState } from '@chat-adapter/state-memory';
import { type Agent, dispatch } from '@flue/runtime';
import { Chat } from 'chat';
const webhookSecret = 'chat-sdk-example-secret';
const githubApiUrl = process.env.CHAT_SDK_GITHUB_API_URL ?? 'http://localhost:3585/api/github';
export const bot = new Chat({
userName: 'flue-bot',
adapters: {
github: createGitHubAdapter({
token: 'chat-sdk-example-token',
webhookSecret,
userName: 'flue-bot',
botUserId: 1,
apiUrl: githubApiUrl,
}),
},
state: createMemoryState(),
concurrency: 'queue',
});
3.1 适配器配置项逐项说明
createGitHubAdapter 是本例的对外"收发接口",各配置项含义如下:
| 配置项 | 示例值 | 作用 |
|---|---|---|
token |
'chat-sdk-example-token' |
GitHub API 访问令牌(示例中为占位值,生产环境应使用真实 PAT) |
webhookSecret |
'chat-sdk-example-secret' |
GitHub Webhook 签名密钥,用于校验 issue_comment 事件的签名 |
userName |
'flue-bot' |
机器人在线程中呈现的用户名 |
botUserId |
1 |
机器人用户 ID,用于识别"提到我"的消息 |
apiUrl |
http://localhost:3585/api/github |
GitHub REST API 的覆写地址。这是本示例能在本地全量跑通的关键:SDK 的出站评论请求会被重定向到本地伪造 API,而不是真实 GitHub |
注意 apiUrl 默认值可以通过环境变量 CHAT_SDK_GITHUB_API_URL 覆盖,这为接入真实 GitHub 或 CI 环境提供了配置入口。
3.2 状态适配器与并发策略
state: createMemoryState():使用 内存状态适配器,机器人会话状态不落盘。README 明确指出这是为测试的可确定性服务的——生产集成应替换为持久化状态适配器(如数据库/对象存储),否则重启即丢失会话上下文。concurrency: 'queue':将并发消息按队列串行处理,避免同一线程内消息乱序或互踩。对于 GitHub Issue 评论这类强顺序场景,"队列"是稳妥的选择。
3.3 桥接函数 registerChatHandlers
let handlersRegistered = false;
export function registerChatHandlers(agent: Agent): void {
if (handlersRegistered) return;
handlersRegistered = true;
bot.onNewMention(async (thread, message) => {
await dispatch(agent, {
id: thread.id,
message: {
kind: 'signal',
type: 'chat.github.mention',
body: message.text,
attributes: { threadId: thread.id, messageId: message.id },
},
});
});
}
要点:
handlersRegistered守卫保证同一进程内只注册一次,避免 HMR(热更新)或重复调用时重复绑定;bot.onNewMention监听"@机器人"的评论消息,把thread与message打包成一个 signal 消息 派发给 Agent;- 派发载荷的
id: thread.id使 Agent 实例与 GitHub 线程一一对应(同一 Issue 的后续评论会路由到同一个 Agent 实例,保持对话状态); - signal 类型
chat.github.mention与threadId、messageId属性一起被序列化进消息,Agent 侧可以据此还原"这条消息来自哪个线程"。
四、dispatch-only Agent:无 HTTP 路由的私有 Agent
README 用一整段强调了本示例最反直觉的设计:
The assistant agent is dispatch-only: its module carries the
'use agent'directive (which registers it with the app at build time), butsrc/app.tsnever mounts it — the webhook handler reaches it throughdispatch()with no HTTP route.
4.1 'use agent' 指令与构建期注册
src/agents/assistant.ts 第一行就是魔法指令:
'use agent';
在 flue 的运行时中,'use agent' 模块是 Agent 的标准注册入口——由 @flue/vite 的 flue() 插件在构建期扫描识别,将其导出(本示例为 Assistant 函数)注册到应用。这一点可以从 packages/runtime/src/runtime/flue-app.ts 的 dispatch() 签名得到印证:
export async function dispatch(
agent: Agent,
request: AgentDispatchRequest,
): Promise<DispatchReceipt> {
// ... 校验 runtimeConfig 与 agent 函数合法性
return enqueueDispatch({
request: resolveAgentDefinitionDispatchRequest(agent, request),
dispatchQueue: rt.dispatchQueue,
});
}
dispatch() 要求第一个参数是"已注册到本应用的 Agent 函数"('use agent' 模块的导出,或 start() 入口),随后把请求压入运行时的派发队列(rt.dispatchQueue)。因此只要 Agent 被构建期扫描注册过,不挂 HTTP 路由也能被 dispatch() 命中。
src/app.ts 末尾的注释把对比写得非常直白:
Mounting would be one extra line —
app.route('/agents/assistant', createAgentRouter(Assistant))— but this webhook pipeline never exposes the agent over HTTP, so it stays private.
也就是说,如果想让 Agent 也能被 HTTP 调用,只需一行 app.route('/agents/assistant', createAgentRouter(Assistant));但本示例刻意不这么做——Agent 是纯私有组件,只经由 Chat SDK 的消息通道触达。这带来两个直接收益:攻击面更小(不暴露 Agent 的 HTTP 接口),且职责单一(Agent 只服务聊天渠道)。
4.2 从 flue-app.ts 看 dispatch 的运行时契约
再补充一个实现细节:packages/runtime/src/runtime/flue-app.ts 中的 dispatch() 在调用前会校验两点:
runtimeConfig必须已配置,否则抛出'[flue] dispatch() called before runtime was configured...'——即dispatch()只能用在 Flue 构建的 server 入口内;- 第一个参数必须是 Agent 函数,否则抛出
InvalidRequestError,并给出提示'dispatch() requires an agent function as its first argument: dispatch(agent, { id, message }).'。
同时,交付的持久性取决于目标运行时:Node 目标默认使用进程生命周期的内存队列,而 Cloudflare 目标会把工作持久化提交到目标 Agent 的 Durable Object 并在中断后重试。因此 Cloudflare 上的处理可能是"至少一次"(at-least-once),README 建议把外部副作用设计成幂等——这是将本示例迁往生产环境时必须注意的约束。
五、Agent 实现:假 Provider、两段式脚本与回帖工具
src/agents/assistant.ts(examples/chat-sdk/src/agents/assistant.ts)把"无密钥、可确定性"的离线测试思路贯彻到了模型层。
5.1 脚本化假 Provider(模块作用域)
const faux = fauxProvider({
api: 'chat-sdk-example',
provider: 'chat-sdk-example',
models: [{ id: 'assistant' }],
});
setProvider(faux.provider);
fauxProvider来自@earendil-works/pi-ai,提供一个无需 API key 的假模型 Provider,模型 id 为assistant;- 关键设计:
setProvider放在模块作用域而不是 Agent 函数体内。注释解释得很清楚——Agent 函数是一个"渲染(render)",可能被反复执行,所以一次性初始化必须外置到模块作用域,这也意味着该 Provider 对flue run同样生效。
5.2 两段式脚本队列:无限循环的"工具调用→收尾"
function queueExchange(): void {
faux.appendResponses([
(context) => {
// ...从最后一条消息解析文本与 threadId...
const threadId = /threadId\s*=\s*"([^"]+)"/.exec(text)?.[1] ?? '';
return fauxAssistantMessage(
fauxToolCall('reply_to_chat_thread', { threadId, text: 'Reply from a Flue agent through Chat SDK.' }),
{ stopReason: 'toolUse' },
);
},
() => {
queueExchange();
return fauxAssistantMessage(fauxText('Reply sent.'));
},
]);
}
queueExchange();
这段代码是离线确定性测试的核心技巧:
- 假响应按模型调用逐个消费:每次模型调用消耗一个脚本响应;
- 第一段响应让模型"调用"
reply_to_chat_thread工具(stopReason: 'toolUse'),并从 dispatch 消息中正则提取threadId。注意注释中的说明:派发的 signal 消息渲染给模型时是带属性的 XML 风格标签(<signal ... threadId="...">),所以可以用/threadId\s*=\s*"([^"]+)"/提取; - 第二段响应返回最终文本
'Reply sent.',同时递归调用queueExchange()重新排队——从而对每一条 dispatch 进来的聊天消息,模型都会执行"工具调用→收尾文本"这一轮完整交互,可以无限循环、随时续接。
5.3 回帖工具 reply_to_chat_thread
const replyToChatThread = defineTool({
name: 'reply_to_chat_thread',
description: 'Post a response into the originating Chat SDK thread.',
input: v.object({
threadId: v.string(),
text: v.string(),
}),
async run({ data }) {
await bot.thread(data.threadId).post(data.text);
return 'Reply sent.';
},
});
- 使用
defineTool+valibot声明入参 schema(threadId与text均为字符串); - 执行体只有一行核心逻辑:
await bot.thread(data.threadId).post(data.text)——通过 Chat SDK 的线程句柄把回复写回发起线程,这正是 README 流程图中的bot.thread(threadId).post(...)一步; - 工具执行是异步的,
await保证了回复发出后工具才算完成,返回字符串'Reply sent.'作为给模型的观察结果。
5.4 Agent 函数本体
export function Assistant() {
useModel('chat-sdk-example/assistant');
useTool(replyToChatThread);
return 'When receiving a chat message, use reply_to_chat_thread to reply in the supplied thread.';
}
Agent 是极简的声明式函数:useModel 指定使用假模型,useTool 挂载回帖工具,返回值作为 Agent 的系统提示词,告诉模型"收到聊天消息时用 reply_to_chat_thread 在指定线程中回复"。
六、Hono 入口:Webhook 与伪造 GitHub 评论 API
src/app.ts(examples/chat-sdk/src/app.ts)用 Hono 组装了三个路由:
6.1 GitHub Webhook 接入
const app = new Hono();
app.post('/webhooks/github', (c) =>
bot.webhooks.github(c.req.raw, {
waitUntil: (task) => {
try {
c.executionCtx.waitUntil(task);
} catch {
void task;
}
},
}),
);
- 把
issue_comment的 signed webhook 原样交给 Chat SDK 的 GitHub webhook 处理器; waitUntil回调把任务交给运行时的事件循环(Hono/Workers 的executionCtx.waitUntil),确保后台派发任务不会被请求生命周期提前终止;若上下文不可用则降级为void task(即不等待,让其自生自灭)。
6.2 伪造的 GitHub 评论 API(本地测试的关键)
const outboundComments: Array<{ issueNumber: number; body: string }> = [];
app.post('/api/github/repos/:owner/:repo/issues/:issueNumber/comments', async (c) => {
const issueNumber = Number(c.req.param('issueNumber'));
const payload = await c.req.json<{ body?: string }>();
const body = typeof payload.body === 'string' ? payload.body : '';
outboundComments.push({ issueNumber, body });
return c.json({
id: outboundComments.length,
body,
user: { id: 1, login: 'flue-bot', type: 'Bot' },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
});
app.get('/test/outbound-comments', (c) => c.json(outboundComments));
这正是 README 流程图最后一步 fake local GitHub comment API 的实现:
- 模拟 GitHub 的
POST /repos/:owner/:repo/issues/:issueNumber/comments端点,把 Agent 出站的评论"假装"发成功,返回符合 GitHub 响应结构的 JSON(含id、user、时间戳); - 所有出站评论被累积到
outboundComments数组,配合GET /test/outbound-comments端点,端到端测试可以断言"Agent 到底往哪个 Issue、发了什么内容"——这就是本地确定性验证的闭环。
6.3 为什么端口必须固定 3585
export default defineConfig({
plugins: [flue()],
server: { port: 3585, strictPort: true },
});
plugins: [flue()]:接入@flue/vite插件,承担构建期 Agent 扫描等职责;server: { port: 3585, strictPort: true }:dev server 强制占用 3585 端口,与chat.ts中 fake GitHub API 的默认地址http://localhost:3585/api/github严格对应——如果端口漂移,Agent 的出站评论就找不到"假的 GitHub"了。构建产物运行方式同理:PORT=3585 node dist/server.mjs。
七、运行与端到端测试
README 提供了两种运行方式。
7.1 开发模式(推荐)
pnpm run dev # vite dev on port 3585 (pinned in vite.config.ts)
pnpm run test:e2e # in another terminal
pnpm run dev启动 Vite dev server(端口 3585,strictPort强制占用);- 在另一个终端执行
pnpm run test:e2e跑端到端测试(对应 examples/chat-sdk/package.json 中的"test:e2e": "node ./test/e2e.mjs")。
7.2 生产构建模式
pnpm run build
PORT=3585 node dist/server.mjs
pnpm run test:e2e # in another terminal
pnpm run build由vite build产出生产构建(dist/server.mjs);- 用
PORT=3585环境变量启动 Node 服务端,端口与 fake API 约定保持一致; - 同样在另一个终端跑
pnpm run test:e2e。
7.3 本地测试为何是确定性的
README 对此有精确定位:
The fixture uses Chat SDK's in-memory state adapter and a scripted model provider so its end-to-end test is local and deterministic. Use persistent Chat SDK state for production integrations.
本示例从三个层面保证"本地且确定":
- 内存状态适配器(
createMemoryState):会话状态不落盘、不依赖外部服务; - 脚本化模型 Provider(
fauxProvider):不调用真实 LLM API,回复完全由脚本队列决定,结果可复现; - 伪造 GitHub API:出站评论打到本地端点并被记录,测试可以精确断言。
这三者组合起来,意味着在没有网络、没有 API key、没有真实 GitHub 仓库的情况下,整个"Webhook → dispatch → Agent 工具调用 → 回帖"链路都能在本地被端到端验证。一旦迁往生产,只需把内存状态换成持久化状态适配器、把 apiUrl 指向真实 GitHub(或用 CHAT_SDK_GITHUB_API_URL 注入)、接入真实模型 Provider 即可,装配层的代码结构无需改变。
八、延伸:如何把本示例迁移到生产
结合前文的分析,把该示例生产化的关键改动点可以归纳如下:
| 层面 | 示例现状 | 生产建议 | 依据 |
|---|---|---|---|
| 状态存储 | createMemoryState() |
替换为持久化状态适配器 | src/chat.ts + README 明确建议 |
| GitHub API | 本地 fake API(:3585) |
配置真实 apiUrl 或经 CHAT_SDK_GITHUB_API_URL 注入,并配置真实 token |
src/chat.ts |
| Webhook 密钥 | 硬编码示例值 | 用环境变量管理 webhookSecret,与 GitHub 仓库设置保持一致 |
src/chat.ts |
| 模型 | fauxProvider 脚本 |
替换为真实模型 Provider(setProvider 机制不变) |
src/agents/assistant.ts |
| 交付语义 | Node 内存队列 | 若部署到 Cloudflare,注意 at-least-once 语义,外部副作用保持幂等 | packages/runtime/src/runtime/flue-app.ts |
| Agent 暴露 | dispatch-only(不挂路由) | 如需 HTTP 调用,仅需追加 app.route('/agents/assistant', createAgentRouter(Assistant)) |
src/app.ts |
其中"至少一次"交付语义的说明来自 packages/runtime/src/runtime/flue-app.ts 的注释:Node 默认是进程生命周期内存队列,而 Cloudflare 会持久化提交到 Durable Object 并在中断后重试——所以设计外部副作用时务必幂等。
结语
examples/chat-sdk 这个示例的价值在于它示范了一种清晰的关注点分离:Chat SDK 负责渠道适配与消息收发,Flue 负责 Agent 执行与工具编排,二者通过 dispatch() 和 'use agent' 构建期注册机制无缝衔接,且 Agent 可以保持纯私有(dispatch-only)。同时,通过"内存状态 + 脚本化模型 + 伪造 GitHub API"三件套,它把一个原本需要真实 GitHub 仓库、真实 Webhook、真实 LLM 的集成场景,压缩成了完全本地、确定性可复现的端到端测试——这套方法论可以直接复用到其他渠道(Slack、Discord、Telegram 等,参见仓库 examples 目录下的各 channel 示例)的集成开发中。