@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Add Host-selected families to the API loop without inventing a Harness Agent.
|
|
2
|
+
* Native CLI routes retain their private, typed MCP doors. Families are resolved
|
|
3
|
+
* again per catalog/call, and never supply model or funding authority.
|
|
4
|
+
*/
|
|
5
|
+
const fail = (code, message = code) => { throw Object.assign(new Error(message), { code }) }
|
|
6
|
+
|
|
7
|
+
export function extendIndependentToolHost({ base, familiesFor, validateCall = () => {} } = {}) {
|
|
8
|
+
if (typeof base?.forBot !== 'function' || typeof familiesFor !== 'function') throw new TypeError('INDEPENDENT_FAMILIES_HOST_REQUIRED')
|
|
9
|
+
let closed = false
|
|
10
|
+
const lifetime = new AbortController()
|
|
11
|
+
const alive = () => { if (closed) fail('TOOL_HOST_CLOSED', '宿主工具服务已关闭,本次调用被拒绝。') }
|
|
12
|
+
return Object.freeze({ ...base,
|
|
13
|
+
forBot(botId, options = {}) {
|
|
14
|
+
const view = base.forBot(botId, options)
|
|
15
|
+
// The native adapter has its own schema/parser/renderers and must never
|
|
16
|
+
// convert a CLI-observed call into an SDK executable call.
|
|
17
|
+
if (options.nativeRoute) return view
|
|
18
|
+
const offered = context => {
|
|
19
|
+
alive()
|
|
20
|
+
const entries = familiesFor(botId, context) ?? []
|
|
21
|
+
if (!Array.isArray(entries)) fail('INDEPENDENT_FAMILIES_INVALID')
|
|
22
|
+
const names = new Set(view.schemas().map(entry => entry.name))
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const name = entry.handler?.definition?.name
|
|
25
|
+
if (typeof name !== 'string' || !entry.handler.definition.inputSchema
|
|
26
|
+
|| typeof entry.handler.parse !== 'function' || typeof entry.handler.execute !== 'function'
|
|
27
|
+
|| names.has(name)) fail('INDEPENDENT_FAMILIES_INVALID')
|
|
28
|
+
names.add(name)
|
|
29
|
+
}
|
|
30
|
+
return entries
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze({ ...view,
|
|
33
|
+
schemas: () => [...view.schemas(), ...offered().map(({ handler }) => ({ name: handler.definition.name,
|
|
34
|
+
description: handler.definition.description, parameters: handler.definition.inputSchema }))],
|
|
35
|
+
isReadonly: name => {
|
|
36
|
+
const entry = offered().find(row => row.handler.definition.name === name)
|
|
37
|
+
return entry ? entry.readonly === true : view.isReadonly(name)
|
|
38
|
+
},
|
|
39
|
+
async call(name, args = {}, context = {}) {
|
|
40
|
+
alive()
|
|
41
|
+
if (context.botId !== undefined && context.botId !== botId) fail('SUBJECT_MISMATCH')
|
|
42
|
+
await validateCall(botId, context)
|
|
43
|
+
alive(); context.signal?.throwIfAborted()
|
|
44
|
+
const entry = offered(context).find(row => row.handler.definition.name === name)
|
|
45
|
+
if (!entry) {
|
|
46
|
+
const value = await view.call(name, args, context)
|
|
47
|
+
alive(); context.signal?.throwIfAborted()
|
|
48
|
+
await validateCall(botId, context)
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
if (typeof context.turnId !== 'string' || !context.turnId || typeof context.callId !== 'string' || !context.callId || !context.signal) fail('INDEPENDENT_TOOL_TURN_REQUIRED')
|
|
52
|
+
const signal = AbortSignal.any([lifetime.signal, context.signal])
|
|
53
|
+
signal.throwIfAborted()
|
|
54
|
+
const parsed = entry.handler.parse(args)
|
|
55
|
+
await entry.validate?.()
|
|
56
|
+
const value = await entry.handler.execute(parsed, { context: { turnId: context.turnId, callId: context.callId,
|
|
57
|
+
...(typeof context.conversationId === 'string' ? { conversationId: context.conversationId } : {}) }, signal })
|
|
58
|
+
alive(); signal.throwIfAborted()
|
|
59
|
+
await entry.validate?.()
|
|
60
|
+
await validateCall(botId, context)
|
|
61
|
+
alive(); signal.throwIfAborted()
|
|
62
|
+
return value
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
},
|
|
66
|
+
close() {
|
|
67
|
+
if (closed) return
|
|
68
|
+
closed = true
|
|
69
|
+
lifetime.abort(new Error('TOOL_HOST_CLOSED'))
|
|
70
|
+
base.close?.()
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Host's own tool service for one independent Bot.
|
|
3
|
+
*
|
|
4
|
+
* The independent loop has a transcript and a cancel signal; it has no machine, no task journal and
|
|
5
|
+
* no authority. This module is the seam the Host composes once and publishes, and it is the only
|
|
6
|
+
* way an independent turn reaches the outside world:
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* ctx.provide('botIndependentToolHost', createIndependentToolHost({
|
|
10
|
+
* directory, machines: () => machines, taskService: () => taskService,
|
|
11
|
+
* backends: () => taskBackends ? taskBackends.catalog().map(entry => entry.id) : [], now,
|
|
12
|
+
* }))
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `forBot(botId)` answers with a live view of that Bot: `schemas()` is re-decided from committed
|
|
16
|
+
* grants on every call, `isReadonly()` says whether a call may run beside another, and `call()`
|
|
17
|
+
* re-decides everything again before a single byte leaves the process.
|
|
18
|
+
*
|
|
19
|
+
* Four rules are load-bearing:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Identity is Host-owned.** The machine, the root, the task id, the attempt id, the operation
|
|
22
|
+
* id and the request key are derived from the Bot's committed binding and the turn identity the
|
|
23
|
+
* loop hands over. A reply may name one of its own bound machines or roots, and may reference an
|
|
24
|
+
* operation or task the Host already issued; it can never mint one.
|
|
25
|
+
* 2. **A write is a durable Task or it does not happen.** `bot_fs_write` and `bot_command` go
|
|
26
|
+
* through the existing task service — submit, attempt, operation, journal — with a request key
|
|
27
|
+
* derived from the turn and the call, so a repeated call re-reads the same task instead of
|
|
28
|
+
* dispatching a second one.
|
|
29
|
+
* 3. **An unknown outcome is reconciled, never replayed.** When the backend cannot prove what
|
|
30
|
+
* happened the call throws with the task identity attached; nothing here submits a replacement.
|
|
31
|
+
* 4. **The cancel signal reaches the machine and the task wait.** A cancel that arrives before
|
|
32
|
+
* dispatch produces zero machine calls; one that arrives during leaves the remote operation its
|
|
33
|
+
* own identity and its own stop evidence.
|
|
34
|
+
*
|
|
35
|
+
* Disposal is part of the contract: `close()` aborts this service's own waits and drops its
|
|
36
|
+
* registries, and refuses further calls. It never claims a remote operation stopped — the durable
|
|
37
|
+
* task keeps its identity and the Host reconciles it. A Host that published this service should call
|
|
38
|
+
* `close()` from its own dispose effect, beside closing the task service and the machine directory.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
42
|
+
import { catalogFor, independentToolEntry } from './independent-tool-catalog.mjs'
|
|
43
|
+
|
|
44
|
+
const fail = (code, message, extra) => { throw Object.assign(new Error(message ?? code), { code, ...(extra ?? {}) }) }
|
|
45
|
+
const ref = value => typeof value === 'string' && /^[-A-Za-z0-9_.]{1,128}$/.test(value)
|
|
46
|
+
const key = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
|
|
47
|
+
const plain = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
48
|
+
const cancelled = () => Object.assign(new Error('这一轮已被取消。'), { code: 'BOT_LOOP_CANCELLED' })
|
|
49
|
+
const closedError = taskId => Object.assign(new Error('宿主工具服务已关闭,本次等待结束;远端任务保持自己的身份,由宿主对账。'), { code: 'TOOL_HOST_CLOSED', taskId })
|
|
50
|
+
|
|
51
|
+
/** Task states that will not change without a Host decision. */
|
|
52
|
+
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'blocked', 'skipped', 'needs-reconciliation', 'waiting-approval'])
|
|
53
|
+
/** Task states the loop may keep waiting on. */
|
|
54
|
+
const PENDING = new Set(['ready', 'admitting', 'running', 'cancelling', 'waiting-budget'])
|
|
55
|
+
|
|
56
|
+
/** How many issued operation/task ids one Bot may reference. Older ones simply stop being citable. */
|
|
57
|
+
const ISSUED_LIMIT = 512
|
|
58
|
+
/**
|
|
59
|
+
* A stable, Host-owned reference from a turn identity and one call id. Both come from the loop, not
|
|
60
|
+
* from the model, and neither is ever echoed back into the request as authority.
|
|
61
|
+
*/
|
|
62
|
+
const reference = (turnId, callId) => createHash('sha256').update(`${String(turnId ?? '')}/${String(callId ?? '')}`).digest('hex').slice(0, 32)
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {{
|
|
66
|
+
* directory: { forBot: (botId: string) => unknown },
|
|
67
|
+
* authority?: { assertTool: (subject: unknown, name: string) => void } | null,
|
|
68
|
+
* machines: object | (() => object | null),
|
|
69
|
+
* taskService?: object | (() => object | null) | null,
|
|
70
|
+
* backends?: string[] | (() => string[]),
|
|
71
|
+
* supportsToolsFor?: (botId: string) => boolean,
|
|
72
|
+
* now?: () => number,
|
|
73
|
+
* pollMs?: number,
|
|
74
|
+
* waitMs?: number,
|
|
75
|
+
* issuedLimit?: number,
|
|
76
|
+
* }} options
|
|
77
|
+
*/
|
|
78
|
+
export function createIndependentToolHost({ directory, authority = null, machines, taskService = null, backends = [], supportsToolsFor = () => true, now = Date.now, pollMs = 50, waitMs = 120_000, issuedLimit = ISSUED_LIMIT } = {}) {
|
|
79
|
+
if (!directory || typeof directory.forBot !== 'function') throw new TypeError('INDEPENDENT_TOOL_HOST_REQUIRES_DIRECTORY')
|
|
80
|
+
if (machines === undefined || machines === null) throw new TypeError('INDEPENDENT_TOOL_HOST_REQUIRES_MACHINES')
|
|
81
|
+
if (typeof supportsToolsFor !== 'function') throw new TypeError('INDEPENDENT_TOOL_HOST_REQUIRES_MODEL_CAPABILITIES')
|
|
82
|
+
if (!Number.isSafeInteger(pollMs) || pollMs < 5 || pollMs > 1000) throw new TypeError('INVALID_TOOL_HOST_POLL')
|
|
83
|
+
if (!Number.isSafeInteger(waitMs) || waitMs < 1000 || waitMs > 3_600_000) throw new TypeError('INVALID_TOOL_HOST_WAIT')
|
|
84
|
+
if (!Number.isSafeInteger(issuedLimit) || issuedLimit < 1 || issuedLimit > 100_000) throw new TypeError('INVALID_TOOL_HOST_ISSUED_LIMIT')
|
|
85
|
+
const machineStore = () => {
|
|
86
|
+
const value = typeof machines === 'function' ? machines() : machines
|
|
87
|
+
if (!value || typeof value.call !== 'function') fail('MACHINE_DIRECTORY_UNAVAILABLE')
|
|
88
|
+
return value
|
|
89
|
+
}
|
|
90
|
+
const tasks = () => {
|
|
91
|
+
const value = typeof taskService === 'function' ? taskService() : taskService
|
|
92
|
+
return value && typeof value.submit === 'function' ? value : null
|
|
93
|
+
}
|
|
94
|
+
const backendIds = () => {
|
|
95
|
+
const value = typeof backends === 'function' ? backends() : backends
|
|
96
|
+
const ids = Array.isArray(value) ? value.filter(id => typeof id === 'string') : []
|
|
97
|
+
return tasks() ? [...ids, 'task-service'] : ids
|
|
98
|
+
}
|
|
99
|
+
// Operations and tasks this Host issued, per Bot. A reply may only reference one of these; it
|
|
100
|
+
// cannot ask about an operation it never received. The registry is bounded, and it is the only
|
|
101
|
+
// state this service keeps.
|
|
102
|
+
const issued = new Map()
|
|
103
|
+
const waits = new Set()
|
|
104
|
+
let closed = false
|
|
105
|
+
const remember = (botId, id) => {
|
|
106
|
+
const set = issued.get(botId) ?? new Set()
|
|
107
|
+
set.add(id)
|
|
108
|
+
while (set.size > issuedLimit) set.delete(set.values().next().value)
|
|
109
|
+
issued.set(botId, set)
|
|
110
|
+
}
|
|
111
|
+
const known = (botId, id) => Boolean(issued.get(botId)?.has(id))
|
|
112
|
+
const alive = () => { if (closed) fail('TOOL_HOST_CLOSED', '宿主工具服务已关闭,本次调用被拒绝。') }
|
|
113
|
+
/** Every wait this service is sitting in, so a close can end them without a stop claim. */
|
|
114
|
+
const track = waiter => { waits.add(waiter); return () => waits.delete(waiter) }
|
|
115
|
+
|
|
116
|
+
/** Live committed bindings for one Bot, straight from the machine directory. */
|
|
117
|
+
const scopesFor = botId => {
|
|
118
|
+
const store = machineStore()
|
|
119
|
+
const rows = typeof store.scopes === 'function' ? store.scopes(botId) : []
|
|
120
|
+
return rows.filter(scope => scope && ref(scope.machineId) && Array.isArray(scope.tools) && Array.isArray(scope.roots))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether this Bot's authority still holds a capability.
|
|
125
|
+
*
|
|
126
|
+
* The catalog is not allowed to offer a tool the authority would refuse, so a grant that was
|
|
127
|
+
* withdrawn takes the tool out of the request instead of leaving the model to discover the refusal
|
|
128
|
+
* one call at a time. A Host that publishes no authority keeps the machine binding as its only
|
|
129
|
+
* gate, and the call itself is still re-checked there.
|
|
130
|
+
*/
|
|
131
|
+
const grantedFor = botId => capability => {
|
|
132
|
+
if (!authority || typeof authority.assertTool !== 'function') return true
|
|
133
|
+
try { authority.assertTool(directory.forBot(botId), capability); return true } catch { return false }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The machine and root this call runs on, re-decided now.
|
|
138
|
+
*
|
|
139
|
+
* A reply may name one of its own live bindings and one of that binding's roots; anything else is
|
|
140
|
+
* refused before the machine directory is touched.
|
|
141
|
+
*/
|
|
142
|
+
const targetFor = (botId, entry, args) => {
|
|
143
|
+
const scopes = scopesFor(botId)
|
|
144
|
+
const wanted = args.machineId
|
|
145
|
+
if (wanted !== undefined && (typeof wanted !== 'string' || !scopes.some(scope => scope.machineId === wanted))) fail('MACHINE_NOT_BOUND')
|
|
146
|
+
const scope = wanted ? scopes.find(item => item.machineId === wanted) : scopes[0]
|
|
147
|
+
if (!scope) fail('MACHINE_NOT_BOUND')
|
|
148
|
+
if (!scope.tools.includes(entry.machineTool)) fail('MACHINE_BOT_DENIED', `该 Bot 未获授 ${entry.machineTool}。`)
|
|
149
|
+
if (entry.effect === 'workspace-write' && scope.mode !== 'workspace-write') fail('MACHINE_BOT_DENIED', '该授权是只读的。')
|
|
150
|
+
const rootWanted = args.rootId
|
|
151
|
+
if (rootWanted !== undefined && (typeof rootWanted !== 'string' || !scope.roots.includes(rootWanted))) fail('MACHINE_ROOT_NOT_BOUND')
|
|
152
|
+
const rootId = rootWanted ?? scope.roots[0]
|
|
153
|
+
if (typeof rootId !== 'string' || !rootId) fail('MACHINE_ROOT_NOT_BOUND')
|
|
154
|
+
return { machineId: scope.machineId, rootId }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The business arguments: identity keys are stripped before they can reach the machine. */
|
|
158
|
+
const businessOf = args => {
|
|
159
|
+
const calls = { ...args }
|
|
160
|
+
for (const name of ['machineId', 'rootId', 'botId', 'taskId', 'attemptId', 'operationId', 'endpoint', 'secretRef', 'grantToken', 'executable', 'env']) delete calls[name]
|
|
161
|
+
return calls
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const waitForTask = async (botId, taskId, signal) => {
|
|
165
|
+
const service = tasks()
|
|
166
|
+
const deadline = now() + waitMs
|
|
167
|
+
for (;;) {
|
|
168
|
+
alive()
|
|
169
|
+
signal?.throwIfAborted()
|
|
170
|
+
const task = service.get(directory.forBot(botId), { taskId })
|
|
171
|
+
if (!task) fail('TASK_NOT_FOUND', '任务记录不存在。', { taskId })
|
|
172
|
+
if (TERMINAL.has(task.state)) return task
|
|
173
|
+
if (!PENDING.has(task.state)) fail('TASK_STATE_UNREADABLE', `任务状态无法读取:${task.state}`, { taskId, state: task.state })
|
|
174
|
+
if (now() >= deadline) fail('TASK_WAIT_TIMEOUT', '等待任务结束时超时。', { taskId, state: task.state })
|
|
175
|
+
await new Promise((resolve, reject) => {
|
|
176
|
+
let untrack = () => {}
|
|
177
|
+
const stop = why => { untrack(); clearTimeout(timer); signal?.removeEventListener('abort', onAbort); reject(why) }
|
|
178
|
+
const onAbort = () => stop(cancelled())
|
|
179
|
+
// A close ends this wait locally; it says nothing about whether the remote task stopped.
|
|
180
|
+
untrack = track(() => stop(closedError(taskId)))
|
|
181
|
+
const timer = setTimeout(() => { untrack(); resolve() }, pollMs)
|
|
182
|
+
if (signal?.aborted) return stop(cancelled())
|
|
183
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const runMachineTool = (botId, entry, args, context) => {
|
|
188
|
+
const subject = directory.forBot(botId)
|
|
189
|
+
const target = targetFor(botId, entry, args)
|
|
190
|
+
const taskId = `bot-${reference(context.turnId, botId)}`
|
|
191
|
+
const attemptId = `attempt-${reference(context.callId, entry.name)}`
|
|
192
|
+
const calls = businessOf(args)
|
|
193
|
+
// The machine layer addresses its own workspace by root, and it requires the two spellings to
|
|
194
|
+
// agree; the root is the one the Host just chose, never one the reply named.
|
|
195
|
+
calls.rootId = target.rootId
|
|
196
|
+
// An observation carries its own operation id as Host metadata, and the machine layer requires
|
|
197
|
+
// the two spellings to agree.
|
|
198
|
+
const observation = entry.machineTool === 'operation_status' || entry.machineTool === 'operation_cancel'
|
|
199
|
+
if (observation) calls.operationId = args.operationId
|
|
200
|
+
return machineStore().call(subject, {
|
|
201
|
+
machineId: target.machineId, rootId: target.rootId, taskId, attemptId, tool: entry.machineTool, args: calls,
|
|
202
|
+
...(observation ? { operationId: args.operationId } : {}),
|
|
203
|
+
}, { ...(context.signal ? { signal: context.signal } : {}) })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const runTaskTool = async (botId, entry, args, context) => {
|
|
207
|
+
const service = tasks()
|
|
208
|
+
const subject = directory.forBot(botId)
|
|
209
|
+
const target = targetFor(botId, entry, args)
|
|
210
|
+
// The request key is the Host's own correlation of this turn and this call: a repeated call
|
|
211
|
+
// reads the same task instead of dispatching a second one.
|
|
212
|
+
const requestKey = `${botId}-${reference(context.turnId, context.callId)}`
|
|
213
|
+
const input = { machineId: target.machineId, rootId: target.rootId, ...businessOf(args) }
|
|
214
|
+
let submitted
|
|
215
|
+
try {
|
|
216
|
+
submitted = service.submit(subject, { requestKey, backend: entry.backend, input })
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error?.code === 'TASK_BACKEND_UNAVAILABLE') fail('TASK_BACKEND_UNAVAILABLE', '宿主未注册该持久任务后端。', { capability: entry.name })
|
|
219
|
+
throw error
|
|
220
|
+
}
|
|
221
|
+
const taskId = submitted?.taskId
|
|
222
|
+
if (!key(taskId)) fail('TASK_SUBMIT_UNREADABLE', '任务提交没有返回可读身份。')
|
|
223
|
+
remember(botId, taskId)
|
|
224
|
+
const cancel = () => { try { service.cancel(subject, { taskId }) } catch { /* the journal retains its current outcome */ } }
|
|
225
|
+
context.signal?.addEventListener('abort', cancel, { once: true })
|
|
226
|
+
let task
|
|
227
|
+
try {
|
|
228
|
+
if (context.signal?.aborted) cancel()
|
|
229
|
+
task = await waitForTask(botId, taskId, context.signal)
|
|
230
|
+
} finally { context.signal?.removeEventListener('abort', cancel) }
|
|
231
|
+
if (task.state !== 'succeeded') {
|
|
232
|
+
// Unknown stays unknown: the task keeps its identity and its evidence, and the caller is told
|
|
233
|
+
// which task to reconcile. Nothing here submits a replacement.
|
|
234
|
+
const code = task.state === 'needs-reconciliation' ? 'TASK_OUTCOME_UNKNOWN' : `TASK_${String(task.state).toUpperCase()}`
|
|
235
|
+
fail(code, `持久任务未成功完成:${task.state}`, { taskId, attemptId: task.attemptId, operationId: task.operationId, state: task.state })
|
|
236
|
+
}
|
|
237
|
+
return { taskId, attemptId: task.attemptId, operationId: task.operationId, result: task.result ?? null }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const observeTool = (botId, entry, args, context) => {
|
|
241
|
+
if (entry.machineTool === 'operation_status' || entry.machineTool === 'operation_cancel') {
|
|
242
|
+
const operationId = args.operationId
|
|
243
|
+
if (!ref(operationId)) fail('INVALID_TOOL_ARGS', 'operationId 缺失或格式不正确。')
|
|
244
|
+
// Only an operation this Host issued for this Bot may be observed or stopped.
|
|
245
|
+
if (!known(botId, operationId)) fail('OPERATION_NOT_ISSUED', '该 operationId 不是宿主发给本 Bot 的。')
|
|
246
|
+
}
|
|
247
|
+
if (entry.machineTool === 'artifact_stat' || entry.machineTool === 'artifact_read') {
|
|
248
|
+
if (!/^[a-f0-9]{64}$/.test(String(args.artifactId ?? ''))) fail('INVALID_TOOL_ARGS', 'artifactId 缺失或格式不正确。')
|
|
249
|
+
}
|
|
250
|
+
const result = runMachineTool(botId, entry, args, context)
|
|
251
|
+
if (typeof result?.operationId === 'string') remember(botId, result.operationId)
|
|
252
|
+
return result
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const readTask = async (botId, args, context) => {
|
|
256
|
+
const service = tasks()
|
|
257
|
+
const taskId = args.taskId
|
|
258
|
+
if (!key(taskId)) fail('INVALID_TOOL_ARGS', 'taskId 缺失或格式不正确。')
|
|
259
|
+
if (!known(botId, taskId)) fail('TASK_NOT_ISSUED', '该 taskId 不是宿主发给本 Bot 的。')
|
|
260
|
+
context.signal?.throwIfAborted()
|
|
261
|
+
return service.get(directory.forBot(botId), { taskId })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const cancelTask = async (botId, args, context) => {
|
|
265
|
+
const service = tasks()
|
|
266
|
+
const taskId = args.taskId
|
|
267
|
+
if (!key(taskId)) fail('INVALID_TOOL_ARGS', 'taskId 缺失或格式不正确。')
|
|
268
|
+
if (!known(botId, taskId)) fail('TASK_NOT_ISSUED', '该 taskId 不是宿主发给本 Bot 的。')
|
|
269
|
+
const outcome = service.cancel(directory.forBot(botId), { taskId })
|
|
270
|
+
// A cancel is a request, not a confirmation: the attempt keeps its own identity and the Host
|
|
271
|
+
// reconciles it. The loop reports the request, never a stop proof it does not have.
|
|
272
|
+
return { taskId, cancelRequested: true, state: outcome?.state ?? null }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return Object.freeze({
|
|
276
|
+
/**
|
|
277
|
+
* One Bot's live tool surface.
|
|
278
|
+
*
|
|
279
|
+
* The returned object is cheap and stateless: every method re-reads committed grants, so a
|
|
280
|
+
* cached loop can never freeze an older grant into a turn.
|
|
281
|
+
*/
|
|
282
|
+
forBot(botId, { nativeRoute = null } = {}) {
|
|
283
|
+
if (typeof botId !== 'string' || !botId) throw new TypeError('INDEPENDENT_TOOL_HOST_REQUIRES_BOT')
|
|
284
|
+
const supported = () => supportsToolsFor(botId, nativeRoute) === true
|
|
285
|
+
const view = Object.freeze({
|
|
286
|
+
botId,
|
|
287
|
+
/** The schemas this Bot may see right now. */
|
|
288
|
+
schemas() {
|
|
289
|
+
if(!supported())return []
|
|
290
|
+
return catalogFor({ scopes: scopesFor(botId), backends: backendIds(), granted: grantedFor(botId) }).tools
|
|
291
|
+
},
|
|
292
|
+
/** What this Host cannot run for this Bot, and why. */
|
|
293
|
+
unavailable() {
|
|
294
|
+
const catalog=catalogFor({ scopes: scopesFor(botId), backends: backendIds(), granted: grantedFor(botId) })
|
|
295
|
+
return supported()?catalog.unavailable:[...catalog.unavailable,...catalog.tools.map(tool=>({name:tool.name,code:'MODEL_TOOLS_UNSUPPORTED'}))]
|
|
296
|
+
},
|
|
297
|
+
/** Which machines this Bot's own live bindings cover. */
|
|
298
|
+
machines() {
|
|
299
|
+
return catalogFor({ scopes: scopesFor(botId), backends: backendIds(), granted: grantedFor(botId) }).machines
|
|
300
|
+
},
|
|
301
|
+
isReadonly(name) {
|
|
302
|
+
const entry = independentToolEntry(name)
|
|
303
|
+
return Boolean(entry) && entry.effect === 'read'
|
|
304
|
+
},
|
|
305
|
+
async call(name, args = {}, context = {}) {
|
|
306
|
+
alive()
|
|
307
|
+
if(!supported())fail('MODEL_TOOLS_UNSUPPORTED','当前对话引擎不支持工具调用;Bot 的工具权限仍保留。')
|
|
308
|
+
const entry = independentToolEntry(name)
|
|
309
|
+
// Offered-or-refused is decided here, not at schema time: a grant that changed between
|
|
310
|
+
// the request and the call stops the call.
|
|
311
|
+
if (!entry) fail('TOOL_NOT_GRANTED', `未获授的工具:${String(name)}`)
|
|
312
|
+
if (!plain(args)) fail('INVALID_TOOL_ARGS', '工具参数必须是一个普通对象。')
|
|
313
|
+
const granted = view.schemas().some(tool => tool.name === name)
|
|
314
|
+
if (!granted) fail('TOOL_NOT_GRANTED', `该 Bot 当前未获授 ${name}。`)
|
|
315
|
+
const turnId = context.turnId ?? randomUUID()
|
|
316
|
+
const callId = context.callId ?? randomUUID()
|
|
317
|
+
const scoped = { ...context, turnId, callId }
|
|
318
|
+
// Reading or stopping one of this Bot's own tasks is not a machine effect at all.
|
|
319
|
+
if (entry.backend === 'task-service') return entry.taskAction === 'cancel' ? cancelTask(botId, args, scoped) : readTask(botId, args, scoped)
|
|
320
|
+
if (entry.backend) return runTaskTool(botId, entry, args, scoped)
|
|
321
|
+
if (entry.machineTool === 'operation_status' || entry.machineTool === 'operation_cancel'
|
|
322
|
+
|| entry.machineTool === 'artifact_stat' || entry.machineTool === 'artifact_read') return observeTool(botId, entry, args, scoped)
|
|
323
|
+
return runMachineTool(botId, entry, args, scoped)
|
|
324
|
+
},
|
|
325
|
+
})
|
|
326
|
+
return view
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Dispose this service: end its own waits, drop its registries and refuse further calls.
|
|
331
|
+
*
|
|
332
|
+
* This is not a stop of anything remote. A task still in flight keeps its identity and its
|
|
333
|
+
* evidence in the journal, and the Host reconciles it; nothing here claims it stopped, and
|
|
334
|
+
* nothing here submits a replacement.
|
|
335
|
+
*/
|
|
336
|
+
close() {
|
|
337
|
+
if (closed) return
|
|
338
|
+
closed = true
|
|
339
|
+
for (const stop of [...waits]) stop()
|
|
340
|
+
waits.clear()
|
|
341
|
+
issued.clear()
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Main's full-control role comes from committed Host state. Worker checkbox
|
|
2
|
+
* grants do not narrow it. Services still own actual availability and accounting.
|
|
3
|
+
*/
|
|
4
|
+
export function compileMainBotGrant({ base, catalog, routes, toolNames = [], teamToolNames = [] }) {
|
|
5
|
+
return {
|
|
6
|
+
tools: [...new Set([
|
|
7
|
+
...(base.tools ?? []), 'model/infer', 'task/execute', 'task/submit', 'task/read', 'task/cancel', 'task/retry', 'task/reconcile',
|
|
8
|
+
...(catalog.capabilities ?? []).flatMap(item => item.tools ?? []),
|
|
9
|
+
...(catalog.tools ?? []).flatMap(item => item.grantTools ?? []),
|
|
10
|
+
...toolNames.map(name => `tool/${name}`), ...teamToolNames.map(name => `tool/${name}`),
|
|
11
|
+
'tool/bot_dispatch', 'tool/bot_handoff_task',
|
|
12
|
+
])].sort(),
|
|
13
|
+
modelRoutes: routes.map(route => ({ provider: route.provider, model: route.model })),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main Bot per-exact-route execution/funding profiles.
|
|
3
|
+
*
|
|
4
|
+
* A pure, I/O-free helper that lives beside the closed `bot_settings` schema.
|
|
5
|
+
* It stores an optional `settings.mainRoutes` collection (schema version 1) in
|
|
6
|
+
* the same `bot_settings_records` revision, coexisting with the existing
|
|
7
|
+
* default-model binding. It never builds a second funding, price or receipt
|
|
8
|
+
* ledger, never opens SQLite and never invents a catalog: every amount, token
|
|
9
|
+
* bound, mode and cross-channel rule is delegated to the Host's own legacy
|
|
10
|
+
* funding normalizer, which the parent injects so the old rules are not copied
|
|
11
|
+
* into a second place.
|
|
12
|
+
*
|
|
13
|
+
* Roles, accounts, credentials, endpoints and paths are never read here. The
|
|
14
|
+
* Host commits the Main role, the exact model existence, the funding scope and
|
|
15
|
+
* the price facts; this helper only normalizes the declared shape, assembles a
|
|
16
|
+
* complete legacy funding record from a profile plus the unified global limits,
|
|
17
|
+
* and refuses anything it cannot prove.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MAIN_ROUTES_SCHEMA = 1
|
|
21
|
+
const MAX_ROUTES = 64
|
|
22
|
+
const MAX_BYTES = 64 * 1024
|
|
23
|
+
|
|
24
|
+
// Legacy shapes, mirrored so this module stays free of a bot-settings import
|
|
25
|
+
// (and therefore free of the circular dependency the closed schema would add).
|
|
26
|
+
const plain = value => value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
27
|
+
const ref = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
|
|
28
|
+
const token = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/@+\-]{0,255}$/.test(value)
|
|
29
|
+
const clone = value => structuredClone(value)
|
|
30
|
+
const fail = (code, message, details = {}) => { throw Object.assign(new Error(message), { code, details }) }
|
|
31
|
+
const keys = (value, allowed, path) => {
|
|
32
|
+
if (!plain(value) || Object.keys(value).some(key => !allowed.includes(key))) fail('BOT_SETTINGS_INVALID', `${path} 含有未知字段或格式无效。`, { field: path })
|
|
33
|
+
}
|
|
34
|
+
const list = (value, limit, path) => {
|
|
35
|
+
if (!Array.isArray(value) || value.length > limit) fail('BOT_SETTINGS_INVALID', `${path} 列表格式无效或超过 ${limit} 项。`, { field: path })
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
// A binary tuple key, never a joined string: a model may itself contain "/", so
|
|
39
|
+
// `provider/model` would let `a/b`+`c` collide with `a`+`b/c`.
|
|
40
|
+
const routeKey = (provider, model) => `${provider}\u0000${model}`
|
|
41
|
+
|
|
42
|
+
const requireNormalizer = options => {
|
|
43
|
+
const normalizeFunding = options?.normalizeFunding
|
|
44
|
+
if (typeof normalizeFunding !== 'function') throw new TypeError('normalizeMainModelRoutes/materializeMainRouteDefault require an injected legacy funding normalizer { normalizeFunding }')
|
|
45
|
+
return normalizeFunding
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const normalizeApiLimit = (value, normalizeFunding) => {
|
|
49
|
+
if (value === null || value === undefined) return null
|
|
50
|
+
keys(value, ['dailyBudgetUsd', 'monthlyBudgetUsd'], 'mainRoutes.limits.api')
|
|
51
|
+
// Reuse the legacy amount bound and field checks instead of restating them.
|
|
52
|
+
const funding = normalizeFunding({ mode: 'api', scopeId: null, pricing: null, request: null, dailyBudgetUsd: value.dailyBudgetUsd, monthlyBudgetUsd: value.monthlyBudgetUsd, subscription: null })
|
|
53
|
+
// The group as a whole is either configured (both numbers, 0 included) or left
|
|
54
|
+
// null for "not configured"; a null inside a present group is never stored.
|
|
55
|
+
if (funding.dailyBudgetUsd === null || funding.monthlyBudgetUsd === null) fail('BOT_SETTINGS_INVALID', 'API 每日与每月预算必须同时填写;整组留空表示未配置,0 表示禁止付费。', { field: 'mainRoutes.limits.api' })
|
|
56
|
+
return { dailyBudgetUsd: funding.dailyBudgetUsd, monthlyBudgetUsd: funding.monthlyBudgetUsd }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const normalizeSubscriptionLimit = (value, normalizeFunding) => {
|
|
60
|
+
if (value === null || value === undefined) return null
|
|
61
|
+
keys(value, ['dailyCalls', 'monthlyCalls'], 'mainRoutes.limits.subscription')
|
|
62
|
+
// Reuse the legacy integer-call bound and field checks.
|
|
63
|
+
const funding = normalizeFunding({ mode: 'subscription', scopeId: null, pricing: null, request: null, dailyBudgetUsd: null, monthlyBudgetUsd: null, subscription: { dailyCalls: value.dailyCalls, monthlyCalls: value.monthlyCalls } })
|
|
64
|
+
return { dailyCalls: funding.subscription.dailyCalls, monthlyCalls: funding.subscription.monthlyCalls }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const normalizeRouteFunding = (value, normalizeFunding) => {
|
|
68
|
+
keys(value, ['mode', 'scopeId', 'pricing', 'request'], 'mainRoutes.routes.funding')
|
|
69
|
+
const mode = value.mode
|
|
70
|
+
if (mode !== 'api' && mode !== 'subscription') fail('BOT_SETTINGS_INVALID', '路线资金渠道必须是 api 或 subscription。', { field: 'mainRoutes.routes.funding.mode' })
|
|
71
|
+
// A subscription profile never carries API price or request bounds, and an API
|
|
72
|
+
// profile never carries a subscription group (an extra key is already refused
|
|
73
|
+
// by the closed field list). These are refused, not silently dropped.
|
|
74
|
+
if (mode === 'subscription' && ((value.pricing ?? null) !== null || (value.request ?? null) !== null)) fail('BOT_SETTINGS_INVALID', '订阅路线不能夹带 API 价格或请求上界;不会隐式切换渠道。', { field: 'mainRoutes.routes.funding' })
|
|
75
|
+
const funding = normalizeFunding(mode === 'api'
|
|
76
|
+
? { mode, scopeId: value.scopeId ?? null, pricing: value.pricing ?? null, request: value.request ?? null, dailyBudgetUsd: null, monthlyBudgetUsd: null, subscription: null }
|
|
77
|
+
: { mode, scopeId: value.scopeId ?? null, pricing: null, request: null, dailyBudgetUsd: null, monthlyBudgetUsd: null, subscription: null })
|
|
78
|
+
return { mode: funding.mode, scopeId: funding.scopeId, pricing: funding.pricing, request: funding.request }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const normalizeRoute = (value, normalizeFunding) => {
|
|
82
|
+
keys(value, ['provider', 'model', 'enabled', 'funding'], 'mainRoutes.routes')
|
|
83
|
+
if (!token(value.provider) || !token(value.model)) fail('BOT_SETTINGS_INVALID', '路线需要准确的 provider/model。', { field: 'mainRoutes.routes' })
|
|
84
|
+
if (typeof value.enabled !== 'boolean') fail('BOT_SETTINGS_INVALID', 'enabled 必须是布尔值。', { field: 'mainRoutes.routes.enabled' })
|
|
85
|
+
return { provider: value.provider, model: value.model, enabled: value.enabled, funding: normalizeRouteFunding(value.funding, normalizeFunding) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Normalize an optional `settings.mainRoutes` value. Returns `null` for the
|
|
90
|
+
* absent/null case (old records keep their shape). Otherwise returns the closed
|
|
91
|
+
* `{ schemaVersion, limits, routes }` collection: at most 64 routes, stably
|
|
92
|
+
* sorted by exact provider/model, no duplicate tuple, total JSON <= 64 KiB.
|
|
93
|
+
* Every amount, token bound, mode and cross-channel rule is checked by the
|
|
94
|
+
* injected legacy normalizer.
|
|
95
|
+
*/
|
|
96
|
+
export function normalizeMainModelRoutes(value, options = {}) {
|
|
97
|
+
const normalizeFunding = requireNormalizer(options)
|
|
98
|
+
if (value === null || value === undefined) return null
|
|
99
|
+
keys(value, ['schemaVersion', 'limits', 'routes'], 'mainRoutes')
|
|
100
|
+
if (value.schemaVersion !== MAIN_ROUTES_SCHEMA) fail('BOT_SETTINGS_VERSION_UNSUPPORTED', '多路线配置版本不受支持,请升级宿主。', { field: 'mainRoutes.schemaVersion' })
|
|
101
|
+
keys(value.limits, ['api', 'subscription'], 'mainRoutes.limits')
|
|
102
|
+
const limits = {
|
|
103
|
+
api: normalizeApiLimit(value.limits.api, normalizeFunding),
|
|
104
|
+
subscription: normalizeSubscriptionLimit(value.limits.subscription, normalizeFunding),
|
|
105
|
+
}
|
|
106
|
+
const routes = list(value.routes, MAX_ROUTES, 'mainRoutes.routes').map(route => normalizeRoute(route, normalizeFunding))
|
|
107
|
+
const seen = new Set()
|
|
108
|
+
for (const route of routes) {
|
|
109
|
+
const key = routeKey(route.provider, route.model)
|
|
110
|
+
if (seen.has(key)) fail('BOT_SETTINGS_DUPLICATE', `路线 ${route.provider}/${route.model} 重复。`, { field: 'mainRoutes.routes' })
|
|
111
|
+
seen.add(key)
|
|
112
|
+
}
|
|
113
|
+
routes.sort((a, b) => a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model))
|
|
114
|
+
const collection = { schemaVersion: MAIN_ROUTES_SCHEMA, limits, routes }
|
|
115
|
+
if (Buffer.byteLength(JSON.stringify(collection)) > MAX_BYTES) fail('BOT_SETTINGS_LIMIT', '多路线配置不能超过 64 KiB。', { field: 'mainRoutes' })
|
|
116
|
+
return collection
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The complete legacy funding record for one exact, enabled route, or `null`.
|
|
121
|
+
*
|
|
122
|
+
* `null` means the route is absent, disabled, or not ready: the required global
|
|
123
|
+
* limit group, the API price/request bounds, or the funding scope is missing.
|
|
124
|
+
* The result is a deep copy, so a caller cannot mutate the normalized profile.
|
|
125
|
+
*
|
|
126
|
+
* Every API route draws on the same global `limits.api` budget and every
|
|
127
|
+
* subscription route on the same global `limits.subscription` quota; no per-route
|
|
128
|
+
* counter is created, so the Host's aggregate ceilings keep applying across all
|
|
129
|
+
* routes. This helper does not run the ledger, so it cannot claim the aggregate
|
|
130
|
+
* was actually enforced.
|
|
131
|
+
*/
|
|
132
|
+
export function fundingForMainRoute(collection, target) {
|
|
133
|
+
if (!plain(collection) || !Array.isArray(collection.routes)) return null
|
|
134
|
+
const provider = target?.provider, model = target?.model
|
|
135
|
+
if (typeof provider !== 'string' || typeof model !== 'string') return null
|
|
136
|
+
const route = collection.routes.find(item => item.provider === provider && item.model === model)
|
|
137
|
+
if (!route || route.enabled !== true || !plain(route.funding)) return null
|
|
138
|
+
const scopeId = route.funding.scopeId
|
|
139
|
+
if (scopeId === null) return null
|
|
140
|
+
if (route.funding.mode === 'api') {
|
|
141
|
+
if (collection.limits.api === null || route.funding.pricing === null || route.funding.request === null) return null
|
|
142
|
+
return clone({ mode: 'api', scopeId, pricing: route.funding.pricing, request: route.funding.request,
|
|
143
|
+
dailyBudgetUsd: collection.limits.api.dailyBudgetUsd, monthlyBudgetUsd: collection.limits.api.monthlyBudgetUsd, subscription: null })
|
|
144
|
+
}
|
|
145
|
+
if (route.funding.mode !== 'subscription' || collection.limits.subscription === null) return null
|
|
146
|
+
return clone({ mode: 'subscription', scopeId, pricing: null, request: null, dailyBudgetUsd: null, monthlyBudgetUsd: null,
|
|
147
|
+
subscription: { dailyCalls: collection.limits.subscription.dailyCalls, monthlyCalls: collection.limits.subscription.monthlyCalls } })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Derive `settings.funding` from the default exact model's route plus the
|
|
152
|
+
* unified global limits, returning a clone.
|
|
153
|
+
*
|
|
154
|
+
* The default is the committed `settings.model`; a route is never silently
|
|
155
|
+
* chosen as "the first one". When `mainRoutes` is absent/null the clone is
|
|
156
|
+
* value-identical to the input (the legacy single-route case is untouched).
|
|
157
|
+
* When `mainRoutes` is present and the default route is ready, `settings.funding`
|
|
158
|
+
* is generated from that route plus the unified limits whether or not the Bot is
|
|
159
|
+
* enabled. When the default route is not ready the call is refused only if the
|
|
160
|
+
* settings are enabled; a disabled settings keeps its unfinished draft and never
|
|
161
|
+
* receives a fabricated available funding.
|
|
162
|
+
*/
|
|
163
|
+
export function materializeMainRouteDefault(settings, options = {}) {
|
|
164
|
+
if (!plain(settings)) throw new TypeError('materializeMainRouteDefault requires a plain settings object')
|
|
165
|
+
const next = clone(settings)
|
|
166
|
+
if (next.mainRoutes === null || next.mainRoutes === undefined) return next
|
|
167
|
+
const collection = normalizeMainModelRoutes(next.mainRoutes, options)
|
|
168
|
+
const model = plain(next.model) ? next.model : null
|
|
169
|
+
const funding = model ? fundingForMainRoute(collection, { provider: model.provider, model: model.model }) : null
|
|
170
|
+
if (funding !== null) next.funding = funding
|
|
171
|
+
else if (next.enabled === true) fail('MAIN_ROUTE_DEFAULT_UNAVAILABLE', '启用多路线前,默认模型必须对应一条已启用且已就绪的路线;不会自动选择第一条路线。', { field: 'mainRoutes' })
|
|
172
|
+
return next
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const routeState = settings => {
|
|
176
|
+
if (!plain(settings) || !Object.hasOwn(settings, 'mainRoutes') || settings.mainRoutes === undefined) return 'absent'
|
|
177
|
+
return settings.mainRoutes === null ? 'exit' : 'present'
|
|
178
|
+
}
|
|
179
|
+
// Key-order-independent deep serialization, so a re-serialized legacy funding
|
|
180
|
+
// still compares equal.
|
|
181
|
+
const canonical = value => {
|
|
182
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value === undefined ? null : value) ?? 'null'
|
|
183
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
184
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`
|
|
185
|
+
}
|
|
186
|
+
const identityUnchanged = (next, previous) => canonical(next.model) === canonical(previous.model) && canonical(next.funding) === canonical(previous.funding)
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Protect a stored multi-route collection from an older client that omits the
|
|
190
|
+
* field it does not know about.
|
|
191
|
+
*
|
|
192
|
+
* An explicit `mainRoutes` value (object or null) on `next` is always honored:
|
|
193
|
+
* `null` exits multi-route, an object is the new declaration. Only a missing
|
|
194
|
+
* key triggers protection: if `previous` carried a collection and neither the
|
|
195
|
+
* default model nor the funding changed, the stored collection is re-attached;
|
|
196
|
+
* if either changed the client must re-read, so the call is refused. A previous
|
|
197
|
+
* that had no collection leaves `next` untouched.
|
|
198
|
+
*/
|
|
199
|
+
export function preserveMainModelRoutes(next, previous) {
|
|
200
|
+
if (!plain(next)) throw new TypeError('preserveMainModelRoutes requires a plain next settings object')
|
|
201
|
+
const previousSafe = plain(previous) ? previous : {}
|
|
202
|
+
if (routeState(next) !== 'absent') return clone(next)
|
|
203
|
+
if (routeState(previousSafe) === 'present') {
|
|
204
|
+
if (identityUnchanged(next, previousSafe)) return { ...clone(next), mainRoutes: clone(previousSafe.mainRoutes) }
|
|
205
|
+
fail('MAIN_ROUTES_RELOAD_REQUIRED', '默认模型或资金已更改,请重新读取多路线配置后再保存。', { field: 'mainRoutes' })
|
|
206
|
+
}
|
|
207
|
+
return clone(next)
|
|
208
|
+
}
|