@ljwei-stak/dsh-model-route-bot 0.66.3 → 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.
- package/.dsh-plugin/bot-product.mjs +81 -5
- package/.dsh-plugin/client.js +17490 -1791
- package/README.i18n.yaml +4 -4
- package/README.md +409 -364
- package/README.zh.md +409 -364
- package/apps/integration-client/README.md +31 -7
- package/apps/integration-client/api.mjs +14 -3
- package/apps/integration-client/binding-editor.jsx +308 -109
- package/apps/integration-client/binding-model.mjs +262 -0
- package/apps/integration-client/binding.css +144 -0
- package/apps/integration-client/bot-model-status.mjs +18 -0
- package/apps/integration-client/bot-profile-identity.jsx +197 -0
- package/apps/integration-client/bot-profile.css +57 -0
- package/apps/integration-client/bot-profile.mjs +97 -0
- package/apps/integration-client/bot-settings-dialog.css +18 -0
- package/apps/integration-client/bot-settings-dialog.jsx +111 -0
- package/apps/integration-client/bot-thread-header-bridge.css +20 -0
- package/apps/integration-client/bot-thread-header.jsx +278 -0
- package/apps/integration-client/bot-threads-panel.jsx +378 -0
- package/apps/integration-client/bot-threads.css +169 -0
- package/apps/integration-client/chat-design.mjs +250 -0
- package/apps/integration-client/chat-ui.jsx +175 -0
- package/apps/integration-client/chat.css +477 -0
- package/apps/integration-client/composer-draft-binding.mjs +50 -0
- package/apps/integration-client/composer-draft-bound.jsx +14 -0
- package/apps/integration-client/composer-draft-host.jsx +142 -0
- package/apps/integration-client/composer-draft-session.jsx +287 -0
- package/apps/integration-client/composer-draft-witness.mjs +115 -0
- package/apps/integration-client/composer-draft.css +4 -0
- package/apps/integration-client/composer-draft.mjs +322 -0
- package/apps/integration-client/contact-attention.jsx +54 -0
- package/apps/integration-client/data-panel.jsx +439 -0
- package/apps/integration-client/data.css +69 -0
- package/apps/integration-client/desktop-bridge.mjs +1 -0
- package/apps/integration-client/desktop-http-socket.mjs +49 -0
- package/apps/integration-client/desktop-keyboard.mjs +32 -0
- package/apps/integration-client/desktop-pane.jsx +99 -30
- package/apps/integration-client/desktop-stream.mjs +13 -3
- package/apps/integration-client/desktop-tools.jsx +19 -0
- package/apps/integration-client/desktop-touch.mjs +27 -0
- package/apps/integration-client/desktop.css +41 -0
- package/apps/integration-client/independent-chat-controller.mjs +224 -0
- package/apps/integration-client/independent-chat-entry.jsx +61 -0
- package/apps/integration-client/independent-chat-navigation.mjs +21 -0
- package/apps/integration-client/independent-chat-page.jsx +148 -0
- package/apps/integration-client/independent-chat-rich.jsx +64 -0
- package/apps/integration-client/independent-chat-rich.mjs +30 -0
- package/apps/integration-client/independent-chat.css +73 -0
- package/apps/integration-client/index.jsx +24 -16
- package/apps/integration-client/library-panel.jsx +4 -1
- package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
- package/apps/integration-client/main-route-editor.jsx +94 -0
- package/apps/integration-client/main-route-model.mjs +100 -0
- package/apps/integration-client/model-picker.css +37 -0
- package/apps/integration-client/model-picker.jsx +83 -0
- package/apps/integration-client/native-activity.css +141 -0
- package/apps/integration-client/native-activity.jsx +276 -0
- package/apps/integration-client/native-activity.mjs +384 -0
- package/apps/integration-client/notifications-panel.jsx +186 -0
- package/apps/integration-client/notifications.css +126 -0
- package/apps/integration-client/pairing-qr.jsx +79 -0
- package/apps/integration-client/panels.jsx +126 -115
- package/apps/integration-client/product-slots.jsx +46 -0
- package/apps/integration-client/routine-recorder.mjs +23 -2
- package/apps/integration-client/secure-pairing.jsx +84 -0
- package/apps/integration-client/tasks-panel.jsx +32 -10
- package/apps/integration-client/ui-icons.jsx +17 -0
- package/apps/integration-client/usage-explorer.jsx +43 -0
- package/apps/integration-client/usage-report.mjs +71 -0
- package/apps/integration-client/usage.css +34 -0
- package/apps/integration-client/web-panel-model.mjs +140 -0
- package/apps/integration-client/web-panel-page.jsx +221 -0
- package/apps/integration-client/web-panel-pins-live.jsx +28 -0
- package/apps/integration-client/web-panel.css +139 -0
- package/apps/integration-client/workspace-model.mjs +67 -0
- package/apps/integration-client/workspace-navigation.mjs +56 -0
- package/apps/integration-client/workspace-ui.jsx +173 -0
- package/apps/integration-client/workspace.css +332 -0
- package/modules/bot-runtime/SOURCE.json +176 -50
- package/modules/bot-runtime/index.mjs +169 -13
- package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
- package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
- package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
- package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
- package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
- package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
- package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
- package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
- package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
- package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
- package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
- package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
- package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
- package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
- package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
- package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
- package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
- package/modules/bot-runtime/read-model.mjs +19 -4
- package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
- package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
- package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
- package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
- package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
- package/modules/independent-runtime/index.mjs +152 -62
- package/modules/independent-runtime/remote-tools.mjs +8 -5
- package/modules/openmaus-protocol/NOTICE +10 -0
- package/modules/openmaus-protocol/README.md +15 -0
- package/modules/openmaus-protocol/SOURCE.json +68 -0
- package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
- package/modules/openmaus-protocol/index.mjs +1 -0
- package/modules/openmaus-protocol/transport.mjs +77 -0
- package/modules/openmaus-protocol/vendor/LICENSE +202 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
- package/package.json +20 -6
- package/packages/bot-acp/README.md +156 -0
- package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-acp/licenses/NOTICE.md +27 -0
- package/packages/bot-acp/src/acp-adapter.mjs +795 -0
- package/packages/bot-acp/src/acp-host.mjs +148 -0
- package/packages/bot-acp/src/native-launch.mjs +121 -0
- package/packages/bot-acp/src/permission-policy.mjs +283 -0
- package/packages/bot-acp/src/rpc-codec.mjs +265 -0
- package/packages/bot-acp/src/session-core.mjs +581 -0
- package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
- package/packages/bot-engines/README.md +281 -0
- package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-engines/licenses/NOTICE.md +32 -0
- package/packages/bot-engines/src/claude-engine.mjs +772 -0
- package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
- package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
- package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
- package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
- package/packages/bot-engines/src/claude-protocol.mjs +177 -0
- package/packages/bot-engines/src/driver-catalog.mjs +334 -0
- package/packages/bot-engines/src/host-loop-request.mjs +95 -0
- package/packages/bot-engines/src/host-registrations.mjs +105 -0
- package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
- package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
- package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
- package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
- package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
- package/packages/cli-runtime/src/native-peer.ps1 +51 -3
- package/packages/cli-runtime/src/native-process.mjs +77 -14
- package/packages/model-router-plugin/README.md +3 -3
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/binding-product.mjs +19 -0
- package/packages/orchestration/src/bot-data-actions.mjs +45 -0
- package/packages/orchestration/src/bot-data-chart.mjs +109 -0
- package/packages/orchestration/src/bot-data-context.mjs +44 -0
- package/packages/orchestration/src/bot-data-engine.mjs +809 -0
- package/packages/orchestration/src/bot-data-io.mjs +186 -0
- package/packages/orchestration/src/bot-data-sql.mjs +195 -0
- package/packages/orchestration/src/bot-data-surface.mjs +144 -0
- package/packages/orchestration/src/bot-data-tools.mjs +68 -0
- package/packages/orchestration/src/bot-data-worker.mjs +228 -0
- package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
- package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
- package/packages/orchestration/src/bot-loop.mjs +152 -19
- package/packages/orchestration/src/bot-notifications.mjs +159 -0
- package/packages/orchestration/src/bot-policy.mjs +43 -4
- package/packages/orchestration/src/bot-profile.mjs +171 -0
- package/packages/orchestration/src/bot-routine.mjs +4 -4
- package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
- package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
- package/packages/orchestration/src/bot-serving.mjs +164 -0
- package/packages/orchestration/src/bot-session-store.mjs +105 -9
- package/packages/orchestration/src/bot-settings.mjs +50 -11
- package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
- package/packages/orchestration/src/bot-thread-host.mjs +187 -0
- package/packages/orchestration/src/bot-threads.mjs +790 -0
- package/packages/orchestration/src/data-product-access.mjs +37 -0
- package/packages/orchestration/src/data-tool-contract.mjs +291 -0
- package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
- package/packages/orchestration/src/desktop-proxy.mjs +311 -0
- package/packages/orchestration/src/desktop-sessions.mjs +43 -9
- package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
- package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
- package/packages/orchestration/src/independent-chat-page.mjs +39 -0
- package/packages/orchestration/src/independent-chat-service.mjs +308 -0
- package/packages/orchestration/src/independent-context-state.mjs +44 -0
- package/packages/orchestration/src/independent-message-wire.mjs +84 -0
- package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
- package/packages/orchestration/src/independent-tool-families.mjs +73 -0
- package/packages/orchestration/src/independent-tool-host.mjs +344 -0
- package/packages/orchestration/src/main-bot-grant.mjs +15 -0
- package/packages/orchestration/src/main-model-routes.mjs +208 -0
- package/packages/orchestration/src/model-accounting.mjs +99 -35
- package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
- package/packages/orchestration/src/model-loop-frame.mjs +179 -0
- package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
- package/packages/orchestration/src/model-request-bounds.mjs +111 -0
- package/packages/orchestration/src/native-activity-contract.mjs +491 -0
- package/packages/orchestration/src/native-activity-service.mjs +102 -0
- package/packages/orchestration/src/native-activity-store.mjs +249 -0
- package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
- package/packages/orchestration/src/native-data-tools.mjs +100 -0
- package/packages/orchestration/src/native-remote-actions.mjs +742 -0
- package/packages/orchestration/src/native-team-tools.mjs +73 -0
- package/packages/orchestration/src/pairing-invite.mjs +37 -0
- package/packages/orchestration/src/product-composition.mjs +1559 -1001
- package/packages/orchestration/src/remote-machines.mjs +82 -14
- package/packages/orchestration/src/routine-execution.mjs +5 -3
- package/packages/orchestration/src/routine-session.mjs +19 -13
- package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
- package/packages/orchestration/src/task-journal.mjs +29 -0
- package/packages/orchestration/src/task-product.mjs +17 -0
- package/packages/orchestration/src/team-assignment-store.mjs +120 -0
- package/packages/orchestration/src/team-bridge.mjs +282 -0
- package/packages/orchestration/src/team-scheduler.mjs +83 -0
- package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
- package/packages/orchestration/src/team-tool-contract.mjs +284 -0
- package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
- package/packages/orchestration/src/web-panel-actions.mjs +103 -0
- package/packages/orchestration/src/web-panel-framing.mjs +227 -0
- package/packages/orchestration/src/web-panel-store.mjs +206 -0
- package/remote-computer-agent/README.md +39 -29
- package/remote-computer-agent/build.mjs +3 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
- package/remote-computer-agent/dist/cli.mjs +1688 -168
- package/remote-computer-agent/dist/cli.mjs.map +4 -4
- package/remote-computer-agent/dist/index.mjs +1579 -122
- package/remote-computer-agent/dist/index.mjs.map +4 -4
- package/remote-computer-agent/dist/native-peer.ps1 +51 -3
- package/remote-computer-agent/package-lock.json +308 -2
- package/remote-computer-agent/package.json +2 -1
- package/remote-computer-agent/pair-device.ps1 +20 -0
- package/remote-computer-agent/setup.ps1 +38 -16
- package/remote-computer-agent/src/cli.ts +24 -3
- package/remote-computer-agent/src/desktop-session.ts +11 -3
- package/remote-computer-agent/src/e2e-server.ts +81 -0
- package/remote-computer-agent/src/pairing-page.ts +35 -0
- package/remote-computer-agent/src/protocol.ts +25 -25
- package/remote-computer-agent/src/secure-pairing.ts +105 -0
- package/remote-computer-agent/src/store.ts +20 -5
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
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
|
|
202
|
+
for (const call of calls) results.push(await invoke(call))
|
|
75
203
|
}
|
|
76
|
-
|
|
204
|
+
checkpoint()
|
|
205
|
+
for (const [at, call] of calls.entries()) {
|
|
77
206
|
executed.push({ id: call.id, name: call.name })
|
|
78
|
-
|
|
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
|
|
123
|
+
signal?.throwIfAborted()
|
|
85
124
|
authority.assertModel(subject, options.provider, options.model)
|
|
86
125
|
for await (const chunk of next()) {
|
|
87
|
-
signal
|
|
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
|
+
}
|