@nodetool-ai/websocket 0.7.0-rc.23 → 0.8.2
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/README.md +50 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/agent/run-node-tool.d.ts +39 -0
- package/dist/agent/run-node-tool.d.ts.map +1 -0
- package/dist/agent/run-node-tool.js +55 -0
- package/dist/agent/run-node-tool.js.map +1 -0
- package/dist/chat-prediction-forwarder.d.ts +13 -0
- package/dist/chat-prediction-forwarder.d.ts.map +1 -0
- package/dist/chat-prediction-forwarder.js +22 -0
- package/dist/chat-prediction-forwarder.js.map +1 -0
- package/dist/chat-tool-call-repair.d.ts +45 -0
- package/dist/chat-tool-call-repair.d.ts.map +1 -0
- package/dist/chat-tool-call-repair.js +100 -0
- package/dist/chat-tool-call-repair.js.map +1 -0
- package/dist/chat-turn-registry.d.ts +169 -0
- package/dist/chat-turn-registry.d.ts.map +1 -0
- package/dist/chat-turn-registry.js +326 -0
- package/dist/chat-turn-registry.js.map +1 -0
- package/dist/collection-api.d.ts +2 -2
- package/dist/collection-api.d.ts.map +1 -1
- package/dist/collection-api.js +82 -14
- package/dist/collection-api.js.map +1 -1
- package/dist/configured-providers.d.ts +52 -0
- package/dist/configured-providers.d.ts.map +1 -0
- package/dist/configured-providers.js +85 -0
- package/dist/configured-providers.js.map +1 -0
- package/dist/cors.d.ts +22 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +91 -0
- package/dist/cors.js.map +1 -0
- package/dist/credit-gate.d.ts +36 -0
- package/dist/credit-gate.d.ts.map +1 -0
- package/dist/credit-gate.js +76 -0
- package/dist/credit-gate.js.map +1 -0
- package/dist/custom-providers.d.ts +40 -0
- package/dist/custom-providers.d.ts.map +1 -0
- package/dist/custom-providers.js +134 -0
- package/dist/custom-providers.js.map +1 -0
- package/dist/debug-sessions.d.ts +10 -0
- package/dist/debug-sessions.d.ts.map +1 -0
- package/dist/debug-sessions.js +9 -0
- package/dist/debug-sessions.js.map +1 -0
- package/dist/drain.d.ts +34 -0
- package/dist/drain.d.ts.map +1 -0
- package/dist/drain.js +68 -0
- package/dist/drain.js.map +1 -0
- package/dist/e2e-server.d.ts +30 -0
- package/dist/e2e-server.d.ts.map +1 -0
- package/dist/e2e-server.js +111 -0
- package/dist/e2e-server.js.map +1 -0
- package/dist/error-traces.d.ts +56 -0
- package/dist/error-traces.d.ts.map +1 -0
- package/dist/error-traces.js +179 -0
- package/dist/error-traces.js.map +1 -0
- package/dist/example-workflows.d.ts +20 -0
- package/dist/example-workflows.d.ts.map +1 -0
- package/dist/example-workflows.js +123 -0
- package/dist/example-workflows.js.map +1 -0
- package/dist/external-mcp.d.ts +58 -0
- package/dist/external-mcp.d.ts.map +1 -0
- package/dist/external-mcp.js +288 -0
- package/dist/external-mcp.js.map +1 -0
- package/dist/fake-runtime.d.ts +114 -0
- package/dist/fake-runtime.d.ts.map +1 -0
- package/dist/fake-runtime.js +307 -0
- package/dist/fake-runtime.js.map +1 -0
- package/dist/file-api.d.ts +4 -7
- package/dist/file-api.d.ts.map +1 -1
- package/dist/file-api.js +122 -39
- package/dist/file-api.js.map +1 -1
- package/dist/frontend-renderer-registry.d.ts +75 -0
- package/dist/frontend-renderer-registry.d.ts.map +1 -0
- package/dist/frontend-renderer-registry.js +111 -0
- package/dist/frontend-renderer-registry.js.map +1 -0
- package/dist/generation-recovery.d.ts +13 -0
- package/dist/generation-recovery.d.ts.map +1 -0
- package/dist/generation-recovery.js +219 -0
- package/dist/generation-recovery.js.map +1 -0
- package/dist/headless-job-runner.d.ts +71 -0
- package/dist/headless-job-runner.d.ts.map +1 -0
- package/dist/headless-job-runner.js +198 -0
- package/dist/headless-job-runner.js.map +1 -0
- package/dist/http-api.d.ts +157 -24
- package/dist/http-api.d.ts.map +1 -1
- package/dist/http-api.js +628 -646
- package/dist/http-api.js.map +1 -1
- package/dist/http-body-schemas.d.ts +109 -0
- package/dist/http-body-schemas.d.ts.map +1 -0
- package/dist/http-body-schemas.js +146 -0
- package/dist/http-body-schemas.js.map +1 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/interrupted-jobs.d.ts +14 -0
- package/dist/interrupted-jobs.d.ts.map +1 -0
- package/dist/interrupted-jobs.js +28 -0
- package/dist/interrupted-jobs.js.map +1 -0
- package/dist/job-control.d.ts +57 -0
- package/dist/job-control.d.ts.map +1 -0
- package/dist/job-control.js +134 -0
- package/dist/job-control.js.map +1 -0
- package/dist/job-queue.d.ts +47 -0
- package/dist/job-queue.d.ts.map +1 -0
- package/dist/job-queue.js +52 -0
- package/dist/job-queue.js.map +1 -0
- package/dist/job-run-registry.d.ts +176 -0
- package/dist/job-run-registry.d.ts.map +1 -0
- package/dist/job-run-registry.js +312 -0
- package/dist/job-run-registry.js.map +1 -0
- package/dist/lib/app-build-service.d.ts +16 -0
- package/dist/lib/app-build-service.d.ts.map +1 -0
- package/dist/lib/app-build-service.js +39 -0
- package/dist/lib/app-build-service.js.map +1 -0
- package/dist/lib/app-debug-service.d.ts +16 -0
- package/dist/lib/app-debug-service.d.ts.map +1 -0
- package/dist/lib/app-debug-service.js +48 -0
- package/dist/lib/app-debug-service.js.map +1 -0
- package/dist/lib/app-deployment-service.d.ts +82 -0
- package/dist/lib/app-deployment-service.d.ts.map +1 -0
- package/dist/lib/app-deployment-service.js +282 -0
- package/dist/lib/app-deployment-service.js.map +1 -0
- package/dist/lib/app-instances-service.d.ts +1284 -0
- package/dist/lib/app-instances-service.d.ts.map +1 -0
- package/dist/lib/app-instances-service.js +378 -0
- package/dist/lib/app-instances-service.js.map +1 -0
- package/dist/lib/app-run-cancellation.d.ts +4 -0
- package/dist/lib/app-run-cancellation.d.ts.map +1 -0
- package/dist/lib/app-run-cancellation.js +28 -0
- package/dist/lib/app-run-cancellation.js.map +1 -0
- package/dist/lib/app-run-server.d.ts +7 -0
- package/dist/lib/app-run-server.d.ts.map +1 -0
- package/dist/lib/app-run-server.js +6 -0
- package/dist/lib/app-run-server.js.map +1 -0
- package/dist/lib/app-session-scope.d.ts +91 -0
- package/dist/lib/app-session-scope.d.ts.map +1 -0
- package/dist/lib/app-session-scope.js +91 -0
- package/dist/lib/app-session-scope.js.map +1 -0
- package/dist/lib/applications-service.d.ts +354 -0
- package/dist/lib/applications-service.d.ts.map +1 -0
- package/dist/lib/applications-service.js +507 -0
- package/dist/lib/applications-service.js.map +1 -0
- package/dist/lib/asset-duration.d.ts +7 -0
- package/dist/lib/asset-duration.d.ts.map +1 -0
- package/dist/lib/asset-duration.js +24 -0
- package/dist/lib/asset-duration.js.map +1 -0
- package/dist/lib/asset-export.d.ts +34 -0
- package/dist/lib/asset-export.d.ts.map +1 -0
- package/dist/lib/asset-export.js +113 -0
- package/dist/lib/asset-export.js.map +1 -0
- package/dist/lib/asset-model-interface.d.ts +41 -0
- package/dist/lib/asset-model-interface.d.ts.map +1 -0
- package/dist/lib/asset-model-interface.js +74 -0
- package/dist/lib/asset-model-interface.js.map +1 -0
- package/dist/lib/asset-paths.d.ts +49 -1
- package/dist/lib/asset-paths.d.ts.map +1 -1
- package/dist/lib/asset-paths.js +127 -31
- package/dist/lib/asset-paths.js.map +1 -1
- package/dist/lib/asset-response.d.ts +12 -0
- package/dist/lib/asset-response.d.ts.map +1 -0
- package/dist/lib/asset-response.js +89 -0
- package/dist/lib/asset-response.js.map +1 -0
- package/dist/lib/audio-peaks.d.ts +23 -0
- package/dist/lib/audio-peaks.d.ts.map +1 -0
- package/dist/lib/audio-peaks.js +335 -0
- package/dist/lib/audio-peaks.js.map +1 -0
- package/dist/lib/bridge.d.ts +9 -1
- package/dist/lib/bridge.d.ts.map +1 -1
- package/dist/lib/bridge.js +27 -4
- package/dist/lib/bridge.js.map +1 -1
- package/dist/lib/compression.d.ts +3 -0
- package/dist/lib/compression.d.ts.map +1 -0
- package/dist/lib/compression.js +3 -0
- package/dist/lib/compression.js.map +1 -0
- package/dist/lib/document-model-interfaces.d.ts +30 -0
- package/dist/lib/document-model-interfaces.d.ts.map +1 -0
- package/dist/lib/document-model-interfaces.js +296 -0
- package/dist/lib/document-model-interfaces.js.map +1 -0
- package/dist/lib/env-config.d.ts +9 -0
- package/dist/lib/env-config.d.ts.map +1 -0
- package/dist/lib/env-config.js +18 -0
- package/dist/lib/env-config.js.map +1 -0
- package/dist/lib/example-apps.d.ts +52 -0
- package/dist/lib/example-apps.d.ts.map +1 -0
- package/dist/lib/example-apps.js +192 -0
- package/dist/lib/example-apps.js.map +1 -0
- package/dist/lib/example-games.d.ts +5 -0
- package/dist/lib/example-games.d.ts.map +1 -0
- package/dist/lib/example-games.js +13 -0
- package/dist/lib/example-games.js.map +1 -0
- package/dist/lib/example-recipes.d.ts +33 -0
- package/dist/lib/example-recipes.d.ts.map +1 -0
- package/dist/lib/example-recipes.js +213 -0
- package/dist/lib/example-recipes.js.map +1 -0
- package/dist/lib/example-storyboards.d.ts +40 -0
- package/dist/lib/example-storyboards.d.ts.map +1 -0
- package/dist/lib/example-storyboards.js +131 -0
- package/dist/lib/example-storyboards.js.map +1 -0
- package/dist/lib/example-thumbnail.d.ts +21 -0
- package/dist/lib/example-thumbnail.d.ts.map +1 -0
- package/dist/lib/example-thumbnail.js +50 -0
- package/dist/lib/example-thumbnail.js.map +1 -0
- package/dist/lib/example-timelines.d.ts +9 -0
- package/dist/lib/example-timelines.d.ts.map +1 -0
- package/dist/lib/example-timelines.js +25 -0
- package/dist/lib/example-timelines.js.map +1 -0
- package/dist/lib/extension-dist.d.ts +24 -0
- package/dist/lib/extension-dist.d.ts.map +1 -0
- package/dist/lib/extension-dist.js +67 -0
- package/dist/lib/extension-dist.js.map +1 -0
- package/dist/lib/external-asset-lookup.d.ts +30 -0
- package/dist/lib/external-asset-lookup.d.ts.map +1 -0
- package/dist/lib/external-asset-lookup.js +99 -0
- package/dist/lib/external-asset-lookup.js.map +1 -0
- package/dist/lib/external-assets.d.ts +13 -0
- package/dist/lib/external-assets.d.ts.map +1 -0
- package/dist/lib/external-assets.js +46 -0
- package/dist/lib/external-assets.js.map +1 -0
- package/dist/lib/fly-replay.d.ts +34 -0
- package/dist/lib/fly-replay.d.ts.map +1 -0
- package/dist/lib/fly-replay.js +111 -0
- package/dist/lib/fly-replay.js.map +1 -0
- package/dist/lib/http-rate-limit.d.ts +32 -0
- package/dist/lib/http-rate-limit.d.ts.map +1 -0
- package/dist/lib/http-rate-limit.js +47 -0
- package/dist/lib/http-rate-limit.js.map +1 -0
- package/dist/lib/http-tracing.d.ts +12 -0
- package/dist/lib/http-tracing.d.ts.map +1 -0
- package/dist/lib/http-tracing.js +81 -0
- package/dist/lib/http-tracing.js.map +1 -0
- package/dist/lib/instance-id.d.ts +25 -0
- package/dist/lib/instance-id.d.ts.map +1 -0
- package/dist/lib/instance-id.js +44 -0
- package/dist/lib/instance-id.js.map +1 -0
- package/dist/lib/link-codes.d.ts +82 -0
- package/dist/lib/link-codes.d.ts.map +1 -0
- package/dist/lib/link-codes.js +100 -0
- package/dist/lib/link-codes.js.map +1 -0
- package/dist/lib/local-asset-upload.d.ts +5 -0
- package/dist/lib/local-asset-upload.d.ts.map +1 -0
- package/dist/lib/local-asset-upload.js +69 -0
- package/dist/lib/local-asset-upload.js.map +1 -0
- package/dist/lib/local-file-access.d.ts +31 -0
- package/dist/lib/local-file-access.d.ts.map +1 -0
- package/dist/lib/local-file-access.js +169 -0
- package/dist/lib/local-file-access.js.map +1 -0
- package/dist/lib/localhost-trust.d.ts +59 -0
- package/dist/lib/localhost-trust.d.ts.map +1 -0
- package/dist/lib/localhost-trust.js +139 -0
- package/dist/lib/localhost-trust.js.map +1 -0
- package/dist/lib/mcp-mount.d.ts +25 -0
- package/dist/lib/mcp-mount.d.ts.map +1 -0
- package/dist/lib/mcp-mount.js +32 -0
- package/dist/lib/mcp-mount.js.map +1 -0
- package/dist/lib/media-size.d.ts +21 -0
- package/dist/lib/media-size.d.ts.map +1 -0
- package/dist/lib/media-size.js +48 -0
- package/dist/lib/media-size.js.map +1 -0
- package/dist/lib/media.d.ts +33 -0
- package/dist/lib/media.d.ts.map +1 -0
- package/dist/lib/media.js +110 -0
- package/dist/lib/media.js.map +1 -0
- package/dist/lib/package-asset-export.d.ts +83 -0
- package/dist/lib/package-asset-export.d.ts.map +1 -0
- package/dist/lib/package-asset-export.js +212 -0
- package/dist/lib/package-asset-export.js.map +1 -0
- package/dist/lib/package-assets.d.ts +41 -0
- package/dist/lib/package-assets.d.ts.map +1 -0
- package/dist/lib/package-assets.js +155 -0
- package/dist/lib/package-assets.js.map +1 -0
- package/dist/lib/project-delete.d.ts +11 -0
- package/dist/lib/project-delete.d.ts.map +1 -0
- package/dist/lib/project-delete.js +67 -0
- package/dist/lib/project-delete.js.map +1 -0
- package/dist/lib/project-document-copy.d.ts +31 -0
- package/dist/lib/project-document-copy.d.ts.map +1 -0
- package/dist/lib/project-document-copy.js +473 -0
- package/dist/lib/project-document-copy.js.map +1 -0
- package/dist/lib/public-app-assets.d.ts +8 -0
- package/dist/lib/public-app-assets.d.ts.map +1 -0
- package/dist/lib/public-app-assets.js +77 -0
- package/dist/lib/public-app-assets.js.map +1 -0
- package/dist/lib/public-routes.d.ts +102 -0
- package/dist/lib/public-routes.d.ts.map +1 -0
- package/dist/lib/public-routes.js +168 -0
- package/dist/lib/public-routes.js.map +1 -0
- package/dist/lib/public-workflow-share.d.ts +20 -0
- package/dist/lib/public-workflow-share.d.ts.map +1 -0
- package/dist/lib/public-workflow-share.js +45 -0
- package/dist/lib/public-workflow-share.js.map +1 -0
- package/dist/lib/run-trace-live-delivery.d.ts +25 -0
- package/dist/lib/run-trace-live-delivery.d.ts.map +1 -0
- package/dist/lib/run-trace-live-delivery.js +91 -0
- package/dist/lib/run-trace-live-delivery.js.map +1 -0
- package/dist/lib/runtime-detection.d.ts +17 -0
- package/dist/lib/runtime-detection.d.ts.map +1 -0
- package/dist/lib/runtime-detection.js +90 -0
- package/dist/lib/runtime-detection.js.map +1 -0
- package/dist/lib/storage-access.d.ts +21 -0
- package/dist/lib/storage-access.d.ts.map +1 -0
- package/dist/lib/storage-access.js +74 -0
- package/dist/lib/storage-access.js.map +1 -0
- package/dist/lib/storage.d.ts +9 -0
- package/dist/lib/storage.d.ts.map +1 -0
- package/dist/lib/storage.js +28 -0
- package/dist/lib/storage.js.map +1 -0
- package/dist/lib/storyboard-export.d.ts +64 -0
- package/dist/lib/storyboard-export.d.ts.map +1 -0
- package/dist/lib/storyboard-export.js +183 -0
- package/dist/lib/storyboard-export.js.map +1 -0
- package/dist/lib/style-presets.d.ts +34 -0
- package/dist/lib/style-presets.d.ts.map +1 -0
- package/dist/lib/style-presets.js +62 -0
- package/dist/lib/style-presets.js.map +1 -0
- package/dist/lib/temp-url-resolver.d.ts +35 -0
- package/dist/lib/temp-url-resolver.d.ts.map +1 -0
- package/dist/lib/temp-url-resolver.js +88 -0
- package/dist/lib/temp-url-resolver.js.map +1 -0
- package/dist/lib/test-ui-run-routes.d.ts +8 -0
- package/dist/lib/test-ui-run-routes.d.ts.map +1 -0
- package/dist/lib/test-ui-run-routes.js +42 -0
- package/dist/lib/test-ui-run-routes.js.map +1 -0
- package/dist/lib/thumbnail.d.ts +74 -0
- package/dist/lib/thumbnail.d.ts.map +1 -0
- package/dist/lib/thumbnail.js +305 -0
- package/dist/lib/thumbnail.js.map +1 -0
- package/dist/lib/timeline-bundle.d.ts +1821 -0
- package/dist/lib/timeline-bundle.d.ts.map +1 -0
- package/dist/lib/timeline-bundle.js +342 -0
- package/dist/lib/timeline-bundle.js.map +1 -0
- package/dist/lib/video-proxy.d.ts +114 -0
- package/dist/lib/video-proxy.d.ts.map +1 -0
- package/dist/lib/video-proxy.js +546 -0
- package/dist/lib/video-proxy.js.map +1 -0
- package/dist/lib/wire-values.d.ts +31 -0
- package/dist/lib/wire-values.d.ts.map +1 -0
- package/dist/lib/wire-values.js +49 -0
- package/dist/lib/wire-values.js.map +1 -0
- package/dist/lib/workflow-bundle.d.ts +102 -0
- package/dist/lib/workflow-bundle.d.ts.map +1 -0
- package/dist/lib/workflow-bundle.js +306 -0
- package/dist/lib/workflow-bundle.js.map +1 -0
- package/dist/lib/workflow-service.d.ts +82 -0
- package/dist/lib/workflow-service.d.ts.map +1 -0
- package/dist/lib/workflow-service.js +197 -0
- package/dist/lib/workflow-service.js.map +1 -0
- package/dist/lib/workflow-workspace.d.ts +20 -0
- package/dist/lib/workflow-workspace.d.ts.map +1 -0
- package/dist/lib/workflow-workspace.js +65 -0
- package/dist/lib/workflow-workspace.js.map +1 -0
- package/dist/lib/ws-connection-health.d.ts +10 -0
- package/dist/lib/ws-connection-health.d.ts.map +1 -0
- package/dist/lib/ws-connection-health.js +38 -0
- package/dist/lib/ws-connection-health.js.map +1 -0
- package/dist/lib/ws-rate-limit.d.ts +22 -0
- package/dist/lib/ws-rate-limit.d.ts.map +1 -0
- package/dist/lib/ws-rate-limit.js +45 -0
- package/dist/lib/ws-rate-limit.js.map +1 -0
- package/dist/lib/ws-upgrade.d.ts +40 -0
- package/dist/lib/ws-upgrade.d.ts.map +1 -0
- package/dist/lib/ws-upgrade.js +66 -0
- package/dist/lib/ws-upgrade.js.map +1 -0
- package/dist/lib/zip-stream.d.ts +46 -0
- package/dist/lib/zip-stream.d.ts.map +1 -0
- package/dist/lib/zip-stream.js +140 -0
- package/dist/lib/zip-stream.js.map +1 -0
- package/dist/mcp-agent-tools.d.ts +56 -0
- package/dist/mcp-agent-tools.d.ts.map +1 -0
- package/dist/mcp-agent-tools.js +1147 -0
- package/dist/mcp-agent-tools.js.map +1 -0
- package/dist/mcp-client-config.d.ts +64 -0
- package/dist/mcp-client-config.d.ts.map +1 -0
- package/dist/mcp-client-config.js +353 -0
- package/dist/mcp-client-config.js.map +1 -0
- package/dist/mcp-server.d.ts +56 -7
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +255 -762
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tool-deps.d.ts +20 -0
- package/dist/mcp-tool-deps.d.ts.map +1 -0
- package/dist/mcp-tool-deps.js +61 -0
- package/dist/mcp-tool-deps.js.map +1 -0
- package/dist/messagepack.d.ts +7 -0
- package/dist/messagepack.d.ts.map +1 -0
- package/dist/messagepack.js +19 -0
- package/dist/messagepack.js.map +1 -0
- package/dist/model-download-runtime.d.ts +9 -0
- package/dist/model-download-runtime.d.ts.map +1 -0
- package/dist/model-download-runtime.js +77 -0
- package/dist/model-download-runtime.js.map +1 -0
- package/dist/model-execution-availability.d.ts +14 -0
- package/dist/model-execution-availability.d.ts.map +1 -0
- package/dist/model-execution-availability.js +161 -0
- package/dist/model-execution-availability.js.map +1 -0
- package/dist/models-api.d.ts +61 -5
- package/dist/models-api.d.ts.map +1 -1
- package/dist/models-api.js +182 -689
- package/dist/models-api.js.map +1 -1
- package/dist/node-registry-setup.d.ts +84 -0
- package/dist/node-registry-setup.d.ts.map +1 -0
- package/dist/node-registry-setup.js +163 -0
- package/dist/node-registry-setup.js.map +1 -0
- package/dist/oauth/cimd.d.ts +28 -0
- package/dist/oauth/cimd.d.ts.map +1 -0
- package/dist/oauth/cimd.js +177 -0
- package/dist/oauth/cimd.js.map +1 -0
- package/dist/oauth/gate.d.ts +38 -0
- package/dist/oauth/gate.d.ts.map +1 -0
- package/dist/oauth/gate.js +77 -0
- package/dist/oauth/gate.js.map +1 -0
- package/dist/oauth/metadata.d.ts +35 -0
- package/dist/oauth/metadata.d.ts.map +1 -0
- package/dist/oauth/metadata.js +56 -0
- package/dist/oauth/metadata.js.map +1 -0
- package/dist/oauth/pending-store.d.ts +79 -0
- package/dist/oauth/pending-store.d.ts.map +1 -0
- package/dist/oauth/pending-store.js +133 -0
- package/dist/oauth/pending-store.js.map +1 -0
- package/dist/oauth/pkce.d.ts +16 -0
- package/dist/oauth/pkce.d.ts.map +1 -0
- package/dist/oauth/pkce.js +29 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/validate.d.ts +25 -0
- package/dist/oauth/validate.d.ts.map +1 -0
- package/dist/oauth/validate.js +62 -0
- package/dist/oauth/validate.js.map +1 -0
- package/dist/oauth/www-authenticate.d.ts +9 -0
- package/dist/oauth/www-authenticate.d.ts.map +1 -0
- package/dist/oauth/www-authenticate.js +33 -0
- package/dist/oauth/www-authenticate.js.map +1 -0
- package/dist/oauth-api.d.ts +19 -0
- package/dist/oauth-api.d.ts.map +1 -1
- package/dist/oauth-api.js +531 -12
- package/dist/oauth-api.js.map +1 -1
- package/dist/openai-api.d.ts.map +1 -1
- package/dist/openai-api.js +4 -9
- package/dist/openai-api.js.map +1 -1
- package/dist/pack-snapshot.d.ts +24 -0
- package/dist/pack-snapshot.d.ts.map +1 -0
- package/dist/pack-snapshot.js +35 -0
- package/dist/pack-snapshot.js.map +1 -0
- package/dist/plugins/websocket.d.ts +21 -4
- package/dist/plugins/websocket.d.ts.map +1 -1
- package/dist/plugins/websocket.js +106 -297
- package/dist/plugins/websocket.js.map +1 -1
- package/dist/python-bridge-gate.d.ts +28 -0
- package/dist/python-bridge-gate.d.ts.map +1 -0
- package/dist/python-bridge-gate.js +36 -0
- package/dist/python-bridge-gate.js.map +1 -0
- package/dist/resolve-media-urls.d.ts +17 -3
- package/dist/resolve-media-urls.d.ts.map +1 -1
- package/dist/resolve-media-urls.js +117 -31
- package/dist/resolve-media-urls.js.map +1 -1
- package/dist/resource-events.d.ts +28 -0
- package/dist/resource-events.d.ts.map +1 -0
- package/dist/resource-events.js +20 -0
- package/dist/resource-events.js.map +1 -0
- package/dist/routes/account.d.ts +100 -0
- package/dist/routes/account.d.ts.map +1 -0
- package/dist/routes/account.js +197 -0
- package/dist/routes/account.js.map +1 -0
- package/dist/routes/app-runs.d.ts +4 -0
- package/dist/routes/app-runs.d.ts.map +1 -0
- package/dist/routes/app-runs.js +173 -0
- package/dist/routes/app-runs.js.map +1 -0
- package/dist/routes/applications.d.ts +36 -0
- package/dist/routes/applications.d.ts.map +1 -0
- package/dist/routes/applications.js +183 -0
- package/dist/routes/applications.js.map +1 -0
- package/dist/routes/assets.d.ts +3 -3
- package/dist/routes/assets.d.ts.map +1 -1
- package/dist/routes/assets.js +78 -44
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/atlascloud-webhook.d.ts +93 -0
- package/dist/routes/atlascloud-webhook.d.ts.map +1 -0
- package/dist/routes/atlascloud-webhook.js +369 -0
- package/dist/routes/atlascloud-webhook.js.map +1 -0
- package/dist/routes/collections.d.ts.map +1 -1
- package/dist/routes/config.d.ts +26 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +64 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/documents.d.ts +27 -0
- package/dist/routes/documents.d.ts.map +1 -0
- package/dist/routes/documents.js +155 -0
- package/dist/routes/documents.js.map +1 -0
- package/dist/routes/fal-credits.d.ts +4 -0
- package/dist/routes/fal-credits.d.ts.map +1 -0
- package/dist/routes/fal-credits.js +101 -0
- package/dist/routes/fal-credits.js.map +1 -0
- package/dist/routes/fal-pricing-estimate.d.ts +4 -0
- package/dist/routes/fal-pricing-estimate.d.ts.map +1 -0
- package/dist/routes/fal-pricing-estimate.js +114 -0
- package/dist/routes/fal-pricing-estimate.js.map +1 -0
- package/dist/routes/fal-pricing.d.ts +4 -0
- package/dist/routes/fal-pricing.d.ts.map +1 -0
- package/dist/routes/fal-pricing.js +93 -0
- package/dist/routes/fal-pricing.js.map +1 -0
- package/dist/routes/fal-webhook.d.ts +102 -0
- package/dist/routes/fal-webhook.d.ts.map +1 -0
- package/dist/routes/fal-webhook.js +379 -0
- package/dist/routes/fal-webhook.js.map +1 -0
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +4 -3
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/health.d.ts +1 -0
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +41 -7
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/integrations.d.ts +39 -0
- package/dist/routes/integrations.d.ts.map +1 -0
- package/dist/routes/integrations.js +211 -0
- package/dist/routes/integrations.js.map +1 -0
- package/dist/routes/js-scripts.d.ts +32 -0
- package/dist/routes/js-scripts.d.ts.map +1 -0
- package/dist/routes/js-scripts.js +498 -0
- package/dist/routes/js-scripts.js.map +1 -0
- package/dist/routes/kie-credits.d.ts +4 -0
- package/dist/routes/kie-credits.d.ts.map +1 -0
- package/dist/routes/kie-credits.js +93 -0
- package/dist/routes/kie-credits.js.map +1 -0
- package/dist/routes/kie-pricing.d.ts +4 -0
- package/dist/routes/kie-pricing.d.ts.map +1 -0
- package/dist/routes/kie-pricing.js +45 -0
- package/dist/routes/kie-pricing.js.map +1 -0
- package/dist/routes/kie-webhook.d.ts +4 -0
- package/dist/routes/kie-webhook.d.ts.map +1 -0
- package/dist/routes/kie-webhook.js +70 -0
- package/dist/routes/kie-webhook.js.map +1 -0
- package/dist/routes/nodes.d.ts.map +1 -1
- package/dist/routes/nodes.js +29 -51
- package/dist/routes/nodes.js.map +1 -1
- package/dist/routes/oauth-as.d.ts +30 -0
- package/dist/routes/oauth-as.d.ts.map +1 -0
- package/dist/routes/oauth-as.js +412 -0
- package/dist/routes/oauth-as.js.map +1 -0
- package/dist/routes/oauth.d.ts.map +1 -1
- package/dist/routes/openai.d.ts.map +1 -1
- package/dist/routes/public-apps.d.ts +27 -0
- package/dist/routes/public-apps.d.ts.map +1 -0
- package/dist/routes/public-apps.js +99 -0
- package/dist/routes/public-apps.js.map +1 -0
- package/dist/routes/public-shared-workflows.d.ts +16 -0
- package/dist/routes/public-shared-workflows.d.ts.map +1 -0
- package/dist/routes/public-shared-workflows.js +55 -0
- package/dist/routes/public-shared-workflows.js.map +1 -0
- package/dist/routes/run-spans.d.ts +4 -0
- package/dist/routes/run-spans.d.ts.map +1 -0
- package/dist/routes/run-spans.js +53 -0
- package/dist/routes/run-spans.js.map +1 -0
- package/dist/routes/sandbox-modules.d.ts +17 -0
- package/dist/routes/sandbox-modules.d.ts.map +1 -0
- package/dist/routes/sandbox-modules.js +85 -0
- package/dist/routes/sandbox-modules.js.map +1 -0
- package/dist/routes/sdk-v1.d.ts +24 -0
- package/dist/routes/sdk-v1.d.ts.map +1 -0
- package/dist/routes/sdk-v1.js +320 -0
- package/dist/routes/sdk-v1.js.map +1 -0
- package/dist/routes/storage.d.ts.map +1 -1
- package/dist/routes/storyboards.d.ts +16 -0
- package/dist/routes/storyboards.d.ts.map +1 -0
- package/dist/routes/storyboards.js +97 -0
- package/dist/routes/storyboards.js.map +1 -0
- package/dist/routes/timeline-animations.d.ts +23 -0
- package/dist/routes/timeline-animations.d.ts.map +1 -0
- package/dist/routes/timeline-animations.js +99 -0
- package/dist/routes/timeline-animations.js.map +1 -0
- package/dist/routes/timeline-audio-bake.d.ts +51 -0
- package/dist/routes/timeline-audio-bake.d.ts.map +1 -0
- package/dist/routes/timeline-audio-bake.js +110 -0
- package/dist/routes/timeline-audio-bake.js.map +1 -0
- package/dist/routes/timeline-fonts.d.ts +51 -0
- package/dist/routes/timeline-fonts.d.ts.map +1 -0
- package/dist/routes/timeline-fonts.js +195 -0
- package/dist/routes/timeline-fonts.js.map +1 -0
- package/dist/routes/timeline-isolate-subject.d.ts +55 -0
- package/dist/routes/timeline-isolate-subject.d.ts.map +1 -0
- package/dist/routes/timeline-isolate-subject.js +128 -0
- package/dist/routes/timeline-isolate-subject.js.map +1 -0
- package/dist/routes/timeline-track-object.d.ts +39 -0
- package/dist/routes/timeline-track-object.d.ts.map +1 -0
- package/dist/routes/timeline-track-object.js +97 -0
- package/dist/routes/timeline-track-object.js.map +1 -0
- package/dist/routes/timelines.d.ts +18 -0
- package/dist/routes/timelines.d.ts.map +1 -0
- package/dist/routes/timelines.js +191 -0
- package/dist/routes/timelines.js.map +1 -0
- package/dist/routes/workflows.d.ts +6 -9
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/routes/workflows.js +100 -11
- package/dist/routes/workflows.js.map +1 -1
- package/dist/routes/workspace.d.ts.map +1 -1
- package/dist/run-supervisor.d.ts +30 -0
- package/dist/run-supervisor.d.ts.map +1 -0
- package/dist/run-supervisor.js +86 -0
- package/dist/run-supervisor.js.map +1 -0
- package/dist/sandbox-catalog.d.ts +28 -0
- package/dist/sandbox-catalog.d.ts.map +1 -0
- package/dist/sandbox-catalog.js +74 -0
- package/dist/sandbox-catalog.js.map +1 -0
- package/dist/screenshot-projects.d.ts +27 -0
- package/dist/screenshot-projects.d.ts.map +1 -0
- package/dist/screenshot-projects.js +880 -0
- package/dist/screenshot-projects.js.map +1 -0
- package/dist/screenshot-server.js +950 -47
- package/dist/screenshot-server.js.map +1 -1
- package/dist/sdk/sdk-cached-model-inventory.d.ts +21 -0
- package/dist/sdk/sdk-cached-model-inventory.d.ts.map +1 -0
- package/dist/sdk/sdk-cached-model-inventory.js +51 -0
- package/dist/sdk/sdk-cached-model-inventory.js.map +1 -0
- package/dist/sdk/sdk-capabilities-service.d.ts +32 -0
- package/dist/sdk/sdk-capabilities-service.d.ts.map +1 -0
- package/dist/sdk/sdk-capabilities-service.js +47 -0
- package/dist/sdk/sdk-capabilities-service.js.map +1 -0
- package/dist/sdk/sdk-execution-readiness-probe.d.ts +19 -0
- package/dist/sdk/sdk-execution-readiness-probe.d.ts.map +1 -0
- package/dist/sdk/sdk-execution-readiness-probe.js +90 -0
- package/dist/sdk/sdk-execution-readiness-probe.js.map +1 -0
- package/dist/sdk/sdk-execution-target-readiness.d.ts +15 -0
- package/dist/sdk/sdk-execution-target-readiness.d.ts.map +1 -0
- package/dist/sdk/sdk-execution-target-readiness.js +50 -0
- package/dist/sdk/sdk-execution-target-readiness.js.map +1 -0
- package/dist/sdk/sdk-feature-flags.d.ts +18 -0
- package/dist/sdk/sdk-feature-flags.d.ts.map +1 -0
- package/dist/sdk/sdk-feature-flags.js +30 -0
- package/dist/sdk/sdk-feature-flags.js.map +1 -0
- package/dist/sdk/sdk-huggingface-download-state.d.ts +15 -0
- package/dist/sdk/sdk-huggingface-download-state.d.ts.map +1 -0
- package/dist/sdk/sdk-huggingface-download-state.js +25 -0
- package/dist/sdk/sdk-huggingface-download-state.js.map +1 -0
- package/dist/sdk/sdk-live-runner-registry.d.ts +21 -0
- package/dist/sdk/sdk-live-runner-registry.d.ts.map +1 -0
- package/dist/sdk/sdk-live-runner-registry.js +26 -0
- package/dist/sdk/sdk-live-runner-registry.js.map +1 -0
- package/dist/sdk/sdk-model-catalog-service.d.ts +20 -0
- package/dist/sdk/sdk-model-catalog-service.d.ts.map +1 -0
- package/dist/sdk/sdk-model-catalog-service.js +219 -0
- package/dist/sdk/sdk-model-catalog-service.js.map +1 -0
- package/dist/sdk/sdk-model-download-service.d.ts +39 -0
- package/dist/sdk/sdk-model-download-service.d.ts.map +1 -0
- package/dist/sdk/sdk-model-download-service.js +277 -0
- package/dist/sdk/sdk-model-download-service.js.map +1 -0
- package/dist/sdk/sdk-model-requirement-probe.d.ts +30 -0
- package/dist/sdk/sdk-model-requirement-probe.d.ts.map +1 -0
- package/dist/sdk/sdk-model-requirement-probe.js +120 -0
- package/dist/sdk/sdk-model-requirement-probe.js.map +1 -0
- package/dist/sdk/sdk-node-package-requirement-probe.d.ts +20 -0
- package/dist/sdk/sdk-node-package-requirement-probe.d.ts.map +1 -0
- package/dist/sdk/sdk-node-package-requirement-probe.js +44 -0
- package/dist/sdk/sdk-node-package-requirement-probe.js.map +1 -0
- package/dist/sdk/sdk-node-type-inventory-service.d.ts +11 -0
- package/dist/sdk/sdk-node-type-inventory-service.d.ts.map +1 -0
- package/dist/sdk/sdk-node-type-inventory-service.js +36 -0
- package/dist/sdk/sdk-node-type-inventory-service.js.map +1 -0
- package/dist/sdk/sdk-preflight-orchestrator.d.ts +54 -0
- package/dist/sdk/sdk-preflight-orchestrator.d.ts.map +1 -0
- package/dist/sdk/sdk-preflight-orchestrator.js +63 -0
- package/dist/sdk/sdk-preflight-orchestrator.js.map +1 -0
- package/dist/sdk/sdk-preflight-requirement-resolver.d.ts +35 -0
- package/dist/sdk/sdk-preflight-requirement-resolver.d.ts.map +1 -0
- package/dist/sdk/sdk-preflight-requirement-resolver.js +67 -0
- package/dist/sdk/sdk-preflight-requirement-resolver.js.map +1 -0
- package/dist/sdk/sdk-preflight-service.d.ts +61 -0
- package/dist/sdk/sdk-preflight-service.d.ts.map +1 -0
- package/dist/sdk/sdk-preflight-service.js +104 -0
- package/dist/sdk/sdk-preflight-service.js.map +1 -0
- package/dist/sdk/sdk-preflight-workflow-source.d.ts +18 -0
- package/dist/sdk/sdk-preflight-workflow-source.d.ts.map +1 -0
- package/dist/sdk/sdk-preflight-workflow-source.js +42 -0
- package/dist/sdk/sdk-preflight-workflow-source.js.map +1 -0
- package/dist/sdk/sdk-route-policy.d.ts +3 -0
- package/dist/sdk/sdk-route-policy.d.ts.map +1 -0
- package/dist/sdk/sdk-route-policy.js +18 -0
- package/dist/sdk/sdk-route-policy.js.map +1 -0
- package/dist/sdk/sdk-runtime-capabilities-service.d.ts +60 -0
- package/dist/sdk/sdk-runtime-capabilities-service.d.ts.map +1 -0
- package/dist/sdk/sdk-runtime-capabilities-service.js +30 -0
- package/dist/sdk/sdk-runtime-capabilities-service.js.map +1 -0
- package/dist/sdk/sdk-runtime-requirement-probe.d.ts +13 -0
- package/dist/sdk/sdk-runtime-requirement-probe.d.ts.map +1 -0
- package/dist/sdk/sdk-runtime-requirement-probe.js +55 -0
- package/dist/sdk/sdk-runtime-requirement-probe.js.map +1 -0
- package/dist/sdk/sdk-static-preflight-service.d.ts +66 -0
- package/dist/sdk/sdk-static-preflight-service.d.ts.map +1 -0
- package/dist/sdk/sdk-static-preflight-service.js +492 -0
- package/dist/sdk/sdk-static-preflight-service.js.map +1 -0
- package/dist/sdk/sdk-temporary-asset-service.d.ts +18 -0
- package/dist/sdk/sdk-temporary-asset-service.d.ts.map +1 -0
- package/dist/sdk/sdk-temporary-asset-service.js +33 -0
- package/dist/sdk/sdk-temporary-asset-service.js.map +1 -0
- package/dist/sdk/sdk-temporary-asset-upload-http-handler.d.ts +9 -0
- package/dist/sdk/sdk-temporary-asset-upload-http-handler.d.ts.map +1 -0
- package/dist/sdk/sdk-temporary-asset-upload-http-handler.js +65 -0
- package/dist/sdk/sdk-temporary-asset-upload-http-handler.js.map +1 -0
- package/dist/sdk/sdk-v1-handler-map.d.ts +44 -0
- package/dist/sdk/sdk-v1-handler-map.d.ts.map +1 -0
- package/dist/sdk/sdk-v1-handler-map.js +37 -0
- package/dist/sdk/sdk-v1-handler-map.js.map +1 -0
- package/dist/sdk/sdk-v1-service-error.d.ts +21 -0
- package/dist/sdk/sdk-v1-service-error.d.ts.map +1 -0
- package/dist/sdk/sdk-v1-service-error.js +62 -0
- package/dist/sdk/sdk-v1-service-error.js.map +1 -0
- package/dist/sdk/sdk-v1-service.d.ts +74 -0
- package/dist/sdk/sdk-v1-service.d.ts.map +1 -0
- package/dist/sdk/sdk-v1-service.js +219 -0
- package/dist/sdk/sdk-v1-service.js.map +1 -0
- package/dist/sdk/sdk-worker-readiness-adapter.d.ts +22 -0
- package/dist/sdk/sdk-worker-readiness-adapter.d.ts.map +1 -0
- package/dist/sdk/sdk-worker-readiness-adapter.js +29 -0
- package/dist/sdk/sdk-worker-readiness-adapter.js.map +1 -0
- package/dist/server.js +1091 -206
- package/dist/server.js.map +1 -1
- package/dist/session/app-run-fold.d.ts +7 -0
- package/dist/session/app-run-fold.d.ts.map +1 -0
- package/dist/session/app-run-fold.js +45 -0
- package/dist/session/app-run-fold.js.map +1 -0
- package/dist/session/asset-autosave.d.ts +80 -0
- package/dist/session/asset-autosave.d.ts.map +1 -0
- package/dist/session/asset-autosave.js +456 -0
- package/dist/session/asset-autosave.js.map +1 -0
- package/dist/session/chat-compaction.d.ts +107 -0
- package/dist/session/chat-compaction.d.ts.map +1 -0
- package/dist/session/chat-compaction.js +191 -0
- package/dist/session/chat-compaction.js.map +1 -0
- package/dist/session/chat-history.d.ts +75 -0
- package/dist/session/chat-history.d.ts.map +1 -0
- package/dist/session/chat-history.js +194 -0
- package/dist/session/chat-history.js.map +1 -0
- package/dist/session/chat-prompt.d.ts +45 -0
- package/dist/session/chat-prompt.d.ts.map +1 -0
- package/dist/session/chat-prompt.js +502 -0
- package/dist/session/chat-prompt.js.map +1 -0
- package/dist/session/chat-turn.d.ts +337 -0
- package/dist/session/chat-turn.d.ts.map +1 -0
- package/dist/session/chat-turn.js +3612 -0
- package/dist/session/chat-turn.js.map +1 -0
- package/dist/session/client-session.d.ts +59 -0
- package/dist/session/client-session.d.ts.map +1 -0
- package/dist/session/client-session.js +2 -0
- package/dist/session/client-session.js.map +1 -0
- package/dist/session/commands.d.ts +133 -0
- package/dist/session/commands.d.ts.map +1 -0
- package/dist/session/commands.js +784 -0
- package/dist/session/commands.js.map +1 -0
- package/dist/session/inference.d.ts +218 -0
- package/dist/session/inference.d.ts.map +1 -0
- package/dist/session/inference.js +1143 -0
- package/dist/session/inference.js.map +1 -0
- package/dist/session/job-execution.d.ts +484 -0
- package/dist/session/job-execution.d.ts.map +1 -0
- package/dist/session/job-execution.js +2379 -0
- package/dist/session/job-execution.js.map +1 -0
- package/dist/session/media-generation.d.ts +94 -0
- package/dist/session/media-generation.d.ts.map +1 -0
- package/dist/session/media-generation.js +234 -0
- package/dist/session/media-generation.js.map +1 -0
- package/dist/session/model-interfaces.d.ts +28 -0
- package/dist/session/model-interfaces.d.ts.map +1 -0
- package/dist/session/model-interfaces.js +133 -0
- package/dist/session/model-interfaces.js.map +1 -0
- package/dist/session/sanitize.d.ts +7 -0
- package/dist/session/sanitize.d.ts.map +1 -0
- package/dist/session/sanitize.js +64 -0
- package/dist/session/sanitize.js.map +1 -0
- package/dist/settings-registry.d.ts +6 -15
- package/dist/settings-registry.d.ts.map +1 -1
- package/dist/settings-registry.js +6 -103
- package/dist/settings-registry.js.map +1 -1
- package/dist/storage-api.d.ts +20 -0
- package/dist/storage-api.d.ts.map +1 -1
- package/dist/storage-api.js +260 -77
- package/dist/storage-api.js.map +1 -1
- package/dist/storage-retention.d.ts +14 -0
- package/dist/storage-retention.d.ts.map +1 -0
- package/dist/storage-retention.js +95 -0
- package/dist/storage-retention.js.map +1 -0
- package/dist/system-stats.d.ts +52 -0
- package/dist/system-stats.d.ts.map +1 -0
- package/dist/system-stats.js +134 -0
- package/dist/system-stats.js.map +1 -0
- package/dist/test-ui-server.d.ts +46 -0
- package/dist/test-ui-server.d.ts.map +1 -1
- package/dist/test-ui-server.js +207 -42
- package/dist/test-ui-server.js.map +1 -1
- package/dist/timeline-generation-recovery.d.ts +4 -0
- package/dist/timeline-generation-recovery.d.ts.map +1 -0
- package/dist/timeline-generation-recovery.js +72 -0
- package/dist/timeline-generation-recovery.js.map +1 -0
- package/dist/triggers/boot.d.ts +73 -0
- package/dist/triggers/boot.d.ts.map +1 -0
- package/dist/triggers/boot.js +149 -0
- package/dist/triggers/boot.js.map +1 -0
- package/dist/triggers/dispatcher.d.ts +234 -0
- package/dist/triggers/dispatcher.d.ts.map +1 -0
- package/dist/triggers/dispatcher.js +525 -0
- package/dist/triggers/dispatcher.js.map +1 -0
- package/dist/triggers/file-watch.d.ts +90 -0
- package/dist/triggers/file-watch.d.ts.map +1 -0
- package/dist/triggers/file-watch.js +405 -0
- package/dist/triggers/file-watch.js.map +1 -0
- package/dist/triggers/registration-sync.d.ts +34 -0
- package/dist/triggers/registration-sync.d.ts.map +1 -0
- package/dist/triggers/registration-sync.js +162 -0
- package/dist/triggers/registration-sync.js.map +1 -0
- package/dist/triggers/schedule-timing.d.ts +52 -0
- package/dist/triggers/schedule-timing.d.ts.map +1 -0
- package/dist/triggers/schedule-timing.js +90 -0
- package/dist/triggers/schedule-timing.js.map +1 -0
- package/dist/triggers/scheduler.d.ts +55 -0
- package/dist/triggers/scheduler.d.ts.map +1 -0
- package/dist/triggers/scheduler.js +122 -0
- package/dist/triggers/scheduler.js.map +1 -0
- package/dist/triggers/settle.d.ts +66 -0
- package/dist/triggers/settle.d.ts.map +1 -0
- package/dist/triggers/settle.js +99 -0
- package/dist/triggers/settle.js.map +1 -0
- package/dist/triggers/stores.d.ts +38 -0
- package/dist/triggers/stores.d.ts.map +1 -0
- package/dist/triggers/stores.js +168 -0
- package/dist/triggers/stores.js.map +1 -0
- package/dist/triggers/webhook-route.d.ts +49 -0
- package/dist/triggers/webhook-route.d.ts.map +1 -0
- package/dist/triggers/webhook-route.js +148 -0
- package/dist/triggers/webhook-route.js.map +1 -0
- package/dist/trpc/context.d.ts +52 -5
- package/dist/trpc/context.d.ts.map +1 -1
- package/dist/trpc/context.js +4 -1
- package/dist/trpc/context.js.map +1 -1
- package/dist/trpc/error-formatter.d.ts.map +1 -1
- package/dist/trpc/error-formatter.js +23 -2
- package/dist/trpc/error-formatter.js.map +1 -1
- package/dist/trpc/error-logging.d.ts +24 -0
- package/dist/trpc/error-logging.d.ts.map +1 -0
- package/dist/trpc/error-logging.js +48 -0
- package/dist/trpc/error-logging.js.map +1 -0
- package/dist/trpc/index.d.ts +2 -2
- package/dist/trpc/index.d.ts.map +1 -1
- package/dist/trpc/index.js +0 -2
- package/dist/trpc/index.js.map +1 -1
- package/dist/trpc/middleware.d.ts +6 -3
- package/dist/trpc/middleware.d.ts.map +1 -1
- package/dist/trpc/router.d.ts +100 -2540
- package/dist/trpc/router.d.ts.map +1 -1
- package/dist/trpc/router.js +58 -6
- package/dist/trpc/router.js.map +1 -1
- package/dist/trpc/routers/agent-access.d.ts +149 -0
- package/dist/trpc/routers/agent-access.d.ts.map +1 -0
- package/dist/trpc/routers/agent-access.js +224 -0
- package/dist/trpc/routers/agent-access.js.map +1 -0
- package/dist/trpc/routers/app-instances.d.ts +2269 -0
- package/dist/trpc/routers/app-instances.d.ts.map +1 -0
- package/dist/trpc/routers/app-instances.js +52 -0
- package/dist/trpc/routers/app-instances.js.map +1 -0
- package/dist/trpc/routers/app-runs.d.ts +1092 -0
- package/dist/trpc/routers/app-runs.d.ts.map +1 -0
- package/dist/trpc/routers/app-runs.js +28 -0
- package/dist/trpc/routers/app-runs.js.map +1 -0
- package/dist/trpc/routers/applications.d.ts +2212 -0
- package/dist/trpc/routers/applications.d.ts.map +1 -0
- package/dist/trpc/routers/applications.js +221 -0
- package/dist/trpc/routers/applications.js.map +1 -0
- package/dist/trpc/routers/assets.d.ts +194 -42
- package/dist/trpc/routers/assets.d.ts.map +1 -1
- package/dist/trpc/routers/assets.js +503 -105
- package/dist/trpc/routers/assets.js.map +1 -1
- package/dist/trpc/routers/bug-reports.d.ts +33 -0
- package/dist/trpc/routers/bug-reports.d.ts.map +1 -0
- package/dist/trpc/routers/bug-reports.js +69 -0
- package/dist/trpc/routers/bug-reports.js.map +1 -0
- package/dist/trpc/routers/code-gen.d.ts +99 -0
- package/dist/trpc/routers/code-gen.d.ts.map +1 -0
- package/dist/trpc/routers/code-gen.js +164 -0
- package/dist/trpc/routers/code-gen.js.map +1 -0
- package/dist/trpc/routers/collections.d.ts +1 -26
- package/dist/trpc/routers/collections.d.ts.map +1 -1
- package/dist/trpc/routers/collections.js +145 -78
- package/dist/trpc/routers/collections.js.map +1 -1
- package/dist/trpc/routers/costs.d.ts +40 -96
- package/dist/trpc/routers/costs.d.ts.map +1 -1
- package/dist/trpc/routers/costs.js +14 -89
- package/dist/trpc/routers/costs.js.map +1 -1
- package/dist/trpc/routers/credits.d.ts +110 -0
- package/dist/trpc/routers/credits.d.ts.map +1 -0
- package/dist/trpc/routers/credits.js +69 -0
- package/dist/trpc/routers/credits.js.map +1 -0
- package/dist/trpc/routers/custom-providers.d.ts +88 -0
- package/dist/trpc/routers/custom-providers.d.ts.map +1 -0
- package/dist/trpc/routers/custom-providers.js +140 -0
- package/dist/trpc/routers/custom-providers.js.map +1 -0
- package/dist/trpc/routers/documents.d.ts +61 -0
- package/dist/trpc/routers/documents.d.ts.map +1 -0
- package/dist/trpc/routers/documents.js +29 -0
- package/dist/trpc/routers/documents.js.map +1 -0
- package/dist/trpc/routers/error-traces.d.ts +132 -0
- package/dist/trpc/routers/error-traces.d.ts.map +1 -0
- package/dist/trpc/routers/error-traces.js +118 -0
- package/dist/trpc/routers/error-traces.js.map +1 -0
- package/dist/trpc/routers/extension.d.ts +24 -0
- package/dist/trpc/routers/extension.d.ts.map +1 -0
- package/dist/trpc/routers/extension.js +28 -0
- package/dist/trpc/routers/extension.js.map +1 -0
- package/dist/trpc/routers/external-mcp.d.ts +108 -0
- package/dist/trpc/routers/external-mcp.d.ts.map +1 -0
- package/dist/trpc/routers/external-mcp.js +70 -0
- package/dist/trpc/routers/external-mcp.js.map +1 -0
- package/dist/trpc/routers/files.d.ts +9 -2
- package/dist/trpc/routers/files.d.ts.map +1 -1
- package/dist/trpc/routers/files.js +67 -37
- package/dist/trpc/routers/files.js.map +1 -1
- package/dist/trpc/routers/fonts.d.ts +30 -0
- package/dist/trpc/routers/fonts.d.ts.map +1 -0
- package/dist/trpc/routers/fonts.js +112 -0
- package/dist/trpc/routers/fonts.js.map +1 -0
- package/dist/trpc/routers/gameDraftHistory.d.ts +8 -0
- package/dist/trpc/routers/gameDraftHistory.d.ts.map +1 -0
- package/dist/trpc/routers/gameDraftHistory.js +40 -0
- package/dist/trpc/routers/gameDraftHistory.js.map +1 -0
- package/dist/trpc/routers/games.d.ts +26502 -0
- package/dist/trpc/routers/games.d.ts.map +1 -0
- package/dist/trpc/routers/games.js +674 -0
- package/dist/trpc/routers/games.js.map +1 -0
- package/dist/trpc/routers/integrations.d.ts +86 -0
- package/dist/trpc/routers/integrations.d.ts.map +1 -0
- package/dist/trpc/routers/integrations.js +129 -0
- package/dist/trpc/routers/integrations.js.map +1 -0
- package/dist/trpc/routers/jobs.d.ts +117 -21
- package/dist/trpc/routers/jobs.d.ts.map +1 -1
- package/dist/trpc/routers/jobs.js +191 -30
- package/dist/trpc/routers/jobs.js.map +1 -1
- package/dist/trpc/routers/js-scripts.d.ts +410 -0
- package/dist/trpc/routers/js-scripts.d.ts.map +1 -0
- package/dist/trpc/routers/js-scripts.js +303 -0
- package/dist/trpc/routers/js-scripts.js.map +1 -0
- package/dist/trpc/routers/mcp-config.d.ts +4 -2
- package/dist/trpc/routers/mcp-config.d.ts.map +1 -1
- package/dist/trpc/routers/mcp-config.js +46 -206
- package/dist/trpc/routers/mcp-config.js.map +1 -1
- package/dist/trpc/routers/memories.d.ts +79 -0
- package/dist/trpc/routers/memories.d.ts.map +1 -0
- package/dist/trpc/routers/memories.js +66 -0
- package/dist/trpc/routers/memories.js.map +1 -0
- package/dist/trpc/routers/messages.d.ts +4 -77
- package/dist/trpc/routers/messages.d.ts.map +1 -1
- package/dist/trpc/routers/messages.js +9 -58
- package/dist/trpc/routers/messages.js.map +1 -1
- package/dist/trpc/routers/models.d.ts +1544 -249
- package/dist/trpc/routers/models.d.ts.map +1 -1
- package/dist/trpc/routers/models.js +624 -318
- package/dist/trpc/routers/models.js.map +1 -1
- package/dist/trpc/routers/nodes.d.ts +32 -16
- package/dist/trpc/routers/nodes.d.ts.map +1 -1
- package/dist/trpc/routers/nodes.js +70 -31
- package/dist/trpc/routers/nodes.js.map +1 -1
- package/dist/trpc/routers/packs.d.ts +184 -0
- package/dist/trpc/routers/packs.d.ts.map +1 -0
- package/dist/trpc/routers/packs.js +259 -0
- package/dist/trpc/routers/packs.js.map +1 -0
- package/dist/trpc/routers/projects.d.ts +422 -0
- package/dist/trpc/routers/projects.d.ts.map +1 -0
- package/dist/trpc/routers/projects.js +373 -0
- package/dist/trpc/routers/projects.js.map +1 -0
- package/dist/trpc/routers/resources.d.ts +120 -0
- package/dist/trpc/routers/resources.d.ts.map +1 -0
- package/dist/trpc/routers/resources.js +212 -0
- package/dist/trpc/routers/resources.js.map +1 -0
- package/dist/trpc/routers/runs.d.ts +472 -0
- package/dist/trpc/routers/runs.d.ts.map +1 -0
- package/dist/trpc/routers/runs.js +40 -0
- package/dist/trpc/routers/runs.js.map +1 -0
- package/dist/trpc/routers/scripts.d.ts +1116 -0
- package/dist/trpc/routers/scripts.d.ts.map +1 -0
- package/dist/trpc/routers/scripts.js +131 -0
- package/dist/trpc/routers/scripts.js.map +1 -0
- package/dist/trpc/routers/segmentation.d.ts +62 -0
- package/dist/trpc/routers/segmentation.d.ts.map +1 -0
- package/dist/trpc/routers/segmentation.js +146 -0
- package/dist/trpc/routers/segmentation.js.map +1 -0
- package/dist/trpc/routers/settings.d.ts +180 -10
- package/dist/trpc/routers/settings.d.ts.map +1 -1
- package/dist/trpc/routers/settings.js +125 -33
- package/dist/trpc/routers/settings.js.map +1 -1
- package/dist/trpc/routers/sketch.d.ts +868 -0
- package/dist/trpc/routers/sketch.d.ts.map +1 -0
- package/dist/trpc/routers/sketch.js +617 -0
- package/dist/trpc/routers/sketch.js.map +1 -0
- package/dist/trpc/routers/skills.d.ts +76 -22
- package/dist/trpc/routers/skills.d.ts.map +1 -1
- package/dist/trpc/routers/skills.js +195 -170
- package/dist/trpc/routers/skills.js.map +1 -1
- package/dist/trpc/routers/storage.d.ts +3 -30
- package/dist/trpc/routers/storage.d.ts.map +1 -1
- package/dist/trpc/routers/storage.js +60 -117
- package/dist/trpc/routers/storage.js.map +1 -1
- package/dist/trpc/routers/storyboards.d.ts +6639 -0
- package/dist/trpc/routers/storyboards.d.ts.map +1 -0
- package/dist/trpc/routers/storyboards.js +156 -0
- package/dist/trpc/routers/storyboards.js.map +1 -0
- package/dist/trpc/routers/threads.d.ts +33 -21
- package/dist/trpc/routers/threads.d.ts.map +1 -1
- package/dist/trpc/routers/threads.js +36 -30
- package/dist/trpc/routers/threads.js.map +1 -1
- package/dist/trpc/routers/timeline.d.ts +9085 -0
- package/dist/trpc/routers/timeline.d.ts.map +1 -0
- package/dist/trpc/routers/timeline.js +669 -0
- package/dist/trpc/routers/timeline.js.map +1 -0
- package/dist/trpc/routers/triggers.d.ts +46 -0
- package/dist/trpc/routers/triggers.d.ts.map +1 -0
- package/dist/trpc/routers/triggers.js +117 -0
- package/dist/trpc/routers/triggers.js.map +1 -0
- package/dist/trpc/routers/users.d.ts +1 -14
- package/dist/trpc/routers/users.d.ts.map +1 -1
- package/dist/trpc/routers/users.js +1 -12
- package/dist/trpc/routers/users.js.map +1 -1
- package/dist/trpc/routers/worker.d.ts +243 -0
- package/dist/trpc/routers/worker.d.ts.map +1 -0
- package/dist/trpc/routers/worker.js +221 -0
- package/dist/trpc/routers/worker.js.map +1 -0
- package/dist/trpc/routers/workflows.d.ts +428 -187
- package/dist/trpc/routers/workflows.d.ts.map +1 -1
- package/dist/trpc/routers/workflows.js +347 -345
- package/dist/trpc/routers/workflows.js.map +1 -1
- package/dist/trpc/routers/workspace.d.ts +59 -33
- package/dist/trpc/routers/workspace.d.ts.map +1 -1
- package/dist/trpc/routers/workspace.js +230 -93
- package/dist/trpc/routers/workspace.js.map +1 -1
- package/dist/trpc/sandbox-coverage.d.ts +64 -0
- package/dist/trpc/sandbox-coverage.d.ts.map +1 -0
- package/dist/trpc/sandbox-coverage.js +1139 -0
- package/dist/trpc/sandbox-coverage.js.map +1 -0
- package/dist/websocket-client-session.d.ts +322 -0
- package/dist/websocket-client-session.d.ts.map +1 -0
- package/dist/websocket-client-session.js +1267 -0
- package/dist/websocket-client-session.js.map +1 -0
- package/dist/workflow-interface-service.d.ts +87 -0
- package/dist/workflow-interface-service.d.ts.map +1 -0
- package/dist/workflow-interface-service.js +131 -0
- package/dist/workflow-interface-service.js.map +1 -0
- package/dist/workspace-api.d.ts.map +1 -1
- package/dist/workspace-api.js +33 -23
- package/dist/workspace-api.js.map +1 -1
- package/dist/ws-adapter.d.ts +46 -3
- package/dist/ws-adapter.d.ts.map +1 -1
- package/dist/ws-adapter.js +164 -26
- package/dist/ws-adapter.js.map +1 -1
- package/package.json +97 -36
- package/dist/agent/agent-runtime.d.ts +0 -47
- package/dist/agent/agent-runtime.d.ts.map +0 -1
- package/dist/agent/agent-runtime.js +0 -579
- package/dist/agent/agent-runtime.js.map +0 -1
- package/dist/agent/codex-agent.d.ts +0 -41
- package/dist/agent/codex-agent.d.ts.map +0 -1
- package/dist/agent/codex-agent.js +0 -334
- package/dist/agent/codex-agent.js.map +0 -1
- package/dist/agent/index.d.ts +0 -6
- package/dist/agent/index.d.ts.map +0 -1
- package/dist/agent/index.js +0 -4
- package/dist/agent/index.js.map +0 -1
- package/dist/agent/llm-agent.d.ts +0 -51
- package/dist/agent/llm-agent.d.ts.map +0 -1
- package/dist/agent/llm-agent.js +0 -526
- package/dist/agent/llm-agent.js.map +0 -1
- package/dist/agent/mcp-tool-server.d.ts +0 -33
- package/dist/agent/mcp-tool-server.d.ts.map +0 -1
- package/dist/agent/mcp-tool-server.js +0 -292
- package/dist/agent/mcp-tool-server.js.map +0 -1
- package/dist/agent/opencode-agent.d.ts +0 -32
- package/dist/agent/opencode-agent.d.ts.map +0 -1
- package/dist/agent/opencode-agent.js +0 -332
- package/dist/agent/opencode-agent.js.map +0 -1
- package/dist/agent/pi-agent.d.ts +0 -42
- package/dist/agent/pi-agent.d.ts.map +0 -1
- package/dist/agent/pi-agent.js +0 -429
- package/dist/agent/pi-agent.js.map +0 -1
- package/dist/agent/sdk-provider.d.ts +0 -46
- package/dist/agent/sdk-provider.d.ts.map +0 -1
- package/dist/agent/sdk-provider.js +0 -50
- package/dist/agent/sdk-provider.js.map +0 -1
- package/dist/agent/socket-route.d.ts +0 -11
- package/dist/agent/socket-route.d.ts.map +0 -1
- package/dist/agent/socket-route.js +0 -262
- package/dist/agent/socket-route.js.map +0 -1
- package/dist/agent/transport.d.ts +0 -39
- package/dist/agent/transport.d.ts.map +0 -1
- package/dist/agent/transport.js +0 -17
- package/dist/agent/transport.js.map +0 -1
- package/dist/agent/types.d.ts +0 -11
- package/dist/agent/types.d.ts.map +0 -1
- package/dist/agent/types.js +0 -10
- package/dist/agent/types.js.map +0 -1
- package/dist/routes/jobs.d.ts +0 -16
- package/dist/routes/jobs.d.ts.map +0 -1
- package/dist/routes/jobs.js +0 -25
- package/dist/routes/jobs.js.map +0 -1
- package/dist/trpc/routers/sandboxes.d.ts +0 -64
- package/dist/trpc/routers/sandboxes.d.ts.map +0 -1
- package/dist/trpc/routers/sandboxes.js +0 -327
- package/dist/trpc/routers/sandboxes.js.map +0 -1
- package/dist/unified-websocket-runner.d.ts +0 -280
- package/dist/unified-websocket-runner.d.ts.map +0 -1
- package/dist/unified-websocket-runner.js +0 -3840
- package/dist/unified-websocket-runner.js.map +0 -1
|
@@ -0,0 +1,3612 @@
|
|
|
1
|
+
import { registerChatRunTrace, registerWorkflowRunTrace, withRegisteredRunTrace, settleRegisteredRunTrace } from "@nodetool-ai/execution";
|
|
2
|
+
import { getRunTraceScope, suppressRunTraceContent } from "@nodetool-ai/runtime";
|
|
3
|
+
import { TRACE_RESTRICTED_CAPABILITY_MODULES } from "@nodetool-ai/protocol";
|
|
4
|
+
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
|
5
|
+
import { settleRunTrace, registerRunTraceParent, registerRunTraceParents, threadHasTraceContentTools } from "@nodetool-ai/models";
|
|
6
|
+
import { capabilityModuleOf, listCapabilitySpecs } from "@nodetool-ai/agents";
|
|
7
|
+
import { createLogger } from "@nodetool-ai/config";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { SUPERSEDED_TOOL_RESULT, repairOrphanedToolCalls } from "../chat-tool-call-repair.js";
|
|
10
|
+
import { attachChatPredictionForwarder } from "../chat-prediction-forwarder.js";
|
|
11
|
+
import { isGoogleWorkspaceEnabled } from "@nodetool-ai/config";
|
|
12
|
+
import { isFunctionValue, isNonEmptyString, isNumber, isObjectLike, isRecord, isString } from "../lib/wire-values.js";
|
|
13
|
+
import { storeAssetWithThumbnail } from "../lib/thumbnail.js";
|
|
14
|
+
import { createGenerationRun, generateSpeechBytes, generateMusicBytes } from "./media-generation.js";
|
|
15
|
+
import { attachRunCostLedger, linkGenerationAssets, ExecutionSession, isExecutionPreflightError, toRawGraphInput } from "@nodetool-ai/execution";
|
|
16
|
+
import { Asset, COMPACTION_EVENT_TYPE, compactionMessageContent, isCompactionMessage, Job, Message, Prediction, Project, Skill, Thread, Memory, Workflow } from "@nodetool-ai/models";
|
|
17
|
+
import { WORKFLOW_DOCUMENT_TOOL_NAMES } from "@nodetool-ai/node-sdk";
|
|
18
|
+
import { ACTIVE_MODEL_CONTEXT_KEY, DIRECT_TOOL_NAMES, RUN_BUDGET_CONTEXT_KEY, createRunBudget, detectImageMime, IMAGE_MIME_TO_EXT, expandEntitiesForGeneration, getProcessSandboxModuleCatalog, estimatePromptTokens, isToolCall, isProviderSessionUpdate, isProviderMessageEvent, isProviderStop, httpStatusFromError, groqRequestFailureMessage, mediaResolverFor, providerFailureDetail, resolveContextWindow } from "@nodetool-ai/runtime";
|
|
19
|
+
import { isModelSelection, PROVIDER_IDS, NO_MODEL_SELECTED_MESSAGE, noMediaModelSelectedMessage } from "@nodetool-ai/protocol";
|
|
20
|
+
import { Tool, compactAssetUris, compactResourceIds } from "@nodetool-ai/agents";
|
|
21
|
+
import { createChatCodeActSession, createSandboxClock, sandboxPackagesForChat, CODEACT_RESIDENT_TOOL_NAMES, EXECUTE_CODE_TOOL_NAME } from "@nodetool-ai/agents";
|
|
22
|
+
import { availableBuiltinToolNames, GOOGLE_WORKSPACE_TOOL_NAMES, APIFY_TOOL_NAMES, SERPAPI_TOOL_NAMES, capabilityForName, capabilityProviderTool, getAllMcpCapabilitySpecs, toolFromCapability, capabilityFromTool, createCapabilityRun, contextSecretAvailability, BackgroundSubtaskRegistry, PERMISSION_GATE_CONTEXT_KEY, GAME_ASSET_NODE_RUNNER_CONTEXT_KEY, extractInjectableImages, registerTemporaryImageHandle } from "@nodetool-ai/agents";
|
|
23
|
+
import { mcpToolHostDeps } from "../mcp-tool-deps.js";
|
|
24
|
+
import { externalMcpToolsFor } from "../external-mcp.js";
|
|
25
|
+
import { formatSkillCatalogForPrompt, mergeSystemSkills, formatMemoriesForPrompt } from "@nodetool-ai/agents";
|
|
26
|
+
import { RunNodeTool } from "../agent/run-node-tool.js";
|
|
27
|
+
import { extractEmbeddedImage } from "./asset-autosave.js";
|
|
28
|
+
import { buildChatAgentSystemPrompt, focusedUiToolNames, normalizeToolCallName, RESIDENT_TOOL_NAMES, unroutableToolMessage } from "./chat-prompt.js";
|
|
29
|
+
import { createRuntimeContext } from "./model-interfaces.js";
|
|
30
|
+
import { getSetting } from "../settings-registry.js";
|
|
31
|
+
import { appendContextToLastUser, createWorkflowResponseContent, dbMessageToProviderMessage, extractTextContent, historySinceCompaction, invokedSkillsSection, toolResultDisplayText } from "./chat-history.js";
|
|
32
|
+
import { chooseCompactionCut, compactionThreshold, COMPACTION_SUMMARY_MAX_TOKENS, holdsTranscriptServerSide, readCompactionSettings, summarizeForCompaction } from "./chat-compaction.js";
|
|
33
|
+
import { createRelayActivityWaiter, DEFAULT_RUN_JOB_EXECUTION_OPTIONS } from "./job-execution.js";
|
|
34
|
+
function restrictedTraceToolNames() {
|
|
35
|
+
const modules = new Set(TRACE_RESTRICTED_CAPABILITY_MODULES);
|
|
36
|
+
return listCapabilitySpecs().filter((spec) => modules.has(capabilityModuleOf(spec.name) ?? "")).map((spec) => spec.name);
|
|
37
|
+
}
|
|
38
|
+
function seedStoredToolProvenance(messages) {
|
|
39
|
+
for (const message of messages) {
|
|
40
|
+
const names = message.name ? [message.name] : [];
|
|
41
|
+
if (isCompactionMessage(message)) {
|
|
42
|
+
names.push(...(message.tools ?? []));
|
|
43
|
+
}
|
|
44
|
+
for (const call of message.tool_calls ?? []) {
|
|
45
|
+
if (!isRecord(call)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (isString(call.name)) {
|
|
49
|
+
names.push(call.name);
|
|
50
|
+
}
|
|
51
|
+
if (isRecord(call.function) && isString(call.function.name)) {
|
|
52
|
+
names.push(call.function.name);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
const moduleName = capabilityModuleOf(name);
|
|
57
|
+
if (moduleName === "email" || moduleName === "google" || moduleName === "browser") {
|
|
58
|
+
suppressRunTraceContent();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const log = createLogger("nodetool.websocket.runner");
|
|
64
|
+
/** Providers whose credential is an account sign-in, not an API key. */
|
|
65
|
+
const SIGN_IN_PROVIDER_LABELS = {
|
|
66
|
+
[PROVIDER_IDS.CODEX]: "Codex",
|
|
67
|
+
[PROVIDER_IDS.CLAUDE_AGENT_SDK]: "Claude"
|
|
68
|
+
};
|
|
69
|
+
const GENERIC_CHAT_ERROR = "Something went wrong while processing your request. Please try again.";
|
|
70
|
+
/**
|
|
71
|
+
* Keep implementation details out of the client error envelope. Provider
|
|
72
|
+
* messages remain useful when they are short and user-facing, but database
|
|
73
|
+
* diagnostics and query arguments can contain schema details or secrets.
|
|
74
|
+
*/
|
|
75
|
+
function safeClientErrorText(error) {
|
|
76
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
77
|
+
if (text.length > 500 ||
|
|
78
|
+
/\b(?:select|insert|update|delete)\b[\s\S]*\b(?:from|where|into|set)\b/i.test(text) ||
|
|
79
|
+
/(?:sqlite|sqlstate|drizzle|query\s+(?:failed|parameter)|bind\s+parameter|database\s+error|stack\s+trace|\bparams?\s*[:=]|\bparameters?\s*[:=]|\b(?:api[_ -]?key|authorization|bearer)\s*[:=]|https?:\/\/\S*\?\S*=)/i.test(text)) {
|
|
80
|
+
return GENERIC_CHAT_ERROR;
|
|
81
|
+
}
|
|
82
|
+
return text || GENERIC_CHAT_ERROR;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Gemini wraps JSON failures in a plain Error (`Gemini API error 429: …`), so
|
|
86
|
+
* the shared status helper cannot see the status when the wrapper says
|
|
87
|
+
* `API error` instead of `HTTP`. Parse only that known wrapper and retain a
|
|
88
|
+
* bounded, user-actionable classification rather than forwarding the JSON.
|
|
89
|
+
*/
|
|
90
|
+
function geminiFailureSummary(error, providerId) {
|
|
91
|
+
if (providerId.toLowerCase() !== "gemini") {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const text = error instanceof Error ? error.message : "";
|
|
95
|
+
const wrapper = text.match(/^Gemini API error(?:\s+(\d{3}))?:\s*([\s\S]+)$/i);
|
|
96
|
+
if (!wrapper) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const wrapperStatus = Number(wrapper[1]);
|
|
100
|
+
let status = wrapperStatus >= 100 && wrapperStatus <= 599 ? wrapperStatus : null;
|
|
101
|
+
let message = wrapper[2].trim();
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(wrapper[2]);
|
|
104
|
+
if (isObjectLike(parsed)) {
|
|
105
|
+
const root = parsed;
|
|
106
|
+
const nested = isObjectLike(root.error) ? root.error : root;
|
|
107
|
+
if (isObjectLike(nested)) {
|
|
108
|
+
const code = nested.code;
|
|
109
|
+
if (status === null &&
|
|
110
|
+
isNumber(code) &&
|
|
111
|
+
Number.isInteger(code) &&
|
|
112
|
+
code >= 100 &&
|
|
113
|
+
code <= 599) {
|
|
114
|
+
status = code;
|
|
115
|
+
}
|
|
116
|
+
if (isString(nested.message)) {
|
|
117
|
+
message = nested.message;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// The provider sometimes returns plain text after the same wrapper.
|
|
124
|
+
}
|
|
125
|
+
const lower = message.toLowerCase();
|
|
126
|
+
let kind = null;
|
|
127
|
+
if (/quota|resource_exhausted|rate limit|billing|credit|limit exceeded|daily limit/.test(lower)) {
|
|
128
|
+
kind = "quota";
|
|
129
|
+
}
|
|
130
|
+
else if (/no longer available|model[^.]{0,80}(?:not found|unavailable|unsupported|does not exist)|(?:not found|unavailable|unsupported)[^.]{0,80}model/.test(lower)) {
|
|
131
|
+
kind = "model";
|
|
132
|
+
}
|
|
133
|
+
else if (/tool|function call|function calling|function_declaration/.test(lower) &&
|
|
134
|
+
/not support|unsupported|invalid|disable|not enabled/.test(lower)) {
|
|
135
|
+
kind = "tools";
|
|
136
|
+
}
|
|
137
|
+
const safeMessage = safeClientErrorText(message);
|
|
138
|
+
return {
|
|
139
|
+
status,
|
|
140
|
+
kind,
|
|
141
|
+
message: safeMessage === GENERIC_CHAT_ERROR ? null : safeMessage
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function providerFailureMessage(error, providerId, model) {
|
|
145
|
+
const gemini = geminiFailureSummary(error, providerId);
|
|
146
|
+
const groq = groqRequestFailureMessage(error);
|
|
147
|
+
const status = httpStatusFromError(error) ?? gemini?.status ?? null;
|
|
148
|
+
const statusCode = status ?? undefined;
|
|
149
|
+
const rawMessage = groq ?? gemini?.message ?? safeClientErrorText(error);
|
|
150
|
+
const body = isObjectLike(error) ? (error.body ?? error.response) : undefined;
|
|
151
|
+
const bodyError = isObjectLike(body) && isObjectLike(body.error) ? body.error : body;
|
|
152
|
+
const bodyText = isObjectLike(bodyError) && isString(bodyError.message)
|
|
153
|
+
? safeClientErrorText(bodyError.message)
|
|
154
|
+
: null;
|
|
155
|
+
const bodyMessage = bodyText === GENERIC_CHAT_ERROR ? null : bodyText;
|
|
156
|
+
if (providerId.toLowerCase() === "openai" &&
|
|
157
|
+
/\borganization must be verified\b/i.test(bodyMessage ?? rawMessage)) {
|
|
158
|
+
return {
|
|
159
|
+
errorType: status === null ? "error" : "http_status_error",
|
|
160
|
+
message: "This model requires a verified OpenAI organization. Verify your provider organization or choose a model your account can access.",
|
|
161
|
+
statusCode
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const detail = providerFailureDetail(error);
|
|
165
|
+
if (detail?.code === "context_exceeded") {
|
|
166
|
+
const target = model ? `${providerId}/${model}` : providerId;
|
|
167
|
+
return {
|
|
168
|
+
errorType: "error",
|
|
169
|
+
message: `The ${target} request is too large for the model context window. Shorten the conversation or remove some attachments and try again.`,
|
|
170
|
+
statusCode
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (detail?.code === "provider_auth") {
|
|
174
|
+
const signIn = SIGN_IN_PROVIDER_LABELS[detail.provider];
|
|
175
|
+
const remedy = signIn ? `Sign in to ${signIn} again` : "Check the API key";
|
|
176
|
+
return {
|
|
177
|
+
errorType: "error",
|
|
178
|
+
message: `Authentication failed: ${detail.provider} rejected the configured credentials. ${remedy} in Settings → Models & Providers.`,
|
|
179
|
+
statusCode
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (status === null) {
|
|
183
|
+
return { errorType: "error", message: rawMessage, statusCode };
|
|
184
|
+
}
|
|
185
|
+
const modelName = model || "requested";
|
|
186
|
+
let message;
|
|
187
|
+
if (gemini?.kind === "quota") {
|
|
188
|
+
message = `Account quota exhausted for ${providerId}/${model}. Check your ${providerId} quota or billing and try again later.`;
|
|
189
|
+
}
|
|
190
|
+
else if (gemini?.kind === "model") {
|
|
191
|
+
message = `Model ${modelName} is unavailable on ${providerId}. Choose another model or check that your account has access to it.`;
|
|
192
|
+
}
|
|
193
|
+
else if (gemini?.kind === "tools") {
|
|
194
|
+
message = `Model ${modelName} does not support the requested tools on ${providerId}. Choose a model with tool support or disable tools.`;
|
|
195
|
+
}
|
|
196
|
+
else if (status === 400) {
|
|
197
|
+
message = `Bad request: ${groq ?? bodyMessage ?? rawMessage}`;
|
|
198
|
+
}
|
|
199
|
+
else if (status === 401) {
|
|
200
|
+
message = "Authentication failed: Invalid API key or token";
|
|
201
|
+
}
|
|
202
|
+
else if (status === 402) {
|
|
203
|
+
message = `Account billing or credit limit reached for ${providerId}. Check your ${providerId} plan or billing.`;
|
|
204
|
+
}
|
|
205
|
+
else if (status === 403) {
|
|
206
|
+
message =
|
|
207
|
+
"Access forbidden: You don't have permission for this resource. Check the provider key's model and account access.";
|
|
208
|
+
}
|
|
209
|
+
else if (status === 404) {
|
|
210
|
+
message = `Model ${modelName} was not found or is unavailable on ${providerId}. Choose another model or check account access.`;
|
|
211
|
+
}
|
|
212
|
+
else if (status === 413) {
|
|
213
|
+
const bodyDetail = bodyMessage ?? rawMessage;
|
|
214
|
+
message =
|
|
215
|
+
groq ??
|
|
216
|
+
(bodyDetail === GENERIC_CHAT_ERROR
|
|
217
|
+
? "Request is too large for the provider. Shorten the conversation or remove some attachments and try again."
|
|
218
|
+
: `Request is too large for the provider (${bodyDetail}). Shorten the conversation or remove some attachments and try again.`);
|
|
219
|
+
}
|
|
220
|
+
else if (status === 429) {
|
|
221
|
+
message =
|
|
222
|
+
groq ??
|
|
223
|
+
"Rate limited: Too many requests or insufficient provider quota. Check your provider plan and try again later.";
|
|
224
|
+
}
|
|
225
|
+
else if (status >= 500) {
|
|
226
|
+
message =
|
|
227
|
+
groq ??
|
|
228
|
+
bodyMessage ??
|
|
229
|
+
`Server error (${status}): The service is temporarily unavailable`;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
message = `HTTP error (${status}): ${rawMessage}`;
|
|
233
|
+
}
|
|
234
|
+
return { errorType: "http_status_error", message, statusCode };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* How many of a user's newest memories the turn reads to build its block. The
|
|
238
|
+
* read doubles as the "how many live in other threads" count, so it is capped:
|
|
239
|
+
* the number is a nudge toward `memory_search`, not an audit.
|
|
240
|
+
*/
|
|
241
|
+
const MEMORY_SCAN_LIMIT = 400;
|
|
242
|
+
/** How many of this thread's memories are pasted into the block. */
|
|
243
|
+
const MEMORY_BLOCK_LIMIT = 100;
|
|
244
|
+
/**
|
|
245
|
+
* How many recent messages to scan when probing for a resumable session before
|
|
246
|
+
* deciding whether the full thread needs loading. Large enough to clear the
|
|
247
|
+
* occasional errored turn between sessioned assistant replies, tiny next to a
|
|
248
|
+
* full thread load.
|
|
249
|
+
*/
|
|
250
|
+
const SESSION_PROBE_WINDOW = 50;
|
|
251
|
+
/**
|
|
252
|
+
* Output-token worst case assumed when reserving a chat turn. The chat loop
|
|
253
|
+
* sends no `maxTokens`, so nothing bounds the answer from this side; the
|
|
254
|
+
* reservation charges the same ceiling the Agent node defaults to, which is
|
|
255
|
+
* what makes the dollar cap hold instead of being noticed after the money is
|
|
256
|
+
* spent.
|
|
257
|
+
*/
|
|
258
|
+
const CHAT_TURN_MAX_OUTPUT_TOKENS = 16_384;
|
|
259
|
+
/** Documented defaults for the five `NODETOOL_AGENT_*` budget settings. */
|
|
260
|
+
const AGENT_BUDGET_DEFAULTS = {
|
|
261
|
+
costCapUsd: 5,
|
|
262
|
+
deadlineMs: 1_800_000,
|
|
263
|
+
maxConcurrency: 8,
|
|
264
|
+
maxTurns: 200,
|
|
265
|
+
unpricedTokenCeiling: 400_000
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* Read one budget setting. The store is best-effort — the same rule the rest
|
|
269
|
+
* of the runner follows for settings: an unreachable database falls back to
|
|
270
|
+
* the documented default rather than failing the turn.
|
|
271
|
+
*/
|
|
272
|
+
async function readBudgetSetting(key) {
|
|
273
|
+
try {
|
|
274
|
+
return await getSetting(key);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Settings store unavailable — the caller's default stands.
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function budgetSettingNumber(key, fallback) {
|
|
282
|
+
const raw = await readBudgetSetting(key);
|
|
283
|
+
if (raw === null)
|
|
284
|
+
return fallback;
|
|
285
|
+
const parsed = Number(raw);
|
|
286
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The run's USD ceiling, or `null` for no ceiling at all.
|
|
290
|
+
*
|
|
291
|
+
* `Number("")` is 0 and a cap of 0 refuses every turn rather than lifting the
|
|
292
|
+
* ceiling, so a blank or non-positive value means *no cap* — which is what a
|
|
293
|
+
* local-only install wants. An absent setting takes the documented $5 default;
|
|
294
|
+
* `getSetting` cannot tell an empty stored value from an unset one (it falls
|
|
295
|
+
* through both), so "no cap" is expressed by storing a non-positive number.
|
|
296
|
+
*/
|
|
297
|
+
async function budgetCostCapUsd() {
|
|
298
|
+
const raw = await readBudgetSetting("NODETOOL_AGENT_TURN_COST_CAP_USD");
|
|
299
|
+
// `getSetting` answers null for both an unset and an empty stored value, so
|
|
300
|
+
// clearing the field restores the default rather than removing the cap.
|
|
301
|
+
// Removing it is `0`, which is why a non-positive number means "no cap"
|
|
302
|
+
// instead of a cap nothing could ever pass.
|
|
303
|
+
if (raw === null)
|
|
304
|
+
return AGENT_BUDGET_DEFAULTS.costCapUsd;
|
|
305
|
+
const parsed = Number(raw.trim());
|
|
306
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
307
|
+
return null;
|
|
308
|
+
return parsed;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The one budget a chat turn shares downward (invariant I-2): the capability
|
|
312
|
+
* runs, the sub-agent runtime, the provider loop, and — through the context
|
|
313
|
+
* key — an Agent node started by `run_node` and any JS script the turn runs.
|
|
314
|
+
* Every one of them reserves against the same cap instead of opening its own.
|
|
315
|
+
*/
|
|
316
|
+
async function createChatTurnBudget() {
|
|
317
|
+
const [capUsd, deadlineMs, maxConcurrency, maxTurns, unpricedTokenCeiling] = await Promise.all([
|
|
318
|
+
budgetCostCapUsd(),
|
|
319
|
+
budgetSettingNumber("NODETOOL_AGENT_TURN_DEADLINE_MS", AGENT_BUDGET_DEFAULTS.deadlineMs),
|
|
320
|
+
budgetSettingNumber("NODETOOL_AGENT_MAX_CONCURRENCY", AGENT_BUDGET_DEFAULTS.maxConcurrency),
|
|
321
|
+
budgetSettingNumber("NODETOOL_AGENT_MAX_TURNS", AGENT_BUDGET_DEFAULTS.maxTurns),
|
|
322
|
+
budgetSettingNumber("NODETOOL_AGENT_UNPRICED_TOKEN_CEILING", AGENT_BUDGET_DEFAULTS.unpricedTokenCeiling)
|
|
323
|
+
]);
|
|
324
|
+
return createRunBudget({
|
|
325
|
+
capUsd,
|
|
326
|
+
maxOutputTokens: CHAT_TURN_MAX_OUTPUT_TOKENS,
|
|
327
|
+
unpricedTokenCeiling,
|
|
328
|
+
deadlineMs,
|
|
329
|
+
maxConcurrency,
|
|
330
|
+
maxTurns
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Find the continuation token to resume this thread with: the `provider_session`
|
|
335
|
+
* of the most recent assistant message, but only if it was produced by the same
|
|
336
|
+
* `provider` and `model` as the incoming request (a session is bound to both).
|
|
337
|
+
* Returns null when there is nothing to resume, so the provider starts fresh.
|
|
338
|
+
*/
|
|
339
|
+
function lastMatchingProviderSession(dbMessages, providerId, model) {
|
|
340
|
+
for (let i = dbMessages.length - 1; i >= 0; i--) {
|
|
341
|
+
const m = dbMessages[i];
|
|
342
|
+
if (m.role !== "assistant")
|
|
343
|
+
continue;
|
|
344
|
+
const session = m.provider_session;
|
|
345
|
+
if (!session)
|
|
346
|
+
continue;
|
|
347
|
+
return session.providerId === providerId && session.model === model
|
|
348
|
+
? session
|
|
349
|
+
: null;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* One connection's chat turns: the turn counter and abort controller, the
|
|
355
|
+
* per-thread permission state, the capability run, and the three message
|
|
356
|
+
* shapes a `chat_message` frame can take — an agent turn, a workflow run, or
|
|
357
|
+
* a media generation.
|
|
358
|
+
*
|
|
359
|
+
* It reaches the connection only through {@link ClientSession}. Everything
|
|
360
|
+
* else another region owns arrives in {@link ChatTurnDeps}.
|
|
361
|
+
*/
|
|
362
|
+
export class ChatTurnHandler {
|
|
363
|
+
session;
|
|
364
|
+
deps;
|
|
365
|
+
chatRequestSeq = 0;
|
|
366
|
+
/**
|
|
367
|
+
* Aborts the in-flight chat/inference turn. The seq counter above only filters
|
|
368
|
+
* stale output at yield boundaries — it cannot interrupt a provider that is
|
|
369
|
+
* blocked awaiting a response, nor tell one that owns a subprocess (the Claude
|
|
370
|
+
* Agent provider) to stop working. This signal does, and is threaded into
|
|
371
|
+
* every provider call the turn makes.
|
|
372
|
+
*/
|
|
373
|
+
chatAbort = null;
|
|
374
|
+
/**
|
|
375
|
+
* Per-thread set of tool names the user approved for the rest of the chat
|
|
376
|
+
* via "Allow for this chat". Persists across messages within a thread.
|
|
377
|
+
*/
|
|
378
|
+
chatSessionAllow = new Map();
|
|
379
|
+
/**
|
|
380
|
+
* Live permission mode for an in-flight turn. `set_permission_mode` writes
|
|
381
|
+
* here so switching to Auto mid-turn applies to the next gated call.
|
|
382
|
+
*/
|
|
383
|
+
chatTurnPermissionMode = new Map();
|
|
384
|
+
/**
|
|
385
|
+
* The capability run for the chat turn this connection is executing — the
|
|
386
|
+
* gate, the context, and everything a capability needs that only exists per
|
|
387
|
+
* turn. Built beside the toolbelt; the sandbox still calls the belt, and PR
|
|
388
|
+
* 11 is what switches the guest onto `run.invoke`.
|
|
389
|
+
*/
|
|
390
|
+
chatCapabilityRun = null;
|
|
391
|
+
/**
|
|
392
|
+
* Input window per `provider/model`, resolved once per connection and again
|
|
393
|
+
* whenever a turn selects a model this connection has not used.
|
|
394
|
+
*/
|
|
395
|
+
contextWindows = new Map();
|
|
396
|
+
/**
|
|
397
|
+
* The provider-reported size of each thread's last call, keyed by thread,
|
|
398
|
+
* with the `provider/model` it was measured on. The next turn's prompt is at
|
|
399
|
+
* least this large, and this counts what the local estimate cannot see.
|
|
400
|
+
*/
|
|
401
|
+
measuredThreadTokens = new Map();
|
|
402
|
+
constructor(session, deps) {
|
|
403
|
+
this.session = session;
|
|
404
|
+
this.deps = deps;
|
|
405
|
+
}
|
|
406
|
+
/** The run built for the last chat turn — what PR 11 hands to the sandbox. */
|
|
407
|
+
getCapabilityRun() {
|
|
408
|
+
return this.chatCapabilityRun;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Open a chat/inference turn: cancel whatever was running and hand back the
|
|
412
|
+
* seq + signal the new turn runs under. A superseding message cancels the
|
|
413
|
+
* previous turn exactly as an explicit Stop does.
|
|
414
|
+
*/
|
|
415
|
+
beginTurn() {
|
|
416
|
+
this.cancel();
|
|
417
|
+
this.chatRequestSeq += 1;
|
|
418
|
+
this.chatAbort = new AbortController();
|
|
419
|
+
return {
|
|
420
|
+
seq: this.chatRequestSeq,
|
|
421
|
+
signal: this.chatAbort.signal,
|
|
422
|
+
controller: this.chatAbort
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** Bump the turn counter without opening a new turn — what `stop` does. */
|
|
426
|
+
bumpRequestSeq() {
|
|
427
|
+
this.chatRequestSeq += 1;
|
|
428
|
+
}
|
|
429
|
+
/** The turn a caller's `requestSeq` is compared against. */
|
|
430
|
+
get currentRequestSeq() {
|
|
431
|
+
return this.chatRequestSeq;
|
|
432
|
+
}
|
|
433
|
+
/** Abort the in-flight turn, if any. Idempotent. */
|
|
434
|
+
cancel() {
|
|
435
|
+
this.chatAbort?.abort();
|
|
436
|
+
this.chatAbort = null;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Retire a turn that finished on its own. Clears the controller only when it
|
|
440
|
+
* is still the current one — a superseding turn has already installed its
|
|
441
|
+
* own, and clearing that would make a later Stop a no-op.
|
|
442
|
+
*/
|
|
443
|
+
endTurn(controller) {
|
|
444
|
+
if (controller && this.chatAbort === controller)
|
|
445
|
+
this.chatAbort = null;
|
|
446
|
+
}
|
|
447
|
+
/** Resolve a client tool call this turn is waiting on. */
|
|
448
|
+
resolveToolResult(toolCallId, payload) {
|
|
449
|
+
this.deps.toolBridge.resolveResult(toolCallId, payload);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Apply a mid-turn permission-mode switch. Auto also releases every
|
|
453
|
+
* approval already waiting on this thread — the user just said yes to all
|
|
454
|
+
* of them.
|
|
455
|
+
*/
|
|
456
|
+
setPermissionMode(threadId, mode) {
|
|
457
|
+
const liveMode = this.chatTurnPermissionMode.get(threadId);
|
|
458
|
+
if (liveMode) {
|
|
459
|
+
liveMode.value = mode;
|
|
460
|
+
}
|
|
461
|
+
if (mode === "auto") {
|
|
462
|
+
this.deps.approvalBridge.resolveScope(threadId, { decision: "allow" });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Which workflow decides where this conversation's files go.
|
|
467
|
+
*
|
|
468
|
+
* The workspace must not move between turns of one conversation: a file
|
|
469
|
+
* written in the previous message has to still be there in the next. The
|
|
470
|
+
* message's own `workflow_id` is not stable enough to key it on — the client
|
|
471
|
+
* attaches the id it has when it sends, so a turn sent before the thread list
|
|
472
|
+
* loaded carries none, resolves the default workspace instead of the
|
|
473
|
+
* workflow's, and the files the agent wrote a moment ago read as wiped. The
|
|
474
|
+
* thread's binding is the durable one, so it fills in whenever the message
|
|
475
|
+
* omits it.
|
|
476
|
+
*/
|
|
477
|
+
async threadWorkspaceWorkflowId(userId, threadId, messageWorkflowId) {
|
|
478
|
+
if (messageWorkflowId)
|
|
479
|
+
return messageWorkflowId;
|
|
480
|
+
if (!threadId)
|
|
481
|
+
return null;
|
|
482
|
+
try {
|
|
483
|
+
const thread = await Thread.find(userId, threadId);
|
|
484
|
+
return isNonEmptyString(thread?.workflow_id) ? thread.workflow_id : null;
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
this.session.logError("thread workspace lookup failed", err);
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async ensureThreadExists(threadId, workflowId, projectId) {
|
|
492
|
+
const userId = this.session.requireUserId();
|
|
493
|
+
if (!threadId) {
|
|
494
|
+
if (projectId && projectId !== "default") {
|
|
495
|
+
await Project.requireOwned(userId, projectId);
|
|
496
|
+
}
|
|
497
|
+
const thread = await Thread.create({
|
|
498
|
+
user_id: userId,
|
|
499
|
+
workflow_id: workflowId ?? null,
|
|
500
|
+
project_id: projectId ?? "default",
|
|
501
|
+
title: ""
|
|
502
|
+
});
|
|
503
|
+
return thread.id;
|
|
504
|
+
}
|
|
505
|
+
const existing = await Thread.find(userId, threadId);
|
|
506
|
+
if (existing)
|
|
507
|
+
return existing.id;
|
|
508
|
+
const thread = await Thread.create({
|
|
509
|
+
id: threadId,
|
|
510
|
+
user_id: userId,
|
|
511
|
+
workflow_id: workflowId ?? null,
|
|
512
|
+
project_id: projectId ?? "default",
|
|
513
|
+
title: ""
|
|
514
|
+
});
|
|
515
|
+
return thread.id;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Save a message dict to the database.
|
|
519
|
+
* Mirrors Python's _save_message_to_db_async: pops id, type, user_id before create.
|
|
520
|
+
*/
|
|
521
|
+
async saveMessageToDb(messageData) {
|
|
522
|
+
const data = { ...messageData };
|
|
523
|
+
delete data.id;
|
|
524
|
+
delete data.type;
|
|
525
|
+
const threadId = isString(data.thread_id) ? data.thread_id : "";
|
|
526
|
+
delete data.thread_id;
|
|
527
|
+
const userId = this.session.requireUserId();
|
|
528
|
+
delete data.user_id;
|
|
529
|
+
const message = await Message.create({ thread_id: threadId, user_id: userId, ...data });
|
|
530
|
+
const scope = getRunTraceScope();
|
|
531
|
+
if (scope) {
|
|
532
|
+
await registerRunTraceParent(userId, scope.runId, { kind: "message", id: message.id });
|
|
533
|
+
}
|
|
534
|
+
return message;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Persist raw image bytes carried on an assistant message (native providers
|
|
538
|
+
* that run a server-side image tool emit them inline) as real assets, and
|
|
539
|
+
* rewrite each such block to the wire shape `{ type: "image_url", image: {
|
|
540
|
+
* type: "image", asset_id, mimeType } }`. Blocks that already reference an
|
|
541
|
+
* asset (uri / asset_id, no raw data) and non-image blocks pass through
|
|
542
|
+
* untouched. Raw base64 is never persisted or sent.
|
|
543
|
+
*/
|
|
544
|
+
async materializeAssistantImageContent(content, userId, workflowId, projectId = "default") {
|
|
545
|
+
const out = [];
|
|
546
|
+
for (const block of content) {
|
|
547
|
+
if (block.type !== "image_url") {
|
|
548
|
+
out.push({ ...block });
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const image = block.image;
|
|
552
|
+
const rawData = image.data;
|
|
553
|
+
let bytes = null;
|
|
554
|
+
if (rawData instanceof Uint8Array) {
|
|
555
|
+
bytes = rawData;
|
|
556
|
+
}
|
|
557
|
+
else if (isString(rawData) && rawData) {
|
|
558
|
+
bytes = new Uint8Array(Buffer.from(rawData, "base64"));
|
|
559
|
+
}
|
|
560
|
+
if (!bytes) {
|
|
561
|
+
// Already an asset/uri reference (or empty) — leave as-is.
|
|
562
|
+
out.push({ ...block });
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const mimeType = isString(image.mimeType) ? image.mimeType : "image/png";
|
|
566
|
+
const ext = IMAGE_MIME_TO_EXT[mimeType] ?? "png";
|
|
567
|
+
// Per-block isolation: a storage failure must not abort the whole turn —
|
|
568
|
+
// the image is already generated (and billed), and the assistant text
|
|
569
|
+
// plus any sibling images should still reach the user. Degrade the
|
|
570
|
+
// failed block to a text notice; never fall back to raw base64.
|
|
571
|
+
try {
|
|
572
|
+
const asset = new Asset({
|
|
573
|
+
user_id: userId,
|
|
574
|
+
workflow_id: workflowId ?? null,
|
|
575
|
+
project_id: projectId,
|
|
576
|
+
name: `image_${Date.now()}`,
|
|
577
|
+
content_type: mimeType,
|
|
578
|
+
// Home — see the chat media generation path.
|
|
579
|
+
parent_id: userId
|
|
580
|
+
});
|
|
581
|
+
const fileName = `${asset.id}.${ext}`;
|
|
582
|
+
await storeAssetWithThumbnail(asset.user_id, asset.id, fileName, bytes, mimeType);
|
|
583
|
+
asset.size = bytes.length;
|
|
584
|
+
await asset.save();
|
|
585
|
+
// The DB / wire shape mirrors handleMediaGenerationMessage: an asset_id
|
|
586
|
+
// reference (never raw bytes). resolveContentUrls / resolveContentForProvider
|
|
587
|
+
// dereference asset_id on the way out and on the next turn.
|
|
588
|
+
out.push({
|
|
589
|
+
type: "image_url",
|
|
590
|
+
image: { type: "image", asset_id: asset.id, mimeType }
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
log.error("Failed to store generated image as asset", {
|
|
595
|
+
error: err instanceof Error ? err.message : String(err)
|
|
596
|
+
});
|
|
597
|
+
out.push({
|
|
598
|
+
type: "text",
|
|
599
|
+
text: "[a generated image could not be saved]"
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return out;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Recursively process tool results, handling asset-like objects.
|
|
607
|
+
* Mirrors Python's RegularChatProcessor._process_tool_result().
|
|
608
|
+
*
|
|
609
|
+
* - Asset-like objects (have type + uri/data): materialized via storage
|
|
610
|
+
* - Date/datetime: converted to ISO string
|
|
611
|
+
* - Arrays/objects: recursed into
|
|
612
|
+
* - Primitives: returned as-is
|
|
613
|
+
*/
|
|
614
|
+
// HOLDOUT (anti-slop/no-unknown-returns): a tool result is an arbitrary
|
|
615
|
+
// value — the same open domain `ProcessingContext.normalizeOutputValue`
|
|
616
|
+
// rewrites — and this walk answers in that domain.
|
|
617
|
+
async processToolResult(obj, ctx) {
|
|
618
|
+
if (obj === null || obj === undefined)
|
|
619
|
+
return obj;
|
|
620
|
+
// Asset-like objects: { type: "image"|"audio"|"video"|..., uri?: string, data?: ... }
|
|
621
|
+
if (isRecord(obj)) {
|
|
622
|
+
const record = obj;
|
|
623
|
+
// Check if it's an asset-like object (has type + uri or data)
|
|
624
|
+
if ("type" in record &&
|
|
625
|
+
("uri" in record || "data" in record || "asset_id" in record)) {
|
|
626
|
+
// Use ProcessingContext's normalizeOutputValue to handle asset materialization
|
|
627
|
+
return ctx.normalizeOutputValue(record, "storage_url");
|
|
628
|
+
}
|
|
629
|
+
// Date objects
|
|
630
|
+
if (obj instanceof Date) {
|
|
631
|
+
return obj.toISOString();
|
|
632
|
+
}
|
|
633
|
+
// Regular objects — recurse into values
|
|
634
|
+
const result = {};
|
|
635
|
+
for (const [key, value] of Object.entries(record)) {
|
|
636
|
+
result[key] = await this.processToolResult(value, ctx);
|
|
637
|
+
}
|
|
638
|
+
return result;
|
|
639
|
+
}
|
|
640
|
+
// Arrays — recurse into items
|
|
641
|
+
if (Array.isArray(obj)) {
|
|
642
|
+
return Promise.all(obj.map((item) => this.processToolResult(item, ctx)));
|
|
643
|
+
}
|
|
644
|
+
// Uint8Array/Buffer — store as asset
|
|
645
|
+
if (obj instanceof Uint8Array) {
|
|
646
|
+
if (!ctx.storage)
|
|
647
|
+
return obj;
|
|
648
|
+
const key = `assets/${randomUUID()}.bin`;
|
|
649
|
+
const uri = await ctx.storage.store(key, obj);
|
|
650
|
+
return { type: "asset", uri };
|
|
651
|
+
}
|
|
652
|
+
// Primitives
|
|
653
|
+
return obj;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Persist image bytes to temp storage and return a handle `view_image` can
|
|
657
|
+
* resolve — a bare `<uuid>.<ext>` storage key. No DB asset row is created, so
|
|
658
|
+
* these captures never clutter the user's asset library; the bytes live only
|
|
659
|
+
* in the request's temp storage. Returns null if there is no storage adapter
|
|
660
|
+
* or the write fails.
|
|
661
|
+
*/
|
|
662
|
+
async storeTempImageAsset(ctx, bytes, mimeType) {
|
|
663
|
+
if (!ctx.storage)
|
|
664
|
+
return null;
|
|
665
|
+
const ext = IMAGE_MIME_TO_EXT[mimeType] ?? "png";
|
|
666
|
+
const key = `${randomUUID()}.${ext}`;
|
|
667
|
+
try {
|
|
668
|
+
await ctx.storage.store(key, bytes, mimeType);
|
|
669
|
+
registerTemporaryImageHandle(ctx, key);
|
|
670
|
+
return key;
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
log.error("Failed to store temp image asset", {
|
|
674
|
+
error: err instanceof Error ? err.message : String(err)
|
|
675
|
+
});
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Replace embedded image pixels in a tool result — timeline `frames[]` or an
|
|
681
|
+
* `image_content` blob (e.g. `ui_3d_capture_view`) — with temp-asset handles.
|
|
682
|
+
* The model receives a handle and an instruction to call `view_image`, which
|
|
683
|
+
* is the single mechanism that pulls pixels into context. Keeps image bytes
|
|
684
|
+
* out of the standing chat history. Non-image results pass through untouched.
|
|
685
|
+
*/
|
|
686
|
+
// HOLDOUT (anti-slop/no-unknown-returns): same open tool-result domain as
|
|
687
|
+
// `processToolResult`; non-image results pass through untouched.
|
|
688
|
+
async materializeToolResultImages(toolResult, ctx) {
|
|
689
|
+
if (!isRecord(toolResult)) {
|
|
690
|
+
return toolResult;
|
|
691
|
+
}
|
|
692
|
+
const record = toolResult;
|
|
693
|
+
const handleFor = async (payload) => {
|
|
694
|
+
if (!payload)
|
|
695
|
+
return null;
|
|
696
|
+
if ("uri" in payload)
|
|
697
|
+
return payload.uri;
|
|
698
|
+
return this.storeTempImageAsset(ctx, payload.bytes, payload.mimeType);
|
|
699
|
+
};
|
|
700
|
+
// Timeline frames → one image handle per frame.
|
|
701
|
+
if (Array.isArray(record.frames)) {
|
|
702
|
+
const handles = [];
|
|
703
|
+
let stored = 0;
|
|
704
|
+
for (const frame of record.frames) {
|
|
705
|
+
if (!isObjectLike(frame)) {
|
|
706
|
+
handles.push(frame);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const f = { ...frame };
|
|
710
|
+
const payload = extractEmbeddedImage({
|
|
711
|
+
uri: f.dataUrl,
|
|
712
|
+
mimeType: "image/jpeg"
|
|
713
|
+
});
|
|
714
|
+
delete f.dataUrl;
|
|
715
|
+
const id = await handleFor(payload);
|
|
716
|
+
if (id) {
|
|
717
|
+
f.image_id = id;
|
|
718
|
+
stored++;
|
|
719
|
+
}
|
|
720
|
+
handles.push(f);
|
|
721
|
+
}
|
|
722
|
+
const out = { ...record, frames: handles };
|
|
723
|
+
if (stored > 0) {
|
|
724
|
+
out.note = `Captured ${stored} timeline frame(s) as image assets. Call view_image({ image_id }) to inspect a frame.`;
|
|
725
|
+
}
|
|
726
|
+
return out;
|
|
727
|
+
}
|
|
728
|
+
// Single image_content blob → one image handle.
|
|
729
|
+
if (isObjectLike(record.image_content)) {
|
|
730
|
+
const payload = extractEmbeddedImage(record.image_content);
|
|
731
|
+
const id = await handleFor(payload);
|
|
732
|
+
const out = { ...record };
|
|
733
|
+
delete out.image_content;
|
|
734
|
+
if (id) {
|
|
735
|
+
out.image_id = id;
|
|
736
|
+
const base = isString(record.note) ? record.note : "Captured an image.";
|
|
737
|
+
out.note = `${base} Saved as image asset "${id}". Call view_image({ image_id: "${id}" }) to inspect it.`;
|
|
738
|
+
}
|
|
739
|
+
return out;
|
|
740
|
+
}
|
|
741
|
+
return toolResult;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Render the turn's memory block.
|
|
745
|
+
*
|
|
746
|
+
* Memory is user-scoped, but only **this thread's** memories are pasted in:
|
|
747
|
+
* the store grows for the life of the account, and a block that grew with it
|
|
748
|
+
* would eventually cost more than the turn. What the agent gets instead is
|
|
749
|
+
* this thread's notes in full plus a count of the ones saved elsewhere, so
|
|
750
|
+
* it knows to reach them with `memory_search` rather than assume the block
|
|
751
|
+
* is everything.
|
|
752
|
+
*
|
|
753
|
+
* Resource refs are used as stored (asset refs already carry the `asset://`
|
|
754
|
+
* uri captured at save time) — one indexed query, no per-asset lookups on
|
|
755
|
+
* the hot path. Best-effort: a DB hiccup returns an empty block rather than
|
|
756
|
+
* breaking the turn.
|
|
757
|
+
*/
|
|
758
|
+
async buildMemoryBlock(userId, threadId) {
|
|
759
|
+
try {
|
|
760
|
+
// One read over the user's newest memories, split by thread here rather
|
|
761
|
+
// than issuing a second count query.
|
|
762
|
+
const recent = await Memory.list(userId, { limit: MEMORY_SCAN_LIMIT });
|
|
763
|
+
const mine = recent.filter((memory) => memory.thread_id === threadId);
|
|
764
|
+
const elsewhere = recent.length - mine.length;
|
|
765
|
+
if (mine.length === 0 && elsewhere === 0)
|
|
766
|
+
return "";
|
|
767
|
+
// Stored with full ids; shown with short ones, which every capability
|
|
768
|
+
// accepts back. Free text is only rewritten where the scheme types the
|
|
769
|
+
// id (`asset://…`), so a hash in a note stays intact.
|
|
770
|
+
const rendered = mine.slice(0, MEMORY_BLOCK_LIMIT).map((memory) => ({
|
|
771
|
+
kind: memory.kind,
|
|
772
|
+
title: memory.title,
|
|
773
|
+
content: compactAssetUris(memory.content),
|
|
774
|
+
resources: compactResourceIds(Array.isArray(memory.resources) ? memory.resources : [])
|
|
775
|
+
}));
|
|
776
|
+
return formatMemoriesForPrompt(rendered, elsewhere);
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
log.warn("Failed to build memory block", {
|
|
780
|
+
threadId,
|
|
781
|
+
error: err instanceof Error ? err.message : String(err)
|
|
782
|
+
});
|
|
783
|
+
return "";
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
async buildGenerationBlock(userId, threadId) {
|
|
787
|
+
try {
|
|
788
|
+
const [rows, next] = await Prediction.listGenerations(userId, {
|
|
789
|
+
threadId,
|
|
790
|
+
limit: 50
|
|
791
|
+
});
|
|
792
|
+
if (rows.length === 0)
|
|
793
|
+
return "";
|
|
794
|
+
const generations = rows.map((row) => ({
|
|
795
|
+
generation_id: row.id,
|
|
796
|
+
status: row.status,
|
|
797
|
+
model: row.model,
|
|
798
|
+
capability: row.capability,
|
|
799
|
+
tool_call_id: row.tool_call_id,
|
|
800
|
+
asset_uris: (row.asset_ids ?? []).map((id) => `asset://${id}`)
|
|
801
|
+
}));
|
|
802
|
+
return [
|
|
803
|
+
"Saved generations for this conversation (database snapshot):",
|
|
804
|
+
JSON.stringify(generations),
|
|
805
|
+
"Reuse completed assets. A missing tool response does not mean the generation failed. " +
|
|
806
|
+
"Use get_generation or await_generation to check pending work before starting another paid render. " +
|
|
807
|
+
"An interrupted record does not prove that the provider cancelled its request.",
|
|
808
|
+
next
|
|
809
|
+
? "Older records are available through list_generations with this thread_id."
|
|
810
|
+
: ""
|
|
811
|
+
]
|
|
812
|
+
.filter(Boolean)
|
|
813
|
+
.join("\n");
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
log.warn("Failed to load conversation generations", {
|
|
817
|
+
threadId,
|
|
818
|
+
error: error instanceof Error ? error.message : String(error)
|
|
819
|
+
});
|
|
820
|
+
return "Generation history is temporarily unavailable. Check list_generations before retrying earlier renders.";
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* The user's skills, read once per turn.
|
|
825
|
+
*
|
|
826
|
+
* Two halves with different lifetimes, which is why the caller splits them.
|
|
827
|
+
* The catalog (name + description) changes only when the skills table does,
|
|
828
|
+
* so it belongs in the system prompt where a provider's automatic prefix
|
|
829
|
+
* cache can keep it. The body of a skill the message invoked with `/<name>`
|
|
830
|
+
* changes per turn, so it rides at the tail with the other volatile context.
|
|
831
|
+
*
|
|
832
|
+
* Best-effort like the memory block: a DB hiccup costs the skills, not the
|
|
833
|
+
* turn.
|
|
834
|
+
*/
|
|
835
|
+
async loadUserSkills(userId) {
|
|
836
|
+
let rows = [];
|
|
837
|
+
try {
|
|
838
|
+
rows = await Skill.listByUser(userId);
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
log.warn("Failed to load user skills", {
|
|
842
|
+
error: err instanceof Error ? err.message : String(err)
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
// The shipped skills ride in the same catalog. They come off disk rather
|
|
846
|
+
// than the table, so a DB hiccup costs the user's own rows and not these.
|
|
847
|
+
return mergeSystemSkills(rows.map((row) => ({
|
|
848
|
+
name: row.name,
|
|
849
|
+
description: row.description,
|
|
850
|
+
content: row.content
|
|
851
|
+
})));
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Round-trip a permission approval to the client and resolve with the
|
|
855
|
+
* user's decision. Emits a `tool_approval_request`, then waits for the
|
|
856
|
+
* matching `tool_approval_response` (resolved via {@link approvalBridge}).
|
|
857
|
+
* A cancelled wait (stop) is treated as a denial.
|
|
858
|
+
*/
|
|
859
|
+
async requestToolApproval(threadId, request) {
|
|
860
|
+
const approvalId = `appr_${randomUUID()}`;
|
|
861
|
+
await this.session.send({
|
|
862
|
+
type: "tool_approval_request",
|
|
863
|
+
thread_id: threadId,
|
|
864
|
+
approval_id: approvalId,
|
|
865
|
+
tool_name: request.toolName,
|
|
866
|
+
category: request.category,
|
|
867
|
+
message: request.message,
|
|
868
|
+
description: request.description ?? "",
|
|
869
|
+
args: request.args
|
|
870
|
+
});
|
|
871
|
+
try {
|
|
872
|
+
// No timeout — the user may take a while; `stop` cancels this thread.
|
|
873
|
+
const response = await this.deps.approvalBridge.createWaiter(approvalId, 0, threadId);
|
|
874
|
+
const decision = response.decision;
|
|
875
|
+
if (decision === "allow" ||
|
|
876
|
+
decision === "allow_for_chat" ||
|
|
877
|
+
decision === "deny") {
|
|
878
|
+
return decision;
|
|
879
|
+
}
|
|
880
|
+
return "deny";
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
// Cancelled (generation stopped) — treat as a denial.
|
|
884
|
+
return "deny";
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Open the bespoke secret dialog on the client and resolve with what the
|
|
889
|
+
* user did — never with what they typed.
|
|
890
|
+
*
|
|
891
|
+
* The value does not travel over this socket in either direction. The
|
|
892
|
+
* dialog writes it with the client's own `settings.secrets.upsert` call, so
|
|
893
|
+
* the credential never enters the chat transcript, the run's message log, or
|
|
894
|
+
* the model's context; this frame only asks, and the response only reports.
|
|
895
|
+
*
|
|
896
|
+
* A cancelled wait (the user pressed Stop) is a decline, which is the same
|
|
897
|
+
* fail-closed reading the approval prompts take.
|
|
898
|
+
*/
|
|
899
|
+
async requestSecretEntry(threadId, request) {
|
|
900
|
+
const approvalId = `secret_${randomUUID()}`;
|
|
901
|
+
await this.session.send({
|
|
902
|
+
type: "secret_request",
|
|
903
|
+
thread_id: threadId,
|
|
904
|
+
approval_id: approvalId,
|
|
905
|
+
key: request.key,
|
|
906
|
+
description: request.description ?? null,
|
|
907
|
+
reason: request.reason ?? null,
|
|
908
|
+
help_url: request.helpUrl ?? null
|
|
909
|
+
});
|
|
910
|
+
try {
|
|
911
|
+
// No timeout — finding an API key takes as long as it takes; `stop`
|
|
912
|
+
// cancels this thread.
|
|
913
|
+
const response = await this.deps.approvalBridge.createWaiter(approvalId, 0, threadId);
|
|
914
|
+
return response.status === "saved" ? "saved" : "declined";
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
return "declined";
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Execute a single node by type and return its output. Builds a one-node
|
|
922
|
+
* graph and runs it through a fresh `ExecutionSession` (@nodetool-ai/execution),
|
|
923
|
+
* then returns the
|
|
924
|
+
* node's completed result. Backs the `run_node` chat tool.
|
|
925
|
+
*/
|
|
926
|
+
// HOLDOUT (anti-slop/no-unknown-returns): answers with the node's own output
|
|
927
|
+
// — an arbitrary workflow value — or an `{ error }` bag when the run failed.
|
|
928
|
+
async runSingleNode(nodeType, inputs, userId, projectId = null,
|
|
929
|
+
/** The calling turn's gate, when a chat turn is what started this node. */
|
|
930
|
+
gate = null) {
|
|
931
|
+
const jobId = randomUUID();
|
|
932
|
+
const nodeId = "node_0";
|
|
933
|
+
const rawGraph = {
|
|
934
|
+
nodes: [{ id: nodeId, type: nodeType, data: inputs ?? {} }],
|
|
935
|
+
edges: []
|
|
936
|
+
};
|
|
937
|
+
let graph;
|
|
938
|
+
try {
|
|
939
|
+
graph = await this.deps.hydrateGraph(rawGraph);
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
return {
|
|
943
|
+
error: `Failed to prepare node '${nodeType}': ${err instanceof Error ? err.message : String(err)}`
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
if (this.deps.beforeRunJob) {
|
|
947
|
+
try {
|
|
948
|
+
await this.deps.beforeRunJob(graph);
|
|
949
|
+
}
|
|
950
|
+
catch (err) {
|
|
951
|
+
return {
|
|
952
|
+
error: `Node prerequisites failed: ${err instanceof Error ? err.message : String(err)}`
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const context = createRuntimeContext({
|
|
957
|
+
jobId,
|
|
958
|
+
workflowId: null,
|
|
959
|
+
projectId,
|
|
960
|
+
userId,
|
|
961
|
+
workspace: this.session.workspaceResolver
|
|
962
|
+
? await this.session.workspaceResolver(null, userId)
|
|
963
|
+
: null,
|
|
964
|
+
assetOutputMode: this.session.mode === "text" ? "data_uri" : "temp_url"
|
|
965
|
+
});
|
|
966
|
+
context.runTraceContext = getRunTraceScope() ?? null;
|
|
967
|
+
// This context is built here rather than copied from the turn, so the
|
|
968
|
+
// turn's gate has to be put on it by hand. The node's own loops read it
|
|
969
|
+
// with `gateFromContext`; a node run outside a chat turn carries none and
|
|
970
|
+
// gates headless.
|
|
971
|
+
if (gate)
|
|
972
|
+
context.set(PERMISSION_GATE_CONTEXT_KEY, gate);
|
|
973
|
+
context.setResolveExecutor((node) => this.session.resolveExecutor(node));
|
|
974
|
+
if (this.session.resolveNodeType) {
|
|
975
|
+
const resolverObj = isFunctionValue(this.session.resolveNodeType)
|
|
976
|
+
? { resolveNodeType: this.session.resolveNodeType }
|
|
977
|
+
: this.session.resolveNodeType;
|
|
978
|
+
context.setResolveNodeType((type) => resolverObj.resolveNodeType(type));
|
|
979
|
+
}
|
|
980
|
+
// A node selecting a model this runtime cannot honour is refused before
|
|
981
|
+
// the kernel starts; the tool answers with the reason, like every other
|
|
982
|
+
// preparation failure here.
|
|
983
|
+
let session;
|
|
984
|
+
try {
|
|
985
|
+
session = await ExecutionSession.create({
|
|
986
|
+
graph: toRawGraphInput(graph),
|
|
987
|
+
resolveExecutor: (node) => this.session.resolveExecutor(node),
|
|
988
|
+
bridgeFactory: async () => null,
|
|
989
|
+
jobLifecycleBridge: this.session.pythonBridge ?? null,
|
|
990
|
+
jobId,
|
|
991
|
+
context,
|
|
992
|
+
params: {},
|
|
993
|
+
// A node run from a project's agent thread is that project's spend;
|
|
994
|
+
// the ledger this session attaches writes it. Null outside a project.
|
|
995
|
+
projectId: projectId ?? null,
|
|
996
|
+
validateNode: this.session.validateNode
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
catch (err) {
|
|
1000
|
+
if (!isExecutionPreflightError(err))
|
|
1001
|
+
throw err;
|
|
1002
|
+
return { error: err.message };
|
|
1003
|
+
}
|
|
1004
|
+
const result = await session.result;
|
|
1005
|
+
// Capture the node's completed result from the streamed updates.
|
|
1006
|
+
let nodeResult;
|
|
1007
|
+
while (context.hasMessages()) {
|
|
1008
|
+
const msg = context.popMessage();
|
|
1009
|
+
if (msg &&
|
|
1010
|
+
msg.type === "node_update" &&
|
|
1011
|
+
msg.node_id === nodeId &&
|
|
1012
|
+
msg.status === "completed" &&
|
|
1013
|
+
msg.result != null) {
|
|
1014
|
+
nodeResult = msg.result;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (result.status === "failed") {
|
|
1018
|
+
return { error: result.error ?? `Node '${nodeType}' failed` };
|
|
1019
|
+
}
|
|
1020
|
+
if (nodeResult === undefined) {
|
|
1021
|
+
// Fall back to the runner's collected outputs (e.g. Output nodes).
|
|
1022
|
+
return result.outputs ?? { status: result.status };
|
|
1023
|
+
}
|
|
1024
|
+
return this.processToolResult(nodeResult, context);
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Handle an incoming chat message.
|
|
1028
|
+
*
|
|
1029
|
+
* Mirrors Python's full 3-layer flow:
|
|
1030
|
+
* handle_chat_message → handle_message_impl → process_messages
|
|
1031
|
+
* → _run_processor + RegularChatProcessor.process()
|
|
1032
|
+
*
|
|
1033
|
+
* The processor sends messages to a queue. _run_processor reads them:
|
|
1034
|
+
* - type === "message" → persist to DB AND forward to client
|
|
1035
|
+
* - anything else → forward to client only
|
|
1036
|
+
*
|
|
1037
|
+
* RegularChatProcessor.process():
|
|
1038
|
+
* 1. Prepend system prompt if first message isn't system role
|
|
1039
|
+
* 2. while True: messages_to_send = chat_history + unprocessed_messages
|
|
1040
|
+
* 3. Stream chunks (type: "chunk") — forwarded to client (not persisted)
|
|
1041
|
+
* 4. On tool call: build assistant Message + tool result Message (type: "message")
|
|
1042
|
+
* → persisted to DB AND forwarded to client
|
|
1043
|
+
* 5. If unprocessed_messages empty, break
|
|
1044
|
+
* 6. Send done chunk + final assistant Message
|
|
1045
|
+
*/
|
|
1046
|
+
async handleChatMessage(data, requestSeq, signal) {
|
|
1047
|
+
const messageWorkflowId = isString(data.workflow_id)
|
|
1048
|
+
? data.workflow_id
|
|
1049
|
+
: null;
|
|
1050
|
+
const threadId = await this.ensureThreadExists(isString(data.thread_id) ? data.thread_id : undefined, messageWorkflowId, isString(data.project_id) ? data.project_id : undefined);
|
|
1051
|
+
data.thread_id = threadId;
|
|
1052
|
+
// Route this turn takes: a workflow chatbot and a media generation carry
|
|
1053
|
+
// their own model selection (checked in their handlers), a plain chat turn
|
|
1054
|
+
// is served by the language model the composer picked.
|
|
1055
|
+
const workflowTargetHint = isString(data.workflow_target)
|
|
1056
|
+
? data.workflow_target
|
|
1057
|
+
: null;
|
|
1058
|
+
const mediaModeHint = isObjectLike(data.media_generation)
|
|
1059
|
+
? data.media_generation.mode
|
|
1060
|
+
: null;
|
|
1061
|
+
const isPlainChatTurn = workflowTargetHint !== "workflow" &&
|
|
1062
|
+
(!isString(mediaModeHint) || mediaModeHint === "chat");
|
|
1063
|
+
// A plain chat turn without a chosen model used to fall through to the
|
|
1064
|
+
// built-in default and die deep in provider resolution ("No provider
|
|
1065
|
+
// registered for \"empty\"") — after the user's message had been
|
|
1066
|
+
// persisted. Reject it up front and say what to do instead.
|
|
1067
|
+
if (isPlainChatTurn && !isModelSelection(data.provider, data.model)) {
|
|
1068
|
+
await this.session.send({
|
|
1069
|
+
type: "error",
|
|
1070
|
+
message: NO_MODEL_SELECTED_MESSAGE,
|
|
1071
|
+
thread_id: threadId
|
|
1072
|
+
});
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
// Apply defaults — matches Python's handle_chat_message
|
|
1076
|
+
if (!data.model)
|
|
1077
|
+
data.model = this.deps.defaults.model;
|
|
1078
|
+
if (!data.provider)
|
|
1079
|
+
data.provider = this.deps.defaults.provider;
|
|
1080
|
+
const providerId = data.provider;
|
|
1081
|
+
const model = data.model;
|
|
1082
|
+
const workflowId = messageWorkflowId;
|
|
1083
|
+
const userId = this.session.requireUserId();
|
|
1084
|
+
log.debug("Chat message", { threadId, model, provider: providerId });
|
|
1085
|
+
// Save user message to DB — matches Python's _save_message_to_db_async(data)
|
|
1086
|
+
const turnMessage = await this.saveMessageToDb(data);
|
|
1087
|
+
const rootContext = createRuntimeContext({ jobId: turnMessage.id, threadId, workflowId, userId, workspace: null });
|
|
1088
|
+
await registerChatRunTrace(rootContext, { messageId: turnMessage.id, threadId });
|
|
1089
|
+
return withRegisteredRunTrace(rootContext, "chat.turn", async () => {
|
|
1090
|
+
try {
|
|
1091
|
+
if (await threadHasTraceContentTools(userId, threadId, restrictedTraceToolNames())) {
|
|
1092
|
+
suppressRunTraceContent();
|
|
1093
|
+
}
|
|
1094
|
+
await this.handlePersistedChatTurn(data, turnMessage, rootContext, requestSeq, signal);
|
|
1095
|
+
await settleRegisteredRunTrace(rootContext, signal?.aborted ? "cancelled" : "completed");
|
|
1096
|
+
}
|
|
1097
|
+
catch (error) {
|
|
1098
|
+
await settleRegisteredRunTrace(rootContext, signal?.aborted ? "cancelled" : "failed", error instanceof Error ? error.message : String(error));
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
async handlePersistedChatTurn(data, turnMessage, rootContext, requestSeq, signal) {
|
|
1104
|
+
const threadId = String(data.thread_id);
|
|
1105
|
+
const workflowId = isString(data.workflow_id) ? data.workflow_id : null;
|
|
1106
|
+
const userId = this.session.requireUserId();
|
|
1107
|
+
const providerId = String(data.provider);
|
|
1108
|
+
const model = String(data.model);
|
|
1109
|
+
if (requestSeq !== undefined && requestSeq !== this.chatRequestSeq)
|
|
1110
|
+
return;
|
|
1111
|
+
if (!this.session.resolveProvider) {
|
|
1112
|
+
await settleRegisteredRunTrace(rootContext, "failed", "No provider resolver configured");
|
|
1113
|
+
await this.session.send({
|
|
1114
|
+
type: "error",
|
|
1115
|
+
message: "No provider resolver configured",
|
|
1116
|
+
thread_id: threadId
|
|
1117
|
+
});
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
// Route to the workflow processor ONLY when the client explicitly opts in
|
|
1121
|
+
// via `workflow_target: "workflow"`. A bare `workflow_id` is context, not a
|
|
1122
|
+
// routing signal: the editor binds the open workflow so `ui_*` tools target
|
|
1123
|
+
// it, and that ambient id must not hijack the turn into running the
|
|
1124
|
+
// workflow as a chatbot. Genuine workflow-chatbot runs set `workflow_target`
|
|
1125
|
+
// (and carry `workflow_id`/`graph` for the processor to load/execute).
|
|
1126
|
+
const workflowTarget = isString(data.workflow_target)
|
|
1127
|
+
? data.workflow_target
|
|
1128
|
+
: null;
|
|
1129
|
+
if (workflowTarget === "workflow") {
|
|
1130
|
+
await this.handleWorkflowMessage(data, requestSeq, signal);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
// Route to media generation when the client requests a text-to-image or
|
|
1134
|
+
// text-to-video turn. The composer attaches a `media_generation` field
|
|
1135
|
+
// with mode + params; when mode is a media mode we invoke the provider's
|
|
1136
|
+
// textToImage / textToVideo instead of a regular LLM round and return an
|
|
1137
|
+
// assistant message containing MessageImageContent / MessageVideoContent.
|
|
1138
|
+
const mediaGeneration = isObjectLike(data.media_generation)
|
|
1139
|
+
? data.media_generation
|
|
1140
|
+
: null;
|
|
1141
|
+
if (mediaGeneration &&
|
|
1142
|
+
isString(mediaGeneration.mode) &&
|
|
1143
|
+
mediaGeneration.mode !== "chat") {
|
|
1144
|
+
await this.handleMediaGenerationMessage(data, mediaGeneration, requestSeq, signal);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const provider = await this.session.resolveProvider(providerId, userId);
|
|
1148
|
+
// Permission mode for this turn. Governs whether gated tool calls run,
|
|
1149
|
+
// ask for approval, or are blocked. Defaults to "default".
|
|
1150
|
+
const permissionMode = data.permission_mode === "plan" ||
|
|
1151
|
+
data.permission_mode === "auto" ||
|
|
1152
|
+
data.permission_mode === "default"
|
|
1153
|
+
? data.permission_mode
|
|
1154
|
+
: "default";
|
|
1155
|
+
// A surface can send a context-specific system-prompt addendum (e.g. the
|
|
1156
|
+
// App Builder's build-an-app-UI guidance), layered after the base prompt.
|
|
1157
|
+
const extraSystemPrompt = isString(data.system_prompt)
|
|
1158
|
+
? data.system_prompt
|
|
1159
|
+
: null;
|
|
1160
|
+
// Which documents the user has open, and which one has focus. The `ui_*`
|
|
1161
|
+
// tools all require an explicit document id, so this is what makes them
|
|
1162
|
+
// usable — see `formatUiContext`.
|
|
1163
|
+
const uiContext = isObjectLike(data.ui_context)
|
|
1164
|
+
? data.ui_context
|
|
1165
|
+
: null;
|
|
1166
|
+
// History load. Session-based providers (e.g. claude_agent) keep the
|
|
1167
|
+
// conversation upstream, so when a resumable session exists for this
|
|
1168
|
+
// provider+model we do NOT reload the whole thread: we probe a bounded
|
|
1169
|
+
// recent window, send only the turns since the session, and hand the
|
|
1170
|
+
// provider a `loadFullHistory` thunk it calls only if it must prime context
|
|
1171
|
+
// (resume failed / system prompt changed). Otherwise we load the full
|
|
1172
|
+
// history and use the standard slice-based resume. The DB column is the
|
|
1173
|
+
// source of truth; the provider also keeps an in-process cache.
|
|
1174
|
+
// The action contract + tool catalog section, assigned once the codeact
|
|
1175
|
+
// session exists (before the system message is materialized). Read lazily
|
|
1176
|
+
// here because the resume `loadFullHistory` thunk and the prepend both run
|
|
1177
|
+
// after tool resolution.
|
|
1178
|
+
let codeactPromptSection = "";
|
|
1179
|
+
// The user's skills, read once. The catalog goes into the system prompt
|
|
1180
|
+
// below because it is stable — it changes only when the skills table does,
|
|
1181
|
+
// so a provider's prefix cache keeps it across turns. The body of an
|
|
1182
|
+
// invoked skill is volatile and rides at the tail instead.
|
|
1183
|
+
const userSkills = await this.loadUserSkills(userId);
|
|
1184
|
+
const skillCatalogSection = formatSkillCatalogForPrompt(userSkills);
|
|
1185
|
+
const buildSystemContent = () => {
|
|
1186
|
+
const base = buildChatAgentSystemPrompt(permissionMode, extraSystemPrompt, uiContext, workflowId);
|
|
1187
|
+
const sections = [base];
|
|
1188
|
+
if (codeactPromptSection)
|
|
1189
|
+
sections.push(codeactPromptSection);
|
|
1190
|
+
if (skillCatalogSection)
|
|
1191
|
+
sections.push(skillCatalogSection);
|
|
1192
|
+
return sections.join("\n\n");
|
|
1193
|
+
};
|
|
1194
|
+
const systemChatMessage = () => ({
|
|
1195
|
+
role: "system",
|
|
1196
|
+
content: buildSystemContent(),
|
|
1197
|
+
toolCallId: null,
|
|
1198
|
+
toolCalls: null,
|
|
1199
|
+
threadId: null
|
|
1200
|
+
});
|
|
1201
|
+
const copiedMessageIds = new Set();
|
|
1202
|
+
const convertDbMessages = (rows) => {
|
|
1203
|
+
const out = [];
|
|
1204
|
+
for (const m of rows) {
|
|
1205
|
+
copiedMessageIds.add(m.id);
|
|
1206
|
+
seedStoredToolProvenance([m]);
|
|
1207
|
+
const pm = dbMessageToProviderMessage(m, this.session.userId);
|
|
1208
|
+
if (pm)
|
|
1209
|
+
out.push(pm);
|
|
1210
|
+
}
|
|
1211
|
+
// A thread that took an interleave before this was fixed still holds a
|
|
1212
|
+
// tool call with no result. Anthropic 400s on one, so loading such a
|
|
1213
|
+
// thread under a different provider or model would fail every turn from
|
|
1214
|
+
// here on. Patch the history we send; the stored rows are left alone.
|
|
1215
|
+
return repairOrphanedToolCalls(out);
|
|
1216
|
+
};
|
|
1217
|
+
let chatHistory;
|
|
1218
|
+
let priorSession = null;
|
|
1219
|
+
// The provider calls this only on a priming fallback; null on the full path.
|
|
1220
|
+
let loadFullHistory = null;
|
|
1221
|
+
// Absolute checkpoint to persist on the assistant message when the fast path
|
|
1222
|
+
// sent only a delta (so the stored value matches the full-load path); null
|
|
1223
|
+
// when the provider's own checkpoint is already absolute.
|
|
1224
|
+
let sessionCheckpointOverride = null;
|
|
1225
|
+
{
|
|
1226
|
+
const [recent] = await Message.paginate(threadId, {
|
|
1227
|
+
reverse: true,
|
|
1228
|
+
limit: SESSION_PROBE_WINDOW
|
|
1229
|
+
});
|
|
1230
|
+
seedStoredToolProvenance(recent);
|
|
1231
|
+
const probeHasWholeThread = recent.length < SESSION_PROBE_WINDOW;
|
|
1232
|
+
// `recent` is newest-first. Walk to the first boundary marker: an
|
|
1233
|
+
// assistant carrying a session token, or a compaction record. Both claim
|
|
1234
|
+
// to say where history starts, and whichever is newer wins — the walk
|
|
1235
|
+
// meets it first, so the loop decides by stopping.
|
|
1236
|
+
//
|
|
1237
|
+
// A compaction newer than the session must win: the upstream transcript
|
|
1238
|
+
// that session token resumes is the very history compaction just replaced,
|
|
1239
|
+
// so resuming it would send back everything the cut removed (and the cut
|
|
1240
|
+
// usually happened because that transcript no longer fits).
|
|
1241
|
+
let probeSession = null;
|
|
1242
|
+
let probeCompaction = null;
|
|
1243
|
+
const sinceMarkerNewestFirst = [];
|
|
1244
|
+
for (const m of recent) {
|
|
1245
|
+
if (isCompactionMessage(m)) {
|
|
1246
|
+
probeCompaction = m;
|
|
1247
|
+
break;
|
|
1248
|
+
}
|
|
1249
|
+
if (m.role === "assistant" && m.provider_session) {
|
|
1250
|
+
const s = m.provider_session;
|
|
1251
|
+
if (s.providerId === providerId && s.model === model)
|
|
1252
|
+
probeSession = s;
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
sinceMarkerNewestFirst.push(m);
|
|
1256
|
+
}
|
|
1257
|
+
if (probeCompaction) {
|
|
1258
|
+
// COMPACTED path: the summary row plus everything after it, already in
|
|
1259
|
+
// hand from the probe. No session resumes across a compaction, so
|
|
1260
|
+
// `priorSession` stays null and the provider rebuilds from these rows.
|
|
1261
|
+
chatHistory = convertDbMessages([
|
|
1262
|
+
probeCompaction,
|
|
1263
|
+
...sinceMarkerNewestFirst.reverse()
|
|
1264
|
+
]);
|
|
1265
|
+
}
|
|
1266
|
+
else if (probeSession) {
|
|
1267
|
+
// RESUME fast path: the SDK already holds the prior turns, so send only
|
|
1268
|
+
// the messages since the session — no full-thread load.
|
|
1269
|
+
const newTurns = convertDbMessages(sinceMarkerNewestFirst.reverse());
|
|
1270
|
+
chatHistory = newTurns;
|
|
1271
|
+
// The single system message prepended below sits at index 0, so the new
|
|
1272
|
+
// turns begin at index 1 (the provider's relative resume checkpoint).
|
|
1273
|
+
priorSession = {
|
|
1274
|
+
providerId,
|
|
1275
|
+
model,
|
|
1276
|
+
token: probeSession.token,
|
|
1277
|
+
systemHash: probeSession.systemHash,
|
|
1278
|
+
checkpoint: 1
|
|
1279
|
+
};
|
|
1280
|
+
if (probeSession.traceContentSuppressed) {
|
|
1281
|
+
priorSession.traceContentSuppressed = true;
|
|
1282
|
+
}
|
|
1283
|
+
// Absolute position to persist: prior prefix + the prior assistant + the
|
|
1284
|
+
// new turns — identical to what the full-load path would store.
|
|
1285
|
+
sessionCheckpointOverride =
|
|
1286
|
+
probeSession.checkpoint + 1 + newTurns.length;
|
|
1287
|
+
loadFullHistory = async () => {
|
|
1288
|
+
const [rows] = await Message.paginate(threadId, { limit: 1000 });
|
|
1289
|
+
const full = convertDbMessages(historySinceCompaction(rows));
|
|
1290
|
+
full.unshift(systemChatMessage());
|
|
1291
|
+
const scope = getRunTraceScope();
|
|
1292
|
+
if (scope) {
|
|
1293
|
+
await registerRunTraceParents(userId, scope.runId, [...copiedMessageIds].map((id) => ({ kind: "message", id })));
|
|
1294
|
+
}
|
|
1295
|
+
return full;
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
else if (probeHasWholeThread) {
|
|
1299
|
+
// The whole thread fit in the probe window — reuse it, no second query.
|
|
1300
|
+
const rows = historySinceCompaction([...recent].reverse());
|
|
1301
|
+
chatHistory = convertDbMessages(rows);
|
|
1302
|
+
priorSession = lastMatchingProviderSession(rows, providerId, model);
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
// Long thread without a resumable session in the recent window: load it
|
|
1306
|
+
// all (a far-back session still resumes via the slice path). A session
|
|
1307
|
+
// older than the compaction cut is not one of them: searching only the
|
|
1308
|
+
// compacted slice is what keeps the newest marker winning here too.
|
|
1309
|
+
const [rows] = await Message.paginate(threadId, { limit: 1000 });
|
|
1310
|
+
const kept = historySinceCompaction(rows);
|
|
1311
|
+
chatHistory = convertDbMessages(kept);
|
|
1312
|
+
priorSession = lastMatchingProviderSession(kept, providerId, model);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (priorSession?.traceContentSuppressed) {
|
|
1316
|
+
suppressRunTraceContent();
|
|
1317
|
+
}
|
|
1318
|
+
// Expose the read-only `run_search` fan-out primitive by default. A client
|
|
1319
|
+
// can opt out by sending `enable_read_only_search: false`.
|
|
1320
|
+
const enableReadOnlySearch = data.enable_read_only_search !== false;
|
|
1321
|
+
// Assemble the fixed, always-on toolbelt. There is no per-message tool
|
|
1322
|
+
// selection anymore — the agent reasons over the full toolbelt and the
|
|
1323
|
+
// permission gate (below) governs execution.
|
|
1324
|
+
// Google Workspace runs on the token from the user's Google sign-in, so it
|
|
1325
|
+
// only exists on deployments that have a login. Local mode never sees it.
|
|
1326
|
+
const googleWorkspace = isGoogleWorkspaceEnabled();
|
|
1327
|
+
const chatProviders = await this.deps.configuredProviders(userId);
|
|
1328
|
+
// The project this conversation belongs to, when it is a project's own
|
|
1329
|
+
// agent thread. Documents the turn creates land in it rather than in the
|
|
1330
|
+
// loose bucket, without the model having to pass `project_id` every time,
|
|
1331
|
+
// and everything the turn spends is billed to it.
|
|
1332
|
+
const chatProjectId = (await Project.findByThread(userId, threadId))?.id ?? undefined;
|
|
1333
|
+
// The permission gate the belt is wrapped in below. Built before the belt
|
|
1334
|
+
// because the Apify tools carry it into their own run: in discovery mode
|
|
1335
|
+
// the actor policy asks this gate to approve an actor the install has not
|
|
1336
|
+
// allowlisted, so the user sees that question in the same place as every
|
|
1337
|
+
// other permission prompt. The session allow-set is shared per thread so
|
|
1338
|
+
// "Allow for this chat" sticks.
|
|
1339
|
+
const sessionAllow = this.chatSessionAllow.get(threadId) ?? new Set();
|
|
1340
|
+
this.chatSessionAllow.set(threadId, sessionAllow);
|
|
1341
|
+
// A gated call inside a code action parks the guest program until the user
|
|
1342
|
+
// answers, and the gate stops the clock for exactly that long — the wait is
|
|
1343
|
+
// the user's, not the program's, and charged to the action's wall clock it
|
|
1344
|
+
// would kill the very program that asked.
|
|
1345
|
+
const codeactClock = createSandboxClock();
|
|
1346
|
+
const liveMode = { value: permissionMode };
|
|
1347
|
+
this.chatTurnPermissionMode.set(threadId, liveMode);
|
|
1348
|
+
const chatGate = {
|
|
1349
|
+
get mode() {
|
|
1350
|
+
return liveMode.value;
|
|
1351
|
+
},
|
|
1352
|
+
sessionAllow,
|
|
1353
|
+
requestApproval: async (request) => this.requestToolApproval(threadId, request),
|
|
1354
|
+
clock: codeactClock
|
|
1355
|
+
};
|
|
1356
|
+
// The single-node runner is a closure only this package can build, so
|
|
1357
|
+
// `run_node` reaches a capability run as a host-supplied capability rather
|
|
1358
|
+
// than out of the registry. It runs the node on a context of its own, so
|
|
1359
|
+
// the turn's gate is handed over explicitly — without it an agent loop
|
|
1360
|
+
// inside the node finds no gate and runs headless, and approving
|
|
1361
|
+
// `run_node` becomes approval for whatever the node then calls.
|
|
1362
|
+
const runNodeTool = new RunNodeTool((nodeType, inputs) => this.runSingleNode(nodeType, inputs, userId, chatProjectId, chatGate));
|
|
1363
|
+
// The bounds this turn runs under, created once and shared by everything
|
|
1364
|
+
// it starts (invariant I-2). A loop that made its own would hand every
|
|
1365
|
+
// nested agent a fresh allowance, which is no ceiling at all.
|
|
1366
|
+
const turnBudget = await createChatTurnBudget();
|
|
1367
|
+
// The user's external MCP servers (Blender, Hugging Face, …). Their tools
|
|
1368
|
+
// are plain belt tools named `mcp_<server>_<tool>`, so every loop reaches
|
|
1369
|
+
// them the same way; a server that does not answer is skipped, not fatal.
|
|
1370
|
+
let externalMcpTools = [];
|
|
1371
|
+
try {
|
|
1372
|
+
externalMcpTools = await externalMcpToolsFor(userId);
|
|
1373
|
+
}
|
|
1374
|
+
catch (err) {
|
|
1375
|
+
log.warn("External MCP tools unavailable for this turn", {
|
|
1376
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
const legacyCapabilities = [runNodeTool, ...externalMcpTools].map(capabilityFromTool);
|
|
1380
|
+
const nativeSpecs = [
|
|
1381
|
+
...availableBuiltinToolNames().map((name) => capabilityForName(name).spec),
|
|
1382
|
+
...(googleWorkspace ? GOOGLE_WORKSPACE_TOOL_NAMES : []).map((name) => capabilityForName(name).spec),
|
|
1383
|
+
...[...APIFY_TOOL_NAMES, ...SERPAPI_TOOL_NAMES].map((name) => capabilityForName(name).spec),
|
|
1384
|
+
...getAllMcpCapabilitySpecs({ registry: this.session.nodeRegistry, providers: chatProviders }),
|
|
1385
|
+
capabilityForName("list_collections").spec,
|
|
1386
|
+
capabilityForName("query_collection").spec
|
|
1387
|
+
];
|
|
1388
|
+
const specsByName = new Map();
|
|
1389
|
+
for (const spec of [...nativeSpecs, ...legacyCapabilities.map((entry) => entry.spec)]) {
|
|
1390
|
+
if (!specsByName.has(spec.name)) {
|
|
1391
|
+
specsByName.set(spec.name, spec);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
const baseSpecs = [...specsByName.values()];
|
|
1395
|
+
const legacyByName = new Map(legacyCapabilities.map((entry) => [entry.spec.name, entry]));
|
|
1396
|
+
// Delegated executors still consume Tool[]. Adapt only at that boundary.
|
|
1397
|
+
// Each child context owns a separate run so its memory and depth are local.
|
|
1398
|
+
const childRuns = new WeakMap();
|
|
1399
|
+
const childRun = (context) => {
|
|
1400
|
+
let run = childRuns.get(context);
|
|
1401
|
+
if (!run) {
|
|
1402
|
+
run = createCapabilityRun({
|
|
1403
|
+
context, gate: chatGate, nodeRegistry: this.session.nodeRegistry,
|
|
1404
|
+
providers: chatProviders, budget: turnBudget,
|
|
1405
|
+
availableSecrets: contextSecretAvailability(context),
|
|
1406
|
+
...mcpToolHostDeps(), capabilities: legacyCapabilities
|
|
1407
|
+
});
|
|
1408
|
+
childRuns.set(context, run);
|
|
1409
|
+
}
|
|
1410
|
+
return run;
|
|
1411
|
+
};
|
|
1412
|
+
const baseTools = baseSpecs.map((spec) => {
|
|
1413
|
+
const entry = legacyByName.get(spec.name)
|
|
1414
|
+
?? capabilityForName(spec.name);
|
|
1415
|
+
return toolFromCapability(entry.spec, entry.impl, childRun);
|
|
1416
|
+
});
|
|
1417
|
+
// Inject the recursive-decomposition primitive (ungated — it spawns a
|
|
1418
|
+
// child loop whose own tools are the gated `baseTools`). Child events
|
|
1419
|
+
// stream back tagged with `parent_tool_call_id` so the UI can nest cards.
|
|
1420
|
+
const serverTools = baseSpecs.slice();
|
|
1421
|
+
// The same runtime the delegation tools take, kept for the capability run
|
|
1422
|
+
// below: provider, model, the parent belt, and the event forwarder.
|
|
1423
|
+
let subAgentRuntime;
|
|
1424
|
+
{
|
|
1425
|
+
const subtaskThreadId = threadId;
|
|
1426
|
+
const subtaskWorkflowId = workflowId;
|
|
1427
|
+
const forwardSubtaskMessage = async (msg) => {
|
|
1428
|
+
const enriched = { ...msg };
|
|
1429
|
+
if (enriched.thread_id == null)
|
|
1430
|
+
enriched.thread_id = subtaskThreadId;
|
|
1431
|
+
if (enriched.workflow_id == null)
|
|
1432
|
+
enriched.workflow_id = subtaskWorkflowId;
|
|
1433
|
+
try {
|
|
1434
|
+
await this.session.send(enriched);
|
|
1435
|
+
// Tool calls inside a subtask only arrive here as transient
|
|
1436
|
+
// tool_call_update events; the chat UI needs a persistent assistant
|
|
1437
|
+
// message with tool_calls to render a ToolCallCard. Emit a synthetic
|
|
1438
|
+
// one so child tool calls show up as cards nested below the parent
|
|
1439
|
+
// run_subtask card.
|
|
1440
|
+
await this.emitSyntheticToolCallCard(enriched);
|
|
1441
|
+
}
|
|
1442
|
+
catch (err) {
|
|
1443
|
+
log.warn("Failed to forward subtask event", {
|
|
1444
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
subAgentRuntime = {
|
|
1449
|
+
provider,
|
|
1450
|
+
model,
|
|
1451
|
+
parentTools: () => baseTools,
|
|
1452
|
+
forwardMessage: forwardSubtaskMessage,
|
|
1453
|
+
background: new BackgroundSubtaskRegistry(),
|
|
1454
|
+
// Every delegated loop reserves against this turn's cap rather than
|
|
1455
|
+
// opening one of its own.
|
|
1456
|
+
budget: turnBudget
|
|
1457
|
+
};
|
|
1458
|
+
// All four delegation tools reach the belt as capabilities over this
|
|
1459
|
+
// runtime. The class is still what runs — the `agents` module builds one
|
|
1460
|
+
// per call — so the depth gate, the child's inherited belt (with a
|
|
1461
|
+
// `run_subtask` of its own stitched in by `buildChildToolset`, since this
|
|
1462
|
+
// snapshot deliberately predates the unshift), and the
|
|
1463
|
+
// `parent_tool_call_id` / `subtask_depth` tagging are unchanged.
|
|
1464
|
+
// `start_subtask` / `wait_subtasks` share the per-turn registry above:
|
|
1465
|
+
// spawn returns immediately, and the parent collects on its own terms.
|
|
1466
|
+
serverTools.unshift(capabilityForName("run_subtask").spec);
|
|
1467
|
+
serverTools.unshift(capabilityForName("start_subtask").spec);
|
|
1468
|
+
serverTools.unshift(capabilityForName("wait_subtasks").spec);
|
|
1469
|
+
// Plan mode's own capability: `create_plan` runs the TaskPlanner over the
|
|
1470
|
+
// objective and streams the plan into the thread, executing nothing. It
|
|
1471
|
+
// is offered only here, because plan mode is the one place where a plan
|
|
1472
|
+
// *is* the deliverable — everywhere else the agent does the work instead
|
|
1473
|
+
// of describing it. The belt is a per-turn snapshot, so this reads the
|
|
1474
|
+
// mode the turn started in — `liveMode` covers a mid-turn switch for the
|
|
1475
|
+
// gate, not for which tools were offered.
|
|
1476
|
+
if (permissionMode === "plan") {
|
|
1477
|
+
serverTools.unshift(capabilityForName("create_plan").spec);
|
|
1478
|
+
}
|
|
1479
|
+
// The other half: `execute_plan` runs a plan the user has already seen,
|
|
1480
|
+
// on the same ParallelTaskExecutor `Agent` uses. It is on the belt in
|
|
1481
|
+
// every mode, gated — in plan mode the gate answers
|
|
1482
|
+
// `blocked_in_plan_mode`, which tells the model to have the user switch
|
|
1483
|
+
// out, where dropping it from the belt entirely would only say the tool
|
|
1484
|
+
// does not exist. It is *offered* everywhere but plan mode, below.
|
|
1485
|
+
serverTools.unshift(capabilityForName("execute_plan").spec);
|
|
1486
|
+
// Read-only fan-out search (opt-in). Reuses the same runtime — the
|
|
1487
|
+
// capability filters the parent belt to its read-only allowlist
|
|
1488
|
+
// internally, so passing the full snapshot is correct.
|
|
1489
|
+
if (enableReadOnlySearch) {
|
|
1490
|
+
serverTools.unshift(capabilityForName("run_search").spec);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
const serverToolMap = new Map(serverTools.map((t) => [t.name, t]));
|
|
1494
|
+
const workflowDocumentToolNames = new Set(WORKFLOW_DOCUMENT_TOOL_NAMES);
|
|
1495
|
+
log.info("Resolved server tools", {
|
|
1496
|
+
permissionMode,
|
|
1497
|
+
resolved: serverTools.map((t) => t.name)
|
|
1498
|
+
});
|
|
1499
|
+
const serverSchemas = serverTools.map((t) => capabilityProviderTool(t));
|
|
1500
|
+
// Every client tool the connected UI registered is exposed. They used to be
|
|
1501
|
+
// gated on an active workflow, which made the editor tools unreachable from
|
|
1502
|
+
// plain chat; they are deferred behind `nodetool.searchTools()` anyway, and each one now
|
|
1503
|
+
// takes an explicit document id, so the gate cost reach without buying
|
|
1504
|
+
// safety. Which ids are valid comes from `ui_context` in the system prompt.
|
|
1505
|
+
const clientToolNames = Object.keys(this.deps.clientTools());
|
|
1506
|
+
const clientSchemas = [];
|
|
1507
|
+
for (const [name, manifest] of Object.entries(this.deps.clientTools())) {
|
|
1508
|
+
if (serverToolMap.has(name))
|
|
1509
|
+
continue;
|
|
1510
|
+
// The frontend manifest carries the JSON schema under `parameters`
|
|
1511
|
+
// (FrontendToolRegistry.getManifest); accept `inputSchema` too for any
|
|
1512
|
+
// client that uses the provider-tool field name.
|
|
1513
|
+
const schema = typeof manifest.parameters === "object"
|
|
1514
|
+
? manifest.parameters
|
|
1515
|
+
: typeof manifest.inputSchema === "object"
|
|
1516
|
+
? manifest.inputSchema
|
|
1517
|
+
: undefined;
|
|
1518
|
+
clientSchemas.push({
|
|
1519
|
+
name,
|
|
1520
|
+
description: isString(manifest.description)
|
|
1521
|
+
? manifest.description
|
|
1522
|
+
: undefined,
|
|
1523
|
+
inputSchema: schema
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
const allSchemas = [...serverSchemas, ...clientSchemas];
|
|
1527
|
+
// A chat turn with tools always runs in CodeAct: the model acts by writing
|
|
1528
|
+
// sandboxed JavaScript over the toolbelt (docs/codeact-design.md), and the
|
|
1529
|
+
// session's in-sandbox `nodetool.searchTools()` is the discovery path. The session
|
|
1530
|
+
// is created below, once the tool router and processing context exist.
|
|
1531
|
+
const useCodeAct = allSchemas.length > 0;
|
|
1532
|
+
// The tool list handed to the provider: `execute_code`, the direct tools
|
|
1533
|
+
// (DIRECT_TOOL_NAMES) and `view_image`, pushed once the session exists.
|
|
1534
|
+
const providerToolSchemas = [];
|
|
1535
|
+
log.info("Provider tool schemas", {
|
|
1536
|
+
permissionMode,
|
|
1537
|
+
serverToolCount: serverTools.length,
|
|
1538
|
+
clientToolCount: clientToolNames.length,
|
|
1539
|
+
codeact: useCodeAct
|
|
1540
|
+
});
|
|
1541
|
+
// Create a processing context for tool execution
|
|
1542
|
+
// A chat turn without a workflow still resolves a workspace — the user's
|
|
1543
|
+
// default one. It used to fall back to the whole OS temp dir, which is
|
|
1544
|
+
// neither bounded nor anywhere the user would look for what an agent wrote.
|
|
1545
|
+
const chatWorkspace = this.session.workspaceResolver
|
|
1546
|
+
? await this.session.workspaceResolver(await this.threadWorkspaceWorkflowId(userId, threadId, workflowId), userId)
|
|
1547
|
+
: null;
|
|
1548
|
+
const ctx = createRuntimeContext({
|
|
1549
|
+
jobId: randomUUID(),
|
|
1550
|
+
workflowId,
|
|
1551
|
+
threadId: threadId || null,
|
|
1552
|
+
projectId: chatProjectId,
|
|
1553
|
+
userId,
|
|
1554
|
+
workspace: chatWorkspace,
|
|
1555
|
+
authToken: this.deps.authToken()
|
|
1556
|
+
});
|
|
1557
|
+
ctx.runTraceContext = rootContext.runTraceContext;
|
|
1558
|
+
ctx.set("chat_message_id", turnMessage.id);
|
|
1559
|
+
if (ctx.runTraceContext) {
|
|
1560
|
+
await registerRunTraceParents(userId, ctx.runTraceContext.runId, [...copiedMessageIds].map((id) => ({ kind: "message", id })));
|
|
1561
|
+
}
|
|
1562
|
+
const detachPredictions = attachChatPredictionForwarder((listener) => ctx.addMessageListener(listener), (msg) => this.session.sendDetached(msg), { threadId: threadId || null, workflowId });
|
|
1563
|
+
// A chat turn that generates an image or a video spends real money without
|
|
1564
|
+
// ever constructing an ExecutionSession, so the ledger is attached here
|
|
1565
|
+
// too — otherwise the turn is invisible to `nodetool costs`.
|
|
1566
|
+
const detachCostLedger = attachRunCostLedger(ctx, {
|
|
1567
|
+
userId,
|
|
1568
|
+
workflowId: workflowId ?? null,
|
|
1569
|
+
// A project's own thread attributes its spend to that project, so
|
|
1570
|
+
// `nodetool costs` can answer what a project cost.
|
|
1571
|
+
projectId: chatProjectId ?? null,
|
|
1572
|
+
resolveSecret: (key) => ctx.getSecret(key)
|
|
1573
|
+
});
|
|
1574
|
+
// Stamp the turn's own selection so a tool that launches another harness
|
|
1575
|
+
// inherits this chat's provider/model when the call doesn't name one.
|
|
1576
|
+
ctx.set(ACTIVE_MODEL_CONTEXT_KEY, {
|
|
1577
|
+
provider: providerId,
|
|
1578
|
+
model
|
|
1579
|
+
});
|
|
1580
|
+
// The channel every nested loop already carries: an Agent node started
|
|
1581
|
+
// through `run_node`, a JS script, a sub-agent three levels down all read
|
|
1582
|
+
// the budget off the context instead of creating one (`budgetFromContext`).
|
|
1583
|
+
ctx.set(RUN_BUDGET_CONTEXT_KEY, turnBudget);
|
|
1584
|
+
// The same channel carries the turn's permission gate, so a loop this
|
|
1585
|
+
// package never constructs gates through the user's mode instead of
|
|
1586
|
+
// building an ungated run of its own (`gateFromContext`, invariant I-1).
|
|
1587
|
+
// The object is shared by reference: `chatGate.mode` reads `liveMode`, so
|
|
1588
|
+
// a mid-turn `set_permission_mode` reaches a node that started before it.
|
|
1589
|
+
ctx.set(PERMISSION_GATE_CONTEXT_KEY, chatGate);
|
|
1590
|
+
// The capability run for this turn: the same gate the belt is wrapped in,
|
|
1591
|
+
// this context, and the singletons the tool constructors take today. Every
|
|
1592
|
+
// capability a host must supply itself goes in `capabilities` — `run_node`
|
|
1593
|
+
// carries a closure only this package can build. The codeact session below
|
|
1594
|
+
// mounts it, so an action can import
|
|
1595
|
+
// `@nodetool-ai/sandbox-nodetool/<namespace>` and land on `run.invoke`.
|
|
1596
|
+
this.chatCapabilityRun = createCapabilityRun({
|
|
1597
|
+
context: ctx,
|
|
1598
|
+
gate: chatGate,
|
|
1599
|
+
projectId: chatProjectId,
|
|
1600
|
+
availableSecrets: contextSecretAvailability(ctx),
|
|
1601
|
+
nodeRegistry: this.session.nodeRegistry,
|
|
1602
|
+
providers: chatProviders,
|
|
1603
|
+
subAgent: subAgentRuntime,
|
|
1604
|
+
budget: turnBudget,
|
|
1605
|
+
secretPrompt: (request) => this.requestSecretEntry(threadId, request),
|
|
1606
|
+
...mcpToolHostDeps(),
|
|
1607
|
+
capabilities: legacyCapabilities
|
|
1608
|
+
});
|
|
1609
|
+
const gameAssetRun = this.chatCapabilityRun;
|
|
1610
|
+
const gameAssetNodeRunner = (args) => gameAssetRun.invoke("run_node", args);
|
|
1611
|
+
ctx.set(GAME_ASSET_NODE_RUNNER_CONTEXT_KEY, gameAssetNodeRunner);
|
|
1612
|
+
// CodeAct session for this turn. Created here so its prompt section is in
|
|
1613
|
+
// place before the system message is materialized below. The tool router
|
|
1614
|
+
// (`executeTool`, defined further down) is late-bound through a ref; it is
|
|
1615
|
+
// assigned before the provider loop can run any action.
|
|
1616
|
+
let codeactExecuteToolRef = null;
|
|
1617
|
+
let codeactSession = null;
|
|
1618
|
+
if (useCodeAct) {
|
|
1619
|
+
// The core set (file, search, fetch, todo, delegation) and discovery
|
|
1620
|
+
// (which providers, models and node types this install has) are also
|
|
1621
|
+
// plain tool calls: one question with one answer, which routing through
|
|
1622
|
+
// a sandbox action only delays. They stay on the belt so code can still
|
|
1623
|
+
// compose them; the prompt documents the direct call.
|
|
1624
|
+
// `create_plan` joins them in plan mode: the plan is that turn's whole
|
|
1625
|
+
// deliverable, and a deliverable reached only by writing a sandbox action
|
|
1626
|
+
// to import it is one the model routinely does not reach for.
|
|
1627
|
+
// `execute_plan` is direct for the same reason, in every other mode:
|
|
1628
|
+
// running the plan the user just approved is that turn's whole point.
|
|
1629
|
+
const directNames = new Set(DIRECT_TOOL_NAMES);
|
|
1630
|
+
if (permissionMode === "plan")
|
|
1631
|
+
directNames.add("create_plan");
|
|
1632
|
+
else
|
|
1633
|
+
directNames.add("execute_plan");
|
|
1634
|
+
const directSchemas = allSchemas.filter((s) => s.name !== "view_image" && directNames.has(s.name));
|
|
1635
|
+
codeactSession = createChatCodeActSession({
|
|
1636
|
+
includeProductKnowledge: false,
|
|
1637
|
+
tools: allSchemas
|
|
1638
|
+
.filter((s) => s.name !== "view_image")
|
|
1639
|
+
.map((s) => ({
|
|
1640
|
+
name: s.name,
|
|
1641
|
+
description: s.description,
|
|
1642
|
+
inputSchema: s.inputSchema
|
|
1643
|
+
})),
|
|
1644
|
+
sandboxPackages: sandboxPackagesForChat({
|
|
1645
|
+
source: uiContext?.source,
|
|
1646
|
+
focusedType: uiContext?.focused?.type,
|
|
1647
|
+
catalog: getProcessSandboxModuleCatalog()
|
|
1648
|
+
}),
|
|
1649
|
+
directToolNames: directSchemas.map((s) => s.name),
|
|
1650
|
+
executeTool: async (call) => {
|
|
1651
|
+
if (!codeactExecuteToolRef) {
|
|
1652
|
+
throw new Error("Tool router not ready");
|
|
1653
|
+
}
|
|
1654
|
+
return codeactExecuteToolRef({
|
|
1655
|
+
id: call.id,
|
|
1656
|
+
name: call.name,
|
|
1657
|
+
args: call.args
|
|
1658
|
+
});
|
|
1659
|
+
},
|
|
1660
|
+
residentToolNames: [
|
|
1661
|
+
...CODEACT_RESIDENT_TOOL_NAMES,
|
|
1662
|
+
...RESIDENT_TOOL_NAMES,
|
|
1663
|
+
...focusedUiToolNames(uiContext, allSchemas.map((schema) => schema.name))
|
|
1664
|
+
],
|
|
1665
|
+
context: ctx,
|
|
1666
|
+
signal,
|
|
1667
|
+
clock: codeactClock,
|
|
1668
|
+
capabilityRun: this.chatCapabilityRun ?? undefined
|
|
1669
|
+
});
|
|
1670
|
+
providerToolSchemas.push(codeactSession.providerTool);
|
|
1671
|
+
providerToolSchemas.push(...directSchemas);
|
|
1672
|
+
// `view_image` stays a direct provider tool: it is the one channel that
|
|
1673
|
+
// puts pixels into the model's context, and pixels cannot ride the
|
|
1674
|
+
// sandbox's JSON observation envelope.
|
|
1675
|
+
const viewImage = allSchemas.find((s) => s.name === "view_image");
|
|
1676
|
+
if (viewImage)
|
|
1677
|
+
providerToolSchemas.push(viewImage);
|
|
1678
|
+
codeactPromptSection = codeactSession.systemPromptSection;
|
|
1679
|
+
log.info("Chat turn running in codeact mode", {
|
|
1680
|
+
threadId,
|
|
1681
|
+
toolCount: allSchemas.length,
|
|
1682
|
+
directToolCount: directSchemas.length
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
// Prepend system prompt if first message isn't system role — matches Python
|
|
1686
|
+
if (chatHistory.length === 0 || chatHistory[0].role !== "system") {
|
|
1687
|
+
chatHistory.unshift({
|
|
1688
|
+
role: "system",
|
|
1689
|
+
content: buildSystemContent(),
|
|
1690
|
+
toolCallId: null,
|
|
1691
|
+
toolCalls: null,
|
|
1692
|
+
threadId: null
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
// The agent now discovers and queries collections itself via the
|
|
1696
|
+
// list_collections / query_collection tools, so there is no client-driven
|
|
1697
|
+
// RAG pre-query here.
|
|
1698
|
+
const userContent = extractTextContent(data.content);
|
|
1699
|
+
// Final assistant text. Updated as the provider emits assistant
|
|
1700
|
+
// messages; the last one wins.
|
|
1701
|
+
let content = "";
|
|
1702
|
+
// The session token to persist onto the assistant message. Seeds from the
|
|
1703
|
+
// prior turn's token (so a session-based provider resumes) and is refreshed
|
|
1704
|
+
// whenever the provider emits a new ProviderSessionUpdate this turn.
|
|
1705
|
+
let capturedSession = priorSession;
|
|
1706
|
+
// What to persist onto the assistant message: the provider's token, but with
|
|
1707
|
+
// the absolute checkpoint when the fast path sent only a delta (the
|
|
1708
|
+
// provider's emitted checkpoint is relative to the trimmed view).
|
|
1709
|
+
const sessionForPersist = () => {
|
|
1710
|
+
if (!capturedSession) {
|
|
1711
|
+
return null;
|
|
1712
|
+
}
|
|
1713
|
+
const contentSuppressed = getRunTraceScope()?.policy.contentSuppressed || priorSession?.traceContentSuppressed || capturedSession.traceContentSuppressed;
|
|
1714
|
+
const persisted = { ...capturedSession };
|
|
1715
|
+
if (sessionCheckpointOverride != null) {
|
|
1716
|
+
persisted.checkpoint = sessionCheckpointOverride;
|
|
1717
|
+
}
|
|
1718
|
+
if (contentSuppressed) {
|
|
1719
|
+
persisted.traceContentSuppressed = true;
|
|
1720
|
+
}
|
|
1721
|
+
return persisted;
|
|
1722
|
+
};
|
|
1723
|
+
// Cap on tool-calling rounds before the loop stops. Generous enough to
|
|
1724
|
+
// build a multi-component app UI or run a long edit session in one turn —
|
|
1725
|
+
// 10 was too low and cut off the app builder mid-build.
|
|
1726
|
+
const MAX_TOOL_ROUNDS = 50;
|
|
1727
|
+
// Items still read from a superseded turn, purely to catch tool results
|
|
1728
|
+
// already in flight. A provider that honors the abort ends well inside it.
|
|
1729
|
+
const MAX_SUPERSEDED_DRAIN_ITEMS = 200;
|
|
1730
|
+
const useTools = providerToolSchemas.length > 0;
|
|
1731
|
+
// The wire messages. The provider's generateLoop owns the tool-calling
|
|
1732
|
+
// rounds and message assembly from here.
|
|
1733
|
+
let messagesToSend = [...chatHistory];
|
|
1734
|
+
// Everything turn-scoped, gathered in one place and appended once, after
|
|
1735
|
+
// media resolution, to the last user message. None of it is persisted, so
|
|
1736
|
+
// whatever is injected here is absent from the history a later turn sends
|
|
1737
|
+
// — which is exactly why it must sit behind every byte a later turn will
|
|
1738
|
+
// reuse. See `appendContextToLastUser`.
|
|
1739
|
+
const volatileContext = [];
|
|
1740
|
+
// Durable memories (memory_* tools), so the agent starts each turn aware
|
|
1741
|
+
// of what it recorded — project facts, decisions, and the assets it
|
|
1742
|
+
// generated for reuse. This thread's in full, the rest as a count it can
|
|
1743
|
+
// search. Deterministic and always-on.
|
|
1744
|
+
if (threadId) {
|
|
1745
|
+
const memoryBlock = await this.buildMemoryBlock(userId, threadId);
|
|
1746
|
+
if (memoryBlock)
|
|
1747
|
+
volatileContext.push(memoryBlock);
|
|
1748
|
+
const generationBlock = await this.buildGenerationBlock(userId, threadId);
|
|
1749
|
+
if (generationBlock)
|
|
1750
|
+
volatileContext.push(generationBlock);
|
|
1751
|
+
}
|
|
1752
|
+
// The bodies of any skills the message invoked with `/<name>`. The catalog
|
|
1753
|
+
// half is stable and already sits in the system prompt.
|
|
1754
|
+
const invokedSkills = invokedSkillsSection(userSkills, userContent);
|
|
1755
|
+
if (invokedSkills)
|
|
1756
|
+
volatileContext.push(invokedSkills);
|
|
1757
|
+
// Expand any `asset://<id>.<ext>` references the composer or a prior turn
|
|
1758
|
+
// attached and dereference the URIs to data the provider can consume.
|
|
1759
|
+
// Image / audio mentions typed inline in a text part get split into proper
|
|
1760
|
+
// blocks first (mirroring what the workflow agent node does in
|
|
1761
|
+
// `buildUserMessage`), then every block with an `asset://` / storage URI is
|
|
1762
|
+
// resolved to a data URI. Text-document mentions are inlined as their
|
|
1763
|
+
// decoded contents. Without this step the provider would see literal
|
|
1764
|
+
// `asset://…` text and never look at the referenced media.
|
|
1765
|
+
//
|
|
1766
|
+
// The volatile block is appended after resolution, so a memory's
|
|
1767
|
+
// `asset://` reference stays a reference instead of being inlined as a
|
|
1768
|
+
// data URI. Both steps are one closure because compaction rebuilds the
|
|
1769
|
+
// wire messages from a shortened history and must reproduce them exactly.
|
|
1770
|
+
const withTurnContext = async (base) => {
|
|
1771
|
+
const resolved = await ctx.resolveMessageMediaUris(base);
|
|
1772
|
+
return volatileContext.length > 0
|
|
1773
|
+
? appendContextToLastUser(resolved, volatileContext.join("\n\n"))
|
|
1774
|
+
: resolved;
|
|
1775
|
+
};
|
|
1776
|
+
messagesToSend = await withTurnContext(messagesToSend);
|
|
1777
|
+
// Run one tool call and return the result to feed back to the model. Owns
|
|
1778
|
+
// server/client tool routing, side effects (client round-trips via the
|
|
1779
|
+
// ToolBridge), and asset materialization; the provider's loop orchestrates.
|
|
1780
|
+
// Image results (e.g. ui_3d_capture_view) return MessageContent blocks so
|
|
1781
|
+
// vision providers can see them; everything else returns result text.
|
|
1782
|
+
const executeTool = async (toolCall) => {
|
|
1783
|
+
let toolResult;
|
|
1784
|
+
const serverTool = serverToolMap.get(toolCall.name);
|
|
1785
|
+
const preferClientDocumentTool = workflowDocumentToolNames.has(toolCall.name) &&
|
|
1786
|
+
this.deps.clientTools()[toolCall.name] !== undefined;
|
|
1787
|
+
if (preferClientDocumentTool) {
|
|
1788
|
+
// The renderer owns live, potentially unsaved state. Use it when it is
|
|
1789
|
+
// present; the server implementation remains the headless fallback.
|
|
1790
|
+
await this.session.send({
|
|
1791
|
+
type: "tool_call",
|
|
1792
|
+
thread_id: threadId,
|
|
1793
|
+
tool_call_id: toolCall.id,
|
|
1794
|
+
name: toolCall.name,
|
|
1795
|
+
args: toolCall.args
|
|
1796
|
+
});
|
|
1797
|
+
const clientResult = await this.deps.toolBridge.createWaiter(toolCall.id, 300_000, threadId);
|
|
1798
|
+
toolResult =
|
|
1799
|
+
clientResult.result ?? clientResult.content ?? clientResult;
|
|
1800
|
+
}
|
|
1801
|
+
else if (serverTool) {
|
|
1802
|
+
try {
|
|
1803
|
+
const args = { ...Tool.stripMessage(toolCall.args) };
|
|
1804
|
+
if (serverTool.needsToolCallId) {
|
|
1805
|
+
args["_tool_call_id"] = toolCall.id;
|
|
1806
|
+
}
|
|
1807
|
+
if (!this.chatCapabilityRun) {
|
|
1808
|
+
throw new Error("Chat capability run is unavailable");
|
|
1809
|
+
}
|
|
1810
|
+
toolResult = await this.chatCapabilityRun.invoke(serverTool.name, args);
|
|
1811
|
+
}
|
|
1812
|
+
catch (err) {
|
|
1813
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1814
|
+
log.error("Tool execution failed", {
|
|
1815
|
+
tool: toolCall.name,
|
|
1816
|
+
error: errMsg
|
|
1817
|
+
});
|
|
1818
|
+
toolResult = { error: errMsg };
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
else if (this.deps.clientTools()[toolCall.name]) {
|
|
1822
|
+
// Client-side tool — round-trip through the UI via the ToolBridge.
|
|
1823
|
+
await this.session.send({
|
|
1824
|
+
type: "tool_call",
|
|
1825
|
+
thread_id: threadId,
|
|
1826
|
+
tool_call_id: toolCall.id,
|
|
1827
|
+
name: toolCall.name,
|
|
1828
|
+
args: toolCall.args
|
|
1829
|
+
});
|
|
1830
|
+
const clientResult = await this.deps.toolBridge.createWaiter(toolCall.id, 300_000, threadId);
|
|
1831
|
+
toolResult =
|
|
1832
|
+
clientResult.result ?? clientResult.content ?? clientResult;
|
|
1833
|
+
}
|
|
1834
|
+
else {
|
|
1835
|
+
toolResult = { error: `Tool "${toolCall.name}" not available` };
|
|
1836
|
+
}
|
|
1837
|
+
// view_image is the ONE mechanism that puts pixels into the model's
|
|
1838
|
+
// context: its image_content rides the tool message so the model sees the
|
|
1839
|
+
// image this turn. (The chat loop builds tool messages inside
|
|
1840
|
+
// provider.generateLoop, so the tool return value is the only hook.)
|
|
1841
|
+
if (toolCall.name === "view_image") {
|
|
1842
|
+
const injected = extractInjectableImages(toolResult);
|
|
1843
|
+
if (injected) {
|
|
1844
|
+
return [{ type: "text", text: injected.text }, ...injected.images];
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
// Every other tool that produced pixels (timeline frames, 3D capture, …)
|
|
1848
|
+
// gets them persisted as temp image assets; the model receives only a
|
|
1849
|
+
// handle and calls view_image when it wants to look.
|
|
1850
|
+
toolResult = await this.materializeToolResultImages(toolResult, ctx);
|
|
1851
|
+
const processed = compactResourceIds(await this.processToolResult(toolResult, ctx));
|
|
1852
|
+
return isString(processed) ? processed : JSON.stringify(processed);
|
|
1853
|
+
};
|
|
1854
|
+
// Late-bind the codeact bridge to the router above, and route
|
|
1855
|
+
// `execute_code` calls into the sandbox session; everything else (only
|
|
1856
|
+
// `view_image` is offered in codeact mode) keeps the normal path.
|
|
1857
|
+
codeactExecuteToolRef = executeTool;
|
|
1858
|
+
const session = codeactSession;
|
|
1859
|
+
const beltToolNames = new Set(allSchemas.map((s) => s.name));
|
|
1860
|
+
const effectiveExecuteTool = async (rawCall) => {
|
|
1861
|
+
// A model that read the CodeAct prompt sometimes calls
|
|
1862
|
+
// `tools.<name>` at the top level. Recover the plain name first, so the
|
|
1863
|
+
// call reaches the tool instead of dying as "no such tool".
|
|
1864
|
+
const name = normalizeToolCallName(rawCall.name);
|
|
1865
|
+
const toolCall = name === rawCall.name ? rawCall : { ...rawCall, name };
|
|
1866
|
+
if (!session)
|
|
1867
|
+
return executeTool(toolCall);
|
|
1868
|
+
if (name === EXECUTE_CODE_TOOL_NAME) {
|
|
1869
|
+
return session.executeAction(toolCall.args);
|
|
1870
|
+
}
|
|
1871
|
+
// A belt tool named directly still runs: the router is the same gate the
|
|
1872
|
+
// sandbox bridge goes through, so answering the call is strictly better
|
|
1873
|
+
// than refusing it and spending a round on the correction.
|
|
1874
|
+
if (beltToolNames.has(name))
|
|
1875
|
+
return executeTool(toolCall);
|
|
1876
|
+
// Answer, do not throw: the model can only correct course if the
|
|
1877
|
+
// recovery instructions arrive as this call's tool result.
|
|
1878
|
+
return JSON.stringify({ error: unroutableToolMessage(name) });
|
|
1879
|
+
};
|
|
1880
|
+
// Tool name by call id, so persisted tool messages keep their name (the
|
|
1881
|
+
// provider Message carries only the id).
|
|
1882
|
+
const toolNames = new Map();
|
|
1883
|
+
// Calls announced by an assistant message that have not been answered by a
|
|
1884
|
+
// tool message yet. Whatever is still here when the turn tears down never
|
|
1885
|
+
// got its result row, and the `finally` below writes a stand-in so the
|
|
1886
|
+
// transcript stays well-formed.
|
|
1887
|
+
const openToolCalls = new Set();
|
|
1888
|
+
// Set when a newer turn supersedes this one. The loop then stops feeding
|
|
1889
|
+
// the client and only rescues the results still coming.
|
|
1890
|
+
let superseded = false;
|
|
1891
|
+
let drainedItems = 0;
|
|
1892
|
+
let terminalDoneSent = false;
|
|
1893
|
+
const sendTerminalDone = async () => {
|
|
1894
|
+
if (terminalDoneSent)
|
|
1895
|
+
return;
|
|
1896
|
+
terminalDoneSent = true;
|
|
1897
|
+
await this.session.send({
|
|
1898
|
+
type: "chunk",
|
|
1899
|
+
content: "",
|
|
1900
|
+
done: true,
|
|
1901
|
+
thread_id: threadId
|
|
1902
|
+
});
|
|
1903
|
+
};
|
|
1904
|
+
/**
|
|
1905
|
+
* Write one message this turn produced. `echo` is false for a superseded
|
|
1906
|
+
* turn: the row still belongs in the thread, but the client has moved on
|
|
1907
|
+
* and replaying it there would interleave a dead turn into a live one.
|
|
1908
|
+
*/
|
|
1909
|
+
const persistTurnMessage = async (m, echo) => {
|
|
1910
|
+
if (m.role === "assistant") {
|
|
1911
|
+
// Content may be a plain string or a MessageContent[] carrying
|
|
1912
|
+
// native-image blocks. Raw image bytes are turned into real assets
|
|
1913
|
+
// here so base64 never lands in the DB or on the wire.
|
|
1914
|
+
let persistedContent = m.content ?? null;
|
|
1915
|
+
if (isString(m.content)) {
|
|
1916
|
+
if (echo)
|
|
1917
|
+
content = m.content;
|
|
1918
|
+
}
|
|
1919
|
+
else if (Array.isArray(m.content)) {
|
|
1920
|
+
const materialized = await this.materializeAssistantImageContent(m.content, userId, workflowId, chatProjectId ?? "default");
|
|
1921
|
+
persistedContent = materialized;
|
|
1922
|
+
if (echo) {
|
|
1923
|
+
content = materialized
|
|
1924
|
+
.filter((c) => c.type === "text" && isString(c.text))
|
|
1925
|
+
.map((c) => c.text)
|
|
1926
|
+
.join("");
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
const toolCalls = Array.isArray(m.toolCalls)
|
|
1930
|
+
? m.toolCalls.map((tc) => ({
|
|
1931
|
+
id: tc.id,
|
|
1932
|
+
name: tc.name,
|
|
1933
|
+
args: tc.args,
|
|
1934
|
+
result: null,
|
|
1935
|
+
// Gemini 3 rejects a history that replays a function call
|
|
1936
|
+
// without the signature it issued, so it rides into the DB.
|
|
1937
|
+
thought_signature: tc.thought_signature ?? null
|
|
1938
|
+
}))
|
|
1939
|
+
: null;
|
|
1940
|
+
for (const tc of toolCalls ?? []) {
|
|
1941
|
+
if (!isString(tc.id))
|
|
1942
|
+
continue;
|
|
1943
|
+
openToolCalls.add(tc.id);
|
|
1944
|
+
toolNames.set(tc.id, tc.name);
|
|
1945
|
+
}
|
|
1946
|
+
const assistantMsgData = {
|
|
1947
|
+
type: "message",
|
|
1948
|
+
role: "assistant",
|
|
1949
|
+
content: persistedContent
|
|
1950
|
+
};
|
|
1951
|
+
if (toolCalls) {
|
|
1952
|
+
assistantMsgData.tool_calls = toolCalls;
|
|
1953
|
+
}
|
|
1954
|
+
assistantMsgData.thread_id = threadId;
|
|
1955
|
+
assistantMsgData.workflow_id = workflowId;
|
|
1956
|
+
assistantMsgData.provider = providerId;
|
|
1957
|
+
assistantMsgData.model = model;
|
|
1958
|
+
assistantMsgData.provider_session = sessionForPersist();
|
|
1959
|
+
await this.saveMessageToDb(assistantMsgData);
|
|
1960
|
+
if (echo)
|
|
1961
|
+
await this.session.send(assistantMsgData);
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
if (m.role !== "tool")
|
|
1965
|
+
return;
|
|
1966
|
+
// Image tool results carry MessageContent blocks; persist/echo only
|
|
1967
|
+
// their note text so chat history stays light (the base64 rode the
|
|
1968
|
+
// in-flight provider message, never the DB).
|
|
1969
|
+
const toolContent = Array.isArray(m.content)
|
|
1970
|
+
? toolResultDisplayText(m.content)
|
|
1971
|
+
: isString(m.content)
|
|
1972
|
+
? m.content
|
|
1973
|
+
: "";
|
|
1974
|
+
if (isString(m.toolCallId))
|
|
1975
|
+
openToolCalls.delete(m.toolCallId);
|
|
1976
|
+
const toolMsgData = {
|
|
1977
|
+
type: "message",
|
|
1978
|
+
role: "tool",
|
|
1979
|
+
tool_call_id: m.toolCallId ?? null,
|
|
1980
|
+
name: m.toolCallId ? (toolNames.get(m.toolCallId) ?? null) : null,
|
|
1981
|
+
content: toolContent,
|
|
1982
|
+
thread_id: threadId,
|
|
1983
|
+
workflow_id: workflowId,
|
|
1984
|
+
provider: providerId,
|
|
1985
|
+
model
|
|
1986
|
+
};
|
|
1987
|
+
await this.saveMessageToDb(toolMsgData);
|
|
1988
|
+
if (echo)
|
|
1989
|
+
await this.session.send(toolMsgData);
|
|
1990
|
+
};
|
|
1991
|
+
// The Claude Agent provider runs the SDK's own loop, which resolves skills
|
|
1992
|
+
// through its native `Skill` tool (progressive disclosure) rather than the
|
|
1993
|
+
// always-on catalog every other provider reads from the system prompt. Hand
|
|
1994
|
+
// it the user's DB skills so that loop can list and load them; it
|
|
1995
|
+
// materializes them into an isolated local plugin (no `settingSources`
|
|
1996
|
+
// leakage). Other providers get skills via the system-prompt catalog above
|
|
1997
|
+
// and ignore this field.
|
|
1998
|
+
const skillsForProvider = provider.provider === PROVIDER_IDS.CLAUDE_AGENT_SDK
|
|
1999
|
+
? userSkills.map((skill) => ({
|
|
2000
|
+
name: skill.name,
|
|
2001
|
+
description: skill.description,
|
|
2002
|
+
content: skill.content
|
|
2003
|
+
}))
|
|
2004
|
+
: undefined;
|
|
2005
|
+
// ── Compaction (D5) ──────────────────────────────────────────────────
|
|
2006
|
+
// A long thread eventually stops fitting, and from then on every turn dies
|
|
2007
|
+
// on a provider error the user cannot act on. Compaction replaces the
|
|
2008
|
+
// turns before the last few with one summary row and sends that instead.
|
|
2009
|
+
// The rows stay in the database for the UI and `nodetool.threads.*`; only
|
|
2010
|
+
// the provider's view is cut.
|
|
2011
|
+
const compaction = await readCompactionSettings();
|
|
2012
|
+
const modelKey = `${providerId}/${model}`;
|
|
2013
|
+
let contextWindow = this.contextWindows.get(modelKey);
|
|
2014
|
+
if (!contextWindow && compaction.thresholdTokens === null) {
|
|
2015
|
+
contextWindow = resolveContextWindow(provider, model);
|
|
2016
|
+
this.contextWindows.set(modelKey, contextWindow);
|
|
2017
|
+
}
|
|
2018
|
+
const compactionThresholdTokens = compactionThreshold(compaction, contextWindow ? (await contextWindow).tokens : 0);
|
|
2019
|
+
/** Spent by the reactive trigger below; bounds the turn to one retry. */
|
|
2020
|
+
let compactionRetryUsed = false;
|
|
2021
|
+
/** Tell the user what was compacted, or why it was not. */
|
|
2022
|
+
const compactionLog = async (content, severity) => {
|
|
2023
|
+
await this.session.send({
|
|
2024
|
+
type: "log_update",
|
|
2025
|
+
node_id: "",
|
|
2026
|
+
node_name: "compaction",
|
|
2027
|
+
content,
|
|
2028
|
+
severity,
|
|
2029
|
+
thread_id: threadId
|
|
2030
|
+
});
|
|
2031
|
+
};
|
|
2032
|
+
/**
|
|
2033
|
+
* Which model writes the summary: `NODETOOL_COMPACTION_MODEL` when set,
|
|
2034
|
+
* otherwise the one the turn is already running on. The setting takes the
|
|
2035
|
+
* `provider/model` form the supervisor's does, and a bare id names a model
|
|
2036
|
+
* on this turn's provider.
|
|
2037
|
+
*/
|
|
2038
|
+
const summarizerModel = async () => {
|
|
2039
|
+
const configured = compaction.model;
|
|
2040
|
+
if (configured === null)
|
|
2041
|
+
return { provider, model };
|
|
2042
|
+
const slash = configured.indexOf("/");
|
|
2043
|
+
if (slash <= 0)
|
|
2044
|
+
return { provider, model: configured };
|
|
2045
|
+
const otherId = configured.slice(0, slash);
|
|
2046
|
+
const otherModel = configured.slice(slash + 1);
|
|
2047
|
+
if (otherId === providerId)
|
|
2048
|
+
return { provider, model: otherModel };
|
|
2049
|
+
const resolved = await this.session.resolveProvider?.(otherId, userId);
|
|
2050
|
+
if (!resolved) {
|
|
2051
|
+
throw new Error(`No provider registered for "${otherId}"`);
|
|
2052
|
+
}
|
|
2053
|
+
return { provider: resolved, model: otherModel };
|
|
2054
|
+
};
|
|
2055
|
+
/**
|
|
2056
|
+
* Summarize this thread up to the cut, persist the record, and rebuild the
|
|
2057
|
+
* wire messages from what survives. Answers whether it compacted.
|
|
2058
|
+
*
|
|
2059
|
+
* Everything that can go wrong here leaves the turn running against the
|
|
2060
|
+
* history it already had: a thread too short to cut, a summarizer that
|
|
2061
|
+
* failed, a summarizer with nothing to say. This is the one place A4 does
|
|
2062
|
+
* not fail closed (I-4), because the alternative to a missing summary is a
|
|
2063
|
+
* turn that cannot run at all.
|
|
2064
|
+
*/
|
|
2065
|
+
const compactThread = async (reason) => {
|
|
2066
|
+
const [rows] = await Message.paginate(threadId, { limit: 1000 });
|
|
2067
|
+
const cut = chooseCompactionCut(historySinceCompaction(rows), compaction.keepUserTurns, {
|
|
2068
|
+
// Leave room for the summary, turn context and subsequent replies.
|
|
2069
|
+
maxTokens: compactionThresholdTokens * 0.75 -
|
|
2070
|
+
COMPACTION_SUMMARY_MAX_TOKENS -
|
|
2071
|
+
estimatePromptTokens([systemChatMessage()]) -
|
|
2072
|
+
estimatePromptTokens([
|
|
2073
|
+
{ role: "user", content: volatileContext.join("\n\n") }
|
|
2074
|
+
]),
|
|
2075
|
+
countTokens: (row) => estimatePromptTokens(convertDbMessages([row]))
|
|
2076
|
+
});
|
|
2077
|
+
if (!cut)
|
|
2078
|
+
return false;
|
|
2079
|
+
let summary;
|
|
2080
|
+
try {
|
|
2081
|
+
const summarizer = await summarizerModel();
|
|
2082
|
+
summary = await summarizeForCompaction({
|
|
2083
|
+
provider: summarizer.provider,
|
|
2084
|
+
model: summarizer.model,
|
|
2085
|
+
// The stored rows, not the wire messages: an `asset://` uri is still
|
|
2086
|
+
// a uri here rather than the data it resolves to, which is what lets
|
|
2087
|
+
// the summary carry the reference forward.
|
|
2088
|
+
messages: convertDbMessages(cut.summarize),
|
|
2089
|
+
signal
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
catch (err) {
|
|
2093
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2094
|
+
log.warn("Compaction summary failed", { threadId, error: detail });
|
|
2095
|
+
await compactionLog(`Could not summarize this conversation (${detail}). Sending it in full.`, "warning");
|
|
2096
|
+
return false;
|
|
2097
|
+
}
|
|
2098
|
+
if (summary === null) {
|
|
2099
|
+
await compactionLog("The summary of this conversation came back empty. Sending it in full.", "warning");
|
|
2100
|
+
return false;
|
|
2101
|
+
}
|
|
2102
|
+
// The record belongs at the cut, not at the end of the thread: history
|
|
2103
|
+
// assembly starts from the newest one, so a record written after the kept
|
|
2104
|
+
// turns would drop them from the next turn's view. Rows are ordered by
|
|
2105
|
+
// `created_at`, so it takes the millisecond before the first survivor.
|
|
2106
|
+
const boundary = Date.parse(cut.keep[0].created_at);
|
|
2107
|
+
const record = await Message.create({
|
|
2108
|
+
thread_id: threadId,
|
|
2109
|
+
user_id: userId,
|
|
2110
|
+
role: "user",
|
|
2111
|
+
execution_event_type: COMPACTION_EVENT_TYPE,
|
|
2112
|
+
content: compactionMessageContent(summary),
|
|
2113
|
+
tools: getRunTraceScope()?.policy.contentSuppressed ? restrictedTraceToolNames() : null,
|
|
2114
|
+
created_at: Number.isFinite(boundary)
|
|
2115
|
+
? new Date(boundary - 1).toISOString()
|
|
2116
|
+
: cut.keep[0].created_at
|
|
2117
|
+
});
|
|
2118
|
+
messagesToSend = await withTurnContext([
|
|
2119
|
+
systemChatMessage(),
|
|
2120
|
+
...convertDbMessages([record, ...cut.keep])
|
|
2121
|
+
]);
|
|
2122
|
+
// The upstream transcript a session token resumes is the history the cut
|
|
2123
|
+
// just replaced, so nothing resumes across a compaction.
|
|
2124
|
+
capturedSession = null;
|
|
2125
|
+
this.measuredThreadTokens.delete(threadId);
|
|
2126
|
+
loadFullHistory = null;
|
|
2127
|
+
sessionCheckpointOverride = null;
|
|
2128
|
+
await this.session.send({
|
|
2129
|
+
type: "message",
|
|
2130
|
+
role: "user",
|
|
2131
|
+
execution_event_type: COMPACTION_EVENT_TYPE,
|
|
2132
|
+
content: record.content,
|
|
2133
|
+
thread_id: threadId,
|
|
2134
|
+
workflow_id: workflowId
|
|
2135
|
+
});
|
|
2136
|
+
log.info("Compacted chat history", {
|
|
2137
|
+
threadId,
|
|
2138
|
+
summarized: cut.summarize.length,
|
|
2139
|
+
kept: cut.keep.length
|
|
2140
|
+
});
|
|
2141
|
+
await compactionLog(`Summarized the earlier part of this conversation because ${reason}.`, "info");
|
|
2142
|
+
return true;
|
|
2143
|
+
};
|
|
2144
|
+
// Trigger 1, proactive: the prompt reached the threshold, 90% of the
|
|
2145
|
+
// model's input window unless `NODETOOL_CHAT_COMPACTION_TOKENS` sets it.
|
|
2146
|
+
// The size is the larger of two numbers. The provider-reported size of
|
|
2147
|
+
// this thread's last call counts the system prompt and tool definitions.
|
|
2148
|
+
// `estimatePromptTokens` counts the messages added since, scoring each
|
|
2149
|
+
// attached image, audio, video or document at a fixed per-modality figure
|
|
2150
|
+
// so a resolved `data:` uri never reaches the tokenizer.
|
|
2151
|
+
//
|
|
2152
|
+
// A provider holding the conversation upstream is sent only the turns since
|
|
2153
|
+
// its session token, so this number would describe a fraction of what it
|
|
2154
|
+
// actually has. Those get trigger 2 alone: the provider itself says when
|
|
2155
|
+
// the transcript stopped fitting.
|
|
2156
|
+
if (!holdsTranscriptServerSide(providerId, capturedSession !== null)) {
|
|
2157
|
+
const measured = this.measuredThreadTokens.get(threadId);
|
|
2158
|
+
const promptTokens = Math.max(estimatePromptTokens(messagesToSend), measured?.modelKey === modelKey ? measured.tokens : 0);
|
|
2159
|
+
if (promptTokens >= compactionThresholdTokens) {
|
|
2160
|
+
await compactThread(`it reached about ${promptTokens} tokens, at or over the ` +
|
|
2161
|
+
`${compactionThresholdTokens}-token limit`);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* One pass of the provider's tool-calling loop over the current
|
|
2166
|
+
* `messagesToSend`. A function rather than a statement so the reactive
|
|
2167
|
+
* compaction below can run it a second time against a shortened history.
|
|
2168
|
+
*/
|
|
2169
|
+
const streamTurn = async () => {
|
|
2170
|
+
for await (const item of provider.generateLoop({
|
|
2171
|
+
messages: messagesToSend,
|
|
2172
|
+
model,
|
|
2173
|
+
tools: useTools ? providerToolSchemas : undefined,
|
|
2174
|
+
threadId,
|
|
2175
|
+
providerSession: capturedSession,
|
|
2176
|
+
loadFullHistory: loadFullHistory ?? undefined,
|
|
2177
|
+
executeTool: useTools ? effectiveExecuteTool : undefined,
|
|
2178
|
+
maxIterations: MAX_TOOL_ROUNDS,
|
|
2179
|
+
turnBudget,
|
|
2180
|
+
sequentialTools: session ? true : undefined,
|
|
2181
|
+
workspaceDir: chatWorkspace?.localDir ?? undefined,
|
|
2182
|
+
skills: skillsForProvider,
|
|
2183
|
+
resolveMedia: mediaResolverFor(ctx),
|
|
2184
|
+
signal
|
|
2185
|
+
})) {
|
|
2186
|
+
// A newer turn has taken over. Stop driving the client, but do NOT
|
|
2187
|
+
// drop what this turn already produced: the provider checks its abort
|
|
2188
|
+
// signal before dispatching a tool, never during one, so a call in
|
|
2189
|
+
// flight runs to completion and its result is arriving right now.
|
|
2190
|
+
// Discarding it left the model blind to a side effect it had already
|
|
2191
|
+
// caused, and it silently redid the work.
|
|
2192
|
+
if (!superseded &&
|
|
2193
|
+
requestSeq !== undefined &&
|
|
2194
|
+
requestSeq !== this.chatRequestSeq) {
|
|
2195
|
+
superseded = true;
|
|
2196
|
+
}
|
|
2197
|
+
if (superseded) {
|
|
2198
|
+
if (isProviderMessageEvent(item)) {
|
|
2199
|
+
await persistTurnMessage(item.message, false);
|
|
2200
|
+
}
|
|
2201
|
+
// Nothing left outstanding: the rest of this turn is work the user
|
|
2202
|
+
// has already moved on from.
|
|
2203
|
+
if (openToolCalls.size === 0)
|
|
2204
|
+
break;
|
|
2205
|
+
// The turn's signal is already aborted, so a provider that honors it
|
|
2206
|
+
// ends within a few items. One that does not must never hold this
|
|
2207
|
+
// handler open — stop reading and let the `finally` stand in for
|
|
2208
|
+
// whatever is still missing.
|
|
2209
|
+
if (++drainedItems > MAX_SUPERSEDED_DRAIN_ITEMS) {
|
|
2210
|
+
log.warn("Superseded turn kept producing; stopped draining", {
|
|
2211
|
+
threadId,
|
|
2212
|
+
openToolCalls: openToolCalls.size
|
|
2213
|
+
});
|
|
2214
|
+
break;
|
|
2215
|
+
}
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
if (isProviderStop(item)) {
|
|
2219
|
+
// A limit ended the turn, not the model finishing its answer. Said
|
|
2220
|
+
// nowhere, the two are indistinguishable to the user (invariant
|
|
2221
|
+
// I-3), so the reason is persisted as an ordinary assistant message:
|
|
2222
|
+
// it belongs in the transcript the next turn reads, and it is
|
|
2223
|
+
// per-turn text that must never reach the system prompt (I-7).
|
|
2224
|
+
//
|
|
2225
|
+
// `aborted` is the user pressing Stop or a newer message superseding
|
|
2226
|
+
// this one. They already know why it stopped, and a notice there
|
|
2227
|
+
// would scold them for using the control. The other three are limits
|
|
2228
|
+
// nobody asked for in the moment: the dollar cap, the wall clock,
|
|
2229
|
+
// and the tool-round ceiling all leave the turn unfinished mid-work.
|
|
2230
|
+
if (item.reason !== "aborted") {
|
|
2231
|
+
// The budget names the ceiling it hit ("turn budget of $5
|
|
2232
|
+
// reached"); the round cap is not a budget stop, and reading a
|
|
2233
|
+
// budget a sub-agent exhausted earlier would name the wrong limit.
|
|
2234
|
+
const detail = item.reason === "iterations"
|
|
2235
|
+
? item.detail
|
|
2236
|
+
: (turnBudget.exhausted?.detail ?? item.detail);
|
|
2237
|
+
await persistTurnMessage({ role: "assistant", content: `Stopped: ${detail}` }, true);
|
|
2238
|
+
}
|
|
2239
|
+
else if (signal?.reason === "shutdown") {
|
|
2240
|
+
// …except a shutdown, which nobody asked for either. The reply
|
|
2241
|
+
// stops mid-sentence and, said nowhere, reads as the model
|
|
2242
|
+
// finishing; the note says the server went away, and is what the
|
|
2243
|
+
// next turn reads when the client retries on the machine that
|
|
2244
|
+
// stayed.
|
|
2245
|
+
await persistTurnMessage({ role: "assistant", content: "Stopped: server restarting" }, true);
|
|
2246
|
+
}
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
if (isProviderSessionUpdate(item)) {
|
|
2250
|
+
// Internal continuity token — capture for persistence, never wired.
|
|
2251
|
+
capturedSession = item.session;
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
if (isProviderMessageEvent(item)) {
|
|
2255
|
+
await persistTurnMessage(item.message, true);
|
|
2256
|
+
continue;
|
|
2257
|
+
}
|
|
2258
|
+
if (isToolCall(item)) {
|
|
2259
|
+
// Informational: the loop executes it through executeTool.
|
|
2260
|
+
toolNames.set(item.id, item.name);
|
|
2261
|
+
log.info("Tool call", { tool: item.name, args: item.args });
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
// `isChunk` from the runtime additionally requires string content, so
|
|
2265
|
+
// it drops the native-`Float32Array` audio chunks the client decodes.
|
|
2266
|
+
// `type === "chunk"` is Chunk's alone in this union, so test that.
|
|
2267
|
+
if (item.type === "chunk") {
|
|
2268
|
+
// The provider loop emits a terminal chunk for its completion, and
|
|
2269
|
+
// this handler emits the turn terminal below. Keep one envelope
|
|
2270
|
+
// terminal even when a provider double emits its own `done` item.
|
|
2271
|
+
if (!item.thread_id)
|
|
2272
|
+
item.thread_id = threadId;
|
|
2273
|
+
await this.session.send(item.done === true ? { ...item, done: false } : { ...item });
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
};
|
|
2277
|
+
try {
|
|
2278
|
+
// At most two passes. The second happens only when the first ended with
|
|
2279
|
+
// the provider's own context-exceeded signal and compaction answered it
|
|
2280
|
+
// by shortening the history. A second such failure is surfaced: the
|
|
2281
|
+
// shortened transcript did not fit either, and compacting again would
|
|
2282
|
+
// buy another summarizer call to learn the same thing.
|
|
2283
|
+
//
|
|
2284
|
+
// A provider rejects an oversized request before it generates anything,
|
|
2285
|
+
// so the retry re-reads a turn that produced no messages of its own.
|
|
2286
|
+
for (;;) {
|
|
2287
|
+
try {
|
|
2288
|
+
await streamTurn();
|
|
2289
|
+
const lastCallTokens = provider.lastCallTokens;
|
|
2290
|
+
if (typeof lastCallTokens === "number") {
|
|
2291
|
+
this.measuredThreadTokens.set(threadId, {
|
|
2292
|
+
modelKey,
|
|
2293
|
+
tokens: lastCallTokens
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
break;
|
|
2297
|
+
}
|
|
2298
|
+
catch (err) {
|
|
2299
|
+
if (compactionRetryUsed ||
|
|
2300
|
+
providerFailureDetail(err)?.code !== "context_exceeded") {
|
|
2301
|
+
throw err;
|
|
2302
|
+
}
|
|
2303
|
+
compactionRetryUsed = true;
|
|
2304
|
+
const compacted = await compactThread("the provider refused the request: this conversation no longer fits its context window");
|
|
2305
|
+
if (!compacted)
|
|
2306
|
+
throw err;
|
|
2307
|
+
log.info("Retrying the turn against the compacted history", {
|
|
2308
|
+
threadId
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
// A superseded turn is done once its outstanding results are saved. The
|
|
2313
|
+
// completion chunk and the memory pass belong to the turn the user is
|
|
2314
|
+
// actually watching, which has its own.
|
|
2315
|
+
if (superseded)
|
|
2316
|
+
return;
|
|
2317
|
+
// Log provider call for cost tracking — matches Python's _log_provider_call()
|
|
2318
|
+
await this._logProviderCall(userId, provider, providerId, model, workflowId, chatProjectId ?? null);
|
|
2319
|
+
// What the run spent, where the user can see it. A number only the
|
|
2320
|
+
// server log carries is a number nobody reads; `unpricedTurns` rides
|
|
2321
|
+
// along when there were any, so the total reads as the lower bound it
|
|
2322
|
+
// is rather than as the whole bill (invariant I-4).
|
|
2323
|
+
const spentUsd = turnBudget.turns.spentUsd;
|
|
2324
|
+
const unpricedTurns = turnBudget.turns.unpricedTurns;
|
|
2325
|
+
await this.session.send({
|
|
2326
|
+
type: "log_update",
|
|
2327
|
+
node_id: "",
|
|
2328
|
+
node_name: "budget",
|
|
2329
|
+
content: unpricedTurns > 0
|
|
2330
|
+
? `Run spent at least $${spentUsd.toFixed(4)} (${unpricedTurns} turn(s) on a model with no catalog price)`
|
|
2331
|
+
: `Run spent $${spentUsd.toFixed(4)}`,
|
|
2332
|
+
severity: "info",
|
|
2333
|
+
thread_id: threadId
|
|
2334
|
+
});
|
|
2335
|
+
// Signal completion — matches Python's done chunk.
|
|
2336
|
+
await sendTerminalDone();
|
|
2337
|
+
log.debug("Chat complete", {
|
|
2338
|
+
threadId,
|
|
2339
|
+
chars: content.length,
|
|
2340
|
+
// What the whole run — this loop plus every sub-agent and node it
|
|
2341
|
+
// started — reserved and committed against the shared budget.
|
|
2342
|
+
spentUsd: turnBudget.turns.spentUsd,
|
|
2343
|
+
stoppedBy: turnBudget.exhausted?.kind ?? null
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
catch (err) {
|
|
2347
|
+
// Stop and supersede are expected control flow. Their provider errors
|
|
2348
|
+
// must not become durable assistant failures or a second terminal frame.
|
|
2349
|
+
if (signal?.aborted === true ||
|
|
2350
|
+
(requestSeq !== undefined && requestSeq !== this.chatRequestSeq)) {
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2354
|
+
await settleRegisteredRunTrace(rootContext, "failed", errMsg);
|
|
2355
|
+
log.error("Chat processing error", { threadId, error: errMsg });
|
|
2356
|
+
let errorType = "error";
|
|
2357
|
+
let statusCode;
|
|
2358
|
+
let formattedMsg = safeClientErrorText(err);
|
|
2359
|
+
// Connection errors (ECONNREFUSED, ENOTFOUND, etc.)
|
|
2360
|
+
if (errMsg.includes("ECONNREFUSED") ||
|
|
2361
|
+
errMsg.includes("ENOTFOUND") ||
|
|
2362
|
+
errMsg.includes("fetch failed") ||
|
|
2363
|
+
errMsg.includes("nodename nor servname")) {
|
|
2364
|
+
errorType = "connection_error";
|
|
2365
|
+
if (errMsg.includes("ENOTFOUND") ||
|
|
2366
|
+
errMsg.includes("nodename nor servname")) {
|
|
2367
|
+
formattedMsg =
|
|
2368
|
+
"Connection error: Unable to resolve hostname. Please check your network connection and API endpoint configuration.";
|
|
2369
|
+
}
|
|
2370
|
+
else {
|
|
2371
|
+
formattedMsg = `Connection error: ${safeClientErrorText(err)}`;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
else {
|
|
2375
|
+
const providerFailure = providerFailureMessage(err, providerId, model);
|
|
2376
|
+
errorType = providerFailure.errorType;
|
|
2377
|
+
statusCode = providerFailure.statusCode;
|
|
2378
|
+
formattedMsg = providerFailure.message;
|
|
2379
|
+
}
|
|
2380
|
+
const errorMessage = {
|
|
2381
|
+
type: "error",
|
|
2382
|
+
message: formattedMsg,
|
|
2383
|
+
error_type: errorType
|
|
2384
|
+
};
|
|
2385
|
+
if (statusCode !== undefined) {
|
|
2386
|
+
errorMessage.status_code = statusCode;
|
|
2387
|
+
}
|
|
2388
|
+
errorMessage.thread_id = threadId;
|
|
2389
|
+
errorMessage.workflow_id = workflowId;
|
|
2390
|
+
await this.session.send(errorMessage);
|
|
2391
|
+
// Signal completion even on error — matches Python
|
|
2392
|
+
await sendTerminalDone();
|
|
2393
|
+
const errorMsgData = {
|
|
2394
|
+
type: "message",
|
|
2395
|
+
role: "assistant",
|
|
2396
|
+
content: errorType === "connection_error"
|
|
2397
|
+
? `I encountered a connection error: ${formattedMsg}. Please check your network connection and try again.`
|
|
2398
|
+
: errorType === "http_status_error"
|
|
2399
|
+
? `I encountered an API error (HTTP ${statusCode}): ${formattedMsg}`
|
|
2400
|
+
: `I encountered an error: ${formattedMsg}`,
|
|
2401
|
+
thread_id: threadId,
|
|
2402
|
+
workflow_id: workflowId,
|
|
2403
|
+
provider: providerId,
|
|
2404
|
+
model
|
|
2405
|
+
};
|
|
2406
|
+
await this.saveMessageToDb(errorMsgData);
|
|
2407
|
+
await this.session.send(errorMsgData);
|
|
2408
|
+
}
|
|
2409
|
+
finally {
|
|
2410
|
+
detachPredictions();
|
|
2411
|
+
detachCostLedger();
|
|
2412
|
+
try {
|
|
2413
|
+
await chatWorkspace?.cleanupScratch?.();
|
|
2414
|
+
}
|
|
2415
|
+
catch (err) {
|
|
2416
|
+
log.warn("Chat workspace scratch cleanup failed", {
|
|
2417
|
+
threadId,
|
|
2418
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
// Whatever is still outstanding never got a result row. Leaving the gap
|
|
2422
|
+
// makes the thread malformed — Anthropic rejects a `tool_use` with no
|
|
2423
|
+
// `tool_result` — and leaves the model unaware the call was abandoned,
|
|
2424
|
+
// which is what made it silently redo the work.
|
|
2425
|
+
for (const toolCallId of openToolCalls) {
|
|
2426
|
+
try {
|
|
2427
|
+
await this.saveMessageToDb({
|
|
2428
|
+
type: "message",
|
|
2429
|
+
role: "tool",
|
|
2430
|
+
tool_call_id: toolCallId,
|
|
2431
|
+
name: toolNames.get(toolCallId) ?? null,
|
|
2432
|
+
content: SUPERSEDED_TOOL_RESULT,
|
|
2433
|
+
thread_id: threadId,
|
|
2434
|
+
workflow_id: workflowId,
|
|
2435
|
+
provider: providerId,
|
|
2436
|
+
model
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
catch (err) {
|
|
2440
|
+
// Best effort: a turn that already failed must not fail again here.
|
|
2441
|
+
this.session.logError("superseded tool result save failed", err);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
openToolCalls.clear();
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* Log a provider call for cost tracking — mirrors Python's _log_provider_call().
|
|
2449
|
+
* Best-effort: never throws, logs warnings on failure.
|
|
2450
|
+
*/
|
|
2451
|
+
async _logProviderCall(userId, provider, providerId, model, workflowId, projectId) {
|
|
2452
|
+
if (!providerId || !model) {
|
|
2453
|
+
log.warn("Cannot log provider call: missing provider or model");
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
try {
|
|
2457
|
+
// A provider that could not price a call reports why. Its running total
|
|
2458
|
+
// is then missing that spend, so a zero is written as null — the row
|
|
2459
|
+
// reads unpriced in `nodetool costs` instead of summing as free, the
|
|
2460
|
+
// same posture the cost ledger takes.
|
|
2461
|
+
const unpricedReason = provider.unpricedReason;
|
|
2462
|
+
const cost = provider.cost;
|
|
2463
|
+
const unpriced = cost === 0 && unpricedReason != null;
|
|
2464
|
+
await Prediction.create({
|
|
2465
|
+
user_id: userId,
|
|
2466
|
+
provider: providerId,
|
|
2467
|
+
model,
|
|
2468
|
+
cost: unpriced ? null : cost,
|
|
2469
|
+
metadata: unpricedReason ? { unpriced_reason: unpricedReason } : null,
|
|
2470
|
+
workflow_id: workflowId,
|
|
2471
|
+
// Token spend from a project's agent thread is the project's; a turn
|
|
2472
|
+
// outside a project records a null rather than a bucket name.
|
|
2473
|
+
project_id: projectId,
|
|
2474
|
+
status: "completed",
|
|
2475
|
+
node_id: ""
|
|
2476
|
+
});
|
|
2477
|
+
log.debug("Logged provider call", { provider: providerId, model, cost });
|
|
2478
|
+
}
|
|
2479
|
+
catch (err) {
|
|
2480
|
+
if (err instanceof TypeError || err instanceof ReferenceError) {
|
|
2481
|
+
log.warn("Failed to log provider call due to invalid data", {
|
|
2482
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
else {
|
|
2486
|
+
log.error("Unexpected error logging provider call", {
|
|
2487
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* Detect message input node names from a workflow graph.
|
|
2494
|
+
* Mirrors Python's WorkflowMessageProcessor._detect_message_input_names().
|
|
2495
|
+
*
|
|
2496
|
+
* Scans graph nodes for types ending in .MessageInput / .MessageListInput
|
|
2497
|
+
* and returns their data.name values.
|
|
2498
|
+
*/
|
|
2499
|
+
detectMessageInputNames(graph) {
|
|
2500
|
+
let messageName = null;
|
|
2501
|
+
let messagesName = null;
|
|
2502
|
+
for (const node of graph.nodes) {
|
|
2503
|
+
const nodeType = isString(node.type) ? node.type : "";
|
|
2504
|
+
const data = isObjectLike(node.data)
|
|
2505
|
+
? node.data
|
|
2506
|
+
: {};
|
|
2507
|
+
const nodeName = isString(data.name) ? data.name.trim() : "";
|
|
2508
|
+
if (!nodeName)
|
|
2509
|
+
continue;
|
|
2510
|
+
if (messageName === null &&
|
|
2511
|
+
(nodeType === "nodetool.input.MessageInput" ||
|
|
2512
|
+
nodeType.endsWith(".MessageInput"))) {
|
|
2513
|
+
messageName = nodeName;
|
|
2514
|
+
}
|
|
2515
|
+
if (messagesName === null &&
|
|
2516
|
+
(nodeType === "nodetool.input.MessageListInput" ||
|
|
2517
|
+
nodeType.endsWith(".MessageListInput"))) {
|
|
2518
|
+
messagesName = nodeName;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
return { messageName, messagesName };
|
|
2522
|
+
}
|
|
2523
|
+
/**
|
|
2524
|
+
* Handle a chat_message with a `media_generation` payload by invoking the
|
|
2525
|
+
* selected provider's textToImage / textToVideo API, storing the resulting
|
|
2526
|
+
* asset(s), and returning them to the client as an assistant `Message`
|
|
2527
|
+
* whose `content` is an array of `MessageImageContent` / `MessageVideoContent`
|
|
2528
|
+
* blocks.
|
|
2529
|
+
*
|
|
2530
|
+
* The generated bytes are persisted via `ctx.storage.store()` so each
|
|
2531
|
+
* output receives a stable URI the client can resolve as a server asset.
|
|
2532
|
+
* The `media_generation` echo on the assistant message lets the UI render
|
|
2533
|
+
* the generation header (model, variation count, resolution, etc.) in the
|
|
2534
|
+
* conversation stream.
|
|
2535
|
+
*/
|
|
2536
|
+
async handleMediaGenerationMessage(data, mediaGeneration, requestSeq, signal) {
|
|
2537
|
+
const threadId = isString(data.thread_id) ? data.thread_id : "";
|
|
2538
|
+
const workflowId = isString(data.workflow_id) ? data.workflow_id : null;
|
|
2539
|
+
const userId = this.session.requireUserId();
|
|
2540
|
+
const mode = String(mediaGeneration.mode ?? "");
|
|
2541
|
+
// The media composer's own selection first; a client without a separate
|
|
2542
|
+
// media picker (mobile) sends only the message-level one. The built-in
|
|
2543
|
+
// chat default is not in the chain — a text model can never serve a
|
|
2544
|
+
// generation, so falling back to it only buys an obscure provider error.
|
|
2545
|
+
const providerId = String(mediaGeneration.provider ?? data.provider ?? "");
|
|
2546
|
+
const modelId = String(mediaGeneration.model ?? data.model ?? "");
|
|
2547
|
+
const prompt = extractTextContent(data.content);
|
|
2548
|
+
/**
|
|
2549
|
+
* Whether this turn has been cancelled. The media provider APIs take no
|
|
2550
|
+
* AbortSignal, so an in-flight generation runs to completion regardless —
|
|
2551
|
+
* but its result must not be stored as an asset or delivered to a user who
|
|
2552
|
+
* pressed Stop. Checked after every provider call, before any write.
|
|
2553
|
+
*/
|
|
2554
|
+
const cancelled = () => signal?.aborted === true ||
|
|
2555
|
+
(requestSeq !== undefined && requestSeq !== this.chatRequestSeq);
|
|
2556
|
+
let terminalDoneSent = false;
|
|
2557
|
+
const sendTerminalDone = async () => {
|
|
2558
|
+
if (terminalDoneSent)
|
|
2559
|
+
return;
|
|
2560
|
+
terminalDoneSent = true;
|
|
2561
|
+
await this.session.send({
|
|
2562
|
+
type: "chunk",
|
|
2563
|
+
thread_id: threadId,
|
|
2564
|
+
content: "",
|
|
2565
|
+
done: true
|
|
2566
|
+
});
|
|
2567
|
+
};
|
|
2568
|
+
log.info("Media generation", {
|
|
2569
|
+
threadId,
|
|
2570
|
+
mode,
|
|
2571
|
+
provider: providerId,
|
|
2572
|
+
model: modelId,
|
|
2573
|
+
promptLen: prompt.length
|
|
2574
|
+
});
|
|
2575
|
+
if (!isModelSelection(providerId, modelId)) {
|
|
2576
|
+
await this.session.send({
|
|
2577
|
+
type: "error",
|
|
2578
|
+
message: noMediaModelSelectedMessage(mode),
|
|
2579
|
+
thread_id: threadId
|
|
2580
|
+
});
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
if (!prompt) {
|
|
2584
|
+
await this.session.send({
|
|
2585
|
+
type: "error",
|
|
2586
|
+
message: "Please enter a prompt",
|
|
2587
|
+
thread_id: threadId
|
|
2588
|
+
});
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
if (requestSeq !== undefined && requestSeq !== this.chatRequestSeq)
|
|
2592
|
+
return;
|
|
2593
|
+
let projectId = null;
|
|
2594
|
+
try {
|
|
2595
|
+
projectId = (await Project.findByThread(userId, threadId))?.id ?? null;
|
|
2596
|
+
if (!this.session.resolveProvider) {
|
|
2597
|
+
throw new Error("No provider resolver configured");
|
|
2598
|
+
}
|
|
2599
|
+
// Entity expansion and provider resolution are part of the guarded
|
|
2600
|
+
// setup. A missing entity or provider must follow the same error path as
|
|
2601
|
+
// a provider call and leave a durable assistant failure row.
|
|
2602
|
+
const { prompt: expandedPrompt, referenceImages } = await expandEntitiesForGeneration(prompt, this.deps.entityRefResolver(userId));
|
|
2603
|
+
const entityImageBytes = await this.deps.resolveEntityReferenceImages(userId, referenceImages);
|
|
2604
|
+
const provider = await this.session.resolveProvider(providerId, userId);
|
|
2605
|
+
provider.setMessageEmitter((msg) => {
|
|
2606
|
+
this.session.sendDetached(msg);
|
|
2607
|
+
});
|
|
2608
|
+
const { generate, seamAssetId, storeAsset: storeMediaAsset } = createGenerationRun({
|
|
2609
|
+
userId,
|
|
2610
|
+
providerId,
|
|
2611
|
+
modelId,
|
|
2612
|
+
provider,
|
|
2613
|
+
origin: {
|
|
2614
|
+
surface: "chat",
|
|
2615
|
+
thread_id: threadId || null,
|
|
2616
|
+
...(isString(data.request_id)
|
|
2617
|
+
? { request_id: data.request_id }
|
|
2618
|
+
: requestSeq !== undefined
|
|
2619
|
+
? { request_id: `chat:${threadId}:${requestSeq}` }
|
|
2620
|
+
: {})
|
|
2621
|
+
},
|
|
2622
|
+
threadId,
|
|
2623
|
+
workflowId: workflowId ?? null,
|
|
2624
|
+
projectId,
|
|
2625
|
+
assetNamePrefix: mode,
|
|
2626
|
+
signal
|
|
2627
|
+
});
|
|
2628
|
+
if (mode === "image") {
|
|
2629
|
+
const variations = Math.max(1, Math.min(Number(mediaGeneration.variations ?? 1), 8));
|
|
2630
|
+
const width = isNumber(mediaGeneration.width)
|
|
2631
|
+
? mediaGeneration.width
|
|
2632
|
+
: undefined;
|
|
2633
|
+
const height = isNumber(mediaGeneration.height)
|
|
2634
|
+
? mediaGeneration.height
|
|
2635
|
+
: undefined;
|
|
2636
|
+
const imageModel = {
|
|
2637
|
+
id: modelId,
|
|
2638
|
+
name: modelId,
|
|
2639
|
+
provider: providerId
|
|
2640
|
+
};
|
|
2641
|
+
const params = {
|
|
2642
|
+
model: imageModel,
|
|
2643
|
+
prompt: expandedPrompt,
|
|
2644
|
+
width,
|
|
2645
|
+
height,
|
|
2646
|
+
signal
|
|
2647
|
+
};
|
|
2648
|
+
// Surface a progress chunk so the UI can show the request flight
|
|
2649
|
+
await this.session.send({
|
|
2650
|
+
type: "chunk",
|
|
2651
|
+
thread_id: threadId,
|
|
2652
|
+
content: "",
|
|
2653
|
+
content_type: "text",
|
|
2654
|
+
content_metadata: { media_generation: mediaGeneration },
|
|
2655
|
+
done: false
|
|
2656
|
+
});
|
|
2657
|
+
if (requestSeq !== undefined && requestSeq !== this.chatRequestSeq)
|
|
2658
|
+
return;
|
|
2659
|
+
// A mentioned entity carries a reference image: the generation becomes
|
|
2660
|
+
// an edit against those images, mirroring the generate_media RPC.
|
|
2661
|
+
const generated = entityImageBytes.length > 0
|
|
2662
|
+
? await generate("image_to_image", {
|
|
2663
|
+
prompt: expandedPrompt,
|
|
2664
|
+
target_width: width ?? null,
|
|
2665
|
+
target_height: height ?? null,
|
|
2666
|
+
num_images: variations,
|
|
2667
|
+
images: entityImageBytes
|
|
2668
|
+
}, {}, (abort) => provider.imageToImages(entityImageBytes, {
|
|
2669
|
+
model: imageModel,
|
|
2670
|
+
prompt: expandedPrompt,
|
|
2671
|
+
targetWidth: width ?? null,
|
|
2672
|
+
targetHeight: height ?? null,
|
|
2673
|
+
signal: abort
|
|
2674
|
+
}, variations))
|
|
2675
|
+
: await generate("text_to_image", {
|
|
2676
|
+
prompt: expandedPrompt,
|
|
2677
|
+
width,
|
|
2678
|
+
height,
|
|
2679
|
+
num_images: variations
|
|
2680
|
+
}, {}, (abort) => provider.textToImages({ ...params, signal: abort }, variations));
|
|
2681
|
+
if (cancelled())
|
|
2682
|
+
return;
|
|
2683
|
+
const imageContents = [];
|
|
2684
|
+
for (const [index, bytes] of generated.output.entries()) {
|
|
2685
|
+
// Per-variation: a cancel partway through must not keep persisting.
|
|
2686
|
+
if (cancelled())
|
|
2687
|
+
return;
|
|
2688
|
+
const mimeType = detectImageMime(bytes);
|
|
2689
|
+
const assetId = seamAssetId(generated, index) ??
|
|
2690
|
+
(await storeMediaAsset(bytes, mimeType, IMAGE_MIME_TO_EXT[mimeType] ?? "png"));
|
|
2691
|
+
imageContents.push({
|
|
2692
|
+
type: "image_url",
|
|
2693
|
+
image: { type: "image", asset_id: assetId, mimeType }
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
const assistantMsgData = {
|
|
2697
|
+
type: "message",
|
|
2698
|
+
role: "assistant",
|
|
2699
|
+
content: imageContents,
|
|
2700
|
+
thread_id: threadId,
|
|
2701
|
+
workflow_id: workflowId,
|
|
2702
|
+
provider: providerId,
|
|
2703
|
+
model: modelId,
|
|
2704
|
+
media_generation: mediaGeneration
|
|
2705
|
+
};
|
|
2706
|
+
// Re-check: cancellation may have landed while the asset was persisting.
|
|
2707
|
+
if (cancelled())
|
|
2708
|
+
return;
|
|
2709
|
+
await this.saveMessageToDb(assistantMsgData);
|
|
2710
|
+
await this.session.send(assistantMsgData);
|
|
2711
|
+
await sendTerminalDone();
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
if (mode === "video") {
|
|
2715
|
+
const aspectRatio = isString(mediaGeneration.aspect_ratio)
|
|
2716
|
+
? mediaGeneration.aspect_ratio
|
|
2717
|
+
: null;
|
|
2718
|
+
const resolution = isString(mediaGeneration.resolution)
|
|
2719
|
+
? mediaGeneration.resolution
|
|
2720
|
+
: null;
|
|
2721
|
+
const duration = isNumber(mediaGeneration.duration)
|
|
2722
|
+
? mediaGeneration.duration
|
|
2723
|
+
: null;
|
|
2724
|
+
const videoModel = {
|
|
2725
|
+
id: modelId,
|
|
2726
|
+
name: modelId,
|
|
2727
|
+
provider: providerId
|
|
2728
|
+
};
|
|
2729
|
+
await this.session.send({
|
|
2730
|
+
type: "chunk",
|
|
2731
|
+
thread_id: threadId,
|
|
2732
|
+
content: "",
|
|
2733
|
+
content_type: "text",
|
|
2734
|
+
content_metadata: { media_generation: mediaGeneration },
|
|
2735
|
+
done: false
|
|
2736
|
+
});
|
|
2737
|
+
// If the user referenced/attached an image, they want it animated:
|
|
2738
|
+
// route to image-to-video so the image actually reaches the provider.
|
|
2739
|
+
// Many "video" models (e.g. fal-ai/stable-video) in fact require an
|
|
2740
|
+
// image and reject a text-only request with an opaque 422.
|
|
2741
|
+
const sourceBytes = await this.deps.resolveSourceImageBytes(data, mediaGeneration, userId);
|
|
2742
|
+
const videoParams = {
|
|
2743
|
+
prompt: expandedPrompt,
|
|
2744
|
+
aspect_ratio: aspectRatio,
|
|
2745
|
+
resolution,
|
|
2746
|
+
duration_seconds: duration
|
|
2747
|
+
};
|
|
2748
|
+
const generated = sourceBytes && sourceBytes.length > 0
|
|
2749
|
+
? await generate("image_to_video", { ...videoParams, images: [sourceBytes] }, { mime: "video/mp4" }, (abort) => provider.imageToVideo(sourceBytes, {
|
|
2750
|
+
model: videoModel,
|
|
2751
|
+
prompt: expandedPrompt,
|
|
2752
|
+
aspectRatio,
|
|
2753
|
+
resolution,
|
|
2754
|
+
durationSeconds: duration,
|
|
2755
|
+
numInferenceSteps: null,
|
|
2756
|
+
signal: abort
|
|
2757
|
+
}))
|
|
2758
|
+
: await generate("text_to_video", videoParams, { mime: "video/mp4" }, (abort) => provider.textToVideo({
|
|
2759
|
+
model: videoModel,
|
|
2760
|
+
prompt: expandedPrompt,
|
|
2761
|
+
aspectRatio,
|
|
2762
|
+
resolution,
|
|
2763
|
+
durationSeconds: duration,
|
|
2764
|
+
signal: abort
|
|
2765
|
+
}));
|
|
2766
|
+
if (cancelled())
|
|
2767
|
+
return;
|
|
2768
|
+
const assetId = seamAssetId(generated) ??
|
|
2769
|
+
(await storeMediaAsset(generated.output, "video/mp4", "mp4"));
|
|
2770
|
+
const assistantMsgData = {
|
|
2771
|
+
type: "message",
|
|
2772
|
+
role: "assistant",
|
|
2773
|
+
content: [
|
|
2774
|
+
{
|
|
2775
|
+
type: "video",
|
|
2776
|
+
video: {
|
|
2777
|
+
type: "video",
|
|
2778
|
+
asset_id: assetId,
|
|
2779
|
+
format: "mp4",
|
|
2780
|
+
duration: duration
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
],
|
|
2784
|
+
thread_id: threadId,
|
|
2785
|
+
workflow_id: workflowId,
|
|
2786
|
+
provider: providerId,
|
|
2787
|
+
model: modelId,
|
|
2788
|
+
media_generation: mediaGeneration
|
|
2789
|
+
};
|
|
2790
|
+
// Re-check: cancellation may have landed while the asset was persisting.
|
|
2791
|
+
if (cancelled())
|
|
2792
|
+
return;
|
|
2793
|
+
await this.saveMessageToDb(assistantMsgData);
|
|
2794
|
+
await this.session.send(assistantMsgData);
|
|
2795
|
+
await sendTerminalDone();
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
if (mode === "reference_to_video") {
|
|
2799
|
+
// The whole set of attachments conditions one clip, so every image
|
|
2800
|
+
// and video on the message reaches the provider — plus the reference
|
|
2801
|
+
// images of any `@`-mentioned entity, which is the same kind of input
|
|
2802
|
+
// arriving by a different route.
|
|
2803
|
+
const references = await this.deps.resolveReferenceMediaBytes(data, mediaGeneration, userId);
|
|
2804
|
+
const referenceImages = [...references.images, ...entityImageBytes];
|
|
2805
|
+
const referenceVideos = references.videos;
|
|
2806
|
+
if (referenceImages.length === 0 && referenceVideos.length === 0) {
|
|
2807
|
+
await this.session.send({
|
|
2808
|
+
type: "error",
|
|
2809
|
+
message: "Reference to video needs at least one reference — attach an image or a video first",
|
|
2810
|
+
thread_id: threadId
|
|
2811
|
+
});
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
const aspectRatio = isString(mediaGeneration.aspect_ratio)
|
|
2815
|
+
? mediaGeneration.aspect_ratio
|
|
2816
|
+
: null;
|
|
2817
|
+
const resolution = isString(mediaGeneration.resolution)
|
|
2818
|
+
? mediaGeneration.resolution
|
|
2819
|
+
: null;
|
|
2820
|
+
const duration = isNumber(mediaGeneration.duration)
|
|
2821
|
+
? mediaGeneration.duration
|
|
2822
|
+
: null;
|
|
2823
|
+
// Only some models take it, and a model that does not rejects the
|
|
2824
|
+
// request rather than silently dropping the audio, so the flag is
|
|
2825
|
+
// forwarded only when the composer set it.
|
|
2826
|
+
const useReferenceVideoAudio = typeof mediaGeneration.use_reference_video_audio === "boolean"
|
|
2827
|
+
? mediaGeneration.use_reference_video_audio
|
|
2828
|
+
: undefined;
|
|
2829
|
+
const r2vModel = {
|
|
2830
|
+
id: modelId,
|
|
2831
|
+
name: modelId,
|
|
2832
|
+
provider: providerId
|
|
2833
|
+
};
|
|
2834
|
+
await this.session.send({
|
|
2835
|
+
type: "chunk",
|
|
2836
|
+
thread_id: threadId,
|
|
2837
|
+
content: "",
|
|
2838
|
+
content_type: "text",
|
|
2839
|
+
content_metadata: { media_generation: mediaGeneration },
|
|
2840
|
+
done: false
|
|
2841
|
+
});
|
|
2842
|
+
const generated = await generate("reference_to_video", {
|
|
2843
|
+
prompt: expandedPrompt,
|
|
2844
|
+
aspect_ratio: aspectRatio,
|
|
2845
|
+
resolution,
|
|
2846
|
+
duration_seconds: duration,
|
|
2847
|
+
reference_images: referenceImages,
|
|
2848
|
+
reference_videos: referenceVideos,
|
|
2849
|
+
use_reference_video_audio: useReferenceVideoAudio
|
|
2850
|
+
}, { mime: "video/mp4" }, (abort) => provider.referenceToVideo({ images: referenceImages, videos: referenceVideos }, {
|
|
2851
|
+
model: r2vModel,
|
|
2852
|
+
prompt: expandedPrompt,
|
|
2853
|
+
aspectRatio,
|
|
2854
|
+
resolution,
|
|
2855
|
+
durationSeconds: duration,
|
|
2856
|
+
useReferenceVideoAudio,
|
|
2857
|
+
signal: abort
|
|
2858
|
+
}));
|
|
2859
|
+
if (cancelled())
|
|
2860
|
+
return;
|
|
2861
|
+
const assetId = seamAssetId(generated) ??
|
|
2862
|
+
(await storeMediaAsset(generated.output, "video/mp4", "mp4"));
|
|
2863
|
+
const assistantMsgData = {
|
|
2864
|
+
type: "message",
|
|
2865
|
+
role: "assistant",
|
|
2866
|
+
content: [
|
|
2867
|
+
{
|
|
2868
|
+
type: "video",
|
|
2869
|
+
video: {
|
|
2870
|
+
type: "video",
|
|
2871
|
+
asset_id: assetId,
|
|
2872
|
+
format: "mp4",
|
|
2873
|
+
duration
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
],
|
|
2877
|
+
thread_id: threadId,
|
|
2878
|
+
workflow_id: workflowId,
|
|
2879
|
+
provider: providerId,
|
|
2880
|
+
model: modelId,
|
|
2881
|
+
media_generation: mediaGeneration
|
|
2882
|
+
};
|
|
2883
|
+
// Re-check: cancellation may have landed while the asset was persisting.
|
|
2884
|
+
if (cancelled())
|
|
2885
|
+
return;
|
|
2886
|
+
await this.saveMessageToDb(assistantMsgData);
|
|
2887
|
+
await this.session.send(assistantMsgData);
|
|
2888
|
+
await sendTerminalDone();
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
if (mode === "audio" || mode === "music") {
|
|
2892
|
+
const voice = isString(mediaGeneration.voice)
|
|
2893
|
+
? mediaGeneration.voice
|
|
2894
|
+
: undefined;
|
|
2895
|
+
const speed = isNumber(mediaGeneration.speed)
|
|
2896
|
+
? mediaGeneration.speed
|
|
2897
|
+
: 1.0;
|
|
2898
|
+
const requestedFormatRaw = isString(mediaGeneration.audio_format)
|
|
2899
|
+
? mediaGeneration.audio_format.toLowerCase()
|
|
2900
|
+
: null;
|
|
2901
|
+
const supportedFormats = new Set([
|
|
2902
|
+
"mp3",
|
|
2903
|
+
"wav",
|
|
2904
|
+
"pcm",
|
|
2905
|
+
"opus",
|
|
2906
|
+
"flac",
|
|
2907
|
+
"aac"
|
|
2908
|
+
]);
|
|
2909
|
+
const requestedFormat = requestedFormatRaw && supportedFormats.has(requestedFormatRaw)
|
|
2910
|
+
? requestedFormatRaw
|
|
2911
|
+
: null;
|
|
2912
|
+
await this.session.send({
|
|
2913
|
+
type: "chunk",
|
|
2914
|
+
thread_id: threadId,
|
|
2915
|
+
content: "",
|
|
2916
|
+
content_type: "text",
|
|
2917
|
+
content_metadata: { media_generation: mediaGeneration },
|
|
2918
|
+
done: false
|
|
2919
|
+
});
|
|
2920
|
+
const durationSeconds = isNumber(mediaGeneration.duration)
|
|
2921
|
+
? mediaGeneration.duration
|
|
2922
|
+
: undefined;
|
|
2923
|
+
const audioGenerationId = randomUUID();
|
|
2924
|
+
let assetId;
|
|
2925
|
+
let audioMimeType;
|
|
2926
|
+
await generate(mode === "music" ? "text_to_music" : "text_to_speech", mode === "music"
|
|
2927
|
+
? { prompt: expandedPrompt, duration_seconds: durationSeconds }
|
|
2928
|
+
: {
|
|
2929
|
+
text: expandedPrompt,
|
|
2930
|
+
voice,
|
|
2931
|
+
speed,
|
|
2932
|
+
audio_format: requestedFormat
|
|
2933
|
+
}, null, async () => {
|
|
2934
|
+
const speech = mode === "music"
|
|
2935
|
+
? await generateMusicBytes(provider, {
|
|
2936
|
+
model: {
|
|
2937
|
+
id: modelId,
|
|
2938
|
+
name: modelId,
|
|
2939
|
+
provider: providerId
|
|
2940
|
+
},
|
|
2941
|
+
prompt: expandedPrompt,
|
|
2942
|
+
durationSeconds
|
|
2943
|
+
}, cancelled)
|
|
2944
|
+
: await generateSpeechBytes(provider, {
|
|
2945
|
+
text: expandedPrompt,
|
|
2946
|
+
model: modelId,
|
|
2947
|
+
voice,
|
|
2948
|
+
speed,
|
|
2949
|
+
audioFormat: requestedFormat
|
|
2950
|
+
}, cancelled);
|
|
2951
|
+
if (!speech)
|
|
2952
|
+
return null;
|
|
2953
|
+
assetId = await storeMediaAsset(speech.bytes, speech.mimeType, speech.ext);
|
|
2954
|
+
audioMimeType = speech.mimeType;
|
|
2955
|
+
return assetId;
|
|
2956
|
+
}, audioGenerationId);
|
|
2957
|
+
if (cancelled())
|
|
2958
|
+
return;
|
|
2959
|
+
if (!assetId || !audioMimeType) {
|
|
2960
|
+
throw new Error("Provider returned no audio data");
|
|
2961
|
+
}
|
|
2962
|
+
await linkGenerationAssets([audioGenerationId], [assetId]);
|
|
2963
|
+
const assistantMsgData = {
|
|
2964
|
+
type: "message",
|
|
2965
|
+
role: "assistant",
|
|
2966
|
+
content: [
|
|
2967
|
+
{
|
|
2968
|
+
type: "audio",
|
|
2969
|
+
audio: {
|
|
2970
|
+
type: "audio",
|
|
2971
|
+
asset_id: assetId,
|
|
2972
|
+
mimeType: audioMimeType
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
],
|
|
2976
|
+
thread_id: threadId,
|
|
2977
|
+
workflow_id: workflowId,
|
|
2978
|
+
provider: providerId,
|
|
2979
|
+
model: modelId,
|
|
2980
|
+
media_generation: mediaGeneration
|
|
2981
|
+
};
|
|
2982
|
+
// Re-check: cancellation may have landed while the asset was persisting.
|
|
2983
|
+
if (cancelled())
|
|
2984
|
+
return;
|
|
2985
|
+
await this.saveMessageToDb(assistantMsgData);
|
|
2986
|
+
await this.session.send(assistantMsgData);
|
|
2987
|
+
await sendTerminalDone();
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
if (mode === "image_edit" || mode === "image_to_video") {
|
|
2991
|
+
// Resolve the source image from either the message content (most
|
|
2992
|
+
// common path: user dropped an image into the composer) or from the
|
|
2993
|
+
// explicit `source_asset_id` echo on the media_generation payload.
|
|
2994
|
+
const sourceBytes = await this.deps.resolveSourceImageBytes(data, mediaGeneration, userId);
|
|
2995
|
+
// A zero-length buffer (e.g. a storage read that resolved but came
|
|
2996
|
+
// back empty) is truthy — without the length check it sails past this
|
|
2997
|
+
// guard, then silently drops out of `attachAssets`'s image field
|
|
2998
|
+
// downstream, so fal gets a request with no image at all and rejects
|
|
2999
|
+
// it with an opaque 422 instead of the friendly error below.
|
|
3000
|
+
if (!sourceBytes || sourceBytes.length === 0) {
|
|
3001
|
+
await this.session.send({
|
|
3002
|
+
type: "error",
|
|
3003
|
+
message: "A source image is required — drop or attach an image first",
|
|
3004
|
+
thread_id: threadId
|
|
3005
|
+
});
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
await this.session.send({
|
|
3009
|
+
type: "chunk",
|
|
3010
|
+
thread_id: threadId,
|
|
3011
|
+
content: "",
|
|
3012
|
+
content_type: "text",
|
|
3013
|
+
content_metadata: { media_generation: mediaGeneration },
|
|
3014
|
+
done: false
|
|
3015
|
+
});
|
|
3016
|
+
if (mode === "image_edit") {
|
|
3017
|
+
const variations = Math.max(1, Math.min(Number(mediaGeneration.variations ?? 1), 8));
|
|
3018
|
+
const targetWidth = isNumber(mediaGeneration.width)
|
|
3019
|
+
? mediaGeneration.width
|
|
3020
|
+
: undefined;
|
|
3021
|
+
const targetHeight = isNumber(mediaGeneration.height)
|
|
3022
|
+
? mediaGeneration.height
|
|
3023
|
+
: undefined;
|
|
3024
|
+
const strength = isNumber(mediaGeneration.strength)
|
|
3025
|
+
? mediaGeneration.strength
|
|
3026
|
+
: undefined;
|
|
3027
|
+
const numInferenceSteps = isNumber(mediaGeneration.num_inference_steps)
|
|
3028
|
+
? mediaGeneration.num_inference_steps
|
|
3029
|
+
: undefined;
|
|
3030
|
+
const editModel = {
|
|
3031
|
+
id: modelId,
|
|
3032
|
+
name: modelId,
|
|
3033
|
+
provider: providerId
|
|
3034
|
+
};
|
|
3035
|
+
const params = {
|
|
3036
|
+
model: editModel,
|
|
3037
|
+
prompt: expandedPrompt,
|
|
3038
|
+
targetWidth: targetWidth ?? null,
|
|
3039
|
+
targetHeight: targetHeight ?? null,
|
|
3040
|
+
strength: strength ?? null,
|
|
3041
|
+
numInferenceSteps: numInferenceSteps ?? null,
|
|
3042
|
+
signal
|
|
3043
|
+
};
|
|
3044
|
+
if (requestSeq !== undefined && requestSeq !== this.chatRequestSeq)
|
|
3045
|
+
return;
|
|
3046
|
+
const generated = await generate("image_to_image", {
|
|
3047
|
+
prompt: expandedPrompt,
|
|
3048
|
+
target_width: targetWidth ?? null,
|
|
3049
|
+
target_height: targetHeight ?? null,
|
|
3050
|
+
strength: strength ?? null,
|
|
3051
|
+
num_inference_steps: numInferenceSteps ?? null,
|
|
3052
|
+
num_images: variations,
|
|
3053
|
+
images: [sourceBytes, ...entityImageBytes]
|
|
3054
|
+
}, {}, (abort) => provider.imageToImages([sourceBytes, ...entityImageBytes], { ...params, signal: abort }, variations));
|
|
3055
|
+
if (cancelled())
|
|
3056
|
+
return;
|
|
3057
|
+
const imageContents = [];
|
|
3058
|
+
for (const [index, bytes] of generated.output.entries()) {
|
|
3059
|
+
// Per-variation: a cancel partway through must not keep persisting.
|
|
3060
|
+
if (cancelled())
|
|
3061
|
+
return;
|
|
3062
|
+
const mimeType = detectImageMime(bytes);
|
|
3063
|
+
const assetId = seamAssetId(generated, index) ??
|
|
3064
|
+
(await storeMediaAsset(bytes, mimeType, IMAGE_MIME_TO_EXT[mimeType] ?? "png"));
|
|
3065
|
+
imageContents.push({
|
|
3066
|
+
type: "image_url",
|
|
3067
|
+
image: {
|
|
3068
|
+
type: "image",
|
|
3069
|
+
asset_id: assetId,
|
|
3070
|
+
mimeType
|
|
3071
|
+
}
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
const assistantMsgData = {
|
|
3075
|
+
type: "message",
|
|
3076
|
+
role: "assistant",
|
|
3077
|
+
content: imageContents,
|
|
3078
|
+
thread_id: threadId,
|
|
3079
|
+
workflow_id: workflowId,
|
|
3080
|
+
provider: providerId,
|
|
3081
|
+
model: modelId,
|
|
3082
|
+
media_generation: mediaGeneration
|
|
3083
|
+
};
|
|
3084
|
+
// Re-check: cancellation may have landed while the asset was persisting.
|
|
3085
|
+
if (cancelled())
|
|
3086
|
+
return;
|
|
3087
|
+
await this.saveMessageToDb(assistantMsgData);
|
|
3088
|
+
await this.session.send(assistantMsgData);
|
|
3089
|
+
await sendTerminalDone();
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
// image_to_video
|
|
3093
|
+
const aspectRatio = isString(mediaGeneration.aspect_ratio)
|
|
3094
|
+
? mediaGeneration.aspect_ratio
|
|
3095
|
+
: null;
|
|
3096
|
+
const resolution = isString(mediaGeneration.resolution)
|
|
3097
|
+
? mediaGeneration.resolution
|
|
3098
|
+
: null;
|
|
3099
|
+
const duration = isNumber(mediaGeneration.duration)
|
|
3100
|
+
? mediaGeneration.duration
|
|
3101
|
+
: null;
|
|
3102
|
+
const numInferenceSteps = isNumber(mediaGeneration.num_inference_steps)
|
|
3103
|
+
? mediaGeneration.num_inference_steps
|
|
3104
|
+
: null;
|
|
3105
|
+
const i2vModel = {
|
|
3106
|
+
id: modelId,
|
|
3107
|
+
name: modelId,
|
|
3108
|
+
provider: providerId
|
|
3109
|
+
};
|
|
3110
|
+
const params = {
|
|
3111
|
+
model: i2vModel,
|
|
3112
|
+
prompt: expandedPrompt,
|
|
3113
|
+
aspectRatio,
|
|
3114
|
+
resolution,
|
|
3115
|
+
durationSeconds: duration,
|
|
3116
|
+
numInferenceSteps,
|
|
3117
|
+
signal
|
|
3118
|
+
};
|
|
3119
|
+
const generated = await generate("image_to_video", {
|
|
3120
|
+
prompt: expandedPrompt,
|
|
3121
|
+
aspect_ratio: aspectRatio,
|
|
3122
|
+
resolution,
|
|
3123
|
+
duration_seconds: duration,
|
|
3124
|
+
num_inference_steps: numInferenceSteps,
|
|
3125
|
+
images: [sourceBytes]
|
|
3126
|
+
}, { mime: "video/mp4" }, (abort) => provider.imageToVideo(sourceBytes, { ...params, signal: abort }));
|
|
3127
|
+
if (cancelled())
|
|
3128
|
+
return;
|
|
3129
|
+
const assetId = seamAssetId(generated) ??
|
|
3130
|
+
(await storeMediaAsset(generated.output, "video/mp4", "mp4"));
|
|
3131
|
+
const assistantMsgData = {
|
|
3132
|
+
type: "message",
|
|
3133
|
+
role: "assistant",
|
|
3134
|
+
content: [
|
|
3135
|
+
{
|
|
3136
|
+
type: "video",
|
|
3137
|
+
video: {
|
|
3138
|
+
type: "video",
|
|
3139
|
+
asset_id: assetId,
|
|
3140
|
+
format: "mp4",
|
|
3141
|
+
duration
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
],
|
|
3145
|
+
thread_id: threadId,
|
|
3146
|
+
workflow_id: workflowId,
|
|
3147
|
+
provider: providerId,
|
|
3148
|
+
model: modelId,
|
|
3149
|
+
media_generation: mediaGeneration
|
|
3150
|
+
};
|
|
3151
|
+
// Re-check: cancellation may have landed while the asset was persisting.
|
|
3152
|
+
if (cancelled())
|
|
3153
|
+
return;
|
|
3154
|
+
await this.saveMessageToDb(assistantMsgData);
|
|
3155
|
+
await this.session.send(assistantMsgData);
|
|
3156
|
+
await sendTerminalDone();
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
// Modes not yet implemented on the backend — fall back to an informative
|
|
3160
|
+
// error so the client can render the unsupported state cleanly.
|
|
3161
|
+
await this.session.send({
|
|
3162
|
+
type: "error",
|
|
3163
|
+
message: `Media generation mode "${mode}" is not yet supported`,
|
|
3164
|
+
thread_id: threadId
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
catch (err) {
|
|
3168
|
+
if (cancelled())
|
|
3169
|
+
return;
|
|
3170
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3171
|
+
const scope = getRunTraceScope();
|
|
3172
|
+
if (scope) {
|
|
3173
|
+
trace.getActiveSpan()?.setStatus({ code: SpanStatusCode.ERROR });
|
|
3174
|
+
await settleRunTrace(userId, scope.runId, { status: "failed", error: scope.policy.contentSuppressed ? "Execution failed" : errMsg, secretValues: scope.secretValues, contentSuppressed: scope.policy.contentSuppressed });
|
|
3175
|
+
}
|
|
3176
|
+
log.error("Media generation error", { threadId, mode, error: errMsg });
|
|
3177
|
+
const failure = providerFailureMessage(err, providerId, modelId);
|
|
3178
|
+
const failureMessage = `Generation failed: ${failure.message}`;
|
|
3179
|
+
const assistantMsgData = {
|
|
3180
|
+
type: "message",
|
|
3181
|
+
role: "assistant",
|
|
3182
|
+
content: failureMessage,
|
|
3183
|
+
thread_id: threadId,
|
|
3184
|
+
workflow_id: workflowId,
|
|
3185
|
+
provider: providerId,
|
|
3186
|
+
model: modelId,
|
|
3187
|
+
media_generation: mediaGeneration
|
|
3188
|
+
};
|
|
3189
|
+
try {
|
|
3190
|
+
await this.saveMessageToDb(assistantMsgData);
|
|
3191
|
+
}
|
|
3192
|
+
catch (persistError) {
|
|
3193
|
+
this.session.logError("media failure message save failed", persistError);
|
|
3194
|
+
}
|
|
3195
|
+
await this.session.send({
|
|
3196
|
+
type: "error",
|
|
3197
|
+
message: failureMessage,
|
|
3198
|
+
thread_id: threadId
|
|
3199
|
+
});
|
|
3200
|
+
await this.session.send(assistantMsgData);
|
|
3201
|
+
await sendTerminalDone();
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
/**
|
|
3205
|
+
* Handle a chat message that targets a workflow.
|
|
3206
|
+
*
|
|
3207
|
+
* Mirrors Python's process_messages_for_workflow → WorkflowMessageProcessor/
|
|
3208
|
+
* ChatWorkflowMessageProcessor flow:
|
|
3209
|
+
* 1. Load workflow from DB
|
|
3210
|
+
* 2. Detect message input node names from graph
|
|
3211
|
+
* 3. Prepare params (serialized message + history)
|
|
3212
|
+
* 4. Run workflow via ExecutionSession (@nodetool-ai/execution)
|
|
3213
|
+
* 5. Stream events (job_update, node_update, output_update)
|
|
3214
|
+
* 6. Collect output_update results
|
|
3215
|
+
* 7. Send response message with typed content, then the terminal chunk
|
|
3216
|
+
*/
|
|
3217
|
+
async handleWorkflowMessage(data, requestSeq, signal) {
|
|
3218
|
+
const threadId = isString(data.thread_id) ? data.thread_id : "";
|
|
3219
|
+
const workflowId = isString(data.workflow_id) ? data.workflow_id : null;
|
|
3220
|
+
const providerId = isString(data.provider)
|
|
3221
|
+
? data.provider
|
|
3222
|
+
: this.deps.defaults.provider;
|
|
3223
|
+
const model = isString(data.model) ? data.model : this.deps.defaults.model;
|
|
3224
|
+
const userId = this.session.requireUserId();
|
|
3225
|
+
const chatProjectId = (await Project.findByThread(userId, threadId))?.id ?? undefined;
|
|
3226
|
+
const jobId = randomUUID();
|
|
3227
|
+
log.info("Workflow message", { threadId, workflowId, jobId });
|
|
3228
|
+
// Assigned once the run's abort listener is registered; released in the
|
|
3229
|
+
// finally so a completed workflow's listener can't cancel() a runner that
|
|
3230
|
+
// already finished when a later Stop/disconnect fires the same signal.
|
|
3231
|
+
let releaseAbortListener = null;
|
|
3232
|
+
try {
|
|
3233
|
+
if (!workflowId) {
|
|
3234
|
+
throw new Error("workflow_id is required for workflow processing");
|
|
3235
|
+
}
|
|
3236
|
+
// Load workflow from DB
|
|
3237
|
+
const workflow = await Workflow.find(userId, workflowId);
|
|
3238
|
+
if (!workflow) {
|
|
3239
|
+
throw new Error(`Workflow ${workflowId} not found`);
|
|
3240
|
+
}
|
|
3241
|
+
const rawGraph = workflow.graph;
|
|
3242
|
+
// Detect message input names from raw graph (reads node.data) — matches Python
|
|
3243
|
+
const { messageName, messagesName } = this.detectMessageInputNames(rawGraph);
|
|
3244
|
+
const graph = await this.deps.hydrateGraph(rawGraph);
|
|
3245
|
+
if (this.deps.beforeRunJob) {
|
|
3246
|
+
await this.deps.beforeRunJob(graph);
|
|
3247
|
+
}
|
|
3248
|
+
const messageInputName = (isString(data.workflow_message_input_name)
|
|
3249
|
+
? data.workflow_message_input_name
|
|
3250
|
+
: null) ??
|
|
3251
|
+
messageName ??
|
|
3252
|
+
"message";
|
|
3253
|
+
const messagesInputName = (isString(data.workflow_messages_input_name)
|
|
3254
|
+
? data.workflow_messages_input_name
|
|
3255
|
+
: null) ??
|
|
3256
|
+
messagesName ??
|
|
3257
|
+
"messages";
|
|
3258
|
+
// Build chat history for params — matches Python
|
|
3259
|
+
const [dbMessages] = await Message.paginate(threadId, { limit: 1000 });
|
|
3260
|
+
const inheritedTrace = getRunTraceScope();
|
|
3261
|
+
seedStoredToolProvenance(dbMessages);
|
|
3262
|
+
if (inheritedTrace) {
|
|
3263
|
+
await registerRunTraceParents(userId, inheritedTrace.runId, dbMessages.map((m) => ({ kind: "message", id: m.id })));
|
|
3264
|
+
}
|
|
3265
|
+
const chatHistorySerialized = dbMessages.map((m) => ({
|
|
3266
|
+
role: m.role,
|
|
3267
|
+
content: m.content,
|
|
3268
|
+
created_at: m.created_at,
|
|
3269
|
+
thread_id: m.thread_id
|
|
3270
|
+
}));
|
|
3271
|
+
// Serialize current message
|
|
3272
|
+
const currentMessage = {
|
|
3273
|
+
role: isString(data.role) ? data.role : "user",
|
|
3274
|
+
content: data.content,
|
|
3275
|
+
thread_id: threadId,
|
|
3276
|
+
workflow_id: workflowId,
|
|
3277
|
+
provider: providerId,
|
|
3278
|
+
model
|
|
3279
|
+
};
|
|
3280
|
+
// Prepare params — matches Python's WorkflowMessageProcessor
|
|
3281
|
+
const params = {
|
|
3282
|
+
[messageInputName]: currentMessage,
|
|
3283
|
+
[messagesInputName]: [...chatHistorySerialized, currentMessage]
|
|
3284
|
+
};
|
|
3285
|
+
if (isObjectLike(data.params)) {
|
|
3286
|
+
Object.assign(params, data.params);
|
|
3287
|
+
}
|
|
3288
|
+
// If chat workflow, add legacy params — matches Python's ChatWorkflowMessageProcessor
|
|
3289
|
+
if (workflow.run_mode === "chat") {
|
|
3290
|
+
const legacyChatInput = chatHistorySerialized.map((m) => ({
|
|
3291
|
+
role: m.role,
|
|
3292
|
+
content: extractTextContent(m.content),
|
|
3293
|
+
created_at: m.created_at
|
|
3294
|
+
}));
|
|
3295
|
+
params["chat_input"] = legacyChatInput;
|
|
3296
|
+
if (messagesInputName !== "messages") {
|
|
3297
|
+
params["messages"] = legacyChatInput;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
// Create processing context
|
|
3301
|
+
const workspace = this.session.workspaceResolver
|
|
3302
|
+
? await this.session.workspaceResolver(workflowId, userId)
|
|
3303
|
+
: null;
|
|
3304
|
+
const context = createRuntimeContext({
|
|
3305
|
+
jobId,
|
|
3306
|
+
workflowId,
|
|
3307
|
+
projectId: chatProjectId,
|
|
3308
|
+
userId,
|
|
3309
|
+
workspace,
|
|
3310
|
+
assetOutputMode: this.session.mode === "text" ? "data_uri" : "temp_url"
|
|
3311
|
+
});
|
|
3312
|
+
context.runTraceContext = getRunTraceScope() ?? null;
|
|
3313
|
+
// Expose executor/node-type resolution for sub-workflow nodes
|
|
3314
|
+
context.setResolveExecutor((node) => this.session.resolveExecutor(node));
|
|
3315
|
+
if (this.session.resolveNodeType) {
|
|
3316
|
+
const resolverObj = isFunctionValue(this.session.resolveNodeType)
|
|
3317
|
+
? { resolveNodeType: this.session.resolveNodeType }
|
|
3318
|
+
: this.session.resolveNodeType;
|
|
3319
|
+
context.setResolveNodeType((nodeType) => resolverObj.resolveNodeType(nodeType));
|
|
3320
|
+
}
|
|
3321
|
+
await Job.create({ id: jobId, workflow_id: workflowId, user_id: userId, status: "running", params, graph });
|
|
3322
|
+
await registerWorkflowRunTrace(context, { jobId, workflowId });
|
|
3323
|
+
// Create and run workflow (A5: via the ExecutionSession facade — see
|
|
3324
|
+
// the identical note in `startJobInner`).
|
|
3325
|
+
const session = await ExecutionSession.create({
|
|
3326
|
+
graph: toRawGraphInput(graph),
|
|
3327
|
+
resolveExecutor: (node) => this.session.resolveExecutor(node),
|
|
3328
|
+
bridgeFactory: async () => null,
|
|
3329
|
+
jobLifecycleBridge: this.session.pythonBridge ?? null,
|
|
3330
|
+
jobId,
|
|
3331
|
+
workflowId,
|
|
3332
|
+
context,
|
|
3333
|
+
params,
|
|
3334
|
+
validateNode: this.session.validateNode
|
|
3335
|
+
});
|
|
3336
|
+
const active = {
|
|
3337
|
+
jobId,
|
|
3338
|
+
workflowId,
|
|
3339
|
+
context,
|
|
3340
|
+
session,
|
|
3341
|
+
graph,
|
|
3342
|
+
finished: false,
|
|
3343
|
+
status: "running",
|
|
3344
|
+
requireTerminalResult: false,
|
|
3345
|
+
executionOptions: { ...DEFAULT_RUN_JOB_EXECUTION_OPTIONS },
|
|
3346
|
+
timings: {
|
|
3347
|
+
acceptedAt: performance.now(),
|
|
3348
|
+
queueMs: 0,
|
|
3349
|
+
graphLoadedMs: 0,
|
|
3350
|
+
graphHydratedMs: 0,
|
|
3351
|
+
preRunMs: 0,
|
|
3352
|
+
persistenceMs: 0,
|
|
3353
|
+
kernelStartedAt: performance.now()
|
|
3354
|
+
}
|
|
3355
|
+
};
|
|
3356
|
+
this.deps.jobs.registerJob(jobId, active);
|
|
3357
|
+
// The run already started inside `ExecutionSession.create()` above.
|
|
3358
|
+
const executePromise = session.result;
|
|
3359
|
+
// Stream events, collect output_update results
|
|
3360
|
+
const result = {};
|
|
3361
|
+
await this.session.send({
|
|
3362
|
+
type: "job_update",
|
|
3363
|
+
status: "running",
|
|
3364
|
+
job_id: jobId,
|
|
3365
|
+
workflow_id: workflowId
|
|
3366
|
+
});
|
|
3367
|
+
let finalOutputs = {};
|
|
3368
|
+
const executionSettled = executePromise
|
|
3369
|
+
.then((r) => {
|
|
3370
|
+
active.status = r.status;
|
|
3371
|
+
active.error = r.error;
|
|
3372
|
+
finalOutputs = r.outputs ?? {};
|
|
3373
|
+
})
|
|
3374
|
+
.catch((err) => {
|
|
3375
|
+
active.status = "failed";
|
|
3376
|
+
active.error = err instanceof Error ? err.message : String(err);
|
|
3377
|
+
})
|
|
3378
|
+
.finally(() => {
|
|
3379
|
+
active.finished = true;
|
|
3380
|
+
});
|
|
3381
|
+
const waitForActivity = createRelayActivityWaiter(active.context, executionSettled, signal);
|
|
3382
|
+
const nodeTypes = new Map();
|
|
3383
|
+
const graphNodes = graph.nodes ?? [];
|
|
3384
|
+
for (const n of graphNodes) {
|
|
3385
|
+
if (n.id) {
|
|
3386
|
+
nodeTypes.set(String(n.id), isString(n.type) ? n.type : "");
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
// A chat Stop / superseding message bumps chatRequestSeq. Unlike the
|
|
3390
|
+
// other chat handlers this one owns a workflow runner, so cancel it and
|
|
3391
|
+
// stop streaming when our turn is no longer current — otherwise the run
|
|
3392
|
+
// completes and delivers an assistant message after the user stopped.
|
|
3393
|
+
const superseded = () => requestSeq !== undefined && requestSeq !== this.chatRequestSeq;
|
|
3394
|
+
// Cancel the moment Stop fires rather than waiting for the streaming loop
|
|
3395
|
+
// below to come back around — the run may be parked inside a long node.
|
|
3396
|
+
const onAbort = () => {
|
|
3397
|
+
try {
|
|
3398
|
+
active.session.cancel();
|
|
3399
|
+
}
|
|
3400
|
+
catch {
|
|
3401
|
+
// best-effort cancel
|
|
3402
|
+
}
|
|
3403
|
+
};
|
|
3404
|
+
if (signal?.aborted) {
|
|
3405
|
+
onAbort();
|
|
3406
|
+
}
|
|
3407
|
+
else if (signal) {
|
|
3408
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3409
|
+
releaseAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
3410
|
+
}
|
|
3411
|
+
while (!active.finished || active.context.hasMessages()) {
|
|
3412
|
+
if (superseded()) {
|
|
3413
|
+
try {
|
|
3414
|
+
active.session.cancel();
|
|
3415
|
+
}
|
|
3416
|
+
catch {
|
|
3417
|
+
// best-effort cancel
|
|
3418
|
+
}
|
|
3419
|
+
active.status = "cancelled";
|
|
3420
|
+
try {
|
|
3421
|
+
const job = await Job.get(jobId);
|
|
3422
|
+
if (job && job.status !== "cancelled") {
|
|
3423
|
+
job.markCancelled();
|
|
3424
|
+
await job.save();
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
catch (err) {
|
|
3428
|
+
this.session.logError("workflow chat cancellation persistence failed", err);
|
|
3429
|
+
}
|
|
3430
|
+
this.deps.jobs.dropJob(jobId);
|
|
3431
|
+
return;
|
|
3432
|
+
}
|
|
3433
|
+
while (active.context.hasMessages()) {
|
|
3434
|
+
const msg = active.context.popMessage();
|
|
3435
|
+
if (!msg)
|
|
3436
|
+
break;
|
|
3437
|
+
const outbound = { ...msg };
|
|
3438
|
+
outbound.job_id ??= jobId;
|
|
3439
|
+
outbound.workflow_id ??= workflowId;
|
|
3440
|
+
// Every message, not just node updates: a `prediction` is where
|
|
3441
|
+
// ledger-priced generation spend (Replicate, Gemini, OpenAI, …)
|
|
3442
|
+
// reports itself.
|
|
3443
|
+
this.deps.jobs.handleNodeProviderCost(active, outbound);
|
|
3444
|
+
if (outbound.type === "node_update" ||
|
|
3445
|
+
outbound.type === "output_update") {
|
|
3446
|
+
const nodeId = String(outbound.node_id ?? "");
|
|
3447
|
+
const nodeType = nodeTypes.get(nodeId) ?? "";
|
|
3448
|
+
// Capture output_update values for the response message
|
|
3449
|
+
if (outbound.type === "output_update") {
|
|
3450
|
+
if (nodeType.includes("Output")) {
|
|
3451
|
+
const nodeName = isString(outbound.node_name)
|
|
3452
|
+
? outbound.node_name
|
|
3453
|
+
: nodeType;
|
|
3454
|
+
result[nodeName] = outbound.value;
|
|
3455
|
+
}
|
|
3456
|
+
else {
|
|
3457
|
+
continue; // Skip non-output node output_updates
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
await this.session.send(outbound);
|
|
3462
|
+
}
|
|
3463
|
+
if (!active.finished) {
|
|
3464
|
+
await waitForActivity();
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
// Collect any outputs from the runner result — only Output-type nodes
|
|
3468
|
+
// The kernel considers all leaf nodes as "output nodes", but for the
|
|
3469
|
+
// response message we only want nodes whose type includes "Output"
|
|
3470
|
+
// (matching Python's WorkflowMessageProcessor behavior).
|
|
3471
|
+
for (const [nodeType, values] of Object.entries(finalOutputs)) {
|
|
3472
|
+
if (!nodeType.includes("Output"))
|
|
3473
|
+
continue;
|
|
3474
|
+
if (!result[nodeType] && Array.isArray(values) && values.length > 0) {
|
|
3475
|
+
result[nodeType] = values.length === 1 ? values[0] : values;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
// Send terminal job_update if not already sent
|
|
3479
|
+
await this.session.send({
|
|
3480
|
+
type: "job_update",
|
|
3481
|
+
status: active.status,
|
|
3482
|
+
job_id: jobId,
|
|
3483
|
+
workflow_id: workflowId,
|
|
3484
|
+
error: active.error,
|
|
3485
|
+
result: { outputs: finalOutputs }
|
|
3486
|
+
});
|
|
3487
|
+
// Persist final job status
|
|
3488
|
+
try {
|
|
3489
|
+
const job = (await Job.get(jobId));
|
|
3490
|
+
// Don't overwrite a cancelled row (DB-only tRPC cancel) when the
|
|
3491
|
+
// in-flight run finishes — keep the cancellation authoritative.
|
|
3492
|
+
if (job) {
|
|
3493
|
+
if (job.status !== "cancelled") {
|
|
3494
|
+
if (active.status === "completed")
|
|
3495
|
+
job.markCompleted();
|
|
3496
|
+
else if (active.status === "failed")
|
|
3497
|
+
job.markFailed(active.error ?? "Unknown error");
|
|
3498
|
+
else if (active.status === "cancelled")
|
|
3499
|
+
job.markCancelled();
|
|
3500
|
+
}
|
|
3501
|
+
job.cost = this.deps.jobs.runMeasuredCost(active);
|
|
3502
|
+
await job.save();
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
catch (error) {
|
|
3506
|
+
this.session.logError("workflow job persistence (final) failed", error);
|
|
3507
|
+
}
|
|
3508
|
+
// Create response message from workflow outputs — matches Python's _create_response_message
|
|
3509
|
+
const responseContent = createWorkflowResponseContent(result);
|
|
3510
|
+
const responseMsg = {
|
|
3511
|
+
type: "message",
|
|
3512
|
+
role: "assistant",
|
|
3513
|
+
content: responseContent,
|
|
3514
|
+
thread_id: threadId,
|
|
3515
|
+
workflow_id: workflowId,
|
|
3516
|
+
provider: providerId,
|
|
3517
|
+
model,
|
|
3518
|
+
job_id: jobId
|
|
3519
|
+
};
|
|
3520
|
+
await this.saveMessageToDb(responseMsg);
|
|
3521
|
+
await this.session.send(responseMsg);
|
|
3522
|
+
// Signal completion — done chunk with job_id + workflow_id
|
|
3523
|
+
await this.session.send({
|
|
3524
|
+
type: "chunk",
|
|
3525
|
+
content: "",
|
|
3526
|
+
done: true,
|
|
3527
|
+
job_id: jobId,
|
|
3528
|
+
workflow_id: workflowId,
|
|
3529
|
+
thread_id: threadId
|
|
3530
|
+
});
|
|
3531
|
+
log.debug("Workflow message complete", { threadId, workflowId, jobId });
|
|
3532
|
+
}
|
|
3533
|
+
catch (err) {
|
|
3534
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3535
|
+
const scope = getRunTraceScope();
|
|
3536
|
+
if (scope) {
|
|
3537
|
+
trace.getActiveSpan()?.setStatus({ code: SpanStatusCode.ERROR });
|
|
3538
|
+
await settleRunTrace(userId, scope.runId, { status: "failed", error: scope.policy.contentSuppressed ? "Execution failed" : errMsg, secretValues: scope.secretValues, contentSuppressed: scope.policy.contentSuppressed });
|
|
3539
|
+
}
|
|
3540
|
+
log.error("Workflow message error", {
|
|
3541
|
+
threadId,
|
|
3542
|
+
workflowId,
|
|
3543
|
+
error: errMsg
|
|
3544
|
+
});
|
|
3545
|
+
await this.session.send({
|
|
3546
|
+
type: "error",
|
|
3547
|
+
message: `Error processing workflow: ${errMsg}`,
|
|
3548
|
+
job_id: jobId,
|
|
3549
|
+
workflow_id: workflowId,
|
|
3550
|
+
thread_id: threadId
|
|
3551
|
+
});
|
|
3552
|
+
// Send done chunk even on error — matches Python
|
|
3553
|
+
await this.session.send({
|
|
3554
|
+
type: "chunk",
|
|
3555
|
+
content: "",
|
|
3556
|
+
done: true,
|
|
3557
|
+
job_id: jobId,
|
|
3558
|
+
workflow_id: workflowId,
|
|
3559
|
+
thread_id: threadId
|
|
3560
|
+
});
|
|
3561
|
+
}
|
|
3562
|
+
finally {
|
|
3563
|
+
releaseAbortListener?.();
|
|
3564
|
+
// Always release the concurrency slot and drain the queue, even if
|
|
3565
|
+
// streaming/persist/sendMessage threw above. Otherwise a mid-stream
|
|
3566
|
+
// socket-write failure would orphan the ActiveJob and permanently shrink
|
|
3567
|
+
// the MAX_CONCURRENT_JOBS cap (run_job then queues forever).
|
|
3568
|
+
this.deps.jobs.releaseJob(jobId);
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
/**
|
|
3572
|
+
* If `event` is a tool_call_update, also emit a synthetic assistant message
|
|
3573
|
+
* whose `tool_calls` array contains this call. The chat UI renders a
|
|
3574
|
+
* persistent ToolCallCard from messages with tool_calls; tool_call_update
|
|
3575
|
+
* by itself only drives transient "now running" state. We skip events that
|
|
3576
|
+
* already carry `agent_execution_id` because those are routed to
|
|
3577
|
+
* ExecutionTree via the agent_execution path.
|
|
3578
|
+
*/
|
|
3579
|
+
async emitSyntheticToolCallCard(event) {
|
|
3580
|
+
if (event["type"] !== "tool_call_update")
|
|
3581
|
+
return;
|
|
3582
|
+
const toolCallId = event["tool_call_id"];
|
|
3583
|
+
const name = event["name"];
|
|
3584
|
+
if (!isString(toolCallId) || !isString(name))
|
|
3585
|
+
return;
|
|
3586
|
+
if (!toolCallId || !name)
|
|
3587
|
+
return;
|
|
3588
|
+
const args = isObjectLike(event["args"])
|
|
3589
|
+
? event["args"]
|
|
3590
|
+
: {};
|
|
3591
|
+
const message = isString(event["message"]) ? event["message"] : null;
|
|
3592
|
+
await this.session.send({
|
|
3593
|
+
type: "message",
|
|
3594
|
+
role: "assistant",
|
|
3595
|
+
content: null,
|
|
3596
|
+
tool_calls: [
|
|
3597
|
+
{
|
|
3598
|
+
id: toolCallId,
|
|
3599
|
+
name,
|
|
3600
|
+
args,
|
|
3601
|
+
message,
|
|
3602
|
+
result: null
|
|
3603
|
+
}
|
|
3604
|
+
],
|
|
3605
|
+
parent_tool_call_id: event["parent_tool_call_id"] ?? null,
|
|
3606
|
+
subtask_depth: event["subtask_depth"] ?? null,
|
|
3607
|
+
thread_id: event["thread_id"] ?? null,
|
|
3608
|
+
workflow_id: event["workflow_id"] ?? null
|
|
3609
|
+
});
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
//# sourceMappingURL=chat-turn.js.map
|