@rei-standard/amsg-server 2.6.0-next.7 → 2.6.0-next.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,6 +121,51 @@ const { bytes, remainingBytes, withinLimit } = measurePushPayload(JSON.stringify
121
121
 
122
122
  装不下的内容(长文、附件详情)建议走旁路:正文存进 `client_state`,push 里只带一个引用键,客户端上线后用 `GET /client-state` 取回。单用户 Worker 的 fire-time hook 用 `ctx.writeState()` 写,见 [`examples/cloudflare-single-user/README.md`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md)。
123
123
 
124
+ ## Fire 时刻 hooks
125
+
126
+ 配上 `hooks: { onBeforeFire, onLLMOutput, executeToolCalls }` 之后,AI 类任务的 prompt 不再是排程那一刻冻结的文本,而是 cron 触发时现场组装,工具也在服务端就地跑完,全程不需要客户端在线。完整用法见 [`examples/cloudflare-single-user/README.md`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md) 的「Fire 时刻 hooks」。
127
+
128
+ 三个 hook 拿到的 ctx 上都有这几个口子:
129
+
130
+ | ctx 上的口子 | 干什么 |
131
+ |---|---|
132
+ | `readState(ns)` / `writeState(ns, entries)` | 读写 `client_state`,和客户端 `GET/PUT /client-state` 是同一份数据 |
133
+ | `scheduleTask(options)` | 给同一个用户再建一条定时任务 |
134
+ | `scratch` | 本次 fire 的便签对象,三个 hook 共享同一个引用,fire 结束即丢弃 |
135
+
136
+ ### `ctx.scheduleTask(options)`
137
+
138
+ 角色在这次 fire 里给自己排一条后续任务:「这条发完,一个半小时后我再接着说一句」。建出来的是一条正常的任务行,到点由 cron 触发,用户全程离线也不影响。
139
+
140
+ ```js
141
+ const result = await ctx.scheduleTask({
142
+ firstSendTime: new Date(Date.now() + 90 * 60_000).toISOString(), // 必填,ISO 字符串
143
+ messageType: 'auto', // 可选,默认继承当前任务
144
+ recurrenceType: 'none', // 可选,默认 none
145
+ metadata: { beat: 'followup' }, // 可选,整体替换当前任务的 metadata(不深合并)
146
+ uuid: `fire-${ctx.task.id}-${ctx.task.nextSendAt}`, // 可选,默认随机
147
+ });
148
+ // → { created: true, id, uuid, nextSendAt }
149
+ // 或 { created: false, reason: 'duplicate', uuid }
150
+ ```
151
+
152
+ 凭据和投递配置(`pushSubscription` / `apiUrl` / `apiKey` / `primaryModel` / `maxTokens` / `temperature` / `splitPattern`)以及 `contactName` / `avatarUrl` / `messageSubtype` / `userMessage` 从当前任务继承,宿主只说「什么时候、说什么方向」——hook 全程看不到凭据。`completePrompt` / `messages` 不继承(都置 `null`):hook 每次现场重组 prompt,把排程时冻结的旧 prompt 带过去,新任务万一走回冻结 prompt 老链路就会静默发出一条谁也没打算发的文案。
153
+
154
+ 护栏:
155
+
156
+ | 护栏 | 阈值 / 规则 | 不满足时 | 为什么 |
157
+ |---|---|---|---|
158
+ | `firstSendTime` | 必填、能解析成合法时间、至少比现在晚 **60 秒** | `RangeError` | cron 一分钟一跳,排在 60 秒内等于让下一跳立刻捡走,容易变成自己触发自己的紧密循环 |
159
+ | `messageType` | 只收 `auto` / `prompted` / `fixed` | `TypeError` | `instant` 的语义是「建行的那一刻就投递」,那条路径归 `POST /schedule-message` 管;从 fire 里造这么一行,投递时机反而说不清 |
160
+ | `messageType: 'fixed'` | 必须有 `userMessage`(自己传或继承到) | `TypeError` | 固定文本任务没有正文,就是一条永远发空的任务 |
161
+ | 单次 fire 的建任务条数 | 默认 **2 条**,factory 配置 `maxScheduledTasksPerFire` 可调(`0` = 不许自排) | `RangeError` | 模型自排后续本质上是条能无限延伸的链,没有上限就没人按停止键 |
162
+ | `uuid` 撞车 | 不当错误处理 | 返回 `{ created: false, reason: 'duplicate', uuid }` | fire 失败会整条重跑,宿主传一个由「任务 id + 触发时刻」推出来的确定性 uuid 就天然幂等 |
163
+ | 数据库适配器没有 `createTask` | — | 抛 `AGENTIC_SCHEDULE_UNSUPPORTED` | 静默成功会让宿主以为后续那条排上了,其实谁也不会触发它 |
164
+
165
+ `recurrenceType` 沿用排程接口那套 `none` / `daily` / `weekly`,别的值抛 `TypeError`。参数不合法的调用不占建任务额度;uuid 撞车占(那条任务其实已经建出来了)。
166
+
167
+ `GET /capabilities` 的 features 里有 `agentic-schedule-task`,前端可以据此判断部署的 worker 认不认这条链路。
168
+
124
169
  ## 导出(新增)
125
170
 
126
171
  - `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。和 `@rei-standard/amsg-instant` 的校验规则字节级一致。
@@ -443,6 +443,12 @@ function buildAiRequestBody(payload) {
443
443
  } else if (!Array.isArray(payload.messages)) {
444
444
  requestBody.temperature = 0.8;
445
445
  }
446
+ if (Array.isArray(payload.tools) && payload.tools.length > 0) {
447
+ requestBody.tools = payload.tools;
448
+ if (payload.toolChoice !== void 0 && payload.toolChoice !== null) {
449
+ requestBody.tool_choice = payload.toolChoice;
450
+ }
451
+ }
446
452
  if (payload.maxTokens === void 0 || payload.maxTokens === null) {
447
453
  return requestBody;
448
454
  }
@@ -635,6 +641,10 @@ async function writeClientStateEntries({ db, userId, userKey, entries }) {
635
641
  // src/server/lib/agentic-fire.js
636
642
  var DEFAULT_MAX_TOOL_ITERATIONS = 5;
637
643
  var DEFAULT_TOTAL_TIMEOUT_MS = 24e4;
644
+ var DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE = 2;
645
+ var MIN_SCHEDULE_LEAD_MS = 6e4;
646
+ var SCHEDULABLE_MESSAGE_TYPES = /* @__PURE__ */ new Set(["auto", "prompted", "fixed"]);
647
+ var SCHEDULABLE_RECURRENCE_TYPES = /* @__PURE__ */ new Set(["none", "daily", "weekly"]);
638
648
  var SLEEP_BETWEEN_MESSAGES_MS = 1500;
639
649
  var CREDENTIAL_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["apiKey", "pushSubscription"]);
640
650
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -667,11 +677,16 @@ function normalizeBeforeFireResult(result) {
667
677
  return {
668
678
  messages: result.messages,
669
679
  maxToolIterations: result.maxToolIterations,
670
- totalTimeoutMs: result.totalTimeoutMs
680
+ totalTimeoutMs: result.totalTimeoutMs,
681
+ // 归一成「有就是非空数组,否则 undefined」,让下面每轮的透传判断能直接
682
+ // 用真值。真正决定请求体带不带 tools 的规则住在 llm.js 的
683
+ // buildAiRequestBody(那边有测试钉着),这层只是不把空数组往下传。
684
+ tools: Array.isArray(result.tools) && result.tools.length > 0 ? result.tools : void 0,
685
+ toolChoice: result.toolChoice
671
686
  };
672
687
  }
673
688
  throw new TypeError(
674
- "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | { skip: true } | null"
689
+ "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs?, tools?, toolChoice? } | { skip: true } | null"
675
690
  );
676
691
  }
677
692
  function firstPositiveInt(values, fallback) {
@@ -757,12 +772,123 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
757
772
  });
758
773
  return writeClientStateEntries({ db: ctx.db, userId: task.user_id, userKey, entries: normalized2 });
759
774
  };
775
+ const maxScheduledTasksPerFire = Number.isInteger(ctx.maxScheduledTasksPerFire) && ctx.maxScheduledTasksPerFire >= 0 ? ctx.maxScheduledTasksPerFire : DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE;
776
+ let scheduledTaskCount = 0;
777
+ const scheduleTask = async (options) => {
778
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
779
+ throw new TypeError("scheduleTask(options) \u9700\u8981\u4E00\u4E2A\u5BF9\u8C61\uFF0C\u81F3\u5C11\u5305\u542B { firstSendTime }");
780
+ }
781
+ if (typeof options.firstSendTime !== "string" || !options.firstSendTime.trim()) {
782
+ throw new RangeError("scheduleTask: firstSendTime \u5FC5\u586B\uFF0C\u4E14\u5FC5\u987B\u662F ISO 8601 \u5B57\u7B26\u4E32");
783
+ }
784
+ const firstSendAt = new Date(options.firstSendTime);
785
+ if (Number.isNaN(firstSendAt.getTime())) {
786
+ throw new RangeError(`scheduleTask: firstSendTime \u89E3\u6790\u4E0D\u51FA\u5408\u6CD5\u65F6\u95F4\uFF1A${options.firstSendTime}`);
787
+ }
788
+ const earliest = nowFn() + MIN_SCHEDULE_LEAD_MS;
789
+ if (firstSendAt.getTime() < earliest) {
790
+ throw new RangeError(
791
+ `scheduleTask: firstSendTime \u81F3\u5C11\u8981\u6BD4\u73B0\u5728\u665A ${MIN_SCHEDULE_LEAD_MS / 1e3} \u79D2\uFF08\u6700\u65E9\u53EF\u6392 ${new Date(earliest).toISOString()}\uFF0C\u6536\u5230 ${firstSendAt.toISOString()}\uFF09\u2014\u2014cron \u4E00\u5206\u949F\u4E00\u8DF3\uFF0C\u6392\u5F97\u66F4\u8FD1\u7B49\u4E8E\u8BA9\u4E0B\u4E00\u8DF3\u7ACB\u523B\u6361\u8D70`
792
+ );
793
+ }
794
+ const nextSendAt = firstSendAt.toISOString();
795
+ const inheritedType = options.messageType == null;
796
+ const messageType = inheritedType ? decryptedPayload.messageType : options.messageType;
797
+ if (messageType === "instant") {
798
+ throw new TypeError(
799
+ "scheduleTask: messageType \u4E0D\u80FD\u662F 'instant'\u2014\u2014instant \u7684\u8BED\u4E49\u662F\u300C\u5EFA\u884C\u7684\u90A3\u4E00\u523B\u5C31\u6295\u9012\u300D\uFF0C\u90A3\u6761\u8DEF\u5F84\u5F52 POST /schedule-message \u7BA1\uFF1B\u4ECE fire \u91CC\u5EFA\u4E00\u6761 instant\uFF0C\u6295\u9012\u65F6\u673A\u53CD\u800C\u8BF4\u4E0D\u6E05\u3002" + (inheritedType ? "\uFF08\u8FD9\u4E2A instant \u662F\u4ECE\u5F53\u524D\u4EFB\u52A1\u7EE7\u627F\u6765\u7684\uFF0C\u663E\u5F0F\u4F20 auto / prompted / fixed \u8986\u76D6\u5B83\u3002\uFF09" : "")
800
+ );
801
+ }
802
+ if (!SCHEDULABLE_MESSAGE_TYPES.has(messageType)) {
803
+ throw new TypeError(
804
+ `scheduleTask: messageType \u53EA\u80FD\u662F auto / prompted / fixed\uFF0C\u6536\u5230 ${JSON.stringify(messageType)}`
805
+ );
806
+ }
807
+ const recurrenceType = options.recurrenceType == null ? "none" : options.recurrenceType;
808
+ if (!SCHEDULABLE_RECURRENCE_TYPES.has(recurrenceType)) {
809
+ throw new TypeError(
810
+ `scheduleTask: recurrenceType \u53EA\u80FD\u662F none / daily / weekly\uFF0C\u6536\u5230 ${JSON.stringify(recurrenceType)}`
811
+ );
812
+ }
813
+ const contactName = options.contactName === void 0 ? decryptedPayload.contactName : options.contactName;
814
+ if (typeof contactName !== "string" || !contactName.trim()) {
815
+ throw new TypeError("scheduleTask: contactName \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u9ED8\u8BA4\u7EE7\u627F\u5F53\u524D\u4EFB\u52A1\uFF09");
816
+ }
817
+ const userMessage = options.userMessage === void 0 ? _nullishCoalesce(decryptedPayload.userMessage, () => ( null)) : options.userMessage;
818
+ if (messageType === "fixed" && (typeof userMessage !== "string" || !userMessage.trim())) {
819
+ throw new TypeError(
820
+ "scheduleTask: messageType 'fixed' \u5FC5\u987B\u6709 userMessage\uFF08\u81EA\u5DF1\u4F20\uFF0C\u6216\u4ECE\u5F53\u524D\u4EFB\u52A1\u7EE7\u627F\u5230\uFF09\u2014\u2014\u56FA\u5B9A\u6587\u672C\u4EFB\u52A1\u6CA1\u6709\u6B63\u6587\uFF0C\u5C31\u662F\u4E00\u6761\u6C38\u8FDC\u53D1\u7A7A\u7684\u4EFB\u52A1"
821
+ );
822
+ }
823
+ const metadata = options.metadata === void 0 ? decryptedPayload.metadata || {} : options.metadata;
824
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
825
+ throw new TypeError("scheduleTask: metadata \u5FC5\u987B\u662F\u666E\u901A\u5BF9\u8C61\uFF08\u6574\u4F53\u66FF\u6362\u5F53\u524D\u4EFB\u52A1\u7684 metadata\uFF0C\u4E0D\u505A\u6DF1\u5408\u5E76\uFF09");
826
+ }
827
+ const uuid = options.uuid == null ? randomUUID() : options.uuid;
828
+ if (typeof uuid !== "string" || !uuid.trim()) {
829
+ throw new TypeError("scheduleTask: uuid \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
830
+ }
831
+ if (scheduledTaskCount >= maxScheduledTasksPerFire) {
832
+ throw new RangeError(
833
+ `scheduleTask: \u5355\u6B21 fire \u6700\u591A\u5EFA ${maxScheduledTasksPerFire} \u6761\u4EFB\u52A1\uFF08factory \u914D\u7F6E maxScheduledTasksPerFire \u53EF\u8C03\uFF09\uFF0C\u8FD9\u662F\u7B2C ${scheduledTaskCount + 1} \u6761`
834
+ );
835
+ }
836
+ if (!ctx.db || typeof ctx.db.createTask !== "function") {
837
+ throw new Error("AGENTIC_SCHEDULE_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u5EFA\u4EFB\u52A1\uFF08\u7F3A createTask\uFF09");
838
+ }
839
+ scheduledTaskCount++;
840
+ const fullTaskData = {
841
+ contactName,
842
+ avatarUrl: options.avatarUrl === void 0 ? decryptedPayload.avatarUrl || null : options.avatarUrl || null,
843
+ messageType,
844
+ messageSubtype: (options.messageSubtype === void 0 ? decryptedPayload.messageSubtype : options.messageSubtype) || "chat",
845
+ userMessage: userMessage || null,
846
+ firstSendTime: nextSendAt,
847
+ recurrenceType,
848
+ apiUrl: decryptedPayload.apiUrl || null,
849
+ apiKey: decryptedPayload.apiKey || null,
850
+ primaryModel: decryptedPayload.primaryModel || null,
851
+ // 见文件头:fire-time hook 每次现场重组 prompt,旧 prompt 带过去只会在新
852
+ // 任务走回老链路时顶替宿主的意图。
853
+ completePrompt: null,
854
+ messages: null,
855
+ maxTokens: _nullishCoalesce(decryptedPayload.maxTokens, () => ( null)),
856
+ temperature: _nullishCoalesce(decryptedPayload.temperature, () => ( null)),
857
+ splitPattern: _nullishCoalesce(decryptedPayload.splitPattern, () => ( null)),
858
+ pushSubscription: decryptedPayload.pushSubscription,
859
+ metadata
860
+ };
861
+ const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
862
+ let created;
863
+ try {
864
+ created = await ctx.db.createTask({
865
+ user_id: task.user_id,
866
+ uuid,
867
+ encrypted_payload: encryptedPayload,
868
+ next_send_at: nextSendAt,
869
+ message_type: messageType
870
+ });
871
+ } catch (error) {
872
+ if (isUniqueViolation(error)) return { created: false, reason: "duplicate", uuid };
873
+ throw error;
874
+ }
875
+ if (!created) {
876
+ throw new Error("AGENTIC_SCHEDULE_FAILED: createTask \u6CA1\u6709\u8FD4\u56DE\u65B0\u5EFA\u7684\u4EFB\u52A1\u884C");
877
+ }
878
+ return {
879
+ created: true,
880
+ id: _nullishCoalesce(created.id, () => ( null)),
881
+ uuid: _nullishCoalesce(created.uuid, () => ( uuid)),
882
+ nextSendAt: _nullishCoalesce(created.next_send_at, () => ( nextSendAt))
883
+ };
884
+ };
760
885
  const scratch = {};
761
886
  const fireCtx = Object.freeze({
762
887
  task: buildHookTask(task, decryptedPayload),
763
888
  userId: task.user_id,
764
889
  readState,
765
890
  writeState,
891
+ scheduleTask,
766
892
  now: new Date(nowFn()),
767
893
  scratch
768
894
  });
@@ -789,7 +915,11 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
789
915
  }
790
916
  const roundTimeoutMs = Math.max(1, Math.min(3e5, deadline - nowFn()));
791
917
  const { response: llmResponse } = await callLlm(
792
- { ...decryptedPayload, messages },
918
+ {
919
+ ...decryptedPayload,
920
+ messages,
921
+ ...normalized.tools ? { tools: normalized.tools, toolChoice: normalized.toolChoice } : {}
922
+ },
793
923
  { requireContent: false, timeoutMs: roundTimeoutMs }
794
924
  );
795
925
  const assistantMessage = _amsgshared.extractAssistantMessage.call(void 0, llmResponse);
@@ -807,7 +937,8 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
807
937
  scratch
808
938
  }),
809
939
  readState,
810
- writeState
940
+ writeState,
941
+ scheduleTask
811
942
  });
812
943
  const decision = await hooks.onLLMOutput(sessionCtx);
813
944
  _amsgshared.assertValidDecision.call(void 0, decision, { inlineToolCalls: true });
@@ -845,7 +976,10 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
845
976
  content: `Tool execution failed: ${_nullishCoalesce(_optionalChain([error, 'optionalAccess', _6 => _6.message]), () => ( String(error)))}`
846
977
  }));
847
978
  }
848
- const assistantWithTools = Array.isArray(assistantMessage.tool_calls) && assistantMessage.tool_calls.length > 0 ? assistantMessage : { ...assistantMessage, tool_calls: toolCalls };
979
+ const nativeCalls = Array.isArray(assistantMessage.tool_calls) ? assistantMessage.tool_calls : [];
980
+ const nativeIds = new Set(nativeCalls.map((tc) => tc && tc.id));
981
+ const synthesized = toolCalls.filter((tc) => !nativeIds.has(tc && tc.id));
982
+ const assistantWithTools = synthesized.length === 0 ? assistantMessage : { ...assistantMessage, tool_calls: [...nativeCalls, ...synthesized] };
849
983
  messages = [...messages.slice(0, -1), assistantWithTools, ...toolResults];
850
984
  }
851
985
  throw new Error(`AGENTIC_LOOP_EXCEEDED: no finish/skip-push decision within ${maxToolIterations} LLM round(s)`);
@@ -2262,7 +2396,7 @@ function createClientStateHandler(ctx) {
2262
2396
  }
2263
2397
 
2264
2398
  // src/server/lib/version.js
2265
- var SERVER_VERSION = true ? "2.6.0-next.7" : "0.0.0-dev";
2399
+ var SERVER_VERSION = true ? "2.6.0-next.9" : "0.0.0-dev";
2266
2400
 
2267
2401
  // src/server/handlers/capabilities.js
2268
2402
  var SERVER_FEATURES = Object.freeze([
@@ -2272,6 +2406,8 @@ var SERVER_FEATURES = Object.freeze([
2272
2406
  "agentic-hooks",
2273
2407
  "agentic-scratch",
2274
2408
  "agentic-write-state",
2409
+ "agentic-fire-tools",
2410
+ "agentic-schedule-task",
2275
2411
  "vapid-public-key"
2276
2412
  ]);
2277
2413
  function createCapabilitiesHandler(ctx) {
@@ -2313,7 +2449,9 @@ function createSingleUserServer(config) {
2313
2449
  hooks: config.hooks || null,
2314
2450
  maxToolIterations: config.maxToolIterations,
2315
2451
  totalTimeoutMs: config.totalTimeoutMs,
2316
- maxStateValueBytes: config.maxStateValueBytes
2452
+ maxStateValueBytes: config.maxStateValueBytes,
2453
+ // hook 的 ctx.scheduleTask() 单次 fire 建任务的条数上限(默认 2)。
2454
+ maxScheduledTasksPerFire: config.maxScheduledTasksPerFire
2317
2455
  };
2318
2456
  return {
2319
2457
  ctx,
@@ -2652,6 +2790,8 @@ function createSingleUserCloudflareWorker(buildConfig) {
2652
2790
  totalTimeoutMs: cfg.totalTimeoutMs,
2653
2791
  // hook 的 ctx.writeState() 用它判断单条 value 的上限,和 PUT /client-state 同一个配置。
2654
2792
  maxStateValueBytes: cfg.maxStateValueBytes,
2793
+ // hook 的 ctx.scheduleTask() 单次 fire 建任务的条数上限(默认 2)。
2794
+ maxScheduledTasksPerFire: cfg.maxScheduledTasksPerFire,
2655
2795
  // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
2656
2796
  claimLeaseMs: cfg.claimLeaseMs
2657
2797
  });
@@ -443,6 +443,12 @@ function buildAiRequestBody(payload) {
443
443
  } else if (!Array.isArray(payload.messages)) {
444
444
  requestBody.temperature = 0.8;
445
445
  }
446
+ if (Array.isArray(payload.tools) && payload.tools.length > 0) {
447
+ requestBody.tools = payload.tools;
448
+ if (payload.toolChoice !== void 0 && payload.toolChoice !== null) {
449
+ requestBody.tool_choice = payload.toolChoice;
450
+ }
451
+ }
446
452
  if (payload.maxTokens === void 0 || payload.maxTokens === null) {
447
453
  return requestBody;
448
454
  }
@@ -635,6 +641,10 @@ async function writeClientStateEntries({ db, userId, userKey, entries }) {
635
641
  // src/server/lib/agentic-fire.js
636
642
  var DEFAULT_MAX_TOOL_ITERATIONS = 5;
637
643
  var DEFAULT_TOTAL_TIMEOUT_MS = 24e4;
644
+ var DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE = 2;
645
+ var MIN_SCHEDULE_LEAD_MS = 6e4;
646
+ var SCHEDULABLE_MESSAGE_TYPES = /* @__PURE__ */ new Set(["auto", "prompted", "fixed"]);
647
+ var SCHEDULABLE_RECURRENCE_TYPES = /* @__PURE__ */ new Set(["none", "daily", "weekly"]);
638
648
  var SLEEP_BETWEEN_MESSAGES_MS = 1500;
639
649
  var CREDENTIAL_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["apiKey", "pushSubscription"]);
640
650
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -667,11 +677,16 @@ function normalizeBeforeFireResult(result) {
667
677
  return {
668
678
  messages: result.messages,
669
679
  maxToolIterations: result.maxToolIterations,
670
- totalTimeoutMs: result.totalTimeoutMs
680
+ totalTimeoutMs: result.totalTimeoutMs,
681
+ // 归一成「有就是非空数组,否则 undefined」,让下面每轮的透传判断能直接
682
+ // 用真值。真正决定请求体带不带 tools 的规则住在 llm.js 的
683
+ // buildAiRequestBody(那边有测试钉着),这层只是不把空数组往下传。
684
+ tools: Array.isArray(result.tools) && result.tools.length > 0 ? result.tools : void 0,
685
+ toolChoice: result.toolChoice
671
686
  };
672
687
  }
673
688
  throw new TypeError(
674
- "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | { skip: true } | null"
689
+ "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs?, tools?, toolChoice? } | { skip: true } | null"
675
690
  );
676
691
  }
677
692
  function firstPositiveInt(values, fallback) {
@@ -757,12 +772,123 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
757
772
  });
758
773
  return writeClientStateEntries({ db: ctx.db, userId: task.user_id, userKey, entries: normalized2 });
759
774
  };
775
+ const maxScheduledTasksPerFire = Number.isInteger(ctx.maxScheduledTasksPerFire) && ctx.maxScheduledTasksPerFire >= 0 ? ctx.maxScheduledTasksPerFire : DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE;
776
+ let scheduledTaskCount = 0;
777
+ const scheduleTask = async (options) => {
778
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
779
+ throw new TypeError("scheduleTask(options) \u9700\u8981\u4E00\u4E2A\u5BF9\u8C61\uFF0C\u81F3\u5C11\u5305\u542B { firstSendTime }");
780
+ }
781
+ if (typeof options.firstSendTime !== "string" || !options.firstSendTime.trim()) {
782
+ throw new RangeError("scheduleTask: firstSendTime \u5FC5\u586B\uFF0C\u4E14\u5FC5\u987B\u662F ISO 8601 \u5B57\u7B26\u4E32");
783
+ }
784
+ const firstSendAt = new Date(options.firstSendTime);
785
+ if (Number.isNaN(firstSendAt.getTime())) {
786
+ throw new RangeError(`scheduleTask: firstSendTime \u89E3\u6790\u4E0D\u51FA\u5408\u6CD5\u65F6\u95F4\uFF1A${options.firstSendTime}`);
787
+ }
788
+ const earliest = nowFn() + MIN_SCHEDULE_LEAD_MS;
789
+ if (firstSendAt.getTime() < earliest) {
790
+ throw new RangeError(
791
+ `scheduleTask: firstSendTime \u81F3\u5C11\u8981\u6BD4\u73B0\u5728\u665A ${MIN_SCHEDULE_LEAD_MS / 1e3} \u79D2\uFF08\u6700\u65E9\u53EF\u6392 ${new Date(earliest).toISOString()}\uFF0C\u6536\u5230 ${firstSendAt.toISOString()}\uFF09\u2014\u2014cron \u4E00\u5206\u949F\u4E00\u8DF3\uFF0C\u6392\u5F97\u66F4\u8FD1\u7B49\u4E8E\u8BA9\u4E0B\u4E00\u8DF3\u7ACB\u523B\u6361\u8D70`
792
+ );
793
+ }
794
+ const nextSendAt = firstSendAt.toISOString();
795
+ const inheritedType = options.messageType == null;
796
+ const messageType = inheritedType ? decryptedPayload.messageType : options.messageType;
797
+ if (messageType === "instant") {
798
+ throw new TypeError(
799
+ "scheduleTask: messageType \u4E0D\u80FD\u662F 'instant'\u2014\u2014instant \u7684\u8BED\u4E49\u662F\u300C\u5EFA\u884C\u7684\u90A3\u4E00\u523B\u5C31\u6295\u9012\u300D\uFF0C\u90A3\u6761\u8DEF\u5F84\u5F52 POST /schedule-message \u7BA1\uFF1B\u4ECE fire \u91CC\u5EFA\u4E00\u6761 instant\uFF0C\u6295\u9012\u65F6\u673A\u53CD\u800C\u8BF4\u4E0D\u6E05\u3002" + (inheritedType ? "\uFF08\u8FD9\u4E2A instant \u662F\u4ECE\u5F53\u524D\u4EFB\u52A1\u7EE7\u627F\u6765\u7684\uFF0C\u663E\u5F0F\u4F20 auto / prompted / fixed \u8986\u76D6\u5B83\u3002\uFF09" : "")
800
+ );
801
+ }
802
+ if (!SCHEDULABLE_MESSAGE_TYPES.has(messageType)) {
803
+ throw new TypeError(
804
+ `scheduleTask: messageType \u53EA\u80FD\u662F auto / prompted / fixed\uFF0C\u6536\u5230 ${JSON.stringify(messageType)}`
805
+ );
806
+ }
807
+ const recurrenceType = options.recurrenceType == null ? "none" : options.recurrenceType;
808
+ if (!SCHEDULABLE_RECURRENCE_TYPES.has(recurrenceType)) {
809
+ throw new TypeError(
810
+ `scheduleTask: recurrenceType \u53EA\u80FD\u662F none / daily / weekly\uFF0C\u6536\u5230 ${JSON.stringify(recurrenceType)}`
811
+ );
812
+ }
813
+ const contactName = options.contactName === void 0 ? decryptedPayload.contactName : options.contactName;
814
+ if (typeof contactName !== "string" || !contactName.trim()) {
815
+ throw new TypeError("scheduleTask: contactName \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u9ED8\u8BA4\u7EE7\u627F\u5F53\u524D\u4EFB\u52A1\uFF09");
816
+ }
817
+ const userMessage = options.userMessage === void 0 ? decryptedPayload.userMessage ?? null : options.userMessage;
818
+ if (messageType === "fixed" && (typeof userMessage !== "string" || !userMessage.trim())) {
819
+ throw new TypeError(
820
+ "scheduleTask: messageType 'fixed' \u5FC5\u987B\u6709 userMessage\uFF08\u81EA\u5DF1\u4F20\uFF0C\u6216\u4ECE\u5F53\u524D\u4EFB\u52A1\u7EE7\u627F\u5230\uFF09\u2014\u2014\u56FA\u5B9A\u6587\u672C\u4EFB\u52A1\u6CA1\u6709\u6B63\u6587\uFF0C\u5C31\u662F\u4E00\u6761\u6C38\u8FDC\u53D1\u7A7A\u7684\u4EFB\u52A1"
821
+ );
822
+ }
823
+ const metadata = options.metadata === void 0 ? decryptedPayload.metadata || {} : options.metadata;
824
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
825
+ throw new TypeError("scheduleTask: metadata \u5FC5\u987B\u662F\u666E\u901A\u5BF9\u8C61\uFF08\u6574\u4F53\u66FF\u6362\u5F53\u524D\u4EFB\u52A1\u7684 metadata\uFF0C\u4E0D\u505A\u6DF1\u5408\u5E76\uFF09");
826
+ }
827
+ const uuid = options.uuid == null ? randomUUID() : options.uuid;
828
+ if (typeof uuid !== "string" || !uuid.trim()) {
829
+ throw new TypeError("scheduleTask: uuid \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
830
+ }
831
+ if (scheduledTaskCount >= maxScheduledTasksPerFire) {
832
+ throw new RangeError(
833
+ `scheduleTask: \u5355\u6B21 fire \u6700\u591A\u5EFA ${maxScheduledTasksPerFire} \u6761\u4EFB\u52A1\uFF08factory \u914D\u7F6E maxScheduledTasksPerFire \u53EF\u8C03\uFF09\uFF0C\u8FD9\u662F\u7B2C ${scheduledTaskCount + 1} \u6761`
834
+ );
835
+ }
836
+ if (!ctx.db || typeof ctx.db.createTask !== "function") {
837
+ throw new Error("AGENTIC_SCHEDULE_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u5EFA\u4EFB\u52A1\uFF08\u7F3A createTask\uFF09");
838
+ }
839
+ scheduledTaskCount++;
840
+ const fullTaskData = {
841
+ contactName,
842
+ avatarUrl: options.avatarUrl === void 0 ? decryptedPayload.avatarUrl || null : options.avatarUrl || null,
843
+ messageType,
844
+ messageSubtype: (options.messageSubtype === void 0 ? decryptedPayload.messageSubtype : options.messageSubtype) || "chat",
845
+ userMessage: userMessage || null,
846
+ firstSendTime: nextSendAt,
847
+ recurrenceType,
848
+ apiUrl: decryptedPayload.apiUrl || null,
849
+ apiKey: decryptedPayload.apiKey || null,
850
+ primaryModel: decryptedPayload.primaryModel || null,
851
+ // 见文件头:fire-time hook 每次现场重组 prompt,旧 prompt 带过去只会在新
852
+ // 任务走回老链路时顶替宿主的意图。
853
+ completePrompt: null,
854
+ messages: null,
855
+ maxTokens: decryptedPayload.maxTokens ?? null,
856
+ temperature: decryptedPayload.temperature ?? null,
857
+ splitPattern: decryptedPayload.splitPattern ?? null,
858
+ pushSubscription: decryptedPayload.pushSubscription,
859
+ metadata
860
+ };
861
+ const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
862
+ let created;
863
+ try {
864
+ created = await ctx.db.createTask({
865
+ user_id: task.user_id,
866
+ uuid,
867
+ encrypted_payload: encryptedPayload,
868
+ next_send_at: nextSendAt,
869
+ message_type: messageType
870
+ });
871
+ } catch (error) {
872
+ if (isUniqueViolation(error)) return { created: false, reason: "duplicate", uuid };
873
+ throw error;
874
+ }
875
+ if (!created) {
876
+ throw new Error("AGENTIC_SCHEDULE_FAILED: createTask \u6CA1\u6709\u8FD4\u56DE\u65B0\u5EFA\u7684\u4EFB\u52A1\u884C");
877
+ }
878
+ return {
879
+ created: true,
880
+ id: created.id ?? null,
881
+ uuid: created.uuid ?? uuid,
882
+ nextSendAt: created.next_send_at ?? nextSendAt
883
+ };
884
+ };
760
885
  const scratch = {};
761
886
  const fireCtx = Object.freeze({
762
887
  task: buildHookTask(task, decryptedPayload),
763
888
  userId: task.user_id,
764
889
  readState,
765
890
  writeState,
891
+ scheduleTask,
766
892
  now: new Date(nowFn()),
767
893
  scratch
768
894
  });
@@ -789,7 +915,11 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
789
915
  }
790
916
  const roundTimeoutMs = Math.max(1, Math.min(3e5, deadline - nowFn()));
791
917
  const { response: llmResponse } = await callLlm(
792
- { ...decryptedPayload, messages },
918
+ {
919
+ ...decryptedPayload,
920
+ messages,
921
+ ...normalized.tools ? { tools: normalized.tools, toolChoice: normalized.toolChoice } : {}
922
+ },
793
923
  { requireContent: false, timeoutMs: roundTimeoutMs }
794
924
  );
795
925
  const assistantMessage = extractAssistantMessage(llmResponse);
@@ -807,7 +937,8 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
807
937
  scratch
808
938
  }),
809
939
  readState,
810
- writeState
940
+ writeState,
941
+ scheduleTask
811
942
  });
812
943
  const decision = await hooks.onLLMOutput(sessionCtx);
813
944
  assertValidDecision(decision, { inlineToolCalls: true });
@@ -845,7 +976,10 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
845
976
  content: `Tool execution failed: ${error?.message ?? String(error)}`
846
977
  }));
847
978
  }
848
- const assistantWithTools = Array.isArray(assistantMessage.tool_calls) && assistantMessage.tool_calls.length > 0 ? assistantMessage : { ...assistantMessage, tool_calls: toolCalls };
979
+ const nativeCalls = Array.isArray(assistantMessage.tool_calls) ? assistantMessage.tool_calls : [];
980
+ const nativeIds = new Set(nativeCalls.map((tc) => tc && tc.id));
981
+ const synthesized = toolCalls.filter((tc) => !nativeIds.has(tc && tc.id));
982
+ const assistantWithTools = synthesized.length === 0 ? assistantMessage : { ...assistantMessage, tool_calls: [...nativeCalls, ...synthesized] };
849
983
  messages = [...messages.slice(0, -1), assistantWithTools, ...toolResults];
850
984
  }
851
985
  throw new Error(`AGENTIC_LOOP_EXCEEDED: no finish/skip-push decision within ${maxToolIterations} LLM round(s)`);
@@ -2262,7 +2396,7 @@ function createClientStateHandler(ctx) {
2262
2396
  }
2263
2397
 
2264
2398
  // src/server/lib/version.js
2265
- var SERVER_VERSION = true ? "2.6.0-next.7" : "0.0.0-dev";
2399
+ var SERVER_VERSION = true ? "2.6.0-next.9" : "0.0.0-dev";
2266
2400
 
2267
2401
  // src/server/handlers/capabilities.js
2268
2402
  var SERVER_FEATURES = Object.freeze([
@@ -2272,6 +2406,8 @@ var SERVER_FEATURES = Object.freeze([
2272
2406
  "agentic-hooks",
2273
2407
  "agentic-scratch",
2274
2408
  "agentic-write-state",
2409
+ "agentic-fire-tools",
2410
+ "agentic-schedule-task",
2275
2411
  "vapid-public-key"
2276
2412
  ]);
2277
2413
  function createCapabilitiesHandler(ctx) {
@@ -2313,7 +2449,9 @@ function createSingleUserServer(config) {
2313
2449
  hooks: config.hooks || null,
2314
2450
  maxToolIterations: config.maxToolIterations,
2315
2451
  totalTimeoutMs: config.totalTimeoutMs,
2316
- maxStateValueBytes: config.maxStateValueBytes
2452
+ maxStateValueBytes: config.maxStateValueBytes,
2453
+ // hook 的 ctx.scheduleTask() 单次 fire 建任务的条数上限(默认 2)。
2454
+ maxScheduledTasksPerFire: config.maxScheduledTasksPerFire
2317
2455
  };
2318
2456
  return {
2319
2457
  ctx,
@@ -2652,6 +2790,8 @@ function createSingleUserCloudflareWorker(buildConfig) {
2652
2790
  totalTimeoutMs: cfg.totalTimeoutMs,
2653
2791
  // hook 的 ctx.writeState() 用它判断单条 value 的上限,和 PUT /client-state 同一个配置。
2654
2792
  maxStateValueBytes: cfg.maxStateValueBytes,
2793
+ // hook 的 ctx.scheduleTask() 单次 fire 建任务的条数上限(默认 2)。
2794
+ maxScheduledTasksPerFire: cfg.maxScheduledTasksPerFire,
2655
2795
  // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
2656
2796
  claimLeaseMs: cfg.claimLeaseMs
2657
2797
  });