用 Flue + Chat SDK 构建 GitHub Issue 评论双向对话 Agent:dispatch-only 架构实战

原创2026-09-17 00:00:121,868 阅读
文章标签:人工智能AI AgentAgent 框架MCP ClientsAI 技能工具调用

用 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);
  • hono 4.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), but src/app.ts never mounts it — the webhook handler reaches it through dispatch() 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() 在调用前会校验两点:

  1. runtimeConfig 必须已配置,否则抛出 '[flue] dispatch() called before runtime was configured...'——即 dispatch() 只能用在 Flue 构建的 server 入口内;
  2. 第一个参数必须是 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();

这段代码是离线确定性测试的核心技巧:

  1. 假响应按模型调用逐个消费:每次模型调用消耗一个脚本响应;
  2. 第一段响应让模型"调用" reply_to_chat_thread 工具(stopReason: 'toolUse'),并从 dispatch 消息中正则提取 threadId。注意注释中的说明:派发的 signal 消息渲染给模型时是带属性的 XML 风格标签(<signal ... threadId="...">),所以可以用 /threadId\s*=\s*"([^"]+)"/ 提取;
  3. 第二段响应返回最终文本 '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

vite.config.ts 中:

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.

本示例从三个层面保证"本地且确定":

  1. 内存状态适配器(createMemoryState):会话状态不落盘、不依赖外部服务;
  2. 脚本化模型 Provider(fauxProvider):不调用真实 LLM API,回复完全由脚本队列决定,结果可复现;
  3. 伪造 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 示例)的集成开发中。

登录后查看全文
flue