feat(amsg): 单用户/Cloudflare 满血后台消息(client_state + fire 时刻 hooks + 服务端工具循环)#21
Conversation
buildSessionContext / extractAssistantMessage / assertValidDecision / extractToolCallsFromDecision 下沉到 amsg-shared(assertValidDecision 新增 inlineToolCalls 选项支持服务端就地执行工具的 tool-request 形状)。 amsg-instant 的 session-context 与 decision 校验改为 re-export shared, 对外行为与错误信息不变。
客户端状态的云端镜像:PUT 批量 upsert(按 updatedAt last-write-wins, 单条 value ≤200KB),GET 按 namespace 读取(解密返回),DELETE 清空。 value 用 per-user key encryptForStorage 落库;鉴权、加密头、CORS 全部 沿用现有端点。表只进 SQLite/D1 schema,Netlify/Postgres 路径不受影响。
createSingleUserCloudflareWorker 的 config 新增可选 hooks (onBeforeFire / onLLMOutput / executeToolCalls)与 maxToolIterations (默认 5)、totalTimeoutMs(默认 240s,均可按次覆盖)。配置后 AI 任务 在触发时现场组装 messages(可经 readState 读 client_state),工具在 worker 内就地执行、多轮闭环后推送成品;decision 契约与 amsg-instant 的 onLLMOutput 一致(经 amsg-shared 共享实现)。不配 hooks 或 onBeforeFire 返回 null 时照走冻结 prompt 老链路(含回归守卫测试), 固定文本任务永远走老链路;hook ctx 不含 apiKey/pushSubscription/VAPID。
There was a problem hiding this comment.
Code Review
This pull request introduces server-side agentic loop capabilities and client state synchronization for single-user Cloudflare deployments. It extracts shared agentic contracts to @rei-standard/amsg-shared, adds the client_state database table and endpoints, and implements the server-side tool execution loop in agentic-fire.js with budget guards. Feedback focuses on optimizing the database upsert using D1's batch API to prevent sequential query latency, and dynamically calculating and passing the remaining timeout budget to LLM calls to ensure the loop respects the overall timeout limit.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async upsertClientState(userId, entries) { | ||
| let upserted = 0; | ||
| let skipped = 0; | ||
| for (const entry of entries) { | ||
| const res = await this._db.prepare( | ||
| `INSERT INTO client_state (user_id, namespace, key, value, updated_at) | ||
| VALUES (?, ?, ?, ?, ?) | ||
| ON CONFLICT (user_id, namespace, key) DO UPDATE SET | ||
| value = excluded.value, | ||
| updated_at = excluded.updated_at | ||
| WHERE excluded.updated_at >= client_state.updated_at` | ||
| ).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt).run(); | ||
| if (res.meta.changes > 0) upserted++; else skipped++; | ||
| } | ||
| return { upserted, skipped }; | ||
| } |
There was a problem hiding this comment.
在 upsertClientState 中,使用 for 循环对每个 entry 依次执行 await this._db.prepare(...).bind(...).run() 会导致多达 200 次串行的数据库查询,这在 Cloudflare Workers 环境中会带来极大的网络延迟并可能触发 CPU 超时。
建议使用 D1 的 db.batch() API,将所有 SQL 操作合并为一个批处理请求,在单次数据库往返中高效完成。
async upsertClientState(userId, entries) {
if (entries.length === 0) return { upserted: 0, skipped: 0 };
const stmt = this._db.prepare('INSERT INTO client_state (user_id, namespace, key, value, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (user_id, namespace, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at WHERE excluded.updated_at >= client_state.updated_at');
const batch = entries.map(entry =>
stmt.bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
);
const results = await this._db.batch(batch);
let upserted = 0;
let skipped = 0;
for (const res of results) {
if (res.meta.changes > 0) upserted++; else skipped++;
}
return { upserted, skipped };
}There was a problem hiding this comment.
已改为 db.batch()(7dc0a93):binding 支持 batch 时一次往返写完整批;无 batch() 的 binding(测试 shim、自定义 adapter)回退串行循环,两条路径都加了回归测试。补充一点:串行 await 烧的是墙钟不是 CPU 配额(Workers 的 CPU 限额不计 IO 等待),但客户端只有几秒后台窗口,墙钟延迟本身就足以成立。
| export async function callLlm(payload, options = {}) { | ||
| const requireContent = options.requireContent !== false; | ||
| const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl); | ||
| const requestBody = buildAiRequestBody(payload); | ||
|
|
||
| const aiResponse = await fetch(normalizedApiUrl, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'Authorization': `Bearer ${payload.apiKey}` | ||
| }, | ||
| body: JSON.stringify(requestBody), | ||
| signal: AbortSignal.timeout(300000) | ||
| }); |
There was a problem hiding this comment.
目前 callLlm 中的 AbortSignal.timeout(300000) 是硬编码的 300 秒。在服务端 Agentic 循环中,如果设置了较短的 totalTimeoutMs(例如 10 秒),而 LLM 请求挂起,硬编码的 300 秒超时会导致循环无法及时中断,直到 LLM 请求返回后才会触发超时检查。
建议允许通过 options 传入自定义的 timeout 并在 fetch 中使用。
| export async function callLlm(payload, options = {}) { | |
| const requireContent = options.requireContent !== false; | |
| const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl); | |
| const requestBody = buildAiRequestBody(payload); | |
| const aiResponse = await fetch(normalizedApiUrl, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${payload.apiKey}` | |
| }, | |
| body: JSON.stringify(requestBody), | |
| signal: AbortSignal.timeout(300000) | |
| }); | |
| export async function callLlm(payload, options = {}) { | |
| const requireContent = options.requireContent !== false; | |
| const timeout = typeof options.timeout === 'number' ? options.timeout : 300000; | |
| const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl); | |
| const requestBody = buildAiRequestBody(payload); | |
| const aiResponse = await fetch(normalizedApiUrl, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${payload.apiKey}` | |
| }, | |
| body: JSON.stringify(requestBody), | |
| signal: AbortSignal.timeout(timeout) | |
| }); |
There was a problem hiding this comment.
已加 options.timeoutMs(7dc0a93),默认仍 300s,老路径行为不变。
| const { response: llmResponse } = await callLlm( | ||
| { ...decryptedPayload, messages }, | ||
| { requireContent: false } | ||
| ); |
There was a problem hiding this comment.
在调用 callLlm 时,建议根据剩余的 deadline 动态计算并传入 timeout 参数。这样可以避免在 LLM 请求挂起时无谓地等待,从而在整体超时预算耗尽时立即中止请求。
| const { response: llmResponse } = await callLlm( | |
| { ...decryptedPayload, messages }, | |
| { requireContent: false } | |
| ); | |
| const remainingTimeout = Math.max(0, deadline - nowFn()); | |
| const { response: llmResponse } = await callLlm( | |
| { ...decryptedPayload, messages }, | |
| { requireContent: false, timeout: remainingTimeout } | |
| ); |
There was a problem hiding this comment.
已改(7dc0a93):每轮传 Math.max(1, Math.min(300000, deadline - now))——保留 300s 单次调用上限,且不用 max(0)(AbortSignal.timeout(0) 会立即中止;虽然循环顶部的 deadline 检查保证了剩余 >0,还是钳一下更稳)。加了回归测试断言每轮超时按 250s→150s→50s 收缩。
- upsertClientState 在 binding 支持 batch() 时一次往返写完整批 (客户端只有几秒后台窗口,串行 N 次往返可能吃光窗口);无 batch() 的 binding 回退串行循环,两条路径都有测试。 - callLlm 新增 timeoutMs 选项(默认仍 300s);agentic 循环每轮把 fetch 超时收缩到 totalTimeoutMs 的剩余预算(上限仍 300s),避免 挂起的 LLM 请求把 tick 拖过墙钟兜底。
改了什么
单用户 / Cloudflare 部署的 worker 此前在定时任务触发时只会「取出排程时冻结的 completePrompt → 一次 LLM → 推送」。本 PR 之后,宿主可以让 worker 在触发那一刻现场组装 prompt,并在服务端跑多轮工具循环,全程不需要客户端在线:
client_state状态表 + 端点PUT /client-state批量 upsert(按updatedAtlast-write-wins,单条 value ≤200KB)、GET ?namespace=读取、DELETE清空。value 用 per-user key 加密落库,鉴权/加密头/CORS 沿用现有端点hooks: { onBeforeFire, onLLMOutput, executeToolCalls }+maxToolIterations(默认 5)/totalTimeoutMs(默认 240s),onBeforeFire 返回值可按次覆盖onBeforeFire → callLlm → onLLMOutput → finish / skip-push / continue / tool-request(worker 内就地执行) → …,轮数与墙钟双兜底,超限按现有任务失败语义重试/标记契约对齐
onLLMOutput的 ctx 与 decision 形状和@rei-standard/amsg-instant的同名 hook 一致,instant 的 classifier 可原样复用:tool-request同时接受toolCalls直传与 tool_request pushPayloads 两种形状。@rei-standard/amsg-shared,amsg-instant 改为 re-export 同一实现,消灭了原先两包注释里的 "keep in sync" 手工同步(此部分 instant 仅 patch,行为不变)。向后兼容
client_state只进 SQLite/D1 schema,hooks 只由单用户工厂注入。./cloudflare子路径 exports 不变。安全
hook 收到的所有 ctx 上都没有
apiKey/pushSubscription/ VAPID / masterKey(有测试钉住),console.log(ctx)不会泄密。验收
npm run ci通过(shared 17 新测试、instant 187、server 162,含多租户回归)esbuild --platform=neutralexit 0,产物中node:出现 0 次