cortico 0.1.0

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.
Files changed (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/package.json +43 -0
  4. package/src/boot.ts +46 -0
  5. package/src/bot.ts +1342 -0
  6. package/src/config-file.ts +41 -0
  7. package/src/core/README.md +92 -0
  8. package/src/core/billing.ts +26 -0
  9. package/src/core/blobs.ts +106 -0
  10. package/src/core/bus.ts +311 -0
  11. package/src/core/config-schema.ts +201 -0
  12. package/src/core/config.ts +262 -0
  13. package/src/core/core.ts +699 -0
  14. package/src/core/cost.ts +244 -0
  15. package/src/core/event-store.ts +304 -0
  16. package/src/core/fork.ts +113 -0
  17. package/src/core/generation.ts +118 -0
  18. package/src/core/instance-lock.ts +215 -0
  19. package/src/core/ipc-logger.ts +105 -0
  20. package/src/core/language.ts +62 -0
  21. package/src/core/log-context.ts +38 -0
  22. package/src/core/loop.ts +1858 -0
  23. package/src/core/markers.ts +48 -0
  24. package/src/core/prefix.ts +121 -0
  25. package/src/core/run.ts +95 -0
  26. package/src/core/secrets.ts +18 -0
  27. package/src/core/session.ts +56 -0
  28. package/src/core/sessions.ts +261 -0
  29. package/src/core/state.ts +104 -0
  30. package/src/core/template.ts +72 -0
  31. package/src/core/timers.ts +129 -0
  32. package/src/core/tool-log.ts +135 -0
  33. package/src/core/transcript.ts +92 -0
  34. package/src/core/truncate.ts +121 -0
  35. package/src/core/types.ts +1140 -0
  36. package/src/core/usage-log.ts +79 -0
  37. package/src/core/util.ts +386 -0
  38. package/src/deploy-listing.ts +160 -0
  39. package/src/deploy.ts +162 -0
  40. package/src/extensions/README.md +72 -0
  41. package/src/extensions/dry-mount.ts +390 -0
  42. package/src/extensions/manifest.ts +153 -0
  43. package/src/extensions/runtime.ts +42 -0
  44. package/src/extensions.ts +519 -0
  45. package/src/launcher.ts +285 -0
  46. package/src/paths.ts +155 -0
  47. package/src/protocol/open-responses/LICENSE +201 -0
  48. package/src/protocol/open-responses/README.md +14 -0
  49. package/src/protocol/open-responses/context-helpers.ts +27 -0
  50. package/src/protocol/open-responses/context-log.ts +59 -0
  51. package/src/protocol/open-responses/context.ts +89 -0
  52. package/src/protocol/open-responses/generated.ts +115 -0
  53. package/src/protocol/open-responses/index.ts +33 -0
  54. package/src/protocol/open-responses/openapi.json +4230 -0
  55. package/src/protocol/open-responses/stream.ts +146 -0
  56. package/src/protocol/open-responses/tokens.ts +31 -0
  57. package/src/providers/README.md +86 -0
  58. package/src/providers/base.ts +111 -0
  59. package/src/providers/configuration.ts +97 -0
  60. package/src/providers/console/config.ts +21 -0
  61. package/src/providers/console/settings.ts +443 -0
  62. package/src/providers/console/strings.ts +74 -0
  63. package/src/providers/console/types.ts +11 -0
  64. package/src/providers/llamacpp/archive.ts +148 -0
  65. package/src/providers/llamacpp/catalog.ts +247 -0
  66. package/src/providers/llamacpp/config.ts +36 -0
  67. package/src/providers/llamacpp/console/client.ts +6 -0
  68. package/src/providers/llamacpp/console/models-panel.ts +120 -0
  69. package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
  70. package/src/providers/llamacpp/console/server.ts +153 -0
  71. package/src/providers/llamacpp/index.ts +95 -0
  72. package/src/providers/llamacpp/native.ts +51 -0
  73. package/src/providers/llamacpp/options.ts +117 -0
  74. package/src/providers/llamacpp/runtime-store.ts +139 -0
  75. package/src/providers/llamacpp/runtime.ts +214 -0
  76. package/src/providers/llamacpp/server.ts +286 -0
  77. package/src/providers/llamacpp/strings.ts +230 -0
  78. package/src/providers/openai-responses-compat/config.ts +20 -0
  79. package/src/providers/openai-responses-compat/index.ts +89 -0
  80. package/src/providers/openai-responses-compat/native.ts +127 -0
  81. package/src/providers/openai-responses-compat/strings.ts +19 -0
  82. package/src/providers/pricebook.ts +75 -0
  83. package/src/providers/registry.ts +137 -0
  84. package/src/providers/strings.ts +71 -0
  85. package/src/providers/transport/chat.ts +156 -0
  86. package/src/providers/transport/errors.ts +48 -0
  87. package/src/providers/transport/history.ts +68 -0
  88. package/src/providers/transport/native-input.ts +83 -0
  89. package/src/providers/transport/native-types.ts +13 -0
  90. package/src/providers/transport/response-assembly.ts +191 -0
  91. package/src/providers/transport/response-http.ts +196 -0
  92. package/src/providers/transport/response-meters.ts +27 -0
  93. package/src/providers/transport/responses-input.ts +56 -0
  94. package/src/web/README.md +84 -0
  95. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
  96. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
  97. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
  98. package/src/web/client/console-pages/builtins.ts +10 -0
  99. package/src/web/client/console-pages/context.ts +135 -0
  100. package/src/web/client/console-pages/host.ts +633 -0
  101. package/src/web/client/console-pages/loader.ts +132 -0
  102. package/src/web/client/console-pages/strings.ts +90 -0
  103. package/src/web/client/console-pages/tools/strings.ts +49 -0
  104. package/src/web/client/console-pages/tools/view.ts +215 -0
  105. package/src/web/client/core/api.ts +200 -0
  106. package/src/web/client/core/language.ts +56 -0
  107. package/src/web/client/core/lifecycle.ts +132 -0
  108. package/src/web/client/core/router.ts +199 -0
  109. package/src/web/client/core/stream.ts +157 -0
  110. package/src/web/client/core/websocket.ts +82 -0
  111. package/src/web/client/features/appearance/index.ts +333 -0
  112. package/src/web/client/features/appearance/strings.ts +87 -0
  113. package/src/web/client/features/config/index.ts +11 -0
  114. package/src/web/client/features/config/strings.ts +41 -0
  115. package/src/web/client/features/config/view.ts +428 -0
  116. package/src/web/client/features/core/events.ts +197 -0
  117. package/src/web/client/features/core/index.ts +254 -0
  118. package/src/web/client/features/core/run.ts +56 -0
  119. package/src/web/client/features/core/runlog.ts +214 -0
  120. package/src/web/client/features/core/sessions.ts +53 -0
  121. package/src/web/client/features/core/strings.ts +157 -0
  122. package/src/web/client/features/extensions/index.ts +422 -0
  123. package/src/web/client/features/extensions/strings.ts +164 -0
  124. package/src/web/client/features/feature.ts +93 -0
  125. package/src/web/client/features/live/context.ts +280 -0
  126. package/src/web/client/features/live/fork.ts +156 -0
  127. package/src/web/client/features/live/index.ts +392 -0
  128. package/src/web/client/features/live/onboarding.ts +113 -0
  129. package/src/web/client/features/live/protocol.ts +122 -0
  130. package/src/web/client/features/live/sessions.ts +71 -0
  131. package/src/web/client/features/live/status.ts +68 -0
  132. package/src/web/client/features/live/strings.ts +252 -0
  133. package/src/web/client/features/live/timeline.ts +590 -0
  134. package/src/web/client/features/prompts/editor.ts +292 -0
  135. package/src/web/client/features/prompts/index.ts +346 -0
  136. package/src/web/client/features/prompts/strings.ts +136 -0
  137. package/src/web/client/features/prompts/view.ts +256 -0
  138. package/src/web/client/features/providers/index.ts +100 -0
  139. package/src/web/client/features/providers/strings.ts +23 -0
  140. package/src/web/client/features/settings/general.ts +28 -0
  141. package/src/web/client/features/settings/index.ts +82 -0
  142. package/src/web/client/features/settings/strings.ts +33 -0
  143. package/src/web/client/features/storage/index.ts +1 -0
  144. package/src/web/client/features/storage/strings.ts +45 -0
  145. package/src/web/client/features/storage/view.ts +154 -0
  146. package/src/web/client/features/usage/chart.ts +486 -0
  147. package/src/web/client/features/usage/index.ts +407 -0
  148. package/src/web/client/features/usage/labels.ts +46 -0
  149. package/src/web/client/features/usage/range.ts +36 -0
  150. package/src/web/client/features/usage/state.ts +37 -0
  151. package/src/web/client/features/usage/strings.ts +191 -0
  152. package/src/web/client/features/usage/tooltip.ts +86 -0
  153. package/src/web/client/features/usage/types.ts +96 -0
  154. package/src/web/client/features/worlds/index.ts +424 -0
  155. package/src/web/client/features/worlds/strings.ts +121 -0
  156. package/src/web/client/main.ts +273 -0
  157. package/src/web/client/shell/avatar.ts +233 -0
  158. package/src/web/client/shell/index.ts +549 -0
  159. package/src/web/client/shell/strings.ts +89 -0
  160. package/src/web/client/theme/handoff.ts +43 -0
  161. package/src/web/client/theme/palette.ts +109 -0
  162. package/src/web/client/theme/registry.ts +130 -0
  163. package/src/web/client/theme/storage.ts +72 -0
  164. package/src/web/client/theme/strings.ts +123 -0
  165. package/src/web/client/theme/studio.ts +366 -0
  166. package/src/web/client/ui/actions.ts +109 -0
  167. package/src/web/client/ui/data.ts +185 -0
  168. package/src/web/client/ui/dom.ts +23 -0
  169. package/src/web/client/ui/fields.ts +191 -0
  170. package/src/web/client/ui/format.ts +75 -0
  171. package/src/web/client/ui/icons.ts +183 -0
  172. package/src/web/client/ui/images.ts +91 -0
  173. package/src/web/client/ui/index.ts +89 -0
  174. package/src/web/client/ui/lamp.ts +125 -0
  175. package/src/web/client/ui/log.ts +98 -0
  176. package/src/web/client/ui/overlay.ts +184 -0
  177. package/src/web/client/ui/page.ts +8 -0
  178. package/src/web/client/ui/prompt-input.tsx +311 -0
  179. package/src/web/client/ui/sheet.ts +131 -0
  180. package/src/web/client/ui/strings.ts +71 -0
  181. package/src/web/console-pages.ts +367 -0
  182. package/src/web/files.ts +107 -0
  183. package/src/web/path-picker.ts +296 -0
  184. package/src/web/public/index.html +17 -0
  185. package/src/web/public/styles.css +1715 -0
  186. package/src/web/server.ts +1883 -0
  187. package/src/web/shared/client-panel.ts +603 -0
  188. package/src/web/shared/console-protocol.ts +560 -0
  189. package/src/web/shared/css.d.ts +1 -0
  190. package/src/web/shared/path-picker.ts +23 -0
  191. package/src/web/shared/theme.ts +256 -0
  192. package/src/web/theme-store.ts +37 -0
  193. package/src/world.ts +449 -0
  194. package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
  195. package/src/worlds/bilibili/README.md +176 -0
  196. package/src/worlds/bilibili/audience-admission.ts +995 -0
  197. package/src/worlds/bilibili/client.ts +528 -0
  198. package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
  199. package/src/worlds/bilibili/config.ts +185 -0
  200. package/src/worlds/bilibili/console/client.ts +202 -0
  201. package/src/worlds/bilibili/definition.ts +35 -0
  202. package/src/worlds/bilibili/gift-frame.ts +131 -0
  203. package/src/worlds/bilibili/normalize.ts +452 -0
  204. package/src/worlds/bilibili/overlay/announcement.ts +76 -0
  205. package/src/worlds/bilibili/overlay/assets.ts +82 -0
  206. package/src/worlds/bilibili/overlay/model.ts +494 -0
  207. package/src/worlds/bilibili/overlay/project.ts +202 -0
  208. package/src/worlds/bilibili/overlay/server.ts +286 -0
  209. package/src/worlds/bilibili/overlay/types.ts +217 -0
  210. package/src/worlds/bilibili/overlay/web/app.js +690 -0
  211. package/src/worlds/bilibili/overlay/web/editor.css +297 -0
  212. package/src/worlds/bilibili/overlay/web/editor.html +102 -0
  213. package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
  214. package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
  215. package/src/worlds/bilibili/overlay/web/styles.css +258 -0
  216. package/src/worlds/bilibili/protobuf.ts +123 -0
  217. package/src/worlds/bilibili/wire.ts +94 -0
  218. package/src/worlds/bilibili/world.ts +1389 -0
  219. package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
  220. package/src/worlds/console-fixture/console/client.ts +72 -0
  221. package/src/worlds/console-fixture/world.ts +73 -0
  222. package/src/worlds/index.ts +15 -0
  223. package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
  224. package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
  225. package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
  226. package/src/worlds/minecraft/README.md +917 -0
  227. package/src/worlds/minecraft/blueprint-plan.ts +915 -0
  228. package/src/worlds/minecraft/blueprint-registry.ts +566 -0
  229. package/src/worlds/minecraft/blueprint-repair.ts +236 -0
  230. package/src/worlds/minecraft/blueprint-resource.ts +272 -0
  231. package/src/worlds/minecraft/blueprint.ts +492 -0
  232. package/src/worlds/minecraft/body-lease.ts +340 -0
  233. package/src/worlds/minecraft/bridge.ts +749 -0
  234. package/src/worlds/minecraft/check.ts +651 -0
  235. package/src/worlds/minecraft/chests.ts +292 -0
  236. package/src/worlds/minecraft/client-launch.ts +272 -0
  237. package/src/worlds/minecraft/client-options.ts +85 -0
  238. package/src/worlds/minecraft/client-skins.ts +164 -0
  239. package/src/worlds/minecraft/client-window.ps1 +91 -0
  240. package/src/worlds/minecraft/client.ts +423 -0
  241. package/src/worlds/minecraft/combat-context.ts +109 -0
  242. package/src/worlds/minecraft/combat.ts +1552 -0
  243. package/src/worlds/minecraft/config.ts +499 -0
  244. package/src/worlds/minecraft/console/access.ts +218 -0
  245. package/src/worlds/minecraft/console/client.ts +244 -0
  246. package/src/worlds/minecraft/console/log.ts +110 -0
  247. package/src/worlds/minecraft/console/mount.ts +420 -0
  248. package/src/worlds/minecraft/console/skin.ts +257 -0
  249. package/src/worlds/minecraft/console/style.css +154 -0
  250. package/src/worlds/minecraft/console/world.ts +449 -0
  251. package/src/worlds/minecraft/deaths.ts +135 -0
  252. package/src/worlds/minecraft/definition.ts +18 -0
  253. package/src/worlds/minecraft/engine-child.ts +274 -0
  254. package/src/worlds/minecraft/engine-ipc.ts +98 -0
  255. package/src/worlds/minecraft/entity-facts.ts +132 -0
  256. package/src/worlds/minecraft/escape.ts +407 -0
  257. package/src/worlds/minecraft/executor.ts +14494 -0
  258. package/src/worlds/minecraft/explored.ts +156 -0
  259. package/src/worlds/minecraft/geometry.ts +205 -0
  260. package/src/worlds/minecraft/goal-plan.ts +647 -0
  261. package/src/worlds/minecraft/item-break.ts +126 -0
  262. package/src/worlds/minecraft/item-facts.ts +146 -0
  263. package/src/worlds/minecraft/item-pick.ts +67 -0
  264. package/src/worlds/minecraft/level-dat.ts +176 -0
  265. package/src/worlds/minecraft/log.ts +132 -0
  266. package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
  267. package/src/worlds/minecraft/names.ts +377 -0
  268. package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
  269. package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
  270. package/src/worlds/minecraft/piglin.ts +49 -0
  271. package/src/worlds/minecraft/policy.ts +547 -0
  272. package/src/worlds/minecraft/precheck.ts +655 -0
  273. package/src/worlds/minecraft/proxy.ts +445 -0
  274. package/src/worlds/minecraft/ranged.ts +635 -0
  275. package/src/worlds/minecraft/readouts.ts +102 -0
  276. package/src/worlds/minecraft/round.ts +26 -0
  277. package/src/worlds/minecraft/search-observation.ts +92 -0
  278. package/src/worlds/minecraft/server-config.ts +467 -0
  279. package/src/worlds/minecraft/server.ts +658 -0
  280. package/src/worlds/minecraft/show.ts +84 -0
  281. package/src/worlds/minecraft/skills.ts +1763 -0
  282. package/src/worlds/minecraft/terrain.ts +1735 -0
  283. package/src/worlds/minecraft/window.ts +96 -0
  284. package/src/worlds/minecraft/works.ts +179 -0
  285. package/src/worlds/minecraft/world.ts +6438 -0
  286. package/src/worlds/qq/ENV_PROMPT.md +5 -0
  287. package/src/worlds/qq/config.ts +108 -0
  288. package/src/worlds/qq/console/client.ts +126 -0
  289. package/src/worlds/qq/console/events.ts +155 -0
  290. package/src/worlds/qq/console/gate.ts +232 -0
  291. package/src/worlds/qq/console/roster.ts +203 -0
  292. package/src/worlds/qq/console/style.css +65 -0
  293. package/src/worlds/qq/conversation.ts +28 -0
  294. package/src/worlds/qq/definition.ts +62 -0
  295. package/src/worlds/qq/driver.ts +375 -0
  296. package/src/worlds/qq/history-tools.ts +209 -0
  297. package/src/worlds/qq/normalize.ts +323 -0
  298. package/src/worlds/qq/qface-map.ts +282 -0
  299. package/src/worlds/qq/vision-prompt.ts +27 -0
  300. package/src/worlds/qq/vision.ts +642 -0
  301. package/src/worlds/qq/vlm.ts +142 -0
  302. package/src/worlds/qq/world.ts +1503 -0
  303. package/src/worlds/terminal/ENV_PROMPT.md +11 -0
  304. package/src/worlds/terminal/config.ts +9 -0
  305. package/src/worlds/terminal/definition.ts +14 -0
  306. package/src/worlds/terminal/world.ts +700 -0
  307. package/src/worlds/websearch/ENV_PROMPT.md +1 -0
  308. package/src/worlds/websearch/brave-client.ts +261 -0
  309. package/src/worlds/websearch/config.ts +75 -0
  310. package/src/worlds/websearch/definition.ts +20 -0
  311. package/src/worlds/websearch/world.ts +171 -0
@@ -0,0 +1,1858 @@
1
+ import { message, record, functionCall, functionResult, responseRecords, itemText, withText, type ContextRecord, type Item } from '../protocol/open-responses/context.ts';
2
+ import { hasRole, textOf, withoutPastReasoning, responseRequest, usageCounters } from '../protocol/open-responses/context-helpers.ts';
3
+ import type { Response, StreamEvent } from '../protocol/open-responses/index.ts';
4
+ import { GenerationError, type ResponseClient, type TokenMeters } from './generation.ts';
5
+ /**
6
+ * MainLoop 执行 receivesEvents=true 的常驻 session。
7
+ * 内部文本进入 user 消息;外部正文按 eventDelivery 进入合成工具回执(默认)
8
+ * 或同一条 user 消息,不进入 system role。无工具调用的响应或已执行的 endsTurn 工具结束本轮。
9
+ * 交接在稳定回合边界取快照,期间不投递事件;并发交接请求共用事务。
10
+ * Persona 提供交接内容与主动触发策略;Core 在超过模型容量时强制交接,
11
+ * 修复工具配对、限制保留内容大小并重写上下文。
12
+ */
13
+ import type {
14
+ CandidateEventSpec,
15
+ CandidateProjector,
16
+ CandidateProjectionEvent,
17
+ CoreConfig,
18
+ ContextHandoffResult,
19
+ DeferredEventSpec,
20
+ EventEnvelope,
21
+ FrameEventRef,
22
+ EventStore,
23
+ BlobInput,
24
+ BlobRef,
25
+ DeferredRendered,
26
+ World,
27
+ LLMUsage,
28
+ Logger,
29
+ ModelSpec,
30
+ Persona,
31
+ SessionDecl,
32
+ SessionOpeningReason,
33
+ ToolCallContext,
34
+ ToolDef,
35
+ ToolOutcome,
36
+ ToolSchema,
37
+ ToolTag,
38
+ WakeItem,
39
+ } from './types.ts';
40
+ import type { WakeBus } from './bus.ts';
41
+ import type { SessionLog } from './session.ts';
42
+ import type { CoreState } from './state.ts';
43
+ import type { SessionHandle, SessionTracker } from './sessions.ts';
44
+ import { assembleSystem, type EnvPromptDirs } from './prefix.ts';
45
+ import { recordToolCall, type ToolCallLog } from './tool-log.ts';
46
+ import type { Transcript } from './transcript.ts';
47
+ import { setAnchors, withAnchors } from './log-context.ts';
48
+ import { withBlobLines } from './blobs.ts';
49
+ import {
50
+ MISSING_RESULT_RESTART,
51
+ NOT_EXECUTED_BARRIER,
52
+ NOT_EXECUTED_INCOMPLETE,
53
+ NOT_EXECUTED_LOOP_STOPPED,
54
+ NOT_EXECUTED_STREAM_ABORTED,
55
+ SHUTDOWN_INTERRUPTED,
56
+ TOOL_FAILED_BAD_ARGS,
57
+ UNKNOWN_TOOL,
58
+ eventFrameHeader,
59
+ toolFailed,
60
+ } from './markers.ts';
61
+ import { fixPairing, rebuildTail } from './truncate.ts';
62
+
63
+ import { nowIso, prefixFingerprint, renderEventLines } from './util.ts';
64
+
65
+
66
+ /**
67
+ * 已挂载 World 的当前状态。隐藏不停止 World;事件投递立即停止,
68
+ * 环境前缀与工具表在前缀重建时一同更新,避免保留已不可用工具的说明。
69
+ */
70
+ export interface WorldView {
71
+ /** 全部已挂载 World(含被隐藏的) */
72
+ all(): World[];
73
+ /** 当前对 agent 可见的 World */
74
+ visible(): World[];
75
+ }
76
+
77
+ /** 当前工具名称,用于检查工具表是否需要重建。 */
78
+ function toolSignature(mod: World): string {
79
+ return mod
80
+ .tools()
81
+ .map((t) => t.name)
82
+ .join(',');
83
+ }
84
+
85
+ /** 按当前活跃端点读取上下文容量与计数,支持模型和端点热更新。 */
86
+ export interface ContextFacts {
87
+ /** 输入容量为模型窗口减单轮生成上限;窗口未知时返回 null,Core 不据此限制输入。 */
88
+ hardTokens(): number | null;
89
+ /** 上游还没数过的条目的本地估算(Provider 模块的估算函数,缺席时为字数比例估算)。 */
90
+ estimateTokens(records: readonly ContextRecord[]): number;
91
+ /** 上游拒绝请求的原因是输入超过模型上下文。 */
92
+ contextOverflow(error: GenerationError): boolean;
93
+ }
94
+
95
+ export interface MainLoopDeps {
96
+ cfg: CoreConfig;
97
+ llm: ResponseClient;
98
+ persona: Persona;
99
+ /** 本循环所跑的那个session声明(轮数上限/工具集都从这里实时读) */
100
+ decl: SessionDecl;
101
+ /** 当前活跃端点的模型配置,每轮读取。 */
102
+ spec: () => ModelSpec;
103
+ context: ContextFacts;
104
+ /** 记录落库刻的附件内部化(新字节进日志附件库、已有句柄补 mime);core 提供 */
105
+ blobs: { intern(inputs: readonly (BlobInput | BlobRef)[] | undefined): BlobRef[] | undefined };
106
+ /** 已挂载 World 的**实时**视图(可见性可被运维改;见 WorldView) */
107
+ worlds: WorldView;
108
+ /** 环境提示词的两个覆盖层(包 / 部署);缺席 = 只用 World 自带的模板。 */
109
+ dirs?: EnvPromptDirs;
110
+ bus: WakeBus;
111
+ session: SessionLog;
112
+ store: EventStore;
113
+ state: CoreState;
114
+ log: Logger;
115
+ /** session观察注册表(web面板数据源;可选,不接不影响主循环) */
116
+ tracker?: SessionTracker;
117
+ /** 模型工具调用流水(可选;不接则不落盘) */
118
+ toolLog?: ToolCallLog;
119
+ /** 主 session 的只追加副本;交接、清空、前缀重载在这里留边界记录 */
120
+ transcript?: Transcript;
121
+ /** 工具归属的 World id(工具流水的 mod 列);认不出的是Persona自己的工具 */
122
+ toolOwner?: (name: string) => string | undefined;
123
+ /** 同一批内模型失败后重新请求的预算,缺省使用 DEFAULT_RESUBMIT。 */
124
+ resubmit?: ResubmitPolicy;
125
+ }
126
+
127
+ /**
128
+ * 同一批内模型失败后的重新请求策略,沿用已保存的部分输出与工具回执。
129
+ * 仅状态 0、429、5xx 可重新请求;输入超限触发交接,其他 4xx、抢占、
130
+ * 关机和达到轮数上限均结束本批。
131
+ */
132
+ export interface ResubmitPolicy {
133
+ /** 允许重新请求的连续失败次数;例如 2 表示第三次连续失败结束本批。 */
134
+ maxConsecutive: number;
135
+ /** 一批内重新请求的总次数上限。 */
136
+ maxPerBatch: number;
137
+ /** 第 n 次连续失败后的等待毫秒数,超出长度取最后一档。 */
138
+ backoffMs: readonly number[];
139
+ }
140
+ export const DEFAULT_RESUBMIT: ResubmitPolicy = { maxConsecutive: 2, maxPerBatch: 4, backoffMs: [2_000, 10_000] };
141
+
142
+ /** 单条工具回执超过此长度记 warn。体积归 World 管,core 只观测,不截断。 */
143
+ const LARGE_RESULT_WARN_CHARS = 8_000;
144
+
145
+ export interface LoopStatus {
146
+ running: boolean;
147
+ /** 正在执行上下文交接事务(取快照 → Persona策略 → 重建 → 唤醒)。 */
148
+ truncating: boolean;
149
+ messageCount: number;
150
+ /** 下次请求的输入 token 数:上游计数覆盖部分加新增条目估算;无上游计数时全部估算。 */
151
+ estTokens: number;
152
+ context: {
153
+ /** 一次请求能收的输入上限;窗口未知时 null。 */
154
+ hardTokens: number | null;
155
+ /** estTokens 里上游数过的那部分;整份估算时 0。 */
156
+ countedTokens: number;
157
+ keepPastThinking: boolean;
158
+ };
159
+ batchesHandled: number;
160
+ roundsLastBatch: number;
161
+ lastTruncateAt: string | null;
162
+ /** 人工暂停中(控制台;事件照常落库排队,不投递) */
163
+ paused: boolean;
164
+ /** 当前是否安装了 DeliveryGate。 */
165
+ scheduleBlocked: boolean;
166
+ lastUsage: LLMUsage | null;
167
+ /** 投递水位:最后一条已投递或已了结事件的位置游标。 */
168
+ lastDeliveredCursor: number;
169
+ /** 水位之后该进上下文却还没投递的外部事件数。 */
170
+ behind: number;
171
+ }
172
+
173
+ /**
174
+ * 合成外部投递帧使用的保留名称,不注册为工具。
175
+ * 模型返回的同名调用在写入 session 前丢弃,不检查其参数或 id。
176
+ */
177
+ const EXTERNAL_EVENT_FRAME = 'external_event_frame';
178
+ /** 失败时刻的保留窗口,供 World 查询与恢复通知使用。 */
179
+ const STALL_WINDOW_MS = 3_600_000;
180
+ /**
181
+ * LLM 连续失败达到此次数时报告 error,恢复后解除告警。该阈值仅控制告警,不改变重试节奏。
182
+ */
183
+ const STALL_ALERT_THRESHOLD = 5;
184
+ /** 不能注册为工具的名称;模型返回的同名调用不写入 session。 */
185
+ export const RESERVED_FRAME_NAMES = new Set<string>([EXTERNAL_EVENT_FRAME]);
186
+
187
+ /** 事件正文的位置元数据;各事件 text 用换行拼接,base 是事件块在整条正文中的起点。 */
188
+ function frameEventRefs(events: EventEnvelope[], base: number): FrameEventRef[] {
189
+ let start = base;
190
+ return events.map((e) => {
191
+ const ref: FrameEventRef = { cursor: e.cursor, ts: e.ts, type: e.type, source: e.source, start, chars: e.text.length };
192
+ if (e.tags?.length) ref.tags = e.tags;
193
+ start += e.text.length + 1;
194
+ return ref;
195
+ });
196
+ }
197
+
198
+ /** 一批事件携带的媒体引用,按事件顺序拼接;事件正文的顺序就是分片的顺序。 */
199
+ function eventBlobs(events: readonly EventEnvelope[]): BlobRef[] {
200
+ return events.flatMap((e) => e.blobs ?? []);
201
+ }
202
+
203
+ /** 重启补投的数量上限;仅补投最近事件,更早的事件标记已处理并推进水位。 */
204
+ const MAX_REQUEUE = 200;
205
+
206
+ /**
207
+ * 运行期水位自检周期;检查只报告停滞事实,不自动修复。
208
+ */
209
+ const WATERMARK_AUDIT_INTERVAL_MS = 120_000;
210
+ /**
211
+ * 水位停滞阈值为 5 分钟,为当前模型轮与分钟级工具执行留出时间。配合 2 分钟巡查周期,发现延迟最多约 7 分钟。
212
+ */
213
+ const WATERMARK_STALL_MS = 300_000;
214
+ /**
215
+ * 同一投递水位的停滞告警,在首报后 15 分钟和 1 小时各重报一次。
216
+ */
217
+ const WATERMARK_RESTATE_MS = [15 * 60_000, 60 * 60_000] as const;
218
+
219
+ /** 延迟渲染期限;超时项不归档、不投递。 */
220
+ const RENDER_DEADLINE_MS = 3000;
221
+ /** renderDeferred 超时哨兵(render 合法返回 null,不能拿 null 当超时信号) */
222
+ const RENDER_TIMED_OUT = Symbol('render-timed-out');
223
+
224
+ interface PreparedCandidateProjection {
225
+ source: string;
226
+ origin: EventEnvelope['origin'];
227
+ event: CandidateProjectionEvent;
228
+ sourceCursors: number[];
229
+ }
230
+
231
+ export class MainLoop {
232
+ private d: MainLoopDeps;
233
+ private running = false;
234
+ private stopFn: (() => void) | null = null;
235
+ private toolDefs: ToolDef[] = [];
236
+ private batchesHandled = 0;
237
+ private roundsLastBatch = 0;
238
+ /** 跨唤醒批次单调递增的轮序号;每建一份工具 ctx 加一,透传给 ToolCallContext.round */
239
+ private roundSeq = 0;
240
+ private lastUsage: LLMUsage | null = null;
241
+ /** 主session的观察句柄(tracker未接线时null) */
242
+ private mainTrack: SessionHandle | null = null;
243
+ /** 截断是单实例事务;手动触发和阈值检查并发时复用同一个Promise。 */
244
+ private truncatePromise: Promise<void> | null = null;
245
+ /** 截断与前缀重载共用的维护串行链,避免两个 session.reset 互相覆盖。 */
246
+ private maintenanceChain: Promise<void> = Promise.resolve();
247
+ private prefixReloadPromise: Promise<void> | null = null;
248
+ /** 在一轮处理中请求重载时,等自然回合边界再释放。 */
249
+ private releasePrefixReload: (() => void) | null = null;
250
+ /** 当前正在处理一个事件批;手动交接必须等到该批自然结束,不能重置半轮session。 */
251
+ private processingBatch = false;
252
+ private handoffRequested = false;
253
+ /** onDelivery 同步执行期间,injectInternal 的即时项加入当前批,不经过总线。 */
254
+ private deliveryCollector: EventEnvelope[] | null = null;
255
+ /** 已投递但前面仍有外部缺口的游标;水位只越过连续前缀。 */
256
+ private readonly deliveredCursors = new Set<number>();
257
+ /**
258
+ * 已由候选处理函数处理的原始归档位置,包含选中及丢弃项。
259
+ * 未处理的归档项阻止水位越过;该集合不持久化,重启时根据已生成事件的来源引用恢复。
260
+ */
261
+ private readonly settledArchives = new Set<number>();
262
+ /**
263
+ * 窗口内的 LLM 失败时刻,单位为毫秒。保存在 state.data.llmStall,
264
+ * 跨重启保留;恢复时通知 Persona,World 可按时间窗口查询。
265
+ */
266
+ private get stallAt(): number[] { return this.d.state.data.llmStall.at; }
267
+ /** 当前这串连续失败的第一次发生时刻;0 = 此刻没在失败串里 */
268
+ private get stallSince(): number { return this.d.state.data.llmStall.since; }
269
+ private set stallSince(at: number) { this.d.state.data.llmStall.since = at; }
270
+ /** 当前连续失败是否已告警;恢复后解除,同一串不重复告警。 */
271
+ private stallAlarmActive = false;
272
+ /** run() 启动水位巡查,stop() 取消。 */
273
+ private watermarkAudit: ReturnType<typeof setInterval> | null = null;
274
+ /**
275
+ * 已报告停滞的水位,-1 表示未报告。水位推进后重置;
276
+ * 同一水位按 WATERMARK_RESTATE_MS 间隔再次报告。
277
+ */
278
+ private watermarkStallAt = -1;
279
+ /** 当前这次停滞的首报时刻(退避重报的基准) */
280
+ private watermarkStallSince = 0;
281
+ /** 首报时的积压量,后续报告据此计算增长量。 */
282
+ private watermarkStallBehind = 0;
283
+ /** 同一次停滞已报次数(首报计 1) */
284
+ private watermarkStallReports = 0;
285
+ /**
286
+ * 最近成功请求的输入与输出 token 总数,覆盖前 records 条(含响应)。
287
+ * 新增条目使用本地估算,下次成功后更新计数;上下文整体重写后失效。
288
+ */
289
+ private anchor: { records: number; tokens: number; reasoningTokens: number } | null = null;
290
+ /** 当前 system 前缀和工具表采用的可见 World 集合。 */
291
+ private appliedVisibleWorlds: Set<string> | null = null;
292
+ /** 当前前缀中各可见 World 的工具签名,用于检测工具表漂移。 */
293
+ private appliedWorldTools = new Map<string, string>();
294
+ /** 当前模型轮;非 reasoning 增量一旦外流,本轮不再接受自动抢占。 */
295
+ private currentRound: {
296
+ controller: AbortController;
297
+ externalized: boolean;
298
+ abortReason: 'preempt' | 'shutdown' | null;
299
+ } | null = null;
300
+ /** 每次 stop 都使此前捕获的异步 continuation 永久失效。 */
301
+ private generation = 0;
302
+ private stopped = false;
303
+ /** 主循环退出或 drain 超时后封住持久化出口,迟到的 provider/tool promise 只能在内存中结束。 */
304
+ private sealed = false;
305
+ /** 已落库、但尚未配齐结果的当前 assistant 工具调用。 */
306
+ private pendingToolCalls = new Set<string>();
307
+ /** stop() 提前结束重试等待,由 active() 决定退出。 */
308
+ private backoffWake: (() => void) | null = null;
309
+ /**
310
+ * 工具信号合并关机信号与当前模型调用信号。
311
+ * 自动抢占仅发生在没有外部输出、尚未执行工具时;工具执行期间仅关机或循环换代会取消。
312
+ */
313
+ private readonly shutdown = new AbortController();
314
+
315
+ constructor(deps: MainLoopDeps) {
316
+ this.d = deps;
317
+ deps.session.onReset(() => { this.anchor = null; });
318
+ }
319
+
320
+ private active(generation: number): boolean {
321
+ return !this.stopped && !this.sealed && this.generation === generation;
322
+ }
323
+
324
+ private activeNow(): boolean {
325
+ return this.active(this.generation);
326
+ }
327
+
328
+ /** 尝试取消尚未输出的当前模型调用;无可取消调用时返回 false。 */
329
+ abortCurrentRound(): boolean {
330
+ if (!this.activeNow()) return false;
331
+ const round = this.currentRound;
332
+ if (!round || round.externalized) return false;
333
+ round.abortReason = 'preempt';
334
+ round.controller.abort(new Error('模型轮被新输入抢占'));
335
+ return true;
336
+ }
337
+
338
+ /**
339
+ * 工具 schema 与 handler 来自 session 声明;此处去重、稳定排序并按名称过滤隐藏 World 工具。
340
+ * World 之间、与 Persona 声明的自有工具及保留帧重名时,装配层拒绝挂载。
341
+ * Persona 未声明自有工具名时,此处保留先注册项并告警。
342
+ */
343
+ private assembleTools(hiddenToolNames: Set<string>): void {
344
+ const { decl, log } = this.d;
345
+ const defs: ToolDef[] = [];
346
+ const seen = new Set<string>();
347
+ for (const def of decl.tools()) {
348
+ if (RESERVED_FRAME_NAMES.has(def.name)) {
349
+ // 保留帧名称不能注册为工具,同名模型调用会被丢弃。
350
+ log.warn(`工具名与保留帧撞名,拒绝注册: ${def.name}`);
351
+ continue;
352
+ }
353
+ if (hiddenToolNames.has(def.name)) continue;
354
+ if (seen.has(def.name)) {
355
+ log.warn(`工具重名,跳过后者: ${def.name}`);
356
+ continue;
357
+ }
358
+ if (def.tags.length === 0 && !this.warnedUntagged.has(def.name)) {
359
+ this.warnedUntagged.add(def.name);
360
+ log.warn(`工具未分类(tags为空,不参与任何tag过滤): ${def.name}`);
361
+ }
362
+ seen.add(def.name);
363
+ defs.push(def);
364
+ }
365
+ this.toolDefs = defs;
366
+ }
367
+
368
+ /** 空 tags 只报告一次。 */
369
+ private readonly warnedUntagged = new Set<string>();
370
+
371
+ /** 按当前可见性重建工具表并记录所用 World 集合;重启恢复前缀时也执行。 */
372
+ private bindWorlds(): World[] {
373
+ const { worlds } = this.d;
374
+ const visible = worlds.visible();
375
+ const visibleIds = new Set(visible.map((m) => m.id));
376
+ const hiddenToolNames = new Set(
377
+ worlds
378
+ .all()
379
+ .filter((m) => !visibleIds.has(m.id))
380
+ .flatMap((m) => m.tools().map((t) => t.name)),
381
+ );
382
+ this.assembleTools(hiddenToolNames);
383
+ this.appliedVisibleWorlds = visibleIds;
384
+ this.appliedWorldTools = new Map(visible.map((m) => [m.id, toolSignature(m)]));
385
+ return visible;
386
+ }
387
+
388
+ /**
389
+ * 组装 system 前缀,并在同一时刻换成匹配的工具表。两者同属请求缓存前缀,
390
+ * 必须同步更新。
391
+ */
392
+ private async buildSystem(): Promise<ContextRecord> {
393
+ const { persona, cfg, dirs } = this.d;
394
+ const content = await assembleSystem({
395
+ persona,
396
+ worlds: this.bindWorlds(),
397
+ now: new Date(),
398
+ timezone: cfg.timezone,
399
+ dirs,
400
+ });
401
+ return message('system', content);
402
+ }
403
+
404
+ /**
405
+ * 在持久上下文的 system 消息后插入合成开头,生成请求与快照使用的副本。
406
+ * 继承快照的 fork 保留相同的请求前缀;开头为空时仅复制原上下文。
407
+ */
408
+ outboundMessages(): ContextRecord[] {
409
+ const msgs = this.d.session.records;
410
+ const head = this.sessionHead();
411
+ if (head.length === 0) return [...msgs];
412
+ let at = 0;
413
+ while (at < msgs.length && hasRole(msgs[at], 'system')) at++;
414
+ return [...msgs.slice(0, at), ...head, ...msgs.slice(at)];
415
+ }
416
+
417
+ /**
418
+ * Persona 的合成开头,每次现取:system 与 developer 项丢弃,工具配对补齐,全部带不落盘标记。
419
+ * Persona 没提供或抛错时为空。
420
+ */
421
+ sessionHead(): ContextRecord[] {
422
+ const { persona, log } = this.d;
423
+ let items: Item[];
424
+ try {
425
+ items = persona.sessionHead?.() ?? [];
426
+ } catch (e) {
427
+ log.warn('sessionHead 读取失败,本次不注入', { err: e });
428
+ return [];
429
+ }
430
+ const kept = items.filter((item) => !(item.type === 'message' && (item.role === 'system' || item.role === 'developer')));
431
+ if (kept.length !== items.length) log.warn('sessionHead 里的 system/developer 项已丢弃', { dropped: items.length - kept.length });
432
+ return fixPairing(kept.map((item) => record(item, { head: true })))
433
+ .map((entry) => (entry.context.head ? entry : { ...entry, context: { ...entry.context, head: true as const } }));
434
+ }
435
+
436
+ /**
437
+ * 比较当前 World 可见性及工具名称与构建前缀时的记录,供控制台提示重载。
438
+ * 工具名可同步读取,因此用它检测 World 功能变化,不直接读取异步环境模板。
439
+ */
440
+ modulePrefixDrift(): string[] {
441
+ const applied = this.appliedVisibleWorlds;
442
+ if (!applied) return [];
443
+ const visible = new Map(this.d.worlds.visible().map((m) => [m.id, m]));
444
+ return this.d.worlds
445
+ .all()
446
+ .map((m) => m.id)
447
+ .filter((id) => {
448
+ const mod = visible.get(id);
449
+ if (applied.has(id) !== !!mod) return true;
450
+ return !!mod && this.appliedWorldTools.get(id) !== toolSignature(mod);
451
+ });
452
+ }
453
+
454
+ /** 工具回执落库:附件内部化,每份的文本形态接在正文后。 */
455
+ private toolResult(callId: string, out: ToolOutcome): ContextRecord {
456
+ const blobs = this.d.blobs.intern(out.blobs);
457
+ return functionResult(callId, withBlobLines(out.text, blobs), blobs ? { blobs } : {});
458
+ }
459
+
460
+ /** 一轮结束时先通知 Persona,再通知可见 World。 */
461
+ private finishTurn(): void {
462
+ if (!this.activeNow()) return;
463
+ const { persona, worlds, log } = this.d;
464
+ try {
465
+ persona.onTurnEnded?.();
466
+ } catch (e) {
467
+ log.warn('onTurnEnded钩子异常', { err: e });
468
+ }
469
+ for (const m of worlds.visible()) {
470
+ try {
471
+ m.onTurnEnded?.();
472
+ } catch (e) {
473
+ log.warn('World 回合收束钩子异常', { id: m.id, err: e });
474
+ }
475
+ }
476
+ }
477
+
478
+ /** 外部正文落在上下文的哪个区(声明里没写=工具回执区)。 */
479
+ private eventDelivery(): 'tool' | 'user' {
480
+ return this.d.decl.eventDelivery ?? 'tool';
481
+ }
482
+
483
+ /**
484
+ * 将一批事件写入主 session。即时事件在前,候选生成内容与延迟渲染内容在后。
485
+ * 候选按 source、origin 和处理函数分组,再按来源项在批次中的顺序生成正文。
486
+ * 正文归档后调用 onDelivery;同步注入的内部项追加到内部行末尾、外部正文之前。
487
+ * 内部行合成一条 user 消息;外部正文按 eventDelivery 进入合成工具回执或同一条 user 消息。
488
+ */
489
+ private async deliverBatch(batch: WakeItem[], generation: number): Promise<boolean> {
490
+ if (!this.active(generation)) return false;
491
+ const { session, persona, store, cfg, log } = this.d;
492
+ const projections = this.prepareCandidateProjections(batch, generation);
493
+ if (!this.active(generation)) return false;
494
+ // 已调用候选处理函数的原始事件均标记已处理,含选中与丢弃项。
495
+ // 尚未交给处理函数的候选继续阻止水位推进。
496
+ for (const item of batch) {
497
+ for (const event of item.candidate?.sourceEvents ?? []) this.settledArchives.add(event.cursor);
498
+ }
499
+ const delivered: EventEnvelope[] = [];
500
+ for (const item of batch) {
501
+ if (item.event) delivered.push(item.event);
502
+ }
503
+ for (let index = 0; index < batch.length; index++) {
504
+ for (const projection of projections.get(index) ?? []) {
505
+ delivered.push(store.append({
506
+ ...projection.event,
507
+ ts: nowIso(cfg.timezone),
508
+ source: projection.source,
509
+ origin: projection.origin,
510
+ contextDelivery: 'deliver',
511
+ meta: {
512
+ ...projection.event.meta,
513
+ sourceCursors: projection.sourceCursors,
514
+ },
515
+ }));
516
+ }
517
+ const deferred = batch[index].deferred;
518
+ if (!deferred) continue;
519
+ const rendered = await this.renderDeferred(deferred, generation);
520
+ if (!this.active(generation)) return false;
521
+ if (rendered === null) continue; // 未生成正文,不归档、不投递。
522
+ const body = typeof rendered === 'string' ? { text: rendered } : rendered;
523
+ const blobs = this.d.blobs.intern(body.blobs);
524
+ delivered.push(store.append({
525
+ type: deferred.type,
526
+ ts: nowIso(cfg.timezone),
527
+ source: deferred.source,
528
+ origin: deferred.origin,
529
+ contextDelivery: 'deliver',
530
+ text: withBlobLines(body.text, blobs),
531
+ ...(blobs ? { blobs } : {}),
532
+ senderKey: deferred.senderKey,
533
+ meta: deferred.meta,
534
+ tags: deferred.tags,
535
+ }));
536
+ }
537
+ if (!this.active(generation)) return false;
538
+ if (delivered.length > 0) {
539
+ // 仅捕获同步钩子内的即时注入;退出钩子后恢复总线投递。
540
+ const injected: EventEnvelope[] = [];
541
+ this.deliveryCollector = injected;
542
+ try {
543
+ persona.onDelivery?.({ events: [...delivered] });
544
+ } catch (e) {
545
+ log.warn('onDelivery钩子异常', { err: e });
546
+ } finally {
547
+ this.deliveryCollector = null;
548
+ }
549
+ delivered.push(...injected);
550
+ }
551
+ const lines: string[] = [];
552
+ const events: EventEnvelope[] = [];
553
+ const internals: EventEnvelope[] = [];
554
+ let ephemeralCount = 0;
555
+ for (const e of delivered) {
556
+ if (e.origin === 'internal') {
557
+ lines.push(e.text);
558
+ internals.push(e);
559
+ if (e.ephemeral) ephemeralCount++;
560
+ } else events.push(e);
561
+ }
562
+ const inUser = this.eventDelivery() === 'user';
563
+ if (events.length > 0 && inUser) lines.push(renderEventLines(events));
564
+ // 仅当整条消息都可清除时标记 ephemeral,混合消息需保留其他内容。
565
+ const ephemeral = ephemeralCount > 0 && ephemeralCount === internals.length && events.length === 0;
566
+ if (!this.active(generation)) return false;
567
+ this.dropEphemeral(generation);
568
+ if (lines.length > 0) {
569
+ const msg: ContextRecord = message('user', lines.join('\n'));
570
+ if (ephemeral) msg.context.ephemeral = true;
571
+ // user 模式下事件块接在内部行后面:sidecar 的位置从那一段之后起算
572
+ if (events.length > 0 && inUser) {
573
+ const base = lines.slice(0, -1).reduce((n, l) => n + l.length + 1, 0);
574
+ msg.context.frame = { events: frameEventRefs(events, base) };
575
+ }
576
+ const blobs = eventBlobs(inUser ? [...internals, ...events] : internals);
577
+ if (blobs.length > 0) msg.context.blobs = blobs;
578
+ session.append(msg);
579
+ }
580
+ if (events.length > 0 && !inUser) this.appendEventFrame(events, generation);
581
+ this.noteHandled(delivered, generation);
582
+ const changed = lines.length > 0 || events.length > 0;
583
+ if (changed) this.batchesHandled++;
584
+ return changed;
585
+ }
586
+
587
+ /** 候选选择由来源 World 决定;Core 组批、校验来源引用并收集输出。 */
588
+ private prepareCandidateProjections(
589
+ batch: readonly WakeItem[],
590
+ generation: number,
591
+ ): Map<number, PreparedCandidateProjection[]> {
592
+ const groups: Array<{
593
+ source: string;
594
+ origin: EventEnvelope['origin'];
595
+ project: CandidateProjector;
596
+ entries: Array<{ batchIndex: number; candidate: CandidateEventSpec }>;
597
+ }> = [];
598
+ for (let batchIndex = 0; batchIndex < batch.length; batchIndex++) {
599
+ const candidate = batch[batchIndex].candidate;
600
+ if (!candidate) continue;
601
+ let group = groups.find((value) =>
602
+ value.source === candidate.source &&
603
+ value.origin === candidate.origin &&
604
+ value.project === candidate.project);
605
+ if (!group) {
606
+ group = { source: candidate.source, origin: candidate.origin, project: candidate.project, entries: [] };
607
+ groups.push(group);
608
+ }
609
+ group.entries.push({ batchIndex, candidate });
610
+ }
611
+
612
+ const prepared = new Map<number, PreparedCandidateProjection[]>();
613
+ for (const group of groups) {
614
+ try {
615
+ const projected = group.project(group.entries.map((entry) => entry.candidate));
616
+ if (!this.active(generation)) return new Map();
617
+ const claimed = new Set<number>();
618
+ for (const projection of projected) {
619
+ const indexes = [...new Set(projection.candidateIndexes)].sort((a, b) => a - b);
620
+ const invalid = indexes.length === 0 || indexes.some((index) =>
621
+ !Number.isInteger(index) || index < 0 || index >= group.entries.length || claimed.has(index));
622
+ if (invalid) {
623
+ this.d.log.warn('候选投影含无效或重复源引用,已跳过整条投影', { source: group.source });
624
+ continue;
625
+ }
626
+ for (const index of indexes) claimed.add(index);
627
+ const anchor = group.entries[indexes[0]].batchIndex;
628
+ const sourceCursors = indexes.flatMap((index) =>
629
+ group.entries[index].candidate.sourceEvents.map((event) => event.cursor));
630
+ const list = prepared.get(anchor) ?? [];
631
+ list.push({
632
+ source: group.source,
633
+ origin: group.origin,
634
+ event: projection.event,
635
+ sourceCursors,
636
+ });
637
+ prepared.set(anchor, list);
638
+ }
639
+ } catch (error) {
640
+ this.d.log.warn('候选投影失败,本批候选只保留归档', {
641
+ source: group.source,
642
+ err: error,
643
+ });
644
+ }
645
+ }
646
+ return prepared;
647
+ }
648
+
649
+ /** 每批投递前删除带 ephemeral 标记的旧消息;重启读回的标记同样有效。 */
650
+ private dropEphemeral(generation: number): void {
651
+ if (!this.active(generation)) return;
652
+ const { session } = this.d;
653
+ if (!session.records.some((m) => m.context.ephemeral)) return;
654
+ const kept = session.records.filter((m) => !m.context.ephemeral);
655
+ const dropped = session.records.length - kept.length;
656
+ session.reset(kept);
657
+ this.d.transcript?.boundary('ephemeral-drop', { dropped });
658
+ }
659
+
660
+ /** 延迟渲染应读取当前状态;null、超时或异常均不归档、不投递,超时与异常记录日志。 */
661
+ private async renderDeferred(spec: DeferredEventSpec, generation: number): Promise<DeferredRendered | null> {
662
+ if (!this.active(generation)) return null;
663
+ const { log } = this.d;
664
+ let timer: ReturnType<typeof setTimeout> | null = null;
665
+ try {
666
+ const timeout = new Promise<typeof RENDER_TIMED_OUT>((resolve) => {
667
+ timer = setTimeout(() => resolve(RENDER_TIMED_OUT), RENDER_DEADLINE_MS);
668
+ });
669
+ const out = await Promise.race([Promise.resolve(spec.render()), timeout]);
670
+ if (!this.active(generation)) return null;
671
+ if (out === RENDER_TIMED_OUT) {
672
+ log.warn("延迟渲染超时,该项未归档、未投递", { type: spec.type, source: spec.source });
673
+ return null;
674
+ }
675
+ return out;
676
+ } catch (e) {
677
+ log.warn("延迟渲染失败,该项未归档、未投递", { type: spec.type, source: spec.source, err: e });
678
+ return null;
679
+ } finally {
680
+ if (timer) clearTimeout(timer);
681
+ }
682
+ }
683
+
684
+ /**
685
+ * 以合成调用及回执承载外部正文,不新增模型请求。
686
+ * 该模式下 user 消息仅承载内部文本。
687
+ */
688
+ private appendEventFrame(events: EventEnvelope[], generation: number): void {
689
+ if (!this.active(generation)) return;
690
+ const { session } = this.d;
691
+ const id = `evf_${events[events.length - 1].cursor}`;
692
+ session.append(functionCall(id, EXTERNAL_EVENT_FRAME, '{}'));
693
+ const header = eventFrameHeader(events.length);
694
+ const blobs = eventBlobs(events);
695
+ session.append(functionResult(id, [header, renderEventLines(events)].join('\n'), {
696
+ frame: { events: frameEventRefs(events, header.length + 1) },
697
+ ...(blobs.length > 0 ? { blobs } : {}),
698
+ }));
699
+ }
700
+
701
+ /**
702
+ * 丢弃模型返回的保留帧调用并记录日志,其余调用照常执行。
703
+ * 删除后没有工具调用的响应按自然结束处理。
704
+ */
705
+ private dropReservedCalls(records: ContextRecord[]): ContextRecord[] {
706
+ return records.filter(entry => {
707
+ const item = entry.item;
708
+ if (item.type !== 'function_call' || !RESERVED_FRAME_NAMES.has(item.name)) return true;
709
+ this.d.log.info('模型仿造保留帧调用,已丢弃', { name: item.name, callId: item.call_id });
710
+ return false;
711
+ });
712
+ }
713
+
714
+ /**
715
+ * 投递水位仅推进已了结事件的连续前缀,不跨越未投递外部事件。遍历和推进均使用存储位置游标,不使用信封自报的 cursor。
716
+ */
717
+ private noteHandled(delivered: readonly EventEnvelope[], generation: number): void {
718
+ if (!this.active(generation)) return;
719
+ const { state, store } = this.d;
720
+ for (const event of delivered) this.deliveredCursors.add(event.cursor);
721
+ let top = state.data.lastDeliveredCursor;
722
+ const latest = store.latestCursor();
723
+ for (let c = top + 1; c <= latest; c++) {
724
+ const next = store.get(c);
725
+ if (!next) break;
726
+ // 未处理的 archive-only 项必须保留在水位之后,重启才会补投。
727
+ // 已处理项由 settledArchives 标识。
728
+ const skippable = next.origin === 'internal'
729
+ || (next.contextDelivery === 'archive-only' && this.settledArchives.has(c));
730
+ // 集合记录存储位置;装载期重排保证 next.cursor === c。
731
+ if (!skippable && !this.deliveredCursors.has(c)) break;
732
+ this.deliveredCursors.delete(c);
733
+ this.settledArchives.delete(c);
734
+ top = c;
735
+ }
736
+ if (top === state.data.lastDeliveredCursor) return;
737
+ state.data.lastDeliveredCursor = top;
738
+ state.save();
739
+ }
740
+
741
+ /** session 开场时调用 Persona 钩子;未注入时不添加开场文本。 */
742
+ private pushOpening(reason: SessionOpeningReason): void {
743
+ const { persona, log } = this.d;
744
+ try {
745
+ persona.onOpening?.({ reason });
746
+ } catch (e) {
747
+ log.warn('onOpening钩子异常', { err: e });
748
+ }
749
+ }
750
+
751
+ /**
752
+ * 重启时补投水位之后的外部事件;内部事件不跨重启投递。
753
+ * archive-only 已被投影引用则不重复投递,未被引用则按原文补投。超过条数上限时只入队最近一段,更早事件结清水位。
754
+ */
755
+ private requeueUndelivered(): void {
756
+ const { bus, state, store, log } = this.d;
757
+ // 推进水位前,先从已生成事件的引用恢复 settledArchives。
758
+ // 否则重启前已处理的原始归档仍会阻止水位推进。
759
+ const from = state.data.lastDeliveredCursor + 1;
760
+ if (from > store.latestCursor()) {
761
+ this.noteHandled([], this.generation);
762
+ return;
763
+ }
764
+ const after = store.range({ fromCursor: from });
765
+ const referenced = new Set<number>();
766
+ for (const event of after) {
767
+ if (event.contextDelivery !== 'deliver') continue;
768
+ const cursors = (event.meta as { sourceCursors?: unknown } | undefined)?.sourceCursors;
769
+ if (!Array.isArray(cursors)) continue;
770
+ for (const c of cursors) if (typeof c === 'number') referenced.add(c);
771
+ }
772
+ for (const c of referenced) this.settledArchives.add(c);
773
+ this.noteHandled([], this.generation);
774
+ const pending = after.filter((event) => event.origin === 'external'
775
+ && (event.contextDelivery !== 'archive-only' || !referenced.has(event.cursor)));
776
+ if (pending.length === 0) return;
777
+
778
+ const skipped = Math.max(0, pending.length - MAX_REQUEUE);
779
+ const kept = skipped > 0 ? pending.slice(skipped) : pending;
780
+ if (skipped > 0) {
781
+ const stale = pending.slice(0, skipped);
782
+ state.data.lastDeliveredCursor = Math.max(state.data.lastDeliveredCursor, kept[0].cursor - 1);
783
+ state.save();
784
+ log.warn('重启补投超过上限,更早的一段按上一世代结清,不进上下文', {
785
+ skipped,
786
+ requeued: kept.length,
787
+ max: MAX_REQUEUE,
788
+ earliestTs: stale[0].ts,
789
+ latestTs: stale[stale.length - 1].ts,
790
+ });
791
+ }
792
+ for (const event of kept) bus.push({ event }, { trigger: 'piggyback' });
793
+ log.info('重启补投:水位之后还没进过 session 的外部事件已重新入队', {
794
+ count: kept.length,
795
+ fromCursor: kept[0].cursor,
796
+ });
797
+ const originals = kept.filter((event) => event.contextDelivery === 'archive-only');
798
+ if (originals.length > 0) {
799
+ log.warn('补投里有没等到投影的原始归档,按原文补投', {
800
+ count: originals.length,
801
+ sources: [...new Set(originals.map((event) => event.source))],
802
+ earliestTs: originals[0].ts,
803
+ latestTs: originals[originals.length - 1].ts,
804
+ });
805
+ }
806
+ }
807
+
808
+ private async bootstrap(generation: number): Promise<void> {
809
+ if (!this.active(generation)) return;
810
+ const { session, log } = this.d;
811
+ // 恢复窗口内的失败记录,供重启后的首次成功报告。
812
+ if (this.pruneStalls()) this.d.state.save();
813
+ if (this.stallSince !== 0) {
814
+ const carried = this.stallAt.filter((t) => t >= this.stallSince).length;
815
+ log.warn('已恢复上一进程的 LLM 连续失败记录', {
816
+ since: new Date(this.stallSince).toISOString(),
817
+ count: carried,
818
+ });
819
+ // 已达到阈值的持久记录不重复告警,恢复时仍报告解除。
820
+ this.stallAlarmActive = carried >= STALL_ALERT_THRESHOLD;
821
+ }
822
+ this.requeueUndelivered();
823
+ if (session.records.length === 0) {
824
+ const system = await this.buildSystem();
825
+ if (!this.active(generation)) return;
826
+ session.append(system);
827
+ this.pushOpening('new');
828
+ log.info('bootstrap:全新session');
829
+ return;
830
+ }
831
+
832
+ const unanswered = new Set<string>();
833
+ for (const { item } of session.records) {
834
+ if (item.type === 'function_call') unanswered.add(item.call_id);
835
+ if (item.type === 'function_call_output') unanswered.delete(item.call_id);
836
+ }
837
+ for (const id of unanswered) session.append(functionResult(id, MISSING_RESULT_RESTART));
838
+
839
+ this.pushOpening('restarted');
840
+ log.info('bootstrap:重启恢复');
841
+ }
842
+
843
+ async run(): Promise<void> {
844
+ if (!this.activeNow()) return;
845
+ const generation = this.generation;
846
+ const { bus, log, persona, decl } = this.d;
847
+ this.running = true;
848
+ try {
849
+ this.mainTrack = this.d.tracker?.open(decl.id, decl.label, {
850
+ id: decl.id,
851
+ messagesRef: () => this.d.session.records,
852
+ }) ?? null;
853
+ this.bindWorlds();
854
+ await this.bootstrap(generation);
855
+ if (!this.active(generation)) return;
856
+
857
+ const stopSignal = new Promise<'stop'>((resolve) => {
858
+ this.stopFn = () => resolve('stop');
859
+ });
860
+
861
+ // unref 避免巡查定时器阻止进程退出。
862
+ this.watermarkAudit = setInterval(() => {
863
+ try {
864
+ this.auditDeliveryWatermark();
865
+ } catch (e) {
866
+ log.warn('水位自检异常', { err: e });
867
+ }
868
+ }, WATERMARK_AUDIT_INTERVAL_MS);
869
+ this.watermarkAudit.unref?.();
870
+
871
+ while (this.running) {
872
+ const got = await Promise.race([bus.nextBatch(), stopSignal]);
873
+ if (got === 'stop' || !this.running) break;
874
+ const batch = got;
875
+
876
+ // 空闲时由控制台触发的截断/前缀重载可能仍在收尾;新 batch 等维护完成再投递。
877
+ await this.maintenanceChain;
878
+ if (!this.active(generation)) break;
879
+ this.processingBatch = true;
880
+ try {
881
+ const changed = await this.deliverBatch(batch, generation);
882
+ if (!this.active(generation)) break;
883
+ if (changed) {
884
+ await this.rounds(generation);
885
+ if (!this.active(generation)) break;
886
+ // 先执行本批登记的交接请求,再运行批末钩子与容量检查。
887
+ await this.flushRequestedHandoff(generation);
888
+ if (!this.active(generation)) break;
889
+ await this.batchEndCheck(generation);
890
+ if (!this.active(generation)) break;
891
+ }
892
+
893
+ // 延迟渲染和 piggyback 项不阻止进入空闲钩子。
894
+ if (bus.pendingImmediate() === 0 && persona.onIdle) {
895
+ try {
896
+ await persona.onIdle();
897
+ } catch (e) {
898
+ log.warn('onIdle钩子异常', { err: e });
899
+ }
900
+ if (!this.active(generation)) break;
901
+ }
902
+ // onIdle 等待期间也可能收到手动交接请求。
903
+ await this.flushRequestedHandoff(generation);
904
+ } finally {
905
+ this.processingBatch = false;
906
+ }
907
+ // 异常退出由 stop 释放排队请求;只有正常批次边界执行重载。
908
+ await this.flushRequestedPrefixReload(generation);
909
+ }
910
+ } finally {
911
+ this.stop();
912
+ this.seal();
913
+ }
914
+ }
915
+
916
+ /** 本批模型调用共享 sess 关联字段;每轮更新 round、resp 和 call。 */
917
+ private rounds(generation: number): Promise<void> {
918
+ return withAnchors({ sess: this.d.decl.id }, () => this.roundsInScope(generation));
919
+ }
920
+
921
+ private async roundsInScope(generation: number): Promise<void> {
922
+ if (!this.active(generation)) return;
923
+ const { llm, session, log, bus, decl } = this.d;
924
+ const schemas = this.getToolSchemas();
925
+ const caps = decl.rounds();
926
+ this.roundsLastBatch = 0;
927
+ // tap 接收流式增量;其异常只记日志,不中断模型调用。
928
+ const tap = decl.outputTap;
929
+ const resubmit = this.d.resubmit ?? DEFAULT_RESUBMIT;
930
+ let consecutiveFailures = 0;
931
+ let resubmits = 0;
932
+
933
+ for (let round = 1; ; round++) {
934
+ if (!this.active(generation)) return;
935
+ this.roundsLastBatch = round;
936
+ const spec = this.d.spec();
937
+ // 后续轮输入超限时结束本批,由批末检查执行交接。
938
+ // 首轮仍处理本批新投递的事件;上一批的容量检查已在批末执行。
939
+ if (round > 1) {
940
+ const hard = this.d.context.hardTokens();
941
+ if (hard !== null && this.estTokens() > hard) {
942
+ log.warn('计数越过模型上下文上限,本批在轮边界收束,批末交接', { round, estTokens: this.estTokens(), hardTokens: hard });
943
+ this.finishTurn();
944
+ return;
945
+ }
946
+ }
947
+
948
+ const queuedEvents: EventEnvelope[] = [];
949
+ const roundNo = ++this.roundSeq;
950
+ setAnchors({ round: roundNo, resp: undefined, call: undefined });
951
+ const flight: NonNullable<MainLoop['currentRound']> = {
952
+ controller: new AbortController(), externalized: false, abortReason: null,
953
+ };
954
+ this.currentRound = flight;
955
+ const ctx: ToolCallContext = {
956
+ role: decl.id,
957
+ log,
958
+ round: roundNo,
959
+ // 关机或循环换代取消工具;自动抢占不取消工具。
960
+ signal: AbortSignal.any([flight.controller.signal, this.shutdown.signal]),
961
+ queueExternalEvents: (events) => {
962
+ if (this.active(generation)) queuedEvents.push(...events);
963
+ },
964
+ };
965
+ // 工具调用闭合后可提前执行;协议层保证闭合顺序与消息内顺序一致。
966
+ // barrierAfter 阻止之后的调用提前执行。
967
+ const eager = tap
968
+ ? new EagerDispatch(
969
+ () => this.toolDefs, ctx, log, decl.id, this.d.toolLog,
970
+ () => this.active(generation) && !flight.controller.signal.aborted,
971
+ (name) => this.d.toolOwner?.(name),
972
+ )
973
+ : null;
974
+ // 轮级观测:首个内容事件的延迟、模型往返、工具阻塞,一轮一条 debug 记录(event=round)。
975
+ const roundStart = Date.now();
976
+ let ttftMs: number | null = null;
977
+ let llmMs: number | null = null;
978
+ let toolMs = 0;
979
+ const noteRound = (outcome: string, extra: Record<string, unknown> = {}): void => {
980
+ log.emit('debug', '一轮收束', { event: 'round', data: {
981
+ round: roundNo, outcome, llmMs, ttftMs, outputTokens: meters?.output ?? null, toolMs, ...extra,
982
+ } });
983
+ };
984
+ const tapEvents = tap ? {
985
+ onEvent: (event: StreamEvent): void => {
986
+ if (!this.active(generation) || flight.controller.signal.aborted) return;
987
+ if (event.type === 'response.created') setAnchors({ resp: event.response.id });
988
+ if (ttftMs === null && ('delta' in event || event.type === 'response.output_item.added')) ttftMs = Date.now() - roundStart;
989
+ const tappedEffect = tap.externalizes ? tap.externalizes(event)
990
+ : event.type === 'response.output_text.delta' || event.type === 'response.refusal.delta'
991
+ || (event.type === 'response.output_item.added' && event.item?.type === 'function_call');
992
+ if (tappedEffect || (event.type === 'response.output_item.done' && event.item?.type === 'function_call' && event.item.status === 'completed')) flight.externalized = true;
993
+ eager?.onEvent(event);
994
+ try { tap.onEvent(event); } catch (error) { log.warn('outputTap.onEvent异常', { err: error }); }
995
+ },
996
+ } : undefined;
997
+ let assistant: ContextRecord[];
998
+ let meters: TokenMeters | null = null;
999
+ const outbound = this.outboundMessages();
1000
+ const prefixHash = prefixFingerprint(outbound);
1001
+ try {
1002
+ // role 仅用于故障分类,不写入请求体。
1003
+ const llmStart = Date.now();
1004
+ let res: Awaited<ReturnType<typeof llm.respond>>;
1005
+ try {
1006
+ res = await llm.respond(responseRequest(spec, outbound, schemas), {
1007
+ context: outbound, nativeSpec: spec,
1008
+ ...(tapEvents ?? {}),
1009
+ role: decl.id,
1010
+ sessionId: this.mainTrack?.id,
1011
+ signal: flight.controller.signal,
1012
+ });
1013
+ } finally {
1014
+ llmMs = Date.now() - llmStart;
1015
+ }
1016
+ assistant = responseRecords(res.response, res.origin);
1017
+ setAnchors({ resp: res.response.id });
1018
+ if (!this.active(generation) || flight.controller.signal.aborted) {
1019
+ // 关机或换代丢弃已成功返回的结果时,仍记录这次调用的实际用量。
1020
+ this.mainTrack?.recordAttempts(res.attempts, undefined, { outcome: 'discarded', prefixHash });
1021
+ try {
1022
+ tap?.onAbort?.('core 正在关机');
1023
+ } catch (tapErr) {
1024
+ log.warn('outputTap.onAbort异常', { err: tapErr });
1025
+ }
1026
+ noteRound('discarded');
1027
+ this.finishTurn();
1028
+ return;
1029
+ }
1030
+ meters = res.attempts[res.attempts.length - 1].meters;
1031
+ this.lastUsage = usageCounters(meters);
1032
+ this.mainTrack?.recordAttempts(res.attempts, undefined, { prefixHash });
1033
+ this.noteStallsRecovered();
1034
+ consecutiveFailures = 0;
1035
+ } catch (e) {
1036
+ if (flight.controller.signal.aborted) {
1037
+ this.recordFailedUsage(e, prefixHash);
1038
+ try {
1039
+ tap?.onAbort?.(flight.abortReason === 'shutdown' ? 'core 正在关机' : '模型轮被新输入抢占');
1040
+ } catch (tapErr) {
1041
+ log.warn('outputTap.onAbort异常', { err: tapErr });
1042
+ }
1043
+ log.info(flight.abortReason === 'shutdown' ? '模型轮随关机终止' : '尚未外化的模型轮已被新输入抢占');
1044
+ noteRound(flight.abortReason === 'shutdown' ? 'shutdown' : 'preempted');
1045
+ this.finishTurn();
1046
+ return;
1047
+ }
1048
+ this.recordFailedUsage(e, prefixHash);
1049
+ if (tap && e instanceof GenerationError && e.partial) {
1050
+ // 已向外发送的部分输出必须保存;已执行的工具调用使用真实结果配对。
1051
+ await this.recordAbortedStream(responseRecords(e.partial, e.origin), eager, generation);
1052
+ if (!this.active(generation)) {
1053
+ try {
1054
+ tap.onAbort?.('core 正在关机');
1055
+ } catch (tapErr) {
1056
+ log.warn('outputTap.onAbort异常', { err: tapErr });
1057
+ }
1058
+ return;
1059
+ }
1060
+ try {
1061
+ tap.onAbort?.(e.message);
1062
+ } catch (tapErr) {
1063
+ log.warn('outputTap.onAbort异常', { err: tapErr });
1064
+ }
1065
+ }
1066
+ // 提前执行的工具可能已消费队列;失败后将这些事件退回总线。
1067
+ // 重试前重新接收已就绪事件,否则随下一批投递。
1068
+ for (const event of queuedEvents) {
1069
+ bus.push({ event }, { trigger: 'flush' });
1070
+ }
1071
+ // 上游报告输入超限时,不记入连续失败,结束本批后交接。
1072
+ if (e instanceof GenerationError && this.d.context.contextOverflow(e)) {
1073
+ log.warn('上游拒绝:输入超过模型上下文,本批结束即交接', { estTokens: this.estTokens(), hardTokens: this.d.context.hardTokens() });
1074
+ this.handoffRequested = true;
1075
+ noteRound('overflow');
1076
+ this.finishTurn();
1077
+ return;
1078
+ }
1079
+ this.noteStalled();
1080
+ consecutiveFailures++;
1081
+ const detail = {
1082
+ err: e,
1083
+ // 4xx 正文截断后记录;流内失败保留协议层提供的失败事件。
1084
+ ...(e instanceof GenerationError && e.body ? { body: e.body.slice(0, 500) } : {}),
1085
+ ...(e instanceof GenerationError ? { status: e.status } : {}),
1086
+ attempt: consecutiveFailures,
1087
+ };
1088
+ // 按 ResubmitPolicy 重试,沿用已保存的部分输出与工具回执。
1089
+ const retryable = e instanceof GenerationError && (e.status === 0 || e.status === 429 || e.status >= 500);
1090
+ if (retryable && consecutiveFailures <= resubmit.maxConsecutive && resubmits < resubmit.maxPerBatch && round < caps.hard) {
1091
+ resubmits++;
1092
+ const delayMs = resubmit.backoffMs[Math.min(consecutiveFailures, resubmit.backoffMs.length) - 1] ?? 0;
1093
+ log.warn('LLM 调用失败,退避后在本批内重试', { ...detail, resubmits, delayMs });
1094
+ noteRound('failed', { resubmit: true, delayMs });
1095
+ await this.backoff(delayMs);
1096
+ if (!this.active(generation)) return;
1097
+ // 重试前先投递等待期间已就绪的事件。
1098
+ const ready = bus.takeIfReady();
1099
+ if (ready) {
1100
+ await this.deliverBatch(ready, generation);
1101
+ if (!this.active(generation)) return;
1102
+ }
1103
+ continue;
1104
+ }
1105
+ log.error('LLM调用失败,本轮自然结束', detail);
1106
+ noteRound('failed', { resubmit: false });
1107
+ this.finishTurn();
1108
+ return;
1109
+ } finally {
1110
+ if (this.currentRound === flight) this.currentRound = null;
1111
+ }
1112
+ if (!this.active(generation)) return;
1113
+ assistant = this.dropReservedCalls(assistant);
1114
+ const calls = assistant.flatMap(entry => entry.item.type === 'function_call' ? [entry.item] : []);
1115
+ this.pendingToolCalls = new Set(calls.map((call) => call.call_id));
1116
+ for (const entry of assistant) session.append(entry);
1117
+ if (meters && meters.input !== null && meters.output !== null) {
1118
+ this.anchor = { records: session.records.length, tokens: meters.input + meters.output, reasoningTokens: meters.reasoning ?? 0 };
1119
+ }
1120
+ if (!this.active(generation)) return;
1121
+ if (tap) {
1122
+ try {
1123
+ tap.onRoundEnd?.();
1124
+ } catch (e) {
1125
+ log.warn('outputTap.onRoundEnd异常', { err: e });
1126
+ }
1127
+ }
1128
+
1129
+ if (calls.length === 0) {
1130
+ noteRound('completed');
1131
+ this.finishTurn();
1132
+ return;
1133
+ }
1134
+
1135
+ const results: ContextRecord[] = [];
1136
+ let barrierHit = false;
1137
+ // endsTurn 工具真正执行过(没被屏障跳过、参数合法)才算数
1138
+ let turnEnded = false;
1139
+ const toolsStart = Date.now();
1140
+
1141
+ for (const call of calls) {
1142
+ if (!this.active(generation)) return;
1143
+ if (call.status !== 'completed') {
1144
+ results.push(functionResult(call.call_id, NOT_EXECUTED_INCOMPLETE));
1145
+ barrierHit = true;
1146
+ continue;
1147
+ }
1148
+ if (barrierHit) {
1149
+ results.push(functionResult(call.call_id, NOT_EXECUTED_BARRIER));
1150
+ continue;
1151
+ }
1152
+
1153
+ let out: ToolOutcome;
1154
+ const def = this.toolDefs.find((t) => t.name === call.name);
1155
+ if (!def) {
1156
+ out = { text: UNKNOWN_TOOL };
1157
+ withAnchors({ call: call.call_id }, () => recordToolCall(this.d.toolLog, decl.id, call.name, null, Date.now(), out));
1158
+ } else {
1159
+ const eagerOut = eager?.take(call.call_id);
1160
+ if (eagerOut !== undefined) {
1161
+ out = await eagerOut;
1162
+ if (!this.active(generation)) return;
1163
+ } else {
1164
+ const args = parseToolArgs(call.arguments);
1165
+ if (args === null) {
1166
+ out = { text: TOOL_FAILED_BAD_ARGS, failed: true };
1167
+ withAnchors({ call: call.call_id }, () =>
1168
+ recordToolCall(this.d.toolLog, decl.id, def.name, null, Date.now(), out, this.d.toolOwner?.(def.name)));
1169
+ results.push(functionResult(call.call_id, out.text));
1170
+ if (def.barrierAfter) barrierHit = true;
1171
+ continue;
1172
+ }
1173
+ out = await runToolHandler(
1174
+ def, args, ctx, call.call_id, decl.id, this.d.toolLog,
1175
+ () => this.active(generation), this.d.toolOwner?.(def.name),
1176
+ );
1177
+ if (!this.active(generation)) return;
1178
+ }
1179
+ if (def.barrierAfter) barrierHit = true;
1180
+ if (def.endsTurn) turnEnded = true;
1181
+ }
1182
+ if (out.text.length > LARGE_RESULT_WARN_CHARS) {
1183
+ log.warn('工具回执过长,体积归 World 管', { tool: call.name, chars: out.text.length, limit: LARGE_RESULT_WARN_CHARS });
1184
+ }
1185
+ results.push(this.toolResult(call.call_id, out));
1186
+ }
1187
+ toolMs = Date.now() - toolsStart;
1188
+
1189
+ if (!this.active(generation)) return;
1190
+ if (round === caps.soft && results.length > 0) {
1191
+ const hint = caps.softHint?.();
1192
+ if (hint) results[results.length - 1] = withText(results[results.length - 1], `${textOf(results[results.length - 1])}\n${hint}`);
1193
+ }
1194
+ for (const result of results) session.append(result);
1195
+ this.pendingToolCalls.clear();
1196
+
1197
+ // 工具执行期间消费或达到投递标准的事件接在本轮工具结果之后。
1198
+ const arrived: WakeItem[] = queuedEvents.map((event) => ({ event }));
1199
+ if (round < caps.hard && !turnEnded) {
1200
+ const ready = bus.takeIfReady();
1201
+ if (ready) arrived.push(...ready);
1202
+ }
1203
+ if (arrived.length > 0) {
1204
+ if (round >= caps.hard || turnEnded) {
1205
+ // 不再执行模型请求时,事件退回总线随下一批投递。
1206
+ for (const item of arrived) bus.push(item, { trigger: 'flush' });
1207
+ } else {
1208
+ await this.deliverBatch(arrived, generation);
1209
+ if (!this.active(generation)) return;
1210
+ }
1211
+ }
1212
+
1213
+ // endsTurn 与自然结束使用同一出口;事件已退回总线。
1214
+ if (turnEnded) {
1215
+ noteRound('ended', { toolCalls: calls.length, arrived: arrived.length });
1216
+ this.finishTurn();
1217
+ return;
1218
+ }
1219
+ if (round >= caps.hard) {
1220
+ log.warn('硬上限强制结束本次唤醒', { round });
1221
+ noteRound('hard-cap', { toolCalls: calls.length, arrived: arrived.length });
1222
+ this.finishTurn();
1223
+ return;
1224
+ }
1225
+ noteRound('continue', { toolCalls: calls.length, arrived: arrived.length });
1226
+ }
1227
+ }
1228
+
1229
+ /** 重新请求前等待;stop() 通过 backoffWake 提前结束等待。 */
1230
+ private backoff(ms: number): Promise<void> {
1231
+ if (ms <= 0) return Promise.resolve();
1232
+ return new Promise<void>((resolve) => {
1233
+ const timer = setTimeout(() => { this.backoffWake = null; resolve(); }, ms);
1234
+ this.backoffWake = () => { clearTimeout(timer); this.backoffWake = null; resolve(); };
1235
+ });
1236
+ }
1237
+
1238
+ /**
1239
+ * 保存已经发送的部分响应。已执行调用使用实际结果;
1240
+ * 未执行调用补充未执行标记,保持后续请求的工具配对。
1241
+ */
1242
+ private async recordAbortedStream(
1243
+ partial: ContextRecord[],
1244
+ eager: EagerDispatch | null,
1245
+ generation: number,
1246
+ ): Promise<void> {
1247
+ if (!this.active(generation)) return;
1248
+ const { session } = this.d;
1249
+ // 断流的 partial 里同样丢保留帧调用:不落库,也不补机械回执
1250
+ partial = this.dropReservedCalls(partial);
1251
+ const calls = partial.flatMap(entry => entry.item.type === 'function_call' ? [entry.item] : []);
1252
+ this.pendingToolCalls = new Set(calls.map((call) => call.call_id));
1253
+ for (const entry of partial) session.append(entry);
1254
+ for (const call of calls) {
1255
+ const ran = eager?.take(call.call_id);
1256
+ const out = ran !== undefined ? await ran : { text: NOT_EXECUTED_STREAM_ABORTED };
1257
+ if (!this.active(generation)) return;
1258
+ session.append(this.toolResult(call.call_id, out));
1259
+ this.pendingToolCalls.delete(call.call_id);
1260
+ }
1261
+ this.pendingToolCalls.clear();
1262
+ }
1263
+
1264
+ /** 运维显式丢弃的即时事件也结清水位,但不写入 session。 */
1265
+ acknowledgeDiscarded(events: readonly EventEnvelope[]): void {
1266
+ this.noteHandled(events, this.generation);
1267
+ }
1268
+
1269
+ /** 按请求内容估算,排除不会回传的历史推理。 */
1270
+ private estimateOutbound(msgs: readonly ContextRecord[]): number {
1271
+ const view = this.d.cfg.context.keepPastThinking ? msgs : withoutPastReasoning(msgs);
1272
+ return this.d.context.estimateTokens(view);
1273
+ }
1274
+
1275
+ /**
1276
+ * 使用上次成功请求的 token 计数,加上此后新增条目的本地估算。
1277
+ * 禁用历史推理时扣除上次输出中的推理量;无上游计数时估算完整请求,含合成首轮对话。
1278
+ */
1279
+ estTokens(): number {
1280
+ const { anchor } = this;
1281
+ const records = this.d.session.records;
1282
+ if (anchor && records.length >= anchor.records) {
1283
+ const keep = this.d.cfg.context.keepPastThinking;
1284
+ return anchor.tokens - (keep ? 0 : anchor.reasoningTokens) + this.estimateOutbound(records.slice(anchor.records));
1285
+ }
1286
+ return this.estimateOutbound(this.outboundMessages());
1287
+ }
1288
+
1289
+ /** 上游已计数的部分;全部由本地估算时为 0。 */
1290
+ private countedTokens(): number {
1291
+ const { anchor } = this;
1292
+ if (!anchor || this.d.session.records.length < anchor.records) return 0;
1293
+ return anchor.tokens - (this.d.cfg.context.keepPastThinking ? 0 : anchor.reasoningTokens);
1294
+ }
1295
+
1296
+ /** 主 session 的计数与物理上限(sessionInfo 查询面的数据源)。 */
1297
+ contextGauge(): { estTokens: number; hardTokens: number | null } {
1298
+ return { estTokens: this.estTokens(), hardTokens: this.d.context.hardTokens() };
1299
+ }
1300
+
1301
+ /** 一批结束后调用 Persona 钩子,再检查是否超过模型容量;超限时强制交接。 */
1302
+ private async batchEndCheck(generation: number): Promise<void> {
1303
+ if (!this.active(generation)) return;
1304
+ const { persona, log } = this.d;
1305
+ try {
1306
+ persona.onBatchEnd?.();
1307
+ } catch (e) {
1308
+ log.warn('onBatchEnd钩子异常', { err: e });
1309
+ }
1310
+ if (await this.flushRequestedHandoff(generation)) return;
1311
+ if (!this.active(generation)) return;
1312
+ const hard = this.d.context.hardTokens();
1313
+ if (hard !== null && this.estTokens() > hard) {
1314
+ log.warn('计数越过模型上下文上限,强制交接', { estTokens: this.estTokens(), hardTokens: hard, model: this.d.spec().model });
1315
+ await this.handoffContext();
1316
+ }
1317
+ }
1318
+
1319
+ /**
1320
+ * 统一交接入口。事务是单实例的:阈值触发与运维手动请求复用同一个
1321
+ * Promise,Persona的策略因此不会被重复拉起。
1322
+ */
1323
+ handoffContext(): Promise<void> {
1324
+ const generation = this.generation;
1325
+ if (!this.active(generation)) return Promise.resolve();
1326
+ if (this.truncatePromise) return this.truncatePromise;
1327
+ let tracked: Promise<void>;
1328
+ tracked = this.enqueueMaintenance(() => this.performHandoff(generation), generation).finally(() => {
1329
+ if (this.truncatePromise === tracked) this.truncatePromise = null;
1330
+ });
1331
+ this.truncatePromise = tracked;
1332
+ return tracked;
1333
+ }
1334
+
1335
+ private enqueueMaintenance(task: () => Promise<void>, generation: number): Promise<void> {
1336
+ const guarded = (): Promise<void> => this.active(generation) ? task() : Promise.resolve();
1337
+ const run = this.maintenanceChain.then(guarded, guarded);
1338
+ this.maintenanceChain = run.then(() => undefined, () => undefined);
1339
+ return run;
1340
+ }
1341
+
1342
+ /**
1343
+ * 运维入口:在安全回合边界强制一次交接。正在处理批次时只登记请求,
1344
+ * 由run()在assistant自然结束后兑现;空闲时可立即开始。
1345
+ */
1346
+ requestContextHandoff(): boolean {
1347
+ if (!this.activeNow()) return false;
1348
+ if (this.truncatePromise || this.handoffRequested) return false;
1349
+ if (this.processingBatch) {
1350
+ this.handoffRequested = true;
1351
+ return true;
1352
+ }
1353
+ void this.handoffContext().catch((error) => {
1354
+ this.d.log.error('手动上下文交接失败', { err: error });
1355
+ });
1356
+ return true;
1357
+ }
1358
+
1359
+ private async flushRequestedHandoff(generation: number): Promise<boolean> {
1360
+ if (!this.active(generation)) return false;
1361
+ if (!this.handoffRequested) return false;
1362
+ this.handoffRequested = false;
1363
+ await this.handoffContext();
1364
+ return this.active(generation);
1365
+ }
1366
+
1367
+ /**
1368
+ * Persona.onHandoff 提供保留内容;事务期间停止投递,钩子注入项进入新 session 的首批。
1369
+ * 策略失败或返回内容越界时使用默认重建结果。
1370
+ */
1371
+ private async performHandoff(generation: number): Promise<void> {
1372
+ if (!this.active(generation)) return;
1373
+ const { cfg, session, state, log, persona } = this.d;
1374
+ // 快照包含合成开头,继承它的 fork 保留相同请求前缀;clampTail 把它排除在持久上下文外。
1375
+ const snapshot = this.outboundMessages();
1376
+ const before = this.estTokens();
1377
+ let result: ContextHandoffResult = { tail: null };
1378
+ try {
1379
+ if (persona.onHandoff) result = await persona.onHandoff(snapshot, { hardTokens: this.d.context.hardTokens() });
1380
+ } catch (e) {
1381
+ log.error('上下文交接策略失败,继续机械重建', { err: e });
1382
+ }
1383
+ if (!this.active(generation)) return;
1384
+
1385
+ // 策略执行后重建前缀,读取其可能更新的内容。
1386
+ const sysMsg = await this.buildSystem();
1387
+ if (!this.active(generation)) return;
1388
+ const newTail = this.clampTail(result, snapshot, [sysMsg, ...this.sessionHead()]);
1389
+ session.reset([sysMsg, ...newTail]);
1390
+ for (const m of this.d.worlds.visible()) {
1391
+ try {
1392
+ m.onHandoffEnded?.();
1393
+ } catch (e) {
1394
+ log.warn('World 交接钩子异常', { id: m.id, err: e });
1395
+ }
1396
+ }
1397
+ state.data.lastTruncateAt = nowIso(cfg.timezone);
1398
+ state.save();
1399
+ const summary = { beforeTokens: before, afterTokens: this.estTokens(), kept: newTail.length, dropped: Math.max(0, snapshot.length - newTail.length) };
1400
+ this.d.transcript?.boundary('handoff', summary);
1401
+ log.emit('info', '上下文交接完成', { event: 'handoff', data: summary });
1402
+ }
1403
+
1404
+ /**
1405
+ * 修复保留内容的工具配对,并将新前缀与保留内容限制在 hardTokens 内。
1406
+ * null 从快照末尾重建;上限未知时仅修复配对。trim 表示候选内容允许超限后裁剪。
1407
+ */
1408
+ private clampTail(result: ContextHandoffResult, snapshot: ContextRecord[], prefix: readonly ContextRecord[]): ContextRecord[] {
1409
+ const { log, context } = this.d;
1410
+ const { tail } = result;
1411
+ const hard = context.hardTokens();
1412
+ const budget = hard === null ? null : Math.max(0, hard - context.estimateTokens(prefix));
1413
+ const estimate = (records: readonly ContextRecord[]): number => context.estimateTokens(records);
1414
+ if (tail === null) {
1415
+ let start = 0;
1416
+ while (start < snapshot.length && hasRole(snapshot[start], 'system')) start++;
1417
+ const candidate = snapshot.slice(start).filter((m) => !m.context.head);
1418
+ return budget === null ? fixPairing(candidate) : rebuildTail(candidate, budget, estimate);
1419
+ }
1420
+ // system 和合成开头不属于持久化的保留内容。
1421
+ const paired = fixPairing(tail.filter((m) => !hasRole(m, 'system') && !m.context.head));
1422
+ if (budget === null || estimate(paired) <= budget) return paired;
1423
+ // trim 表示允许 Core 从候选内容裁剪,无需报告策略越界。
1424
+ if (!result.trim) log.warn('交接策略返回的动态尾越过模型上下文上限,按机械默认裁剪', { budget });
1425
+ return rebuildTail(paired, budget, estimate);
1426
+ }
1427
+
1428
+ /** 清空主 session,重建 system 前缀并调用 Persona 开场钩子;事件库保留。 */
1429
+ async clearSession(): Promise<void> {
1430
+ const generation = this.generation;
1431
+ if (!this.active(generation)) return;
1432
+ const { session, log } = this.d;
1433
+ const sysMsg = await this.buildSystem();
1434
+ if (!this.active(generation)) return;
1435
+ session.reset([sysMsg]);
1436
+ this.d.transcript?.boundary('clear', {});
1437
+ this.finishTurn();
1438
+ this.pushOpening('cleared');
1439
+ log.emit('warn', 'session已清空重开', { event: 'session-cleared' });
1440
+ }
1441
+
1442
+ /**
1443
+ * 通过 Persona.systemSegments 和 World 环境模板重建 system 前缀。
1444
+ * 只替换 system 消息,保留既有 user、assistant 和工具记录。
1445
+ */
1446
+ reloadSystemPrefix(): Promise<void> {
1447
+ const generation = this.generation;
1448
+ if (!this.active(generation)) return Promise.resolve();
1449
+ if (this.prefixReloadPromise) return this.prefixReloadPromise;
1450
+ const safeBoundary = this.processingBatch
1451
+ ? new Promise<void>((resolve) => { this.releasePrefixReload = resolve; })
1452
+ : Promise.resolve();
1453
+ let tracked: Promise<void>;
1454
+ tracked = safeBoundary
1455
+ .then(() => this.enqueueMaintenance(() => this.performSystemPrefixReload(generation), generation))
1456
+ .finally(() => {
1457
+ if (this.prefixReloadPromise === tracked) this.prefixReloadPromise = null;
1458
+ });
1459
+ this.prefixReloadPromise = tracked;
1460
+ return tracked;
1461
+ }
1462
+
1463
+ private async flushRequestedPrefixReload(_generation: number): Promise<boolean> {
1464
+ const release = this.releasePrefixReload;
1465
+ if (!release) return false;
1466
+ this.releasePrefixReload = null;
1467
+ release();
1468
+ await this.prefixReloadPromise;
1469
+ return true;
1470
+ }
1471
+
1472
+ private async performSystemPrefixReload(generation: number): Promise<void> {
1473
+ if (!this.active(generation)) return;
1474
+ const { session, log } = this.d;
1475
+ const sysMsg = await this.buildSystem();
1476
+ if (!this.active(generation)) return;
1477
+ const snapshot = [...session.records];
1478
+ let tailStart = 0;
1479
+ while (tailStart < snapshot.length && hasRole(snapshot[tailStart], 'system')) tailStart++;
1480
+ session.reset([sysMsg, ...snapshot.slice(tailStart)]);
1481
+ this.d.transcript?.boundary('prefix-reload', { keptMessages: snapshot.length - tailStart });
1482
+ log.emit('warn', '当前session系统前缀已重载', { event: 'prefix-reload', data: { keptMessages: snapshot.length - tailStart } });
1483
+ }
1484
+
1485
+ /** 每次已发出的 HTTP 尝试均记账;未报告的 token 维度保留为未知。 */
1486
+ private recordFailedUsage(error: unknown, prefixHash?: string): void {
1487
+ if (error instanceof GenerationError) this.mainTrack?.recordAttempts(error.attempts, undefined, { prefixHash });
1488
+ }
1489
+
1490
+ /** 记录失败时刻与连续失败起点,并持久化。 */
1491
+ private noteStalled(): void {
1492
+ const now = Date.now();
1493
+ if (this.stallSince === 0) this.stallSince = now;
1494
+ this.stallAt.push(now);
1495
+ this.pruneStalls(now);
1496
+ this.d.state.save();
1497
+ // 同一串连续失败达到阈值时仅告警一次,不改变重试策略。
1498
+ if (!this.stallAlarmActive && this.stallSince !== 0) {
1499
+ const count = this.stallAt.filter((t) => t >= this.stallSince).length;
1500
+ if (count >= STALL_ALERT_THRESHOLD) {
1501
+ this.stallAlarmActive = true;
1502
+ this.d.log.error(
1503
+ `[告警] LLM 连续失败 ${count} 次;请检查请求错误与上游状态`,
1504
+ {
1505
+ count,
1506
+ since: new Date(this.stallSince).toISOString(),
1507
+ threshold: STALL_ALERT_THRESHOLD,
1508
+ },
1509
+ );
1510
+ }
1511
+ }
1512
+ }
1513
+
1514
+ /** 清除窗口外的失败时刻;窗口内已无失败记录时同时清除连续失败起点。 */
1515
+ private pruneStalls(now = Date.now()): boolean {
1516
+ const stall = this.d.state.data.llmStall;
1517
+ const cutoff = now - STALL_WINDOW_MS;
1518
+ const before = stall.at.length;
1519
+ while (stall.at.length > 0 && stall.at[0] < cutoff) stall.at.shift();
1520
+ const cleared = stall.at.length === 0 && stall.since !== 0;
1521
+ if (cleared) stall.since = 0;
1522
+ return before !== stall.at.length || cleared;
1523
+ }
1524
+
1525
+ /**
1526
+ * 首次成功后将连续失败次数与时长交给 Persona.onStallsRecovered,并清除起点。
1527
+ * 未提供钩子或未返回正文时不注入恢复通知。
1528
+ */
1529
+ private noteStallsRecovered(): void {
1530
+ this.pruneStalls();
1531
+ if (this.stallSince === 0) {
1532
+ // 失败记录超出窗口时解除现有告警。
1533
+ if (this.stallAlarmActive) {
1534
+ this.stallAlarmActive = false;
1535
+ this.d.log.warn('[解除] LLM 连败告警解除:失败串已超出统计窗口');
1536
+ }
1537
+ return;
1538
+ }
1539
+ const count = this.stallAt.filter((t) => t >= this.stallSince).length;
1540
+ const quietMs = Date.now() - this.stallSince;
1541
+ this.stallSince = 0;
1542
+ this.d.state.save();
1543
+ if (this.stallAlarmActive) {
1544
+ this.stallAlarmActive = false;
1545
+ this.d.log.warn('[解除] LLM 连败告警解除:调用已恢复成功', { count, quietMs });
1546
+ }
1547
+ const text = this.d.persona.onStallsRecovered?.({ count, quietMs });
1548
+ if (!text) return;
1549
+ this.d.bus.push(this.internalItem('core', 'core.stall', text));
1550
+ }
1551
+
1552
+ /** 水位积压扫描:该进上下文却还没投递的外部事件计数与最老一条(巡查与状态面共用)。 */
1553
+ private watermarkBacklog(): { behind: number; oldest: EventEnvelope | null } {
1554
+ const { state, store } = this.d;
1555
+ const latest = store.latestCursor();
1556
+ let behind = 0;
1557
+ let oldest: EventEnvelope | null = null;
1558
+ for (let c = state.data.lastDeliveredCursor + 1; c <= latest; c++) {
1559
+ const e = store.get(c);
1560
+ if (!e) continue;
1561
+ if (e.origin !== 'external' || e.contextDelivery === 'archive-only') continue;
1562
+ behind++;
1563
+ if (!oldest) oldest = e;
1564
+ }
1565
+ return { behind, oldest };
1566
+ }
1567
+
1568
+ /**
1569
+ * 最老的待投递外部事件等待超过 WATERMARK_STALL_MS 时报告 error,不自动修复。
1570
+ * 人工暂停或投递 gate 生效时不告警;仅含内部事件或 archive-only 原文的积压不触发该告警。等待 projector 的原文仍须由连续水位规则保护。
1571
+ * 公开入口供巡查、测试与控制台调用。
1572
+ */
1573
+ auditDeliveryWatermark(): void {
1574
+ if (!this.activeNow()) return;
1575
+ const { bus, state, store, log } = this.d;
1576
+ const watermark = state.data.lastDeliveredCursor;
1577
+ if (bus.isPaused() || bus.isDeliveryBlocked()) return;
1578
+
1579
+ const latest = store.latestCursor();
1580
+ const { behind, oldest } = this.watermarkBacklog();
1581
+ const stalledForMs = oldest ? Date.now() - Date.parse(oldest.ts) : 0;
1582
+ const stalled = oldest !== null && Number.isFinite(stalledForMs) && stalledForMs > WATERMARK_STALL_MS;
1583
+
1584
+ if (!stalled) {
1585
+ if (this.watermarkStallAt >= 0 && this.watermarkStallAt !== watermark) {
1586
+ log.warn('投递水位停滞已解除:水位又开始推进了', {
1587
+ stalledAtCursor: this.watermarkStallAt,
1588
+ lastDeliveredCursor: watermark,
1589
+ caughtUp: watermark - this.watermarkStallAt,
1590
+ });
1591
+ this.watermarkStallAt = -1;
1592
+ }
1593
+ return;
1594
+ }
1595
+ if (oldest === null) return; // stalled 为真时 backlog 非空,此处分支用于类型收窄。同一水位按退避表重报告警,并带上落后增量。
1596
+ if (this.watermarkStallAt === watermark) {
1597
+ const idx = this.watermarkStallReports - 1;
1598
+ if (idx >= WATERMARK_RESTATE_MS.length) return;
1599
+ const sinceFirstReportMs = Date.now() - this.watermarkStallSince;
1600
+ if (sinceFirstReportMs < WATERMARK_RESTATE_MS[idx]) return;
1601
+ this.watermarkStallReports++;
1602
+ log.error('投递水位仍在停滞:同一水位持续未推进', {
1603
+ behind,
1604
+ behindDelta: behind - this.watermarkStallBehind,
1605
+ sinceFirstReportMs,
1606
+ stalledForMs,
1607
+ lastDeliveredCursor: watermark,
1608
+ latestCursor: latest,
1609
+ report: this.watermarkStallReports,
1610
+ });
1611
+ return;
1612
+ }
1613
+ this.watermarkStallAt = watermark;
1614
+ this.watermarkStallSince = Date.now();
1615
+ this.watermarkStallBehind = behind;
1616
+ this.watermarkStallReports = 1;
1617
+ log.error('投递水位停滞:有该进上下文的外部事件长时间没被投递,水位没有推进', {
1618
+ behind,
1619
+ stalledForMs,
1620
+ thresholdMs: WATERMARK_STALL_MS,
1621
+ lastDeliveredCursor: watermark,
1622
+ latestCursor: latest,
1623
+ oldestCursor: oldest.cursor,
1624
+ oldestTs: oldest.ts,
1625
+ oldestSource: oldest.source,
1626
+ oldestText: oldest.text.slice(0, 200),
1627
+ });
1628
+ }
1629
+
1630
+ /** 查询最近 withinMs 毫秒内的模型失败次数。 */
1631
+ llmStalls(withinMs: number): number {
1632
+ const from = Date.now() - Math.max(0, withinMs);
1633
+ return this.stallAt.filter((t) => t >= from).length;
1634
+ }
1635
+
1636
+ /** 注入 Persona 提供的内部文本。onDelivery 同步执行期间加入当前批,其余时刻进入总线。 */
1637
+ injectInternal(text: string, kind = 'notice'): void {
1638
+ if (!this.activeNow()) return;
1639
+ const item = this.internalItem('persona', kind, text);
1640
+ if (this.deliveryCollector) {
1641
+ this.deliveryCollector.push(item.event);
1642
+ return;
1643
+ }
1644
+ this.d.bus.push(item);
1645
+ }
1646
+
1647
+ /** 注入 Persona 提供的外部正文;source=persona、origin=external,按 eventDelivery 投递。 */
1648
+ injectExternal(text: string, kind = 'note'): void {
1649
+ if (!this.activeNow()) return;
1650
+ const { store, cfg } = this.d;
1651
+ const event = store.append({
1652
+ type: kind,
1653
+ ts: nowIso(cfg.timezone),
1654
+ source: 'persona',
1655
+ origin: 'external',
1656
+ contextDelivery: 'deliver',
1657
+ text,
1658
+ });
1659
+ this.d.bus.push({ event });
1660
+ }
1661
+
1662
+ /** 内部项在投递时渲染并归档;投递正文与归档正文一致。 */
1663
+ injectDeferred(kind: string, render: () => string | null | Promise<string | null>): void {
1664
+ if (!this.activeNow()) return;
1665
+ this.d.bus.push({ deferred: { type: kind, source: 'persona', origin: 'internal', render } });
1666
+ }
1667
+
1668
+ /**
1669
+ * 内部项与外部事件共用事件库和游标,origin 标记为 internal;调用方决定投递时机。
1670
+ * source 记录生产方。
1671
+ */
1672
+ private internalItem(source: string, type: string, text: string): WakeItem & { event: EventEnvelope } {
1673
+ const { store, cfg } = this.d;
1674
+ const event = store.append({
1675
+ type,
1676
+ ts: nowIso(cfg.timezone),
1677
+ source,
1678
+ origin: 'internal',
1679
+ text,
1680
+ });
1681
+ return { event };
1682
+ }
1683
+
1684
+ /** 当前工具 schema,供模型与控制台使用;run() 前为空。tags 保留声明方的分类。 */
1685
+ getToolSchemas(): Array<ToolSchema & { tags: readonly ToolTag[] }> {
1686
+ return this.toolDefs.map(({ name, description, parameters, tags }) => ({
1687
+ name, description, parameters, tags,
1688
+ }));
1689
+ }
1690
+
1691
+ getStatus(): LoopStatus {
1692
+ return {
1693
+ running: this.running,
1694
+ truncating: this.truncatePromise !== null || this.handoffRequested,
1695
+ messageCount: this.d.session.records.length,
1696
+ estTokens: this.estTokens(),
1697
+ context: {
1698
+ hardTokens: this.d.context.hardTokens(),
1699
+ countedTokens: this.countedTokens(),
1700
+ keepPastThinking: this.d.cfg.context.keepPastThinking,
1701
+ },
1702
+ batchesHandled: this.batchesHandled,
1703
+ roundsLastBatch: this.roundsLastBatch,
1704
+ lastTruncateAt: this.d.state.data.lastTruncateAt,
1705
+ paused: this.d.bus.isPaused(),
1706
+ scheduleBlocked: this.d.bus.isDeliveryBlocked(),
1707
+ lastUsage: this.lastUsage,
1708
+ lastDeliveredCursor: this.d.state.data.lastDeliveredCursor,
1709
+ behind: this.watermarkBacklog().behind,
1710
+ };
1711
+ }
1712
+
1713
+ stop(): void {
1714
+ if (this.stopped) return;
1715
+ this.running = false;
1716
+ this.stopped = true;
1717
+ this.generation++;
1718
+ if (this.watermarkAudit) clearInterval(this.watermarkAudit);
1719
+ this.watermarkAudit = null;
1720
+ this.completePendingToolCallsForShutdown();
1721
+ this.backoffWake?.();
1722
+ if (!this.shutdown.signal.aborted) this.shutdown.abort(new Error('core 正在关机'));
1723
+ this.handoffRequested = false;
1724
+ const releasePrefixReload = this.releasePrefixReload;
1725
+ this.releasePrefixReload = null;
1726
+ releasePrefixReload?.();
1727
+ this.stopFn?.();
1728
+ const round = this.currentRound;
1729
+ if (round && !round.controller.signal.aborted) {
1730
+ round.abortReason = 'shutdown';
1731
+ round.controller.abort(new Error('core 正在关机'));
1732
+ }
1733
+ }
1734
+
1735
+ /** 主循环已退出或 drain 超时;阻止迟到异步链继续写 session、工具账或结束钩子。 */
1736
+ seal(): void {
1737
+ this.sealed = true;
1738
+ }
1739
+
1740
+ /** stop 的同步边界闭合已经落库的工具调用;异步 handler 的迟到结果一律丢弃。 */
1741
+ private completePendingToolCallsForShutdown(): void {
1742
+ const failed: string[] = [];
1743
+ for (const callId of this.pendingToolCalls) {
1744
+ try {
1745
+ this.d.session.append(functionResult(callId, SHUTDOWN_INTERRUPTED));
1746
+ } catch {
1747
+ failed.push(callId);
1748
+ }
1749
+ }
1750
+ this.pendingToolCalls.clear();
1751
+ if (failed.length > 0) {
1752
+ this.d.log.error('关机时工具调用配对记录写入失败', { callIds: failed });
1753
+ }
1754
+ }
1755
+ }
1756
+
1757
+ /** 工具参数解析:非法 JSON 返回 null(两条执行路径共用,回执措辞由调用方给) */
1758
+ function parseToolArgs(raw: string): Record<string, unknown> | null {
1759
+ try {
1760
+ return JSON.parse(raw || '{}') as Record<string, unknown>;
1761
+ } catch {
1762
+ return null;
1763
+ }
1764
+ }
1765
+
1766
+ /** 普通执行与流式提前执行共用工具处理及日志记录;异常转换为失败回执。 */
1767
+ function runToolHandler(
1768
+ def: ToolDef,
1769
+ args: Record<string, unknown>,
1770
+ ctx: ToolCallContext,
1771
+ callId: string,
1772
+ role: string,
1773
+ toolLog?: ToolCallLog,
1774
+ canRecord: () => boolean = () => true,
1775
+ mod?: string,
1776
+ ): Promise<ToolOutcome> {
1777
+ const startedAt = Date.now();
1778
+ return withAnchors({ call: callId }, () => Promise.resolve()
1779
+ .then(() => def.handler(args, { ...ctx, callId }))
1780
+ .then((out): ToolOutcome => (typeof out === 'string' ? { text: out } : out))
1781
+ .catch((e: unknown): ToolOutcome => ({
1782
+ text: toolFailed(e instanceof Error ? e.message : String(e)),
1783
+ failed: true,
1784
+ }))
1785
+ .then((out): ToolOutcome => {
1786
+ if (canRecord()) recordToolCall(toolLog, role, def.name, args, startedAt, out, mod);
1787
+ return out;
1788
+ }));
1789
+ }
1790
+
1791
+ /**
1792
+ * 工具调用在流中闭合后可提前执行,闭合顺序由协议层保证。
1793
+ * barrierAfter 阻止后续调用提前执行;其后调用在响应结束时记为未执行。
1794
+ * 结果按 call id 暂存,rounds() 按消息顺序取回配对;无效 JSON 留待常规路径生成错误回执。
1795
+ */
1796
+ class EagerDispatch {
1797
+ private readonly ready = new Map<number, import('../protocol/open-responses/index.ts').OutputItem>();
1798
+ private readonly results = new Map<string, Promise<ToolOutcome>>();
1799
+ private chain: Promise<void> = Promise.resolve();
1800
+ private barrierHit = false;
1801
+ private nextIndex = 0;
1802
+ constructor(
1803
+ private readonly defs: () => ToolDef[],
1804
+ private readonly ctx: ToolCallContext,
1805
+ private readonly log: Logger,
1806
+ private readonly role: string,
1807
+ private readonly toolLog?: ToolCallLog,
1808
+ private readonly active: () => boolean = () => true,
1809
+ private readonly owner: (name: string) => string | undefined = () => undefined,
1810
+ ) {}
1811
+
1812
+
1813
+ onEvent(event: StreamEvent): void {
1814
+ if (!this.active()) return;
1815
+ if (event.type === 'response.created') { this.ready.clear(); this.nextIndex = 0; return; }
1816
+ if (event.type !== 'response.output_item.done' || !event.item) return;
1817
+ this.ready.set(event.output_index, event.item);
1818
+ while (this.ready.has(this.nextIndex)) {
1819
+ const item = this.ready.get(this.nextIndex)!;
1820
+ this.ready.delete(this.nextIndex++);
1821
+ if (item.type !== 'function_call') continue;
1822
+ if (item.status !== 'completed') { this.barrierHit = true; continue; }
1823
+ this.dispatch({ id: item.call_id, name: item.name, args: item.arguments });
1824
+ }
1825
+ }
1826
+
1827
+ private dispatch(call: { id: string; name: string; args: string }): void {
1828
+ if (!this.active()) return;
1829
+ if (this.barrierHit) return;
1830
+ // 保留帧调用不执行,也不写入 session。
1831
+ if (RESERVED_FRAME_NAMES.has(call.name)) return;
1832
+ if (!call.id) {
1833
+ // 上游没给 call id 时无法在消息落定后配对(空串键会互相覆盖);
1834
+ // 跳过提前派发,落回消息落定后的执行路径
1835
+ this.log.warn('tool_call 缺 id,跳过提前派发', { name: call.name });
1836
+ return;
1837
+ }
1838
+ const def = this.defs().find((t) => t.name === call.name);
1839
+ if (!def) return;
1840
+ // 屏障工具自身可提前执行,后续调用不能提前执行。
1841
+ if (def.barrierAfter) this.barrierHit = true;
1842
+ const args = parseToolArgs(call.args);
1843
+ if (args === null) return; // 落回非流式路径的"arguments are not valid JSON"回执
1844
+ // handler 按调用顺序串行执行。
1845
+ const run = this.chain.then(() => this.active()
1846
+ ? runToolHandler(def, args, this.ctx, call.id, this.role, this.toolLog, this.active, this.owner(def.name))
1847
+ : { text: NOT_EXECUTED_LOOP_STOPPED });
1848
+ this.chain = run.then(() => undefined);
1849
+ this.results.set(call.id, run);
1850
+ }
1851
+
1852
+ /** 取走某次调用的执行结果;没提前派发过返回 undefined(一次性,防重复配对) */
1853
+ take(callId: string): Promise<ToolOutcome> | undefined {
1854
+ const p = this.results.get(callId);
1855
+ this.results.delete(callId);
1856
+ return p;
1857
+ }
1858
+ }