@ljwei-stak/dsh-model-route-bot 0.66.4 → 0.67.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 (271) hide show
  1. package/.dsh-plugin/bot-product.mjs +81 -5
  2. package/.dsh-plugin/client.js +17490 -1791
  3. package/README.i18n.yaml +4 -4
  4. package/README.md +409 -364
  5. package/README.zh.md +409 -364
  6. package/apps/integration-client/README.md +31 -7
  7. package/apps/integration-client/api.mjs +14 -3
  8. package/apps/integration-client/binding-editor.jsx +308 -109
  9. package/apps/integration-client/binding-model.mjs +262 -0
  10. package/apps/integration-client/binding.css +144 -0
  11. package/apps/integration-client/bot-model-status.mjs +18 -0
  12. package/apps/integration-client/bot-profile-identity.jsx +197 -0
  13. package/apps/integration-client/bot-profile.css +57 -0
  14. package/apps/integration-client/bot-profile.mjs +97 -0
  15. package/apps/integration-client/bot-settings-dialog.css +18 -0
  16. package/apps/integration-client/bot-settings-dialog.jsx +111 -0
  17. package/apps/integration-client/bot-thread-header-bridge.css +20 -0
  18. package/apps/integration-client/bot-thread-header.jsx +278 -0
  19. package/apps/integration-client/bot-threads-panel.jsx +378 -0
  20. package/apps/integration-client/bot-threads.css +169 -0
  21. package/apps/integration-client/chat-design.mjs +250 -0
  22. package/apps/integration-client/chat-ui.jsx +175 -0
  23. package/apps/integration-client/chat.css +477 -0
  24. package/apps/integration-client/composer-draft-binding.mjs +50 -0
  25. package/apps/integration-client/composer-draft-bound.jsx +14 -0
  26. package/apps/integration-client/composer-draft-host.jsx +142 -0
  27. package/apps/integration-client/composer-draft-session.jsx +287 -0
  28. package/apps/integration-client/composer-draft-witness.mjs +115 -0
  29. package/apps/integration-client/composer-draft.css +4 -0
  30. package/apps/integration-client/composer-draft.mjs +322 -0
  31. package/apps/integration-client/contact-attention.jsx +54 -0
  32. package/apps/integration-client/data-panel.jsx +439 -0
  33. package/apps/integration-client/data.css +69 -0
  34. package/apps/integration-client/desktop-bridge.mjs +1 -0
  35. package/apps/integration-client/desktop-http-socket.mjs +49 -0
  36. package/apps/integration-client/desktop-keyboard.mjs +32 -0
  37. package/apps/integration-client/desktop-pane.jsx +99 -30
  38. package/apps/integration-client/desktop-stream.mjs +13 -3
  39. package/apps/integration-client/desktop-tools.jsx +19 -0
  40. package/apps/integration-client/desktop-touch.mjs +27 -0
  41. package/apps/integration-client/desktop.css +41 -0
  42. package/apps/integration-client/independent-chat-controller.mjs +224 -0
  43. package/apps/integration-client/independent-chat-entry.jsx +61 -0
  44. package/apps/integration-client/independent-chat-navigation.mjs +21 -0
  45. package/apps/integration-client/independent-chat-page.jsx +148 -0
  46. package/apps/integration-client/independent-chat-rich.jsx +64 -0
  47. package/apps/integration-client/independent-chat-rich.mjs +30 -0
  48. package/apps/integration-client/independent-chat.css +73 -0
  49. package/apps/integration-client/index.jsx +24 -16
  50. package/apps/integration-client/library-panel.jsx +4 -1
  51. package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
  52. package/apps/integration-client/main-route-editor.jsx +94 -0
  53. package/apps/integration-client/main-route-model.mjs +100 -0
  54. package/apps/integration-client/model-picker.css +37 -0
  55. package/apps/integration-client/model-picker.jsx +83 -0
  56. package/apps/integration-client/native-activity.css +141 -0
  57. package/apps/integration-client/native-activity.jsx +276 -0
  58. package/apps/integration-client/native-activity.mjs +384 -0
  59. package/apps/integration-client/notifications-panel.jsx +186 -0
  60. package/apps/integration-client/notifications.css +126 -0
  61. package/apps/integration-client/pairing-qr.jsx +79 -0
  62. package/apps/integration-client/panels.jsx +126 -115
  63. package/apps/integration-client/product-slots.jsx +46 -0
  64. package/apps/integration-client/routine-recorder.mjs +23 -2
  65. package/apps/integration-client/secure-pairing.jsx +84 -0
  66. package/apps/integration-client/tasks-panel.jsx +32 -10
  67. package/apps/integration-client/ui-icons.jsx +17 -0
  68. package/apps/integration-client/usage-explorer.jsx +43 -0
  69. package/apps/integration-client/usage-report.mjs +71 -0
  70. package/apps/integration-client/usage.css +34 -0
  71. package/apps/integration-client/web-panel-model.mjs +140 -0
  72. package/apps/integration-client/web-panel-page.jsx +221 -0
  73. package/apps/integration-client/web-panel-pins-live.jsx +28 -0
  74. package/apps/integration-client/web-panel.css +139 -0
  75. package/apps/integration-client/workspace-model.mjs +67 -0
  76. package/apps/integration-client/workspace-navigation.mjs +56 -0
  77. package/apps/integration-client/workspace-ui.jsx +173 -0
  78. package/apps/integration-client/workspace.css +332 -0
  79. package/modules/bot-runtime/SOURCE.json +176 -50
  80. package/modules/bot-runtime/index.mjs +169 -13
  81. package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
  82. package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
  83. package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
  84. package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
  85. package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
  86. package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
  87. package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
  88. package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
  89. package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
  90. package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
  91. package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
  92. package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
  93. package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
  94. package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
  95. package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
  96. package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
  97. package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
  98. package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
  99. package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
  100. package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
  101. package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
  102. package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
  103. package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
  104. package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
  105. package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
  106. package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
  107. package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
  108. package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
  109. package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
  110. package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
  111. package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
  112. package/modules/bot-runtime/read-model.mjs +19 -4
  113. package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
  114. package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
  115. package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
  116. package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
  117. package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
  118. package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
  119. package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
  120. package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
  121. package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
  122. package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
  123. package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
  124. package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
  125. package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
  126. package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
  127. package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
  128. package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
  129. package/modules/independent-runtime/index.mjs +152 -62
  130. package/modules/independent-runtime/remote-tools.mjs +8 -5
  131. package/modules/openmaus-protocol/NOTICE +10 -0
  132. package/modules/openmaus-protocol/README.md +15 -0
  133. package/modules/openmaus-protocol/SOURCE.json +68 -0
  134. package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
  135. package/modules/openmaus-protocol/index.mjs +1 -0
  136. package/modules/openmaus-protocol/transport.mjs +77 -0
  137. package/modules/openmaus-protocol/vendor/LICENSE +202 -0
  138. package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
  139. package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
  140. package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
  141. package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
  142. package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
  143. package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
  144. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
  145. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
  146. package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
  147. package/package.json +18 -6
  148. package/packages/bot-acp/README.md +156 -0
  149. package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
  150. package/packages/bot-acp/licenses/NOTICE.md +27 -0
  151. package/packages/bot-acp/src/acp-adapter.mjs +795 -0
  152. package/packages/bot-acp/src/acp-host.mjs +148 -0
  153. package/packages/bot-acp/src/native-launch.mjs +121 -0
  154. package/packages/bot-acp/src/permission-policy.mjs +283 -0
  155. package/packages/bot-acp/src/rpc-codec.mjs +265 -0
  156. package/packages/bot-acp/src/session-core.mjs +581 -0
  157. package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
  158. package/packages/bot-engines/README.md +281 -0
  159. package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
  160. package/packages/bot-engines/licenses/NOTICE.md +32 -0
  161. package/packages/bot-engines/src/claude-engine.mjs +772 -0
  162. package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
  163. package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
  164. package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
  165. package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
  166. package/packages/bot-engines/src/claude-protocol.mjs +177 -0
  167. package/packages/bot-engines/src/driver-catalog.mjs +334 -0
  168. package/packages/bot-engines/src/host-loop-request.mjs +95 -0
  169. package/packages/bot-engines/src/host-registrations.mjs +105 -0
  170. package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
  171. package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
  172. package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
  173. package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
  174. package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
  175. package/packages/cli-runtime/src/native-peer.ps1 +51 -3
  176. package/packages/cli-runtime/src/native-process.mjs +77 -14
  177. package/packages/model-router-plugin/package.json +1 -1
  178. package/packages/orchestration/src/binding-product.mjs +19 -0
  179. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  180. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  181. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  182. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  183. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  184. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  185. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  186. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  187. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  188. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  189. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  190. package/packages/orchestration/src/bot-loop.mjs +152 -19
  191. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  192. package/packages/orchestration/src/bot-policy.mjs +43 -4
  193. package/packages/orchestration/src/bot-profile.mjs +171 -0
  194. package/packages/orchestration/src/bot-routine.mjs +4 -4
  195. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  196. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  197. package/packages/orchestration/src/bot-serving.mjs +164 -0
  198. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  199. package/packages/orchestration/src/bot-settings.mjs +50 -11
  200. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  201. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  202. package/packages/orchestration/src/bot-threads.mjs +790 -0
  203. package/packages/orchestration/src/data-product-access.mjs +37 -0
  204. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  205. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  206. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  207. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  208. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  209. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  210. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  211. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  212. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  213. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  214. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  215. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  216. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  217. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  218. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  219. package/packages/orchestration/src/model-accounting.mjs +99 -35
  220. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  221. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  222. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  223. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  224. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  225. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  226. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  227. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  228. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  229. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  230. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  231. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  232. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  233. package/packages/orchestration/src/remote-machines.mjs +82 -14
  234. package/packages/orchestration/src/routine-execution.mjs +5 -3
  235. package/packages/orchestration/src/routine-session.mjs +19 -13
  236. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  237. package/packages/orchestration/src/task-journal.mjs +29 -0
  238. package/packages/orchestration/src/task-product.mjs +17 -0
  239. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  240. package/packages/orchestration/src/team-bridge.mjs +282 -0
  241. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  242. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  243. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  244. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  245. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  246. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  247. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  248. package/remote-computer-agent/README.md +39 -29
  249. package/remote-computer-agent/build.mjs +3 -0
  250. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  253. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  255. package/remote-computer-agent/dist/cli.mjs +1688 -168
  256. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  257. package/remote-computer-agent/dist/index.mjs +1579 -122
  258. package/remote-computer-agent/dist/index.mjs.map +4 -4
  259. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  260. package/remote-computer-agent/package-lock.json +308 -2
  261. package/remote-computer-agent/package.json +2 -1
  262. package/remote-computer-agent/pair-device.ps1 +20 -0
  263. package/remote-computer-agent/setup.ps1 +38 -16
  264. package/remote-computer-agent/src/cli.ts +24 -3
  265. package/remote-computer-agent/src/desktop-session.ts +11 -3
  266. package/remote-computer-agent/src/e2e-server.ts +81 -0
  267. package/remote-computer-agent/src/pairing-page.ts +35 -0
  268. package/remote-computer-agent/src/protocol.ts +25 -25
  269. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  270. package/remote-computer-agent/src/store.ts +20 -5
  271. package/remote-computer-agent/src/transport.ts +39 -15
@@ -9,36 +9,114 @@
9
9
  * It is deliberately small and side-effect-free beyond the store: the provider and the tool
10
10
  * dispatcher are injected, so a test can drive a whole conversation without a network or a Bot
11
11
  * runtime, and the Host decides which tools are reachable.
12
+ *
13
+ * Two things are threaded all the way through rather than sampled at the edges:
14
+ *
15
+ * - The cancel signal reaches the provider and every tool call, and a tool call is raced against
16
+ * it, so a cancel is not discovered only after the whole network or tool round finished. The
17
+ * operation still in flight keeps the Host's own durable task/attempt identity; nothing here
18
+ * replays it.
19
+ * - `guard` re-decides the turn from committed state before every transcript write, every provider
20
+ * step and every tool call. A Bot that was moved, deleted or revoked mid-turn stops at the next
21
+ * checkpoint, and a stop that arrives after the answer was produced writes nothing more.
22
+ *
23
+ * Two more things the loop refuses to fake:
24
+ *
25
+ * - The tool schemas a request offers come from the Host, re-resolved per step, so a model is never
26
+ * told about a tool its Bot no longer holds; and a batch of calls is judged as a whole before the
27
+ * first side effect, so a duplicate call id or an oversized batch cannot execute half of itself.
28
+ * - One turn identity is made per run and handed to every call, which is how a durable Task the
29
+ * Host opens stays correlated to this turn instead of to a string a reply invented.
12
30
  */
13
31
 
32
+ import { createHash, randomUUID } from 'node:crypto'
14
33
  import { openBotSessionStore } from './bot-session-store.mjs'
34
+ import { independentMessageWire } from './independent-message-wire.mjs'
15
35
 
16
36
  export const MAX_STEPS = 12
37
+ /** One provider response may ask for at most this many calls; more is refused as a whole. */
38
+ export const MAX_TOOL_CALLS = 8
39
+ export const botRequestMessageIds = requestId => {
40
+ if (typeof requestId !== 'string' || !/^[A-Za-z0-9_.:-]{1,160}$/.test(requestId)) throw Object.assign(new Error('BOT_REQUEST_ID_INVALID'), { code: 'BOT_REQUEST_ID_INVALID' })
41
+ const digest = createHash('sha256').update(requestId).digest('hex')
42
+ return { user: `request-user-${digest}`, answer: `request-answer-${digest}` }
43
+ }
44
+ export function botRequestReceipt(session, requestId) {
45
+ const ids = botRequestMessageIds(requestId)
46
+ const user = session.messages.find(message => message.messageId === ids.user && message.role === 'user')
47
+ const answer = session.messages.find(message => message.messageId === ids.answer && message.role === 'assistant' && !message.toolCalls)
48
+ return { hasBrief: Boolean(user), hasConfirmedAnswer: Boolean(user && answer && user.contextId === answer.contextId), answer: user && answer && user.contextId === answer.contextId ? answer.content : null, input: user?.content ?? null }
49
+ }
17
50
 
18
- const fail = code => { const error = new Error(code); error.code = code; throw error }
51
+ const fail = (code, message) => { const error = new Error(message ?? code); error.code = code; throw error }
52
+ const idOk = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value)
53
+ const cancelled = signal => {
54
+ const error = Object.assign(new Error('这一轮已被取消。'), { code: 'BOT_LOOP_CANCELLED' })
55
+ if (signal.aborted) throw error
56
+ return new Promise((resolve, reject) => {
57
+ signal.addEventListener('abort', () => reject(signal.reason ?? error), { once: true })
58
+ })
59
+ }
19
60
 
20
61
  /**
21
62
  * @param {{
22
63
  * store: ReturnType<typeof openBotSessionStore>,
23
- * provider: (request: { botId: string, messages: object[] }) => Promise<{ text?: string, toolCalls?: Array<{ id: string, name: string, args?: object }> }>,
24
- * tools?: { call: (name: string, args: object, context: { botId: string }) => Promise<unknown> } | null,
64
+ * provider: (request: { botId: string, messages: object[], signal?: AbortSignal, tools?: object[] }) => Promise<{ text?: string, toolCalls?: Array<{ id: string, name: string, args?: object }>, source?: object }>,
65
+ * tools?: { call: (name: string, args: object, context: { botId: string, signal?: AbortSignal, turnId: string, callId: string }) => Promise<unknown>, schemas?: () => object[] | null, isReadonly?: (name: string) => boolean } | null,
66
+ * guard?: (botId: string) => void,
25
67
  * now?: () => number,
26
68
  * maxSteps?: number,
69
+ * newTurnId?: () => string,
27
70
  * }} options
28
71
  */
29
- export function createBotLoop({ store, provider, tools = null, now = Date.now, maxSteps = MAX_STEPS } = {}) {
72
+ export function createBotLoop({ store, provider, tools = null, guard = null, now = Date.now, maxSteps = MAX_STEPS, newTurnId = () => `turn-${randomUUID()}` } = {}) {
30
73
  if (!store || typeof store.append !== 'function' || typeof store.get !== 'function') throw new TypeError('BOT_LOOP_REQUIRES_STORE')
31
74
  if (typeof provider !== 'function') throw new TypeError('BOT_LOOP_REQUIRES_PROVIDER')
32
75
  if (tools !== null && typeof tools?.call !== 'function') throw new TypeError('BOT_LOOP_INVALID_TOOLS')
76
+ if (guard !== null && typeof guard !== 'function') throw new TypeError('BOT_LOOP_INVALID_GUARD')
33
77
  if (!Number.isSafeInteger(maxSteps) || maxSteps < 1 || maxSteps > 64) throw new TypeError('INVALID_BOT_LOOP_STEPS')
78
+ if (typeof newTurnId !== 'function') throw new TypeError('BOT_LOOP_INVALID_TURN_ID')
79
+
80
+ /**
81
+ * The tools this step may see, resolved now.
82
+ *
83
+ * The Host decides the surface per step and the loop never keeps one: a grant that changed since
84
+ * the last step changes what the next request offers, and a tool service that disappeared is
85
+ * reported as unavailable instead of served from a stale object.
86
+ */
87
+ const offeredTools = () => {
88
+ if (!tools || typeof tools.schemas !== 'function') return null
89
+ const schemas = tools.schemas()
90
+ return Array.isArray(schemas) && schemas.length ? schemas : null
91
+ }
34
92
 
35
- async function step(botId, signal) {
93
+ async function step(botId, signal, checkpoint, onText) {
36
94
  const session = store.get(botId)
37
95
  if (signal?.aborted) fail('BOT_LOOP_CANCELLED')
38
- const reply = await provider({ botId, messages: session.messages.map(({ role, content, name }) => ({ role, content, ...(name ? { name } : {}) })) })
96
+ let reply
97
+ try {
98
+ const offered = offeredTools()
99
+ reply = await provider({ botId, messages: independentMessageWire(session.messages.filter(message =>
100
+ (message.contextId ?? 'legacy') === (session.contextId ?? 'legacy'))), ...(offered ? { tools: offered } : {}), ...(signal ? { signal } : {}), ...(onText ? { onText } : {}) })
101
+ } catch (error) {
102
+ // A provider that stopped because this turn was cancelled is a cancel, not a provider fault.
103
+ if (signal?.aborted) throw Object.assign(new Error('这一轮已被取消。'), { code: 'BOT_LOOP_CANCELLED', cause: error })
104
+ throw error
105
+ }
39
106
  if (signal?.aborted) fail('BOT_LOOP_CANCELLED')
40
107
  if (!reply || typeof reply !== 'object') fail('BOT_LOOP_PROVIDER_REPLY')
41
- const calls = Array.isArray(reply.toolCalls) ? reply.toolCalls.slice(0, 8) : []
108
+ checkpoint()
109
+ const calls = Array.isArray(reply.toolCalls) ? reply.toolCalls : []
110
+ // The whole batch is judged before the first side effect: a duplicate call id or a batch past
111
+ // the cap is refused as a whole, never by quietly running the first few and dropping the rest.
112
+ if (calls.length > MAX_TOOL_CALLS) fail('BOT_LOOP_TOOL_CALL_LIMIT', `一次响应最多请求 ${MAX_TOOL_CALLS} 个工具调用。`)
113
+ const seen = new Set()
114
+ for (const call of calls) {
115
+ if (!call || typeof call !== 'object' || !idOk(call.id)) fail('BOT_LOOP_TOOL_CALL_INVALID')
116
+ if (typeof call.name !== 'string' || !call.name) fail('BOT_LOOP_TOOL_CALL_INVALID')
117
+ if (seen.has(call.id)) fail('BOT_LOOP_TOOL_CALL_DUPLICATE', `同一次响应里重复了工具调用编号 ${call.id}。`)
118
+ seen.add(call.id)
119
+ }
42
120
  return { reply, calls }
43
121
  }
44
122
 
@@ -47,35 +125,90 @@ export function createBotLoop({ store, provider, tools = null, now = Date.now, m
47
125
  * Run one turn: user input in, answer or tool call out. The whole turn is bounded.
48
126
  * @param {{ botId: string, input: string, signal?: AbortSignal }} request
49
127
  */
50
- async run({ botId, input, signal } = {}) {
128
+ async run({ botId, input, signal, onProgress, requestId, conversationId } = {}) {
51
129
  if (typeof input !== 'string' || input.trim() === '') fail('BOT_LOOP_INPUT_REQUIRED')
52
- store.append(botId, [{ role: 'user', content: input }])
130
+ if (onProgress !== undefined && typeof onProgress !== 'function') fail('BOT_LOOP_PROGRESS_INVALID')
131
+ // Committed state is re-read before anything is written, so a Bot that no longer stands
132
+ // leaves no user message behind either.
133
+ const checkpoint = () => {
134
+ if (signal?.aborted) fail('BOT_LOOP_CANCELLED')
135
+ if (guard) guard(botId)
136
+ }
137
+ checkpoint()
138
+ const ids = requestId === undefined ? null : botRequestMessageIds(requestId)
139
+ if (ids) {
140
+ const receipt = botRequestReceipt(store.get(botId), requestId)
141
+ if (receipt.hasBrief) {
142
+ if (receipt.input !== input) fail('BOT_REQUEST_CONFLICT')
143
+ if (receipt.hasConfirmedAnswer) return { botId, answer: receipt.answer, steps: 0, toolCalls: [], turns: store.get(botId).turns, replayed: true }
144
+ fail('BOT_REQUEST_OUTCOME_UNKNOWN')
145
+ }
146
+ }
147
+ store.append(botId, [{ role: 'user', content: input, ...(ids ? { messageId: ids.user } : {}) }])
53
148
  const executed = []
149
+ // One Host turn identity for the whole run, made here and kept for every step: a durable Task
150
+ // the Host opens for a call is correlated to this turn, and a repeated call re-reads the same
151
+ // task instead of opening a second one.
152
+ const turnId = newTurnId()
54
153
  for (let index = 0; index < maxSteps; index += 1) {
55
- const { reply, calls } = await step(botId, signal)
154
+ checkpoint()
155
+ const progress = onProgress ? async text => { checkpoint(); await onProgress({ step: index + 1, text }) } : undefined
156
+ if (progress) await progress('')
157
+ const { reply, calls } = await step(botId, signal, checkpoint, progress)
56
158
  const text = typeof reply.text === 'string' ? reply.text : ''
57
159
  if (calls.length === 0) {
58
- const session = store.append(botId, [{ role: 'assistant', content: text }], { turn: true })
160
+ checkpoint()
161
+ // The answer keeps the route that produced it under the transcript's own field name, so a
162
+ // store that persists it (and the projection that reads it) can carry provenance on.
163
+ const session = store.append(botId, [{ role: 'assistant', content: text, ...(ids ? { messageId: ids.answer } : {}), ...(reply.source ? { modelSource: reply.source } : {}) }], { turn: true })
59
164
  return { botId, answer: text, steps: index + 1, toolCalls: executed, turns: session.turns }
60
165
  }
61
- store.append(botId, [
62
- { role: 'assistant', content: text },
63
- ...calls.map(call => ({ role: 'assistant', content: '', name: call.name, toolCall: { id: call.id, name: call.name, args: call.args ?? {} } })),
64
- ])
166
+ // The intermediate response is about to become a recorded tool batch.
167
+ // Remove its transient preview first so polling cannot show it twice
168
+ // while a long-running tool is still pending.
169
+ if (progress) await progress('')
170
+ checkpoint()
171
+ // One provider response owns the whole call batch. Preserve every id and
172
+ // its producing route so the next request can replay one assistant message.
173
+ const toolCalls = calls.map(call => ({ id: call.id, name: call.name, args: call.args ?? {} }))
174
+ store.append(botId, [{ role: 'assistant', content: text, toolCalls,
175
+ ...(reply.source ? { modelSource: reply.source } : {}),
176
+ // Retained for older transcript readers that show the first call.
177
+ name: toolCalls[0].name, toolCall: toolCalls[0] }])
65
178
  if (!tools) fail('BOT_LOOP_TOOLS_UNAVAILABLE')
66
179
  for (const call of calls) if (typeof call?.name !== 'string' || !call.name) fail('BOT_LOOP_TOOL_CALL_INVALID')
67
180
  // Read-only calls may run together; a mutating call keeps the batch sequential so the
68
181
  // transcript order still matches the order the provider asked for.
69
182
  const concurrent = calls.length > 1 && typeof tools.isReadonly === 'function' && calls.every(call => tools.isReadonly(call.name))
183
+ // The call is raced against the cancel: the loop stops waiting, the transcript keeps what
184
+ // actually happened, and the operation still in flight is reconciled by the Host that owns
185
+ // its durable identity — never replayed from here.
186
+ const invoke = call => {
187
+ const pending = tools.call(call.name, call.args ?? {}, { botId, ...(signal ? { signal } : {}), ...(conversationId ? { conversationId } : {}), turnId, callId: call.id })
188
+ if (!signal) return pending
189
+ pending.catch(() => {})
190
+ const stop = cancelled(signal)
191
+ stop.catch(() => {})
192
+ return Promise.race([pending, stop]).catch(error => {
193
+ // A cancel that arrived while the tool was running is a cancel, not a tool failure.
194
+ if (!signal.aborted) throw error
195
+ throw Object.assign(new Error('这一轮已被取消。'), { code: 'BOT_LOOP_CANCELLED', cause: error })
196
+ })
197
+ }
70
198
  let results
71
- if (concurrent) results = await Promise.all(calls.map(call => tools.call(call.name, call.args ?? {}, { botId })))
199
+ if (concurrent) results = await Promise.all(calls.map(invoke))
72
200
  else {
73
201
  results = []
74
- for (const call of calls) results.push(await tools.call(call.name, call.args ?? {}, { botId }))
202
+ for (const call of calls) results.push(await invoke(call))
75
203
  }
76
- for (const [index, call] of calls.entries()) {
204
+ checkpoint()
205
+ for (const [at, call] of calls.entries()) {
77
206
  executed.push({ id: call.id, name: call.name })
78
- store.append(botId, [{ role: 'tool', name: call.name, content: JSON.stringify(results[index] ?? null) }])
207
+ // The result answers that call, so the transcript keeps the call id beside it: the next
208
+ // turn correlates the two the way the Host does.
209
+ const value = results[at]
210
+ const isError = value?.error && typeof value.error === 'object' && !Array.isArray(value.error) && typeof value.error.code === 'string' && typeof value.error.message === 'string'
211
+ store.append(botId, [{ role: 'tool', name: call.name, toolCallId: call.id, toolCall: { id: call.id }, content: JSON.stringify(value ?? null), ...(isError ? { isError: true } : {}) }])
79
212
  }
80
213
  }
81
214
  fail('BOT_LOOP_STEP_LIMIT')
@@ -0,0 +1,159 @@
1
+ // A durable, read-only notification feed derived from committed Task-journal
2
+ // events. It is a second READ of the journal, never a second execution
3
+ // authority: nothing here starts, stops, retries or approves a Task, and no
4
+ // notification text is taken from a model's own words or from task input —
5
+ // every line is composed from structured journal state (outcome, category, Bot
6
+ // name) plus the durable identity needed to open the original detail.
7
+ //
8
+ // Read state lives in this Host's own SQLite under a single owner scope. It is
9
+ // keyed by the journal's monotonic event `seq`, so the same event can never
10
+ // become two notifications, and an owner's read position never trusts a client
11
+ // clock: the browser may only confirm event ids it was actually shown, or ask
12
+ // to mark everything read (the Host computes that watermark itself).
13
+
14
+ // State transitions a person should hear about. Transient requests (a bare
15
+ // `cancel-requested`) are deliberately absent: a stop request is not a stop
16
+ // confirmation, so it must not read as a finished outcome.
17
+ export const NOTIFY_KINDS = Object.freeze([
18
+ 'settled', 'blocked', 'dependency-blocked', 'deadline', 'budget-wait-expired',
19
+ 'interrupted', 'admission-conflict', 'retry-fenced', 'reconciled',
20
+ 'cancelled-before-dispatch', 'cancelled-while-waiting-budget', 'admission-recovered-cancelled',
21
+ ])
22
+
23
+ const SCOPE = 'owner'
24
+ const UNREAD_CAP = 512
25
+
26
+ // One structured category per outcome, with a tone the panel can colour without
27
+ // parsing any text. `reconcile` is the one that must never read as "stopped".
28
+ const categorize = (kind, outcome) => {
29
+ if (kind === 'reconciled') return { category: 'reconciled', tone: 'ok' }
30
+ if (kind === 'settled') {
31
+ if (outcome === 'succeeded') return { category: 'succeeded', tone: 'ok' }
32
+ if (outcome === 'cancelled') return { category: 'cancelled', tone: 'idle' }
33
+ if (outcome === 'waiting-budget') return { category: 'waiting-budget', tone: 'wait' }
34
+ if (outcome === 'needs-reconciliation') return { category: 'reconcile', tone: 'urgent' }
35
+ return { category: 'failed', tone: 'bad' }
36
+ }
37
+ if (kind === 'blocked' || kind === 'dependency-blocked') return { category: 'blocked', tone: 'wait' }
38
+ if (kind === 'deadline' || kind === 'budget-wait-expired') return { category: 'failed', tone: 'bad' }
39
+ if (kind === 'interrupted' || kind === 'admission-conflict' || kind === 'retry-fenced') return { category: 'reconcile', tone: 'urgent' }
40
+ if (kind === 'cancelled-before-dispatch' || kind === 'cancelled-while-waiting-budget' || kind === 'admission-recovered-cancelled') return { category: 'cancelled', tone: 'idle' }
41
+ return { category: 'update', tone: 'idle' }
42
+ }
43
+
44
+ const line = (category, botName) => {
45
+ switch (category) {
46
+ case 'succeeded': return `${botName} 的任务已完成`
47
+ case 'failed': return `${botName} 的任务失败`
48
+ case 'blocked': return `${botName} 的任务受阻,等待处理`
49
+ case 'reconcile': return `${botName} 的执行需要核对收据:结果未知,不会自动重试`
50
+ case 'waiting-budget': return `${botName} 的任务等待预算`
51
+ case 'cancelled': return `${botName} 的任务已取消`
52
+ case 'reconciled': return `${botName} 的收据已核对`
53
+ default: return `${botName} 的任务有更新`
54
+ }
55
+ }
56
+
57
+ export function createBotNotifications({ Database, file, journal, botIds, botNames, kinds = NOTIFY_KINDS, limit = 50 }) {
58
+ if (typeof journal?.notificationEvents !== 'function') throw new TypeError('BOT_NOTIFICATIONS_JOURNAL_REQUIRED')
59
+ if (typeof Database !== 'function') throw new TypeError('BOT_NOTIFICATIONS_DATABASE_REQUIRED')
60
+ const wanted = [...new Set((Array.isArray(kinds) ? kinds : NOTIFY_KINDS).filter(kind => typeof kind === 'string'))]
61
+ if (!wanted.length) throw new TypeError('BOT_NOTIFICATIONS_KINDS_REQUIRED')
62
+ const pageSize = Number.isSafeInteger(limit) && limit >= 1 && limit <= 200 ? limit : 50
63
+
64
+ const db = new Database(file)
65
+ const prior = db.pragma('busy_timeout', { simple: true })
66
+ try {
67
+ db.pragma('busy_timeout = 5000')
68
+ try { db.pragma('journal_mode = WAL') } catch { /* a read-position store does not require WAL */ }
69
+ db.pragma('synchronous = FULL')
70
+ db.exec(`
71
+ CREATE TABLE IF NOT EXISTS bot_notification_state(scope TEXT PRIMARY KEY, cursor_seq INTEGER NOT NULL, baseline_seq INTEGER NOT NULL);
72
+ CREATE TABLE IF NOT EXISTS bot_notification_reads(seq INTEGER PRIMARY KEY);
73
+ `)
74
+ } catch (error) { db.pragma(`busy_timeout=${prior}`); db.close(); throw error }
75
+
76
+ let closed = false
77
+ const run = sql => { if (closed) throw Object.assign(new Error('BOT_NOTIFICATIONS_CLOSED'), { code: 'BOT_NOTIFICATIONS_CLOSED' }); return db.prepare(sql) }
78
+ const ownerIds = () => {
79
+ const value = typeof botIds === 'function' ? botIds() : botIds
80
+ return [...new Set((Array.isArray(value) ? value : []).filter(id => typeof id === 'string' && id.length > 0))]
81
+ }
82
+ const names = () => {
83
+ const value = typeof botNames === 'function' ? botNames() : botNames
84
+ return value && typeof value === 'object' ? value : {}
85
+ }
86
+
87
+ // The read watermark is fixed on first use and never advances on its own: a
88
+ // restart, a second window or a late response cannot silently mark the future
89
+ // read. Everything at or below it is treated as already-seen history. The
90
+ // parent arms it once, right after the Bot directory has loaded, so "history"
91
+ // is exactly what existed at boot and everything after is new.
92
+ const ensureState = () => {
93
+ const existing = run('SELECT scope,cursor_seq,baseline_seq FROM bot_notification_state WHERE scope=?').get(SCOPE)
94
+ if (existing) return existing
95
+ const { maxSeq } = journal.notificationEvents(ownerIds(), wanted, { limit: 1 })
96
+ run('INSERT OR IGNORE INTO bot_notification_state(scope,cursor_seq,baseline_seq) VALUES(?,?,?)').run(SCOPE, maxSeq, maxSeq)
97
+ return run('SELECT scope,cursor_seq,baseline_seq FROM bot_notification_state WHERE scope=?').get(SCOPE)
98
+ }
99
+ const readSeqs = () => new Set(run('SELECT seq FROM bot_notification_reads').all().map(row => row.seq))
100
+ const countUnread = (state, reads) => {
101
+ const feed = journal.notificationEvents(ownerIds(), wanted, { after: state.cursor_seq, limit: UNREAD_CAP })
102
+ let unread = 0
103
+ for (const item of feed.items) if (!reads.has(item.seq)) unread++
104
+ return { unread, truncated: feed.items.length >= UNREAD_CAP, maxSeq: feed.maxSeq }
105
+ }
106
+ const decorate = (item, state, reads, names) => {
107
+ const { category, tone } = categorize(item.kind, item.outcome)
108
+ const botName = names[item.ownerBotId] ?? item.ownerBotId
109
+ return {
110
+ seq: item.seq, kind: item.kind, category, tone, at: item.at,
111
+ botId: item.ownerBotId, botName, taskId: item.taskId, attemptId: item.attemptId,
112
+ outcome: item.outcome, code: item.code,
113
+ read: item.seq <= state.cursor_seq || reads.has(item.seq), isNew: item.seq > state.baseline_seq,
114
+ text: line(category, botName),
115
+ }
116
+ }
117
+
118
+ return Object.freeze({
119
+ kinds: wanted,
120
+ readModel() {
121
+ const state = ensureState(), reads = readSeqs()
122
+ const { unread, truncated, maxSeq } = countUnread(state, reads)
123
+ return { schemaVersion: 1, status: 'available', unreadCount: unread, maxSeq, cursorSeq: state.cursor_seq, baselineSeq: state.baseline_seq, truncated }
124
+ },
125
+ list({ before = null, limit: size = pageSize } = {}) {
126
+ const state = ensureState(), reads = readSeqs(), botNames = names()
127
+ const cap = Number.isSafeInteger(size) && size >= 1 && size <= 200 ? size : pageSize
128
+ const { items, maxSeq } = journal.notificationEvents(ownerIds(), wanted, { before, limit: cap })
129
+ const { unread, truncated } = countUnread(state, reads)
130
+ return {
131
+ schemaVersion: 1, items: items.map(item => decorate(item, state, reads, botNames)),
132
+ nextBefore: items.length === cap ? items[items.length - 1].seq : null,
133
+ maxSeq, cursorSeq: state.cursor_seq, baselineSeq: state.baseline_seq, unreadCount: unread, truncated,
134
+ }
135
+ },
136
+ // The browser may only confirm an event id it was shown: an unknown, future
137
+ // or foreign seq is refused instead of silently widening the read set.
138
+ read(seq) {
139
+ if (!Number.isSafeInteger(seq) || seq < 1) throw Object.assign(new Error('INVALID_NOTIFICATION_SEQ'), { code: 'INVALID_NOTIFICATION_SEQ' })
140
+ const state = ensureState()
141
+ const probe = journal.notificationEvents(ownerIds(), wanted, { after: seq - 1, before: seq + 1, limit: 1 })
142
+ if (!probe.items.some(item => item.seq === seq)) throw Object.assign(new Error('NOTIFICATION_NOT_FOUND'), { code: 'NOTIFICATION_NOT_FOUND' })
143
+ if (seq > state.cursor_seq) run('INSERT OR IGNORE INTO bot_notification_reads(seq) VALUES(?)').run(seq)
144
+ return this.readModel()
145
+ },
146
+ // Mark everything the Host currently shows as read. The watermark is the
147
+ // Host's own max seq, so a later or reordered event is never swallowed.
148
+ markAll() {
149
+ const state = ensureState()
150
+ const { maxSeq } = journal.notificationEvents(ownerIds(), wanted, { limit: 1 })
151
+ if (maxSeq > state.cursor_seq) {
152
+ run('UPDATE bot_notification_state SET cursor_seq=? WHERE scope=?').run(maxSeq, SCOPE)
153
+ run('DELETE FROM bot_notification_reads WHERE seq<=?').run(maxSeq)
154
+ }
155
+ return this.readModel()
156
+ },
157
+ close() { if (!closed) { closed = true; try { db.pragma(`busy_timeout=${prior}`) } catch {}; db.close() } },
158
+ })
159
+ }
@@ -30,7 +30,7 @@ export function createBotPolicy({ authority, directory, machineId, workspaceId,
30
30
  replaceOwnerGrant(grant) { authority.replaceGrant(owner,grant) },
31
31
  activeIdentity() {
32
32
  const frame=current.getStore()
33
- return frame?{subject:frame.subject,botId:authority.describe(frame.subject).botId,owner:frame.owner}:null
33
+ return frame?{subject:frame.subject,botId:authority.describe(frame.subject).botId,owner:frame.owner,independent:frame.independent===true,...(frame.conversationId?{conversationId:frame.conversationId}:{}),...(frame.workThread?{workThread:frame.workThread}:{})}:null
34
34
  },
35
35
  bindFinancialRun(binding) {
36
36
  const frame=current.getStore()
@@ -56,6 +56,45 @@ export function createBotPolicy({ authority, directory, machineId, workspaceId,
56
56
  authority.assertTool(subject, 'task/execute')
57
57
  return current.run({ subject, owner: false, signal }, operation)
58
58
  },
59
+ /**
60
+ * A model turn's own frame.
61
+ *
62
+ * A text turn is not a Task: `executionContext` asserts `task/execute`, so a session that only
63
+ * talks to a model would have to forge a Task to look like one. This frame carries the subject
64
+ * the Host resolved from committed state — never anything a request carries — plus the turn's
65
+ * signal, so `modelIdentity` and `modelStream` inside it re-decide the route and re-read the
66
+ * cancel on every chunk. `botId` is an optional cross-check: a caller that names one Bot and
67
+ * resolves another is refused instead of silently answered for the wrong Bot.
68
+ */
69
+ modelContext(subject, signal, operation, { botId = null, conversationId = null, workThread = null } = {}) {
70
+ const who = authority.describe(subject)
71
+ if (botId !== null && who.botId !== botId) throw new PolicyError('SUBJECT_MISMATCH')
72
+ // A frame that already speaks for another subject cannot be re-pointed at this one: a nested
73
+ // call must not change whose turn it is.
74
+ const active = current.getStore()
75
+ if (active && !active.owner && active.subject !== subject) throw new PolicyError('SUBJECT_MISMATCH')
76
+ // An admitted financial run stays attached when it belongs to the same subject, so a nested
77
+ // model call bills the run it is inside instead of opening a second one.
78
+ const financial = active && active.subject === subject ? active.financial : undefined
79
+ const signals = [...new Set([active?.signal, signal].filter(Boolean))]
80
+ const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0]
81
+ combined?.throwIfAborted()
82
+ // Optional trusted Host metadata for a delegated work-thread turn: the per-work-thread
83
+ // conversationId the composition surfaces as the native identity, and a small workThread
84
+ // descriptor. Both are Host facts the dispatch boundary supplies; a model can never set
85
+ // them, and they confer no authority — the subject above is still the only identity.
86
+ const conversation = typeof conversationId === 'string' && /^[-A-Za-z0-9_.]{1,128}$/.test(conversationId) ? conversationId : null
87
+ const thread = workThread && typeof workThread === 'object' && !Array.isArray(workThread) ? workThread : null
88
+ return current.run({ subject, owner: false, independent: true, signal: combined, ...(financial ? { financial } : {}), ...(conversation ? { conversationId: conversation } : {}), ...(thread ? { workThread: thread } : {}) }, operation)
89
+ },
90
+ /**
91
+ * Run an operation OUTSIDE the current model-turn frame. The team dispatch and resume
92
+ * boundaries use this so a target turn (or a resumed sender turn) never inherits the
93
+ * calling Bot's async subject or funding: the target re-derives its own subject from
94
+ * committed state inside its own frame. The hard guarantee remains the target's own
95
+ * SUBJECT_MISMATCH guard; this is the explicit boundary on top of it.
96
+ */
97
+ runDetached(operation) { return current.exit(operation) },
59
98
  async run(call, operation) {
60
99
  const who = check(call)
61
100
  const binding = { taskId: `legacy-${randomUUID()}`, attemptId: 'attempt-1', machineId, workspaceId, location: 'local' }
@@ -79,12 +118,12 @@ export function createBotPolicy({ authority, directory, machineId, workspaceId,
79
118
  // If both metadata forms exist they must agree; a nested call cannot select another Bot.
80
119
  if (active && options.sessionId && directory.forSession(options.sessionId) !== subject) throw new PolicyError('SUBJECT_MISMATCH')
81
120
  authority.assertModel(subject, options.provider, options.model)
82
- const signal = active?.signal ? AbortSignal.any([options.signal, active.signal]) : options.signal
121
+ const signal = active?.signal ? AbortSignal.any([options.signal, active.signal].filter(Boolean)) : options.signal
83
122
  return (async function* () {
84
- signal.throwIfAborted()
123
+ signal?.throwIfAborted()
85
124
  authority.assertModel(subject, options.provider, options.model)
86
125
  for await (const chunk of next()) {
87
- signal.throwIfAborted()
126
+ signal?.throwIfAborted()
88
127
  authority.assertModel(subject, options.provider, options.model)
89
128
  yield chunk
90
129
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Bot identity and personality: the owner's own view of what a committed Bot
3
+ * actually persists.
4
+ *
5
+ * The frozen DS roster stores a Bot's name, its one-line role and its standing
6
+ * instructions, and nothing else about who it is. There is no soul file, no
7
+ * reply-style enum and no separate personality column behind this Host, so this
8
+ * service writes exactly those three fields — and only through the Host's
9
+ * trusted `runtime.updateBotProfile` seam. Instructions are text the system
10
+ * prompt reads every turn; they are not a grant, so this service never touches a
11
+ * model, a thinking level, a permission preset, browser grants, a working
12
+ * directory or an avatar, and it never chooses a Bot, a credential or an
13
+ * executable path from its arguments.
14
+ *
15
+ * Two Host facts shape the design:
16
+ *
17
+ * 1. The native `updateBot` answers a role or instructions change with an
18
+ * `update` notice delivered into the Bot's own chat, and a delivered notice
19
+ * opens a turn: an owner edit would start an unsolicited model turn. The
20
+ * seam is the Host's own answer to that — it mutates once and emits no
21
+ * notice — so this service never wraps, patches or re-routes `deliver`, and
22
+ * never impersonates a Bot actor to reuse the native self-edit exception.
23
+ * A Host without the seam refuses the save with BOT_PROFILE_SEAM_UNAVAILABLE
24
+ * instead of falling back to global interception.
25
+ * 2. `updateBot` awaits several times before its final save, so a revision
26
+ * read before those awaits fences nothing. The fence therefore lives inside
27
+ * the seam: it compares `base` against the live Bot and runs this service's
28
+ * `check` at the same synchronous boundary, immediately before the one
29
+ * multi-field mutation. The revision read here is an early exit only; the
30
+ * commit guarantee is the seam's, and it is reported as such.
31
+ */
32
+
33
+ export const BOT_PROFILE_SCHEMA = 1
34
+ export const BOT_PROFILE_LIMITS = Object.freeze({ name: 40, role: 24, instructions: 4000 })
35
+ const FIELDS = Object.freeze(['name', 'role', 'instructions'])
36
+ const EVERYONE = Object.freeze(['all', 'everyone', '所有人', '全体'])
37
+ const STANDS_FOR_USER = Object.freeze(['user', 'you'])
38
+
39
+ const plain = value => value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))
40
+ const fail = (code, message, details = {}) => { throw Object.assign(new Error(message), { code, details }) }
41
+ const keys = (value, allowed, path) => {
42
+ if (!plain(value) || Object.keys(value).some(key => !allowed.includes(key))) fail('BOT_PROFILE_INVALID', `${path} 含有未知字段或格式无效。`, { field: path })
43
+ }
44
+ const text = (value, limit, path) => {
45
+ if (typeof value !== 'string') fail('BOT_PROFILE_INVALID', `${path} 必须是文本。`, { field: path })
46
+ const next = value.trim()
47
+ if (next.length > limit) fail('BOT_PROFILE_LIMIT', `${path} 不能超过 ${limit} 个字符。`, { field: path })
48
+ return next
49
+ }
50
+ const owner = context => { if (context?.kind !== 'owner') fail('REMOTE_OWNER_REQUIRED', '请使用已认证的宿主拥有者账号修改 Bot 身份。') }
51
+
52
+ /** What the frozen roster accepts as a name; the same rule its own create flow applies. */
53
+ export function botProfileNameProblem(name) {
54
+ const text = String(name ?? '').trim()
55
+ if (text === '') return 'Bot 需要一个名称。'
56
+ if (/[@`\r\n]/.test(text)) return '名称不能包含 @、` 或换行。'
57
+ if (EVERYONE.includes(text.toLowerCase())) return `"${text}" 在群聊里等于@所有人,不能作为 Bot 名称。`
58
+ if (STANDS_FOR_USER.includes(text.toLowerCase())) return `"${text}" 在聊天里代表用户自己,不能作为 Bot 名称。`
59
+ return undefined
60
+ }
61
+
62
+ const viewOf = bot => ({ id: bot.id, name: bot.name ?? '', role: bot.role ?? '', instructions: bot.instructions ?? '' })
63
+ const fieldOf = (bot, field) => (field === 'name' ? bot.name ?? '' : field === 'role' ? bot.role ?? '' : bot.instructions ?? '')
64
+
65
+ const lifecycleOf = lifecycle => {
66
+ if (!lifecycle || typeof lifecycle.committedBot !== 'function' || typeof lifecycle.status !== 'function') fail('BOT_PROFILE_LIFECYCLE_UNAVAILABLE', '宿主未提供已提交生命周期,无法确认 Bot 身份。')
67
+ return lifecycle
68
+ }
69
+ const committedBot = (lifecycle, botId) => {
70
+ const bot = lifecycle.committedBot(botId)
71
+ if (!bot) fail('BOT_PROFILE_UNKNOWN_BOT', '这个 Bot 不在已提交的宿主目录中。', { botId })
72
+ return bot
73
+ }
74
+ const revisionOf = lifecycle => {
75
+ const revision = lifecycle.status()?.revision
76
+ return Number.isSafeInteger(revision) && revision >= 0 ? revision : null
77
+ }
78
+
79
+ /** A bounded owner read: the committed Bot's persistable identity, and nothing else. */
80
+ export async function inspectProductBotProfile({ runtime, lifecycle, signal }, payload) {
81
+ keys(payload, ['botId'], 'botProfile.inspect')
82
+ if (typeof payload.botId !== 'string' || payload.botId === '') fail('BOT_PROFILE_INVALID', '请选择当前团队中的 Bot。', { field: 'botId' })
83
+ signal?.throwIfAborted()
84
+ await runtime.load()
85
+ signal?.throwIfAborted()
86
+ const host = lifecycleOf(lifecycle)
87
+ const bot = committedBot(host, payload.botId)
88
+ return { schemaVersion: BOT_PROFILE_SCHEMA, bot: viewOf(bot), revision: revisionOf(host), limits: BOT_PROFILE_LIMITS, availableActions: ['botProfile.save'] }
89
+ }
90
+
91
+ /**
92
+ * One atomic owner save of the identity fields. `base` carries the values the
93
+ * editor read; when the committed revision moved since then, every field this
94
+ * save changes must still hold its base value, so an edit made in another window
95
+ * is refused instead of overwritten while an unrelated save (another Bot, a held
96
+ * greeting) never blocks this one.
97
+ *
98
+ * The commit itself is the Host seam's: it compares `base` against the live Bot
99
+ * and runs `check` at the same synchronous boundary, immediately before one
100
+ * multi-field mutation that persists once and emits no notice. Nothing is
101
+ * written until the whole patch passes, and the committed record is read back
102
+ * before success is reported.
103
+ */
104
+ export async function saveProductBotProfile({ runtime, lifecycle, signal }, payload, context) {
105
+ owner(context)
106
+ keys(payload, ['botId', 'expectedRevision', 'base', 'profile'], 'botProfile.save')
107
+ if (typeof payload.botId !== 'string' || payload.botId === '') fail('BOT_PROFILE_INVALID', '请选择当前团队中的 Bot。', { field: 'botId' })
108
+ if (!Number.isSafeInteger(payload.expectedRevision) || payload.expectedRevision < 0) fail('BOT_PROFILE_INVALID', 'expectedRevision 必须是宿主已提交状态的修订号。', { field: 'expectedRevision' })
109
+ keys(payload.base, FIELDS, 'base')
110
+ for (const field of FIELDS) if (typeof payload.base[field] !== 'string') fail('BOT_PROFILE_INVALID', `base.${field} 必须是读取时保存的文本。`, { field: `base.${field}` })
111
+ keys(payload.profile, FIELDS, 'profile')
112
+ if (Object.keys(payload.profile).length === 0) fail('BOT_PROFILE_INVALID', '没有要保存的身份字段。', { field: 'profile' })
113
+ const wanted = {}
114
+ for (const field of FIELDS) {
115
+ if (payload.profile[field] === undefined) continue
116
+ wanted[field] = text(payload.profile[field], BOT_PROFILE_LIMITS[field], field)
117
+ }
118
+ signal?.throwIfAborted()
119
+ const host = lifecycleOf(lifecycle)
120
+ const before = committedBot(host, payload.botId)
121
+ const revision = revisionOf(host)
122
+ // An early exit, not the fence: the seam re-checks against the live Bot.
123
+ if (payload.expectedRevision !== revision) {
124
+ for (const field of FIELDS) {
125
+ if (wanted[field] === undefined) continue
126
+ const current = fieldOf(before, field)
127
+ if (current !== payload.base[field]) fail('BOT_PROFILE_CONFLICT', '这个 Bot 的身份在别处已更新,请重新读取后再保存。', { field, current, base: payload.base[field], revision })
128
+ }
129
+ }
130
+ if (wanted.name !== undefined) {
131
+ const problem = botProfileNameProblem(wanted.name)
132
+ if (problem !== undefined) fail('BOT_PROFILE_NAME_INVALID', problem, { field: 'name' })
133
+ const taken = host.committedBots().some(bot => bot.id !== payload.botId && String(bot.name ?? '').trim().toLowerCase() === wanted.name.toLowerCase())
134
+ if (taken) fail('BOT_PROFILE_NAME_TAKEN', `名称 ${wanted.name} 已被其他 Bot 占用。`, { field: 'name' })
135
+ }
136
+ const patch = {}
137
+ for (const field of FIELDS) if (wanted[field] !== undefined && wanted[field] !== fieldOf(before, field)) patch[field] = wanted[field]
138
+ if (Object.keys(patch).length === 0) {
139
+ return { schemaVersion: BOT_PROFILE_SCHEMA, botId: payload.botId, bot: viewOf(before), revision, saved: [] }
140
+ }
141
+ const seam = runtime.updateBotProfile
142
+ if (typeof seam !== 'function') {
143
+ fail('BOT_PROFILE_SEAM_UNAVAILABLE', '这个宿主还没有 updateBotProfile 接线,身份保存已被拒绝,没有写入任何内容。请升级到提供该接线的 Host。', { required: 'runtime.updateBotProfile' })
144
+ }
145
+ signal?.throwIfAborted()
146
+ // Runs inside the seam, on the live Bot, immediately before its single
147
+ // mutation: fresh committed ownership, the caller's signal, and the live base.
148
+ const check = bot => {
149
+ signal?.throwIfAborted()
150
+ const committed = lifecycle.committedBot(payload.botId)
151
+ if (!committed || committed.id !== bot?.id) fail('BOT_PROFILE_UNKNOWN_BOT', '这个 Bot 在提交前已不在已提交的宿主目录中。', { botId: payload.botId })
152
+ for (const field of FIELDS) {
153
+ if (patch[field] === undefined) continue
154
+ const live = fieldOf(bot, field)
155
+ if (live !== payload.base[field]) fail('BOT_PROFILE_CONFLICT', '这个 Bot 的身份在别处已更新,请重新读取后再保存。', { field, current: live, base: payload.base[field] })
156
+ }
157
+ return true
158
+ }
159
+ await seam.call(runtime, payload.botId, patch, { base: payload.base, check })
160
+ // Read the committed record, not the live one: only the committed bytes are
161
+ // what the next turn will actually read.
162
+ let after = committedBot(host, payload.botId)
163
+ const unconfirmed = FIELDS.filter(field => patch[field] !== undefined && fieldOf(after, field) !== patch[field])
164
+ if (unconfirmed.length > 0 && typeof host.refreshGrants === 'function') {
165
+ await host.refreshGrants()
166
+ after = committedBot(host, payload.botId)
167
+ }
168
+ const missing = FIELDS.filter(field => patch[field] !== undefined && fieldOf(after, field) !== patch[field])
169
+ if (missing.length > 0) fail('BOT_PROFILE_UNCONFIRMED', '宿主尚未确认这次身份保存,请重新读取后核对。', { fields: missing, patch })
170
+ return { schemaVersion: BOT_PROFILE_SCHEMA, botId: payload.botId, bot: viewOf(after), revision: revisionOf(host), saved: Object.keys(patch) }
171
+ }