@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.
- 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 +18 -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/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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* never mistaken for a finished one.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
export const ROUTINE_ACTIONS = Object.freeze(['click', 'type', 'key', 'scroll', 'wait', 'input'])
|
|
14
|
+
export const ROUTINE_ACTIONS = Object.freeze(['click', 'type', 'key', 'scroll', 'wait', 'input', 'release-input'])
|
|
15
15
|
export const MAX_ROUTINE_STEPS = 200
|
|
16
16
|
export const MAX_TYPE_LENGTH = 2000
|
|
17
17
|
export const MAX_RECORDED_TEXT_LENGTH = 8192
|
|
@@ -70,10 +70,10 @@ export function normalizeStep(input) {
|
|
|
70
70
|
if (!input || typeof input !== 'object' || Array.isArray(input)) fail('ROUTINE_STEP_INVALID', '步骤必须是对象。')
|
|
71
71
|
const action = input.action
|
|
72
72
|
if (!ROUTINE_ACTIONS.includes(action)) fail('ROUTINE_ACTION_UNKNOWN', `未知动作:${String(action)}`)
|
|
73
|
-
if (action === 'input') {
|
|
73
|
+
if (action === 'input' || action === 'release-input') {
|
|
74
74
|
const delayMs = input.delayMs ?? 0
|
|
75
75
|
if (!finiteInt(delayMs) || delayMs < 0 || delayMs > 60_000) fail('ROUTINE_STEP_INVALID')
|
|
76
|
-
return { action, event: normalizeRoutineInput(input.event), delayMs }
|
|
76
|
+
return action === 'release-input' ? { action, delayMs } : { action, event: normalizeRoutineInput(input.event), delayMs }
|
|
77
77
|
}
|
|
78
78
|
if (action === 'click') {
|
|
79
79
|
if (!finiteInt(input.x) || !finiteInt(input.y) || input.x < 0 || input.y < 0) fail('ROUTINE_STEP_INVALID', 'click 需要非负整数坐标。')
|
|
@@ -119,7 +119,7 @@ export function normalizeRoutine(input) {
|
|
|
119
119
|
recordedAt: recorded.recordedAt, durationMs: recorded.durationMs, acknowledgedInputs: recorded.acknowledgedInputs,
|
|
120
120
|
...(recorded.observedInputs !== undefined ? { observedInputs: recorded.observedInputs, pointerSampleMs: recorded.pointerSampleMs } : {}) }
|
|
121
121
|
return {
|
|
122
|
-
schemaVersion: recording || input.steps.some(step => step.action
|
|
122
|
+
schemaVersion: recording || input.steps.some(step => ['input','release-input'].includes(step.action)) ? 2 : 1,
|
|
123
123
|
name,
|
|
124
124
|
...(typeof input.description === 'string' && input.description.trim() ? { description: input.description.trim().slice(0, 500) } : {}),
|
|
125
125
|
steps: input.steps.map(normalizeStep),
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
import { mkdirSync } from 'node:fs'
|
|
12
12
|
import { join } from 'node:path'
|
|
13
|
-
import {
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { createBotLoop, botRequestReceipt } from './bot-loop.mjs'
|
|
14
15
|
import { openBotSessionStore } from './bot-session-store.mjs'
|
|
15
16
|
import { createBotDispatch } from './bot-runtime-dispatch.mjs'
|
|
16
17
|
import { openRuntimeSwitch } from './bot-runtime-switch.mjs'
|
|
18
|
+
import { createBotServing } from './bot-serving.mjs'
|
|
19
|
+
import { contextView, contextRequestOk, independentContextId, retirementConfirmed } from './independent-context-state.mjs'
|
|
17
20
|
|
|
18
21
|
const fail = code => { const error = new Error(code); error.code = code; throw error }
|
|
19
22
|
|
|
@@ -22,22 +25,80 @@ const fail = code => { const error = new Error(code); error.code = code; throw e
|
|
|
22
25
|
* dir: string,
|
|
23
26
|
* integration: (request: object) => Promise<object>,
|
|
24
27
|
* providerFor: (botId: string) => ((request: object) => Promise<object>) | null,
|
|
25
|
-
* toolsFor?: (botId: string) => { names?: string[], call:
|
|
28
|
+
* toolsFor?: (botId: string) => { names?: string[], schemas?: () => object[] | null, isReadonly?: (name: string) => boolean, call: (name: string, args: object, context: { botId: string, signal?: AbortSignal, turnId: string, callId: string }) => Promise<unknown> } | null,
|
|
26
29
|
* isBusy?: (botId: string) => boolean | Promise<boolean>,
|
|
30
|
+
* serving?: object | null,
|
|
31
|
+
* assertTurn?: (botId: string) => void,
|
|
27
32
|
* now?: () => number,
|
|
28
33
|
* maxSteps?: number,
|
|
29
34
|
* }} options
|
|
30
35
|
*/
|
|
31
36
|
export function createIndependentRuntime({
|
|
32
|
-
dir, integration, providerFor, toolsFor = () => null, isBusy = () => false, now = Date.now, maxSteps,
|
|
37
|
+
dir, integration, providerFor, toolsFor = () => null, isBusy = () => false, serving = null, assertTurn = null, now = Date.now, maxSteps,
|
|
38
|
+
retireConversation = null,
|
|
33
39
|
} = {}) {
|
|
34
40
|
if (typeof dir !== 'string' || dir === '') throw new TypeError('RUNTIME_DIR_REQUIRED')
|
|
35
41
|
if (typeof integration !== 'function') throw new TypeError('RUNTIME_REQUIRES_INTEGRATION')
|
|
36
42
|
if (typeof providerFor !== 'function') throw new TypeError('RUNTIME_REQUIRES_PROVIDER_FACTORY')
|
|
43
|
+
if (assertTurn !== null && typeof assertTurn !== 'function') throw new TypeError('RUNTIME_ASSERT_TURN_INVALID')
|
|
44
|
+
if (retireConversation !== null && typeof retireConversation !== 'function') throw new TypeError('RUNTIME_RETIRE_CONVERSATION_INVALID')
|
|
45
|
+
if (serving !== null && (typeof serving !== 'object' || typeof serving.enter !== 'function' || typeof serving.assertMode !== 'function')) {
|
|
46
|
+
throw new TypeError('RUNTIME_SERVING_ADMISSION_INVALID')
|
|
47
|
+
}
|
|
37
48
|
mkdirSync(dir, { recursive: true })
|
|
38
49
|
const store = openBotSessionStore({ dir: join(dir, 'sessions'), now })
|
|
39
50
|
const switchApi = openRuntimeSwitch({ file: join(dir, 'runtime-switch.json'), now })
|
|
40
51
|
const loops = new Map()
|
|
52
|
+
const controllers = new Set(), resetLeases = new Map()
|
|
53
|
+
let closed = false
|
|
54
|
+
const assertOpen = () => { if (closed) fail('INDEPENDENT_RUNTIME_CLOSED') }
|
|
55
|
+
const controlled = signal => {
|
|
56
|
+
const controller = new AbortController()
|
|
57
|
+
const abort = () => controller.abort(signal.reason)
|
|
58
|
+
if (signal?.aborted) abort()
|
|
59
|
+
else signal?.addEventListener('abort', abort, { once: true })
|
|
60
|
+
controllers.add(controller)
|
|
61
|
+
return { controller, release() { controllers.delete(controller); signal?.removeEventListener('abort', abort) } }
|
|
62
|
+
}
|
|
63
|
+
// The shared Host admission: the same lease the integrated stream holds, so a move sees a Bot
|
|
64
|
+
// this path is serving exactly as it sees one the Harness is serving. It is an admission only —
|
|
65
|
+
// the committed switch still decides which runtime answers, and the policy still decides what
|
|
66
|
+
// the answer may do.
|
|
67
|
+
const guard = serving ?? createBotServing({ modeOf: botId => switchApi.modeOf(botId), now })
|
|
68
|
+
// One re-decision per checkpoint, from committed state only: which runtime serves this Bot, and
|
|
69
|
+
// whether the Bot still stands at all. The loop asks before every transcript write, every
|
|
70
|
+
// provider step and every tool call, so a Bot moved, deleted or revoked mid-turn stops at the
|
|
71
|
+
// next checkpoint instead of finishing on a state that no longer stands.
|
|
72
|
+
const turnGuard = botId => {
|
|
73
|
+
assertOpen()
|
|
74
|
+
if (guard) guard.assertMode(botId, 'independent')
|
|
75
|
+
if (assertTurn) assertTurn(botId)
|
|
76
|
+
if (store.get(botId).contextReset) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The tool surface, resolved per step and per call rather than once per Bot.
|
|
81
|
+
*
|
|
82
|
+
* A loop is cached per Bot so a turn does not rebuild it, but the tools behind it are Host state:
|
|
83
|
+
* `schemas()` is asked again for every request, and `call()` resolves the Host's own service
|
|
84
|
+
* again for every call. A grant that changed, or a Host tool service that went away, therefore
|
|
85
|
+
* stops the next call instead of being frozen into a loop somebody cached.
|
|
86
|
+
*/
|
|
87
|
+
const toolServiceFor = botId => Object.freeze({
|
|
88
|
+
schemas: () => {
|
|
89
|
+
const tools = toolsFor(botId)
|
|
90
|
+
return tools && typeof tools.schemas === 'function' ? tools.schemas() : null
|
|
91
|
+
},
|
|
92
|
+
isReadonly: name => {
|
|
93
|
+
const tools = toolsFor(botId)
|
|
94
|
+
return Boolean(tools && typeof tools.isReadonly === 'function' && tools.isReadonly(name))
|
|
95
|
+
},
|
|
96
|
+
call: (name, args, context) => {
|
|
97
|
+
const tools = toolsFor(botId)
|
|
98
|
+
if (!tools) fail('BOT_LOOP_TOOLS_UNAVAILABLE')
|
|
99
|
+
return tools.call(name, args, context)
|
|
100
|
+
},
|
|
101
|
+
})
|
|
41
102
|
|
|
42
103
|
// One loop per Bot: it holds no state beyond the store, but caching keeps a
|
|
43
104
|
// provider factory from being called twice in one turn.
|
|
@@ -46,7 +107,11 @@ export function createIndependentRuntime({
|
|
|
46
107
|
if (existing) return existing
|
|
47
108
|
const provider = providerFor(botId)
|
|
48
109
|
if (typeof provider !== 'function') fail('RUNTIME_PROVIDER_UNAVAILABLE')
|
|
49
|
-
|
|
110
|
+
// Every step re-reads the committed mode: a Bot moved mid-turn stops at the next step
|
|
111
|
+
// instead of finishing on a runtime that no longer owns it.
|
|
112
|
+
const guarded = request => { turnGuard(request.botId); return provider(request) }
|
|
113
|
+
const loop = createBotLoop({ store, provider: guarded,
|
|
114
|
+
tools: typeof toolsFor === 'function' ? toolServiceFor(botId) : null, guard: turnGuard, now, ...(maxSteps ? { maxSteps } : {}) })
|
|
50
115
|
loops.set(botId, loop)
|
|
51
116
|
return loop
|
|
52
117
|
}
|
|
@@ -55,22 +120,160 @@ export function createIndependentRuntime({
|
|
|
55
120
|
switchApi,
|
|
56
121
|
integration,
|
|
57
122
|
independent: async request => {
|
|
58
|
-
|
|
59
|
-
|
|
123
|
+
if (!guard) return loopFor(request.botId).run({ botId: request.botId, input: request.input, signal: request.signal, onProgress: request.onProgress })
|
|
124
|
+
// The whole turn, tools included, under the one lease: a second turn, or a move, is refused
|
|
125
|
+
// while this one runs, and the lease is released on answer, cancel, failure or throw.
|
|
126
|
+
guard.assertMode(request.botId, 'independent')
|
|
127
|
+
const lease = guard.acquire(request.botId)
|
|
128
|
+
try {
|
|
129
|
+
const result = await loopFor(request.botId).run({ botId: request.botId, input: request.input, signal: request.signal, onProgress: request.onProgress })
|
|
130
|
+
return { answer: result.answer, steps: result.steps, toolCalls: result.toolCalls, turns: result.turns }
|
|
131
|
+
} finally {
|
|
132
|
+
lease.release()
|
|
133
|
+
}
|
|
60
134
|
},
|
|
61
135
|
now,
|
|
62
136
|
})
|
|
63
137
|
|
|
138
|
+
const contextCheck = botId => {
|
|
139
|
+
assertOpen()
|
|
140
|
+
guard.assertMode(botId, 'independent')
|
|
141
|
+
if (assertTurn) assertTurn(botId)
|
|
142
|
+
}
|
|
143
|
+
const reset = async (botId, { requestKey, expectedContextId, signal, retirementEvidence } = {}, reconcile = false) => {
|
|
144
|
+
contextCheck(botId)
|
|
145
|
+
if (!contextRequestOk(requestKey)) fail('INVALID_BOT_CONTEXT_RESET')
|
|
146
|
+
if (!reconcile && !retireConversation) throw Object.assign(new Error('INDEPENDENT_CONTEXT_RETIREMENT_UNAVAILABLE'), { code: 'INDEPENDENT_CONTEXT_RETIREMENT_UNAVAILABLE', outcome: 'refused' })
|
|
147
|
+
const contextId = independentContextId(botId, requestKey)
|
|
148
|
+
if (store.contextCommitted(botId, requestKey, contextId)) return contextView(store.get(botId))
|
|
149
|
+
const previous = store.get(botId)
|
|
150
|
+
if (previous.contextReset && (!reconcile || previous.contextReset.requestKey !== requestKey || previous.contextReset.contextId !== contextId)) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
|
|
151
|
+
if (reconcile && !retirementConfirmed(retirementEvidence)) fail('INDEPENDENT_CONTEXT_RETIREMENT_UNCONFIRMED')
|
|
152
|
+
// Acquire synchronously before the first await. A turn or move in this tick cannot enter.
|
|
153
|
+
const lease = resetLeases.get(botId) ?? guard.acquire(botId)
|
|
154
|
+
resetLeases.set(botId, lease)
|
|
155
|
+
const scope = controlled(signal)
|
|
156
|
+
let intentWritten = Boolean(previous.contextReset), confirmed = false
|
|
157
|
+
const checkpoint = () => { scope.controller.signal.throwIfAborted(); contextCheck(botId) }
|
|
158
|
+
try {
|
|
159
|
+
checkpoint()
|
|
160
|
+
if (!reconcile) {
|
|
161
|
+
store.beginContext(botId, { requestKey, contextId, expectedContextId })
|
|
162
|
+
intentWritten = true
|
|
163
|
+
const evidence = await retireConversation(botId, { reason: 'new-conversation', signal: scope.controller.signal })
|
|
164
|
+
if (!retirementConfirmed(evidence)) fail('INDEPENDENT_CONTEXT_RETIREMENT_UNCONFIRMED')
|
|
165
|
+
}
|
|
166
|
+
confirmed = true
|
|
167
|
+
checkpoint()
|
|
168
|
+
const session = store.commitContext(botId, { requestKey, contextId })
|
|
169
|
+
loops.delete(botId)
|
|
170
|
+
return contextView(session)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// A post-rename read error may still have committed the intended context.
|
|
173
|
+
let observed
|
|
174
|
+
try {
|
|
175
|
+
if (store.contextCommitted(botId, requestKey, contextId)) { loops.delete(botId); return contextView(store.get(botId)) }
|
|
176
|
+
if (intentWritten && !confirmed && error.outcome === 'refused') store.abortContext(botId, { requestKey, contextId })
|
|
177
|
+
observed = store.get(botId)
|
|
178
|
+
} catch { /* Preserve uncertainty and its durable admission fence. */ }
|
|
179
|
+
error.outcome = !observed || observed.contextReset?.requestKey === requestKey ? 'unknown' : error.outcome ?? 'refused'
|
|
180
|
+
throw error
|
|
181
|
+
} finally {
|
|
182
|
+
scope.release()
|
|
183
|
+
// Unknown retirement keeps the lease and durable intent. Only explicit trusted
|
|
184
|
+
// reconciliation with stop evidence can release it; inspect never retries it.
|
|
185
|
+
let pending = true
|
|
186
|
+
try { pending = store.get(botId).contextReset?.requestKey === requestKey } catch { /* fail closed */ }
|
|
187
|
+
if (!pending) { resetLeases.delete(botId); lease.release() }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- trusted work-thread turns (teammate coordination) ------------------------------
|
|
192
|
+
// A work-thread is a REAL, distinct conversation history for one (target Bot, work
|
|
193
|
+
// thread): its own session-store file under work-threads/<id>, run through the same
|
|
194
|
+
// guarded loop, the same single-writer lease and the target's own provider/tools/grants
|
|
195
|
+
// as its default conversation — never a label on the default transcript. The Host-only
|
|
196
|
+
// requestId correlation and the persisted user/answer pair make a replay a read, and an
|
|
197
|
+
// unconfirmed answer a fence, never a second dispatch.
|
|
198
|
+
const workThreadIdOk = value => typeof value === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(value)
|
|
199
|
+
const workThreadStores = new Map()
|
|
200
|
+
const workThreadStoreFor = (workThreadId, botId) => {
|
|
201
|
+
if (!workThreadIdOk(workThreadId)) fail('INVALID_WORK_THREAD_ID')
|
|
202
|
+
if (!workThreadIdOk(botId)) fail('INVALID_BOT_ID')
|
|
203
|
+
const key = createHash('sha256').update(JSON.stringify([botId, workThreadId])).digest('hex')
|
|
204
|
+
let store = workThreadStores.get(key)
|
|
205
|
+
if (!store) { store = openBotSessionStore({ dir: join(dir, 'work-threads', key), now }); workThreadStores.set(key, store) }
|
|
206
|
+
return store
|
|
207
|
+
}
|
|
208
|
+
// The loop addresses one transcript by botId; a work-thread keeps the target's identity
|
|
209
|
+
// for provider/guard but its own store key, so this view pins the key and ignores the arg.
|
|
210
|
+
const workThreadStoreView = (store, workThreadId) => Object.freeze({
|
|
211
|
+
get: () => store.get(workThreadId),
|
|
212
|
+
append: (_botId, messages, options) => store.append(workThreadId, messages, options),
|
|
213
|
+
})
|
|
214
|
+
// A work-thread turn re-decides the committed runtime and Bot per checkpoint, but never
|
|
215
|
+
// the default conversation's context reset: the work-thread has its own linear store.
|
|
216
|
+
const workThreadGuard = botId => { assertOpen(); if (guard) guard.assertMode(botId, 'independent'); if (assertTurn) assertTurn(botId) }
|
|
217
|
+
const captureTeamOrigin = ({ botId, conversationId = null, workThreadId = null } = {}) => {
|
|
218
|
+
workThreadGuard(botId)
|
|
219
|
+
// workThreadId is supplied only by the Host's durable assignment lookup.
|
|
220
|
+
if (conversationId?.startsWith('teamwt-')) {
|
|
221
|
+
if (!workThreadId) fail('TEAM_ORIGIN_UNCONFIRMED')
|
|
222
|
+
return { mode: 'independent', conversationId, workThreadId, contextId: workThreadStoreFor(workThreadId, botId).get(workThreadId).contextId }
|
|
223
|
+
}
|
|
224
|
+
const current = store.get(botId)
|
|
225
|
+
if (current.contextReset || conversationId && conversationId !== current.contextId) fail('TEAM_ORIGIN_RETIRED')
|
|
226
|
+
return { mode: 'independent', conversationId: current.contextId, contextId: current.contextId, workThreadId: null }
|
|
227
|
+
}
|
|
228
|
+
const teamOriginCurrent = (botId, origin) => {
|
|
229
|
+
try { workThreadGuard(botId); const current = origin.workThreadId ? workThreadStoreFor(origin.workThreadId, botId).get(origin.workThreadId) : store.get(botId); return !current.contextReset && current.contextId === origin.contextId }
|
|
230
|
+
catch { return false }
|
|
231
|
+
}
|
|
232
|
+
const runTeamTurn = async ({ botId, workThreadId = null, input, requestId, signal, conversationId, consult = false, validate = () => {}, origin = null }) => {
|
|
233
|
+
assertOpen()
|
|
234
|
+
// Validate all identifiers and open the transcript before acquiring admission.
|
|
235
|
+
const transcript = workThreadId ? workThreadStoreFor(workThreadId, botId) : store
|
|
236
|
+
const key = workThreadId ?? botId
|
|
237
|
+
botRequestReceipt(transcript.get(key), requestId)
|
|
238
|
+
const checkpoint = () => { workThreadGuard(botId); validate(); if (origin && !teamOriginCurrent(botId, origin)) fail('TEAM_ORIGIN_RETIRED') }
|
|
239
|
+
checkpoint()
|
|
240
|
+
const lease = guard.acquire(botId), scope = controlled(signal)
|
|
241
|
+
try {
|
|
242
|
+
const provider = providerFor(botId)
|
|
243
|
+
if (typeof provider !== 'function') fail('RUNTIME_PROVIDER_UNAVAILABLE')
|
|
244
|
+
const guarded = request => { checkpoint(); return provider({ ...request, botId, conversationId }) }
|
|
245
|
+
const loop = createBotLoop({ store: workThreadId ? workThreadStoreView(transcript, workThreadId) : transcript, provider: guarded,
|
|
246
|
+
tools: consult ? null : toolServiceFor(botId), guard: checkpoint, now, ...(maxSteps ? { maxSteps } : {}) })
|
|
247
|
+
return await loop.run({ botId, input, requestId, conversationId, signal: scope.controller.signal })
|
|
248
|
+
} finally { scope.release(); lease.release() }
|
|
249
|
+
}
|
|
250
|
+
|
|
64
251
|
return Object.freeze({
|
|
65
252
|
/** Serve one turn on the committed runtime. */
|
|
66
|
-
turn
|
|
253
|
+
async turn(request = {}) {
|
|
254
|
+
assertOpen()
|
|
255
|
+
if (store.get(request.botId).contextReset) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
|
|
256
|
+
const scope = controlled(request.signal)
|
|
257
|
+
try { return await dispatch.turn({ ...request, signal: scope.controller.signal }) }
|
|
258
|
+
finally { scope.release() }
|
|
259
|
+
},
|
|
260
|
+
/** Trusted Host context token; never a Harness/session directory identity. */
|
|
261
|
+
contextOf: botId => store.contextOf(botId),
|
|
262
|
+
newConversation: (botId, options) => reset(botId, options),
|
|
263
|
+
/** Explicit Host evidence recovery only. Owner requests cannot supply evidence. */
|
|
264
|
+
reconcileConversation: (botId, options) => reset(botId, options, true),
|
|
67
265
|
/** Which runtime a Bot is on. */
|
|
68
266
|
describe: botId => dispatch.describe(botId),
|
|
69
267
|
/** Move a Bot between runtimes; refused while it is busy. */
|
|
70
268
|
move: async (botId, mode) => {
|
|
269
|
+
assertOpen()
|
|
270
|
+
if (store.get(botId).contextReset) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
|
|
71
271
|
const before = store.list([botId])[0] ?? null
|
|
72
272
|
const result = await switchApi.move(botId, mode, {
|
|
73
273
|
isBusy,
|
|
274
|
+
// The synchronous re-check at the commit boundary: the await above can lose the race to
|
|
275
|
+
// a turn this Host admitted while it was in flight.
|
|
276
|
+
...(guard ? { assertIdle: () => guard.assertIdle(botId) } : {}),
|
|
74
277
|
snapshot: before ? { turns: before.turns, messages: before.messages, at: now() } : null,
|
|
75
278
|
})
|
|
76
279
|
// A moved Bot must not keep a loop remembering an older provider or grant.
|
|
@@ -79,6 +282,37 @@ export function createIndependentRuntime({
|
|
|
79
282
|
},
|
|
80
283
|
/** Which Bots were moved, in the switch's own words. */
|
|
81
284
|
list: () => switchApi.list(),
|
|
285
|
+
/** Which Bots this Host is serving right now, by either path. */
|
|
286
|
+
busyBots: () => (guard && typeof guard.busyBots === 'function' ? guard.busyBots() : []),
|
|
287
|
+
captureTeamOrigin,
|
|
288
|
+
teamOriginCurrent,
|
|
289
|
+
async resumeTeamTurn({ botId, origin, requestId, input, signal, validate } = {}) {
|
|
290
|
+
if (!origin || !teamOriginCurrent(botId, origin)) return { started: false, retired: true }
|
|
291
|
+
const result = await runTeamTurn({ botId, origin, workThreadId: origin.workThreadId, conversationId: origin.conversationId, requestId, input, signal, validate })
|
|
292
|
+
return { ...result, resumed: true }
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Run ONE real turn in a work-thread's own transcript, under the Bot's single-writer
|
|
297
|
+
* lease and its own provider/tools/grants. The trusted per-work-thread `conversationId`
|
|
298
|
+
* is injected into the provider request so the target's policy frame and native identity
|
|
299
|
+
* carry it; nothing else about the caller crosses the boundary. This always dispatches —
|
|
300
|
+
* dedupe, replay and unknown-fencing are the caller's (the assignment journal's) job.
|
|
301
|
+
*/
|
|
302
|
+
workThreadTurn: request => runTeamTurn(request),
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Reconcile a work-thread's last exchange from its durable transcript: whether a brief
|
|
306
|
+
* was written and whether a confirmed model answer follows it. A brief with no confirmed
|
|
307
|
+
* answer is a fence (the turn may have run), never a re-dispatch; an empty transcript is
|
|
308
|
+
* a request that was never written. Bodies never leave the runtime beyond this bounded
|
|
309
|
+
* answer string the assignment journal stores.
|
|
310
|
+
*/
|
|
311
|
+
workThreadRead(botId, workThreadId, requestId) {
|
|
312
|
+
assertOpen()
|
|
313
|
+
const transcript = workThreadStoreFor(workThreadId, botId)
|
|
314
|
+
return { ...botRequestReceipt(transcript.get(workThreadId), requestId), turns: transcript.get(workThreadId).turns }
|
|
315
|
+
},
|
|
82
316
|
|
|
83
317
|
/**
|
|
84
318
|
* A bounded, redacted view of one Bot transcript: role, time, size and a short preview.
|
|
@@ -92,6 +326,7 @@ export function createIndependentRuntime({
|
|
|
92
326
|
const body = String(message.content ?? '')
|
|
93
327
|
return {
|
|
94
328
|
role: message.role,
|
|
329
|
+
contextId: message.contextId,
|
|
95
330
|
at: message.at ?? null,
|
|
96
331
|
chars: body.length,
|
|
97
332
|
preview: chars === 0 ? '' : body.slice(0, chars),
|
|
@@ -110,10 +345,16 @@ export function createIndependentRuntime({
|
|
|
110
345
|
const message = messages[messages.length - 1 - index]
|
|
111
346
|
return {
|
|
112
347
|
role: message.role,
|
|
348
|
+
contextId: message.contextId,
|
|
113
349
|
at: message.at ?? null,
|
|
114
350
|
chars: String(message.content ?? '').length,
|
|
115
351
|
content: String(message.content ?? ''),
|
|
116
352
|
...(message.name ? { name: message.name } : {}),
|
|
353
|
+
...(message.toolCall ? { toolCall: message.toolCall } : {}),
|
|
354
|
+
...(message.toolCalls ? { toolCalls: message.toolCalls } : {}),
|
|
355
|
+
...(message.toolCallId ? { toolCallId: message.toolCallId } : {}),
|
|
356
|
+
...(message.modelSource ? { modelSource: message.modelSource } : {}),
|
|
357
|
+
...(message.messageId ? { messageId: message.messageId } : {}),
|
|
117
358
|
}
|
|
118
359
|
},
|
|
119
360
|
|
|
@@ -123,6 +364,12 @@ export function createIndependentRuntime({
|
|
|
123
364
|
return rows.map(row => ({ ...row, ...dispatch.describe(row.botId) }))
|
|
124
365
|
},
|
|
125
366
|
/** Drop cached loops when the plugin unloads. */
|
|
126
|
-
close() {
|
|
367
|
+
close() {
|
|
368
|
+
if (closed) return
|
|
369
|
+
closed = true
|
|
370
|
+
for (const controller of controllers) controller.abort(Object.assign(new Error('INDEPENDENT_RUNTIME_CLOSED'), { code: 'BOT_LOOP_CANCELLED' }))
|
|
371
|
+
loops.clear()
|
|
372
|
+
workThreadStores.clear()
|
|
373
|
+
},
|
|
127
374
|
})
|
|
128
375
|
}
|
|
@@ -52,11 +52,17 @@ export function openRuntimeSwitch({ file, now = Date.now } = {}) {
|
|
|
52
52
|
},
|
|
53
53
|
/**
|
|
54
54
|
* Move one Bot to a runtime, atomically.
|
|
55
|
+
*
|
|
56
|
+
* `isBusy` may be asynchronous — a Host that has to ask a live runtime about a running turn
|
|
57
|
+
* can. `assertIdle` is the one that protects the commit: it is synchronous and it stands
|
|
58
|
+
* directly in front of the write, because the await above can lose the race to a turn that
|
|
59
|
+
* started while it was in flight.
|
|
60
|
+
*
|
|
55
61
|
* @param {string} botId
|
|
56
62
|
* @param {'integration'|'independent'} mode
|
|
57
|
-
* @param {{ isBusy?: (botId: string) => boolean | Promise<boolean
|
|
63
|
+
* @param {{ isBusy?: (botId: string) => boolean | Promise<boolean>, assertIdle?: (botId: string) => boolean, snapshot?: object }} [options]
|
|
58
64
|
*/
|
|
59
|
-
async move(botId, mode, { isBusy, snapshot = null } = {}) {
|
|
65
|
+
async move(botId, mode, { isBusy, assertIdle, snapshot = null } = {}) {
|
|
60
66
|
if (!idOk(botId)) fail('INVALID_BOT_ID')
|
|
61
67
|
if (!RUNTIME_MODES.includes(mode)) fail('INVALID_RUNTIME_MODE')
|
|
62
68
|
const busy = typeof isBusy === 'function' ? await isBusy(botId) : false
|
|
@@ -65,6 +71,11 @@ export function openRuntimeSwitch({ file, now = Date.now } = {}) {
|
|
|
65
71
|
const state = read()
|
|
66
72
|
const current = state.bots[botId]?.mode ?? 'integration'
|
|
67
73
|
if (current === mode) return { botId, mode, changed: false }
|
|
74
|
+
// Nothing may await between this check and the write below, or a turn admitted during the
|
|
75
|
+
// check above would be served by two runtimes at once.
|
|
76
|
+
if (typeof assertIdle === 'function' && assertIdle(botId) === false) {
|
|
77
|
+
fail('BOT_BUSY', '这个 Bot 正在一轮对话中,等它空闲后再切换运行时。')
|
|
78
|
+
}
|
|
68
79
|
// A snapshot makes a move auditable afterwards: counts only, never message bodies.
|
|
69
80
|
state.bots[botId] = { mode, changedAt: now(), ...(snapshot ? { snapshot } : {}) }
|
|
70
81
|
write(state)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one Host-side admission a Bot turn runs under.
|
|
3
|
+
*
|
|
4
|
+
* A Bot is served by exactly one runtime, and a Bot that is answering is not a Bot that may be
|
|
5
|
+
* moved. Both paths therefore share this module: the integrated Harness stream and the
|
|
6
|
+
* independent loop take a lease from here, and a move is only committed while this module says
|
|
7
|
+
* the Bot is idle.
|
|
8
|
+
*
|
|
9
|
+
* This is an admission, not a second authority. It grants no model, no tool and no money: the
|
|
10
|
+
* committed Bot policy still decides what a call may do, and the one accounting stream still
|
|
11
|
+
* records what it cost. What it does own is the single-writer question — "is this Bot already
|
|
12
|
+
* being served, and by which runtime" — and it answers it from committed state, never from
|
|
13
|
+
* anything a request carries.
|
|
14
|
+
*
|
|
15
|
+
* Two rules are load-bearing:
|
|
16
|
+
*
|
|
17
|
+
* 1. The committed mode is read back through a trusted reader. A switch that cannot be read, or
|
|
18
|
+
* that answers with something that is not a mode, is never treated as `integration`: an
|
|
19
|
+
* unreadable decision refuses the call instead of assuming the default path.
|
|
20
|
+
* 2. `assertIdle` is synchronous and is called immediately before the switch writes. The check
|
|
21
|
+
* before it may await; nothing between the last check and the commit may, or a turn that
|
|
22
|
+
* started in the meantime would be served by two runtimes.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const RUNTIME_MODES = Object.freeze(['integration', 'independent'])
|
|
26
|
+
const idOk = value => typeof value === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(value)
|
|
27
|
+
const fail = (code, message) => { const error = new Error(message ?? code); error.code = code; throw error }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {{
|
|
31
|
+
* modeOf?: ((botId: string) => string | null) | null,
|
|
32
|
+
* rt?: object | null,
|
|
33
|
+
* now?: () => number,
|
|
34
|
+
* }} options
|
|
35
|
+
*/
|
|
36
|
+
export function createBotServing({ modeOf = null, rt = null, now = Date.now } = {}) {
|
|
37
|
+
if (modeOf !== null && typeof modeOf !== 'function') throw new TypeError('BOT_SERVING_MODE_READER_INVALID')
|
|
38
|
+
// One lease per Bot, counted so a nested call inside a live turn cannot deadlock on itself.
|
|
39
|
+
const leases = new Map()
|
|
40
|
+
// The admission is published before the Bot runtime exists — the product publishes it, then
|
|
41
|
+
// composes the runtime — so the runtime is bound to it afterwards. Generation ownership means a
|
|
42
|
+
// stale release (a disposed runtime) can never clear a newer binding, and a newer binding can
|
|
43
|
+
// never leave the old one's probe running.
|
|
44
|
+
let runtime = rt ?? null
|
|
45
|
+
let generation = 0
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The committed mode, or null when it cannot be established. `null` is a refusal, never a
|
|
49
|
+
* default: a Host that installed no switch serves every Bot on the integration path, and that
|
|
50
|
+
* is the only case where this answers without asking.
|
|
51
|
+
*/
|
|
52
|
+
const readMode = botId => {
|
|
53
|
+
if (typeof modeOf !== 'function') return 'integration'
|
|
54
|
+
let value
|
|
55
|
+
try { value = modeOf(botId) } catch { return null }
|
|
56
|
+
return RUNTIME_MODES.includes(value) ? value : null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Serve on `expected`, or refuse: another runtime owns this Bot, or nobody can say. */
|
|
60
|
+
const assertMode = (botId, expected) => {
|
|
61
|
+
if (!idOk(botId)) fail('INVALID_BOT_ID')
|
|
62
|
+
const committed = readMode(botId)
|
|
63
|
+
if (committed === null) {
|
|
64
|
+
fail('SERVING_MODE_UNKNOWN', `无法确认 ${botId} 的已提交运行时,本次请求被拒绝。`)
|
|
65
|
+
}
|
|
66
|
+
if (committed !== expected) {
|
|
67
|
+
fail('RUNTIME_MODE_MISMATCH', `运行时已切换:${botId} 当前由 ${committed} 提供服务,本次 ${expected} 请求被拒绝。`)
|
|
68
|
+
}
|
|
69
|
+
return committed
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Bots the frozen runtime is working on right now, before any model chunk: a turn that has
|
|
74
|
+
* started, and one whose next step is queued. Counting only the stream would leave the whole
|
|
75
|
+
* front of a turn — the prompt, the tools, the queue — invisible to a move.
|
|
76
|
+
*/
|
|
77
|
+
const nativeBusyBots = () => {
|
|
78
|
+
const busy = new Set()
|
|
79
|
+
const live = runtime?.live
|
|
80
|
+
if (!live || typeof live.entries !== 'function') return busy
|
|
81
|
+
for (const [sessionId, entry] of live.entries()) {
|
|
82
|
+
if (entry?.turn === undefined && entry?.pending === undefined) continue
|
|
83
|
+
const botId = runtime.selfOf?.(sessionId) ?? runtime.groupOwners?.get?.(sessionId)?.botId ?? null
|
|
84
|
+
if (typeof botId === 'string') busy.add(botId)
|
|
85
|
+
}
|
|
86
|
+
return busy
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Take the serving lease for one whole turn or stream. The returned handle releases exactly
|
|
91
|
+
* once, so a completed, cancelled, failed or bypassed turn leaves nothing behind and a Bot can
|
|
92
|
+
* never be stuck permanently busy.
|
|
93
|
+
*/
|
|
94
|
+
const enter = botId => {
|
|
95
|
+
if (!idOk(botId)) fail('INVALID_BOT_ID')
|
|
96
|
+
const lease = leases.get(botId) ?? { count: 0, since: now() }
|
|
97
|
+
lease.count += 1
|
|
98
|
+
leases.set(botId, lease)
|
|
99
|
+
let released = false
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
botId,
|
|
102
|
+
release() {
|
|
103
|
+
if (released) return
|
|
104
|
+
released = true
|
|
105
|
+
const current = leases.get(botId)
|
|
106
|
+
if (!current) return
|
|
107
|
+
current.count -= 1
|
|
108
|
+
if (current.count <= 0) leases.delete(botId)
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Take the only lease for one whole turn: a Bot that is already being served is refused. This
|
|
115
|
+
* is the admission the independent path uses, because two of its turns would write two
|
|
116
|
+
* transcripts over one Bot.
|
|
117
|
+
*/
|
|
118
|
+
const acquire = botId => {
|
|
119
|
+
if (!idOk(botId)) fail('INVALID_BOT_ID')
|
|
120
|
+
if (busy(botId)) fail('BOT_BUSY', '这个 Bot 正在一轮对话中,等它空闲后再切换运行时。')
|
|
121
|
+
return enter(botId)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const busy = botId => (leases.get(botId)?.count ?? 0) > 0 || nativeBusyBots().has(botId)
|
|
125
|
+
|
|
126
|
+
/** Every Bot being served right now, by either path. */
|
|
127
|
+
const busyBots = () => [...new Set([...leases.keys(), ...nativeBusyBots()])].sort()
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The move-time admission: true when the Bot is idle, a refusal when it is not. Synchronous by
|
|
131
|
+
* contract, because it stands directly in front of the switch's write.
|
|
132
|
+
*/
|
|
133
|
+
const assertIdle = botId => {
|
|
134
|
+
if (!idOk(botId)) fail('INVALID_BOT_ID')
|
|
135
|
+
if (busy(botId)) fail('BOT_BUSY', '这个 Bot 正在一轮对话中,等它空闲后再切换运行时。')
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Bind the runtime whose live turn record this admission reads. The returned release clears the
|
|
141
|
+
* binding only while it is still the current one, so disposing a runtime cannot blind a newer
|
|
142
|
+
* one — and a runtime that is never disposed keeps its probe until it is replaced.
|
|
143
|
+
*/
|
|
144
|
+
const bindRuntime = next => {
|
|
145
|
+
if (!next || typeof next !== 'object') throw new TypeError('BOT_SERVING_RUNTIME_REQUIRED')
|
|
146
|
+
generation += 1
|
|
147
|
+
const mine = generation
|
|
148
|
+
runtime = next
|
|
149
|
+
return () => { if (mine === generation) { generation += 1; runtime = null } }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return Object.freeze({
|
|
153
|
+
readMode,
|
|
154
|
+
assertMode,
|
|
155
|
+
enter,
|
|
156
|
+
acquire,
|
|
157
|
+
busy,
|
|
158
|
+
busyBots,
|
|
159
|
+
assertIdle,
|
|
160
|
+
bindRuntime,
|
|
161
|
+
/** Held leases, for a bounded diagnostic: ids and counts, never content. */
|
|
162
|
+
leases: () => [...leases.entries()].map(([botId, lease]) => ({ botId, count: lease.count, since: lease.since })).sort((left, right) => left.botId.localeCompare(right.botId)),
|
|
163
|
+
})
|
|
164
|
+
}
|