From 47080bdea7b1d552fd56c181db85ae0cdd6320e9 Mon Sep 17 00:00:00 2001 From: Piyush Singh Gaur Date: Wed, 13 May 2026 07:59:27 +0530 Subject: [PATCH 1/6] refactor(mastra): migrate ChatWorkflow (Phase 1) - typed context & streaming --- package-lock.json | 2486 ++++++++++++++++- package.json | 1 + .../generation.service.integration.ts | 220 +- src/component.ts | 3 + src/graphs/chat/chat.store.ts | 155 + src/graphs/event.types.ts | 10 +- src/keys.ts | 23 + src/mastra/agents/chat-reasoning.agent.ts | 217 ++ src/mastra/bridge/async-event-queue.ts | 72 + src/mastra/bridge/context-window-manager.ts | 102 + src/mastra/bridge/token-usage-accumulator.ts | 68 + src/mastra/bridge/workflow-request-context.ts | 56 + src/mastra/bridge/workflow-runner.ts | 222 ++ src/mastra/index.ts | 27 + src/mastra/types.ts | 97 + .../workflows/chat/chat-workflow-schemas.ts | 150 + src/mastra/workflows/chat/chat.workflow.ts | 48 + .../chat/steps/agent-reasoning.step.ts | 201 ++ .../workflows/chat/steps/end-session.step.ts | 70 + .../chat/steps/file-processing.step.ts | 185 ++ .../workflows/chat/steps/init-session.step.ts | 69 + .../chat/steps/persist-conversation.step.ts | 89 + .../chat/steps/prepare-context.step.ts | 79 + src/services/generation.service.ts | 22 +- 24 files changed, 4374 insertions(+), 298 deletions(-) create mode 100644 src/mastra/agents/chat-reasoning.agent.ts create mode 100644 src/mastra/bridge/async-event-queue.ts create mode 100644 src/mastra/bridge/context-window-manager.ts create mode 100644 src/mastra/bridge/token-usage-accumulator.ts create mode 100644 src/mastra/bridge/workflow-request-context.ts create mode 100644 src/mastra/bridge/workflow-runner.ts create mode 100644 src/mastra/index.ts create mode 100644 src/mastra/types.ts create mode 100644 src/mastra/workflows/chat/chat-workflow-schemas.ts create mode 100644 src/mastra/workflows/chat/chat.workflow.ts create mode 100644 src/mastra/workflows/chat/steps/agent-reasoning.step.ts create mode 100644 src/mastra/workflows/chat/steps/end-session.step.ts create mode 100644 src/mastra/workflows/chat/steps/file-processing.step.ts create mode 100644 src/mastra/workflows/chat/steps/init-session.step.ts create mode 100644 src/mastra/workflows/chat/steps/persist-conversation.step.ts create mode 100644 src/mastra/workflows/chat/steps/prepare-context.step.ts diff --git a/package-lock.json b/package-lock.json index 675ac57..fe54024 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lb4-llm-chat-component", - "version": "2.1.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lb4-llm-chat-component", - "version": "2.1.0", + "version": "3.0.0", "license": "MIT", "dependencies": { "@langchain/community": "^1.1.27", @@ -15,6 +15,7 @@ "@loopback/context": "^8.0.11", "@loopback/repository": "^8.0.11", "@loopback/sequelize": "^0.8.8", + "@mastra/core": "^1.32.1", "@sourceloop/chat-service": "^17.0.6", "@sourceloop/core": "^20.0.6", "@sourceloop/file-utils": "^0.5.6", @@ -74,6 +75,47 @@ "@loopback/core": "^7.0.11" } }, + "node_modules/@a2a-js/sdk": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.13.tgz", + "integrity": "sha512-BZr0f9JVNQs3GKOM9xINWCh6OKIJWZFPyqqVqTym5mxO2Eemc6I/0zL7zWnljHzGdaf5aZQyQN5xa6PSH62q+A==", + "license": "Apache-2.0", + "dependencies": { + "uuid": "^11.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.10.2", + "@grpc/grpc-js": "^1.11.0", + "express": "^4.21.2 || ^5.1.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + }, + "@grpc/grpc-js": { + "optional": true + }, + "express": { + "optional": true + } + } + }, + "node_modules/@a2a-js/sdk/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/@actions/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz", @@ -113,6 +155,151 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", + "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v5": { + "name": "@ai-sdk/provider-utils", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.23.tgz", + "integrity": "sha512-60GYsRj5wIJQRcq5YwYJq4KhwLeStceXEJiZdecP1miiH+6FMmrnc7lZDOJoQ6m9lrudEb+uI4LEwddLz5+rPQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v6": { + "name": "@ai-sdk/provider-utils", + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz", + "integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils-v6/node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils/node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-v5": { + "name": "@ai-sdk/provider", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", + "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-v6": { + "name": "@ai-sdk/provider", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/ui-utils-v5": { + "name": "@ai-sdk/ui-utils", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", + "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@ai-sdk/ui-utils-v5/node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.27.3.tgz", @@ -2101,7 +2288,8 @@ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@google/generative-ai": { "version": "0.24.1", @@ -2152,6 +2340,18 @@ "@hapi/topo": "^6.0.1" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2609,6 +2809,15 @@ "node": ">=18.0.0" } }, + "node_modules/@isaacs/ttlcache": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz", + "integrity": "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=12" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -4274,107 +4483,603 @@ "node": "20 || 22 || 24" } }, - "node_modules/@messageformat/core": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.0.tgz", - "integrity": "sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw==", + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", "license": "MIT", - "dependencies": { - "@messageformat/date-skeleton": "^1.0.0", - "@messageformat/number-skeleton": "^1.0.0", - "@messageformat/parser": "^5.1.0", - "@messageformat/runtime": "^3.0.1", - "make-plural": "^7.0.0", - "safe-identifier": "^0.4.1" + "engines": { + "node": ">=8" } }, - "node_modules/@messageformat/date-skeleton": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz", - "integrity": "sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==", - "license": "MIT" - }, - "node_modules/@messageformat/number-skeleton": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz", - "integrity": "sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg==", - "license": "MIT" - }, - "node_modules/@messageformat/parser": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.1.tgz", - "integrity": "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==", + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", "license": "MIT", "dependencies": { - "moo": "^0.5.1" + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@messageformat/runtime": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.2.tgz", - "integrity": "sha512-dkIPDCjXcfhSHgNE1/qV6TeczQZR59Yx0xXeafVKgK3QVWoxc38ljwpksUpnzCGvN151KUbCJTDZVmahtf1YZw==", - "license": "MIT", + "node_modules/@mastra/core": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.32.1.tgz", + "integrity": "sha512-6ynJNZ9GMkLs11c9D4Ui9Z0eOP8GsAqPeMVhlnxExcdTls0ufFQSXFgwzBqtS97fot9IOA/fxDLvvW83fnsP0A==", + "license": "Apache-2.0", "dependencies": { - "make-plural": "^7.0.0" + "@a2a-js/sdk": "~0.3.13", + "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.23", + "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.23", + "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.1", + "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.8", + "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", + "@isaacs/ttlcache": "^2.1.4", + "@lukeed/uuid": "^2.0.1", + "@mastra/schema-compat": "1.2.9", + "@modelcontextprotocol/sdk": "^1.29.0", + "@sindresorhus/slugify": "^2.2.1", + "@standard-schema/spec": "^1.1.0", + "ajv": "^8.18.0", + "chat": "^4.24.0", + "croner": "^10.0.1", + "dotenv": "^17.3.1", + "execa": "^9.6.1", + "gray-matter": "^4.0.3", + "hono": "^4.12.8", + "hono-openapi": "^1.3.0", + "ignore": "^7.0.5", + "js-tiktoken": "^1.0.21", + "json-schema": "^0.4.0", + "lru-cache": "^11.2.7", + "p-map": "^7.0.4", + "p-retry": "^7.1.1", + "picomatch": "^4.0.3", + "radash": "^12.1.1", + "tokenx": "^1.3.0", + "ws": "^8.20.0", + "xxhash-wasm": "^1.1.0" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", + "node_modules/@mastra/core/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", "engines": { - "node": "^14.21.3 || >=16" + "node": ">=12" }, "funding": { - "url": "https://paulmillr.com/funding/" + "url": "https://dotenvx.com" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@mastra/core/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@mastra/core/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" }, "engines": { - "node": ">= 8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "dev": true, + "node_modules/@mastra/core/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@mastra/core/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mastra/core/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@mastra/core/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mastra/core/node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mastra/core/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mastra/core/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@mastra/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@mastra/core/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mastra/core/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mastra/schema-compat": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@mastra/schema-compat/-/schema-compat-1.2.9.tgz", + "integrity": "sha512-1/RgazXqi1Wdyx8aR81CVS+sRyzlTGUL1YhhHkSULoEY8aXs58bvWkH/6iixlYsY0xGvn+0OPLCeSRkBCtDx4Q==", + "license": "Apache-2.0", + "dependencies": { + "json-schema-to-zod": "^2.7.0", + "zod-from-json-schema": "^0.5.2", + "zod-from-json-schema-v3": "npm:zod-from-json-schema@^0.0.5", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@messageformat/core": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.0.tgz", + "integrity": "sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw==", + "license": "MIT", + "dependencies": { + "@messageformat/date-skeleton": "^1.0.0", + "@messageformat/number-skeleton": "^1.0.0", + "@messageformat/parser": "^5.1.0", + "@messageformat/runtime": "^3.0.1", + "make-plural": "^7.0.0", + "safe-identifier": "^0.4.1" + } + }, + "node_modules/@messageformat/date-skeleton": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz", + "integrity": "sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==", + "license": "MIT" + }, + "node_modules/@messageformat/number-skeleton": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz", + "integrity": "sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg==", + "license": "MIT" + }, + "node_modules/@messageformat/parser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.1.tgz", + "integrity": "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==", + "license": "MIT", + "dependencies": { + "moo": "^0.5.1" + } + }, + "node_modules/@messageformat/runtime": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.2.tgz", + "integrity": "sha512-dkIPDCjXcfhSHgNE1/qV6TeczQZR59Yx0xXeafVKgK3QVWoxc38ljwpksUpnzCGvN151KUbCJTDZVmahtf1YZw==", + "license": "MIT", + "dependencies": { + "make-plural": "^7.0.0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "dev": true, "license": "ISC", + "optional": true, "dependencies": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" @@ -4387,6 +5092,7 @@ "deprecated": "This functionality has been moved to @npmcli/fs", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" @@ -4401,6 +5107,7 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, "license": "MIT", + "optional": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -4415,6 +5122,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "glob": "^7.1.3" }, @@ -4748,7 +5456,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "dev": true, "license": "MIT" }, "node_modules/@semantic-release/changelog": { @@ -5233,7 +5940,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5242,6 +5948,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sindresorhus/slugify": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", + "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/transliterate": "^1.0.0", + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/slugify/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", + "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -6325,6 +7086,94 @@ "@loopback/core": "^7.0.3" } }, + "node_modules/@standard-community/standard-json": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@standard-community/standard-json/-/standard-json-0.3.5.tgz", + "integrity": "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/json-schema": "^7.0.15", + "@valibot/to-json-schema": "^1.3.0", + "arktype": "^2.1.20", + "effect": "^3.16.8", + "quansync": "^0.2.11", + "sury": "^10.0.0", + "typebox": "^1.0.17", + "valibot": "^1.1.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.24.5" + }, + "peerDependenciesMeta": { + "@valibot/to-json-schema": { + "optional": true + }, + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "sury": { + "optional": true + }, + "typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + }, + "zod-to-json-schema": { + "optional": true + } + } + }, + "node_modules/@standard-community/standard-openapi": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@standard-community/standard-openapi/-/standard-openapi-0.2.9.tgz", + "integrity": "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@standard-community/standard-json": "^0.3.5", + "@standard-schema/spec": "^1.0.0", + "arktype": "^2.1.20", + "effect": "^3.17.14", + "openapi-types": "^12.1.3", + "sury": "^10.0.0", + "typebox": "^1.0.0", + "valibot": "^1.1.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-openapi": "^4" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "sury": { + "optional": true + }, + "typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + }, + "zod-openapi": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6380,6 +7229,7 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 6" } @@ -6569,6 +7419,15 @@ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/memcached": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.7.tgz", @@ -6779,6 +7638,12 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -6985,12 +7850,19 @@ "dev": true, "license": "ISC" }, + "node_modules/@workflow/serde": { + "version": "4.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", + "integrity": "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==", + "license": "Apache-2.0" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/abort-controller": { "version": "3.0.0", @@ -7221,7 +8093,8 @@ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/archy": { "version": "1.0.0", @@ -7237,6 +8110,7 @@ "deprecated": "This package is no longer supported.", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" @@ -7251,6 +8125,7 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -7458,6 +8333,16 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -7809,6 +8694,7 @@ "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", @@ -7839,6 +8725,7 @@ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -7852,6 +8739,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -7865,6 +8753,7 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, "license": "MIT", + "optional": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -7878,6 +8767,7 @@ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "aggregate-error": "^3.0.0" }, @@ -7895,6 +8785,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "glob": "^7.1.3" }, @@ -7910,7 +8801,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/cachedir": { "version": "2.3.0", @@ -8112,6 +9004,16 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -8187,6 +9089,16 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -8203,6 +9115,21 @@ "node": "*" } }, + "node_modules/chat": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/chat/-/chat-4.28.1.tgz", + "integrity": "sha512-oKBeLQ746rSmHWGoXmPgDOqMVdIe9cWFQBQ1G2pw0l2vV4sAsZgfEJmc1UYqSJR4kYy4PxKgRFy31pe4RJ644Q==", + "license": "MIT", + "dependencies": { + "@workflow/serde": "4.1.0-beta.2", + "mdast-util-to-string": "^4.0.0", + "remark-gfm": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "remend": "^1.2.1", + "unified": "^11.0.5" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -8225,6 +9152,7 @@ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true, "license": "ISC", + "optional": true, "engines": { "node": ">=10" } @@ -8590,6 +9518,7 @@ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", "dev": true, "license": "ISC", + "optional": true, "bin": { "color-support": "bin.js" } @@ -8756,7 +9685,8 @@ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/constant-case": { "version": "3.0.4", @@ -9012,6 +9942,25 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "license": "MIT" }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9480,6 +10429,19 @@ "node": ">=0.10.0" } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -9608,7 +10570,8 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/denque": { "version": "1.5.1", @@ -9628,6 +10591,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -9668,6 +10640,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -10108,7 +11093,8 @@ "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/error-ex": { "version": "1.3.4", @@ -10589,7 +11575,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -10678,11 +11663,22 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -10925,8 +11921,19 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", "license": "MIT", - "peer": true + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } }, "node_modules/extsprintf": { "version": "1.3.0", @@ -11091,7 +12098,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dev": true, "license": "MIT", "dependencies": { "is-unicode-supported": "^2.0.0" @@ -11107,7 +12113,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -11567,6 +12572,7 @@ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -11580,6 +12586,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -11592,7 +12599,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/fsevents": { "version": "2.3.2", @@ -11685,6 +12693,7 @@ "deprecated": "This package is no longer supported.", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", @@ -12107,6 +13116,43 @@ "dev": true, "license": "MIT" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/groq-sdk": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-1.1.2.tgz", @@ -12222,7 +13268,8 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/hasha": { "version": "5.2.2", @@ -12325,6 +13372,37 @@ "node": ">=0.10.0" } }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hono-openapi": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/hono-openapi/-/hono-openapi-1.3.0.tgz", + "integrity": "sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig==", + "license": "MIT", + "peerDependencies": { + "@hono/standard-validator": "^0.2.0", + "@standard-community/standard-json": "^0.3.5", + "@standard-community/standard-openapi": "^0.2.9", + "@types/json-schema": "^7.0.15", + "hono": "^4.8.3", + "openapi-types": "^12.1.3" + }, + "peerDependenciesMeta": { + "@hono/standard-validator": { + "optional": true + }, + "hono": { + "optional": true + } + } + }, "node_modules/hook-std": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", @@ -12379,7 +13457,8 @@ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "dev": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "optional": true }, "node_modules/http-errors": { "version": "2.0.1", @@ -12675,7 +13754,6 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -12771,7 +13849,8 @@ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/inflection": { "version": "1.13.4", @@ -13034,6 +14113,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -13117,7 +14205,8 @@ "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/is-map": { "version": "2.0.3", @@ -13208,7 +14297,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -13217,6 +14305,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -13660,6 +14754,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -13761,8 +14864,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)", - "peer": true + "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-compare": { "version": "0.2.2", @@ -13787,12 +14889,27 @@ "node": ">=16" } }, + "node_modules/json-schema-to-zod": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.8.1.tgz", + "integrity": "sha512-fRr1mHgZ7hboLKBUdR428gd9dIHUFGivUqOeiDcSmyXkNZCtB1uGaZLvsjZ4GaN5pwBIs+TGIOf6s+Rp5/R/zA==", + "license": "ISC", + "bin": { + "json-schema-to-zod": "dist/cjs/cli.js" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -13948,6 +15065,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -14421,6 +15547,16 @@ "node": ">=0.10.0" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loopback-connector": { "version": "6.2.12", "resolved": "https://registry.npmjs.org/loopback-connector/-/loopback-connector-6.2.12.tgz", @@ -15260,6 +16396,7 @@ "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", @@ -15288,6 +16425,7 @@ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "debug": "4" }, @@ -15301,6 +16439,7 @@ "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "@tootallnate/once": "1", "agent-base": "6", @@ -15316,6 +16455,7 @@ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -15330,6 +16470,7 @@ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15343,6 +16484,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15356,6 +16498,7 @@ "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", @@ -15370,7 +16513,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/make-plural": { "version": "7.5.0", @@ -15390,6 +16534,16 @@ "node": ">=6" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -15473,6 +16627,207 @@ "is-buffer": "~1.1.6" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -15567,15 +16922,578 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -15688,6 +17606,7 @@ "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -15701,6 +17620,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15713,7 +17633,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/minipass-fetch": { "version": "1.4.1", @@ -15721,6 +17642,7 @@ "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "minipass": "^3.1.0", "minipass-sized": "^1.0.3", @@ -15739,6 +17661,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15751,7 +17674,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/minipass-flush": { "version": "1.0.7", @@ -15759,6 +17683,7 @@ "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", "dev": true, "license": "BlueOak-1.0.0", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -15772,6 +17697,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15784,7 +17710,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/minipass-pipeline": { "version": "1.2.4", @@ -15792,6 +17719,7 @@ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -15805,6 +17733,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15817,7 +17746,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/minipass-sized": { "version": "1.0.3", @@ -15825,6 +17755,7 @@ "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -15838,6 +17769,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15850,7 +17782,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/minizlib": { "version": "2.1.2", @@ -15858,6 +17791,7 @@ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -15872,6 +17806,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15884,7 +17819,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/mkdirp": { "version": "0.5.6", @@ -16636,6 +18572,7 @@ "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "env-paths": "^2.2.0", "glob": "^7.1.4", @@ -16662,6 +18599,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "glob": "^7.1.3" }, @@ -16892,6 +18830,7 @@ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "abbrev": "1" }, @@ -18865,6 +20804,7 @@ "deprecated": "This package is no longer supported.", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", @@ -19928,7 +21868,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -20266,6 +22205,15 @@ "node": ">=4" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-conf": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", @@ -20619,7 +22567,6 @@ "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", - "dev": true, "license": "MIT", "dependencies": { "parse-ms": "^4.0.0" @@ -20677,7 +22624,8 @@ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/promise-retry": { "version": "2.0.1", @@ -20685,6 +22633,7 @@ "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -20699,6 +22648,7 @@ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 4" } @@ -20791,6 +22741,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT", + "peer": true + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -20818,6 +22785,15 @@ ], "license": "MIT" }, + "node_modules/radash": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/radash/-/radash-12.1.1.tgz", + "integrity": "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==", + "license": "MIT", + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/rambda": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/rambda/-/rambda-7.5.0.tgz", @@ -21295,6 +23271,61 @@ "node": ">=4" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remend": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.0.tgz", + "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", + "license": "Apache-2.0" + }, "node_modules/request-ip": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/request-ip/-/request-ip-3.3.0.tgz", @@ -21455,6 +23486,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-async": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", @@ -21621,6 +23678,25 @@ "node": ">=6" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semantic-release": { "version": "25.0.3", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", @@ -22867,6 +24943,7 @@ "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.1.1" }, @@ -22880,6 +24957,7 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -22892,7 +24970,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/stack-trace": { "version": "0.0.10", @@ -23115,6 +25194,15 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", @@ -23776,6 +25864,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tokenx": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tokenx/-/tokenx-1.3.0.tgz", + "integrity": "sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==", + "license": "MIT" + }, "node_modules/toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", @@ -23847,6 +25941,16 @@ "node": ">= 14.0.0" } }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", @@ -24298,12 +26402,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-filename": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "unique-slug": "^2.0.0" } @@ -24314,6 +26438,7 @@ "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "imurmurhash": "^0.1.4" } @@ -24334,6 +26459,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", @@ -24531,6 +26711,34 @@ "license": "MIT", "peer": true }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -24709,6 +26917,7 @@ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } @@ -24884,7 +27093,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -24916,6 +27124,12 @@ "node": ">=0.4" } }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -25062,7 +27276,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -25093,15 +27306,52 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-from-json-schema": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/zod-from-json-schema/-/zod-from-json-schema-0.5.2.tgz", + "integrity": "sha512-/dNaicfdhJTOuUd4RImbLUE2g5yrSzzDjI/S6C2vO2ecAGZzn9UcRVgtyLSnENSmAOBRiSpUdzDS6fDWX3Z35g==", + "license": "MIT", + "dependencies": { + "zod": "^4.0.17" + } + }, + "node_modules/zod-from-json-schema-v3": { + "name": "zod-from-json-schema", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/zod-from-json-schema/-/zod-from-json-schema-0.0.5.tgz", + "integrity": "sha512-zYEoo86M1qpA1Pq6329oSyHLS785z/mTwfr9V1Xf/ZLhuuBGaMlDGu/pDVGVUe4H4oa1EFgWZT53DP0U3oT9CQ==", + "license": "MIT", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/zod-from-json-schema/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zod-to-json-schema": { "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 254e740..fa679f8 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@loopback/context": "^8.0.11", "@loopback/repository": "^8.0.11", "@loopback/sequelize": "^0.8.8", + "@mastra/core": "^1.32.1", "@sourceloop/chat-service": "^17.0.6", "@sourceloop/core": "^20.0.6", "@sourceloop/file-utils": "^0.5.6", diff --git a/src/__tests__/integration/generation.service.integration.ts b/src/__tests__/integration/generation.service.integration.ts index 0a4a625..8fabb4d 100644 --- a/src/__tests__/integration/generation.service.integration.ts +++ b/src/__tests__/integration/generation.service.integration.ts @@ -1,4 +1,3 @@ -import {IterableReadableStream} from '@langchain/core/utils/stream'; import {Request, Response} from '@loopback/rest'; import { createStubInstance, @@ -6,20 +5,39 @@ import { sinon, StubbedInstanceWithSinonAccessor, } from '@loopback/testlab'; -import {PassThrough} from 'stream'; -import {ChatGraph, LLMStreamEvent} from '../../graphs'; +import {WorkflowRunner} from '../../mastra/bridge/workflow-runner'; import {GenerationService} from '../../services'; import {HttpTransport, SSETransport} from '../../transports'; +import type {LLMStreamEvent} from '../../graphs/event.types'; + +/** Returns an empty async generator (no events) — stands in for a no-op workflow run. */ +function emptyEventStream(): AsyncGenerator { + return (async function* (): AsyncGenerator< + LLMStreamEvent, + void, + undefined + > {})(); +} + +/** Returns an async generator that immediately throws the given error. */ +function throwingEventStream( + err: Error, +): AsyncGenerator { + // eslint-disable-next-line require-yield + return (async function* (): AsyncGenerator { + throw err; + })(); +} describe(`GenerationService Integration`, () => { let service: GenerationService; let dummyRequest: Request; let dummyResponse: Response; - let graph: StubbedInstanceWithSinonAccessor; + let runner: StubbedInstanceWithSinonAccessor; describe('with SSETransport', () => { beforeEach(() => { - graph = createStubInstance(ChatGraph); + runner = createStubInstance(WorkflowRunner); dummyResponse = { write: sinon.stub(), end: sinon.stub(), @@ -30,116 +48,44 @@ describe(`GenerationService Integration`, () => { once: sinon.stub(), } as unknown as Request; const transport = new SSETransport(dummyResponse, dummyRequest); - service = new GenerationService(graph, transport); + service = new GenerationService(runner, transport); }); it('should handle generation request and return response', async () => { - const dummyStream = new PassThrough({objectMode: true}); - graph.stubs.execute.callsFake(async () => { - return dummyStream as unknown as IterableReadableStream; - }); - dummyStream.push({ - type: 'text', - data: 'This is a response from LLM', - }); - dummyStream.push({ - type: 'text', - data: 'This is a second response from LLM', - }); - setTimeout(() => { - dummyStream.end(); - }, 10); + // WorkflowRunner.executeChatWorkflow is now an async generator — return an empty stream + runner.stubs.executeChatWorkflow.returns(emptyEventStream()); + await service.generate('test prompt', []); - const writeCalls = (dummyResponse.write as sinon.SinonStub).getCalls(); - const setHeaderCalls = ( - dummyResponse.setHeader as sinon.SinonStub - ).getCalls(); - const statusCalls = (dummyResponse.status as sinon.SinonStub).getCalls(); - const endCalls = (dummyResponse.end as sinon.SinonStub).getCalls(); - expect(writeCalls.length).to.be.eql(2); - expect(writeCalls[0].args[0]).to.deepEqual( - `data: ${JSON.stringify({ - type: 'text', - data: 'This is a response from LLM', - })}\n\n`, - ); - expect(writeCalls[1].args[0]).to.deepEqual( - `data: ${JSON.stringify({ - type: 'text', - data: 'This is a second response from LLM', - })}\n\n`, - ); - expect(setHeaderCalls.length).to.be.eql(4); - expect(setHeaderCalls[0].args[0]).to.be.eql('Content-Type'); - expect(setHeaderCalls[0].args[1]).to.be.eql('text/event-stream'); - expect(setHeaderCalls[1].args[0]).to.be.eql('Cache-Control'); - expect(setHeaderCalls[1].args[1]).to.be.eql('no-cache'); - expect(setHeaderCalls[2].args[0]).to.be.eql('Connection'); - expect(setHeaderCalls[2].args[1]).to.be.eql('keep-alive'); - expect(setHeaderCalls[3].args[0]).to.be.eql('X-Accel-Buffering'); - expect(setHeaderCalls[3].args[1]).to.be.eql('no'); // Disable buffering for Nginx - - expect(statusCalls.length).to.be.eql(1); - expect(statusCalls[0].args[0]).to.be.eql(200); + expect(runner.stubs.executeChatWorkflow.calledOnce).to.be.true(); + const args = runner.stubs.executeChatWorkflow.firstCall.args; + expect(args[0]).to.eql('test prompt'); + expect(args[1]).to.deepEqual([]); + expect(args[3]).to.be.undefined(); // no sessionId + // transport.end() should be called + const endCalls = (dummyResponse.end as sinon.SinonStub).getCalls(); expect(endCalls.length).to.be.eql(1); }); - it('should handle error gracyfully', async () => { - const dummyStream = new PassThrough({objectMode: true}); - graph.stubs.execute.callsFake(async () => { - return dummyStream as unknown as IterableReadableStream; - }); - dummyStream.push({ - type: 'text', - data: 'This is a response from LLM', - }); + it('should handle error gracefully', async () => { const errorToThrow = new Error('Something went wrong!'); - setTimeout(() => { - dummyStream.destroy(errorToThrow); - }, 100); + runner.stubs.executeChatWorkflow.returns( + throwingEventStream(errorToThrow), + ); + await service.generate('test prompt', []).catch(err => { expect(err.message).to.be.eql('Something went wrong!'); }); - const writeCalls = (dummyResponse.write as sinon.SinonStub).getCalls(); - const setHeaderCalls = ( - dummyResponse.setHeader as sinon.SinonStub - ).getCalls(); - const statusCalls = (dummyResponse.status as sinon.SinonStub).getCalls(); - const endCalls = (dummyResponse.end as sinon.SinonStub).getCalls(); - expect(writeCalls.length).to.be.eql(2); - expect(writeCalls[0].args[0]).to.deepEqual( - `data: ${JSON.stringify({ - type: 'text', - data: 'This is a response from LLM', - })}\n\n`, - ); - - expect(writeCalls[1].args[0]).to.deepEqual( - `data: ${JSON.stringify({ - error: errorToThrow, - })}\n\n`, - ); - expect(setHeaderCalls.length).to.be.eql(4); - expect(setHeaderCalls[0].args[0]).to.be.eql('Content-Type'); - expect(setHeaderCalls[0].args[1]).to.be.eql('text/event-stream'); - expect(setHeaderCalls[1].args[0]).to.be.eql('Cache-Control'); - expect(setHeaderCalls[1].args[1]).to.be.eql('no-cache'); - expect(setHeaderCalls[2].args[0]).to.be.eql('Connection'); - expect(setHeaderCalls[2].args[1]).to.be.eql('keep-alive'); - expect(setHeaderCalls[3].args[0]).to.be.eql('X-Accel-Buffering'); - expect(setHeaderCalls[3].args[1]).to.be.eql('no'); // Disable buffering for Nginx - - expect(statusCalls.length).to.be.eql(1); - expect(statusCalls[0].args[0]).to.be.eql(500); + // transport.end() should be called even on error + const endCalls = (dummyResponse.end as sinon.SinonStub).getCalls(); expect(endCalls.length).to.be.eql(1); }); }); describe('with HttpTransport', () => { beforeEach(() => { - graph = createStubInstance(ChatGraph); + runner = createStubInstance(WorkflowRunner); dummyResponse = { write: sinon.stub(), end: sinon.stub(), @@ -150,91 +96,29 @@ describe(`GenerationService Integration`, () => { once: sinon.stub(), } as unknown as Request; const transport = new HttpTransport(dummyResponse, dummyRequest); - service = new GenerationService(graph, transport); + service = new GenerationService(runner, transport); }); it('should handle generation request and return response', async () => { - const dummyStream = new PassThrough({objectMode: true}); - graph.stubs.execute.callsFake(async () => { - return dummyStream as unknown as IterableReadableStream; - }); - dummyStream.push({ - type: 'text', - data: 'This is a response from LLM', - }); - dummyStream.push({ - type: 'text', - data: 'This is a second response from LLM', - }); - setTimeout(() => { - dummyStream.end(); - }, 10); + runner.stubs.executeChatWorkflow.returns(emptyEventStream()); + await service.generate('test prompt', []); - const writeCalls = (dummyResponse.write as sinon.SinonStub).getCalls(); - const setHeaderCalls = ( - dummyResponse.setHeader as sinon.SinonStub - ).getCalls(); - const statusCalls = (dummyResponse.status as sinon.SinonStub).getCalls(); + expect(runner.stubs.executeChatWorkflow.calledOnce).to.be.true(); const endCalls = (dummyResponse.end as sinon.SinonStub).getCalls(); - expect(writeCalls.length).to.be.eql(1); - expect(writeCalls[0].args[0]).to.deepEqual( - `${JSON.stringify([ - { - type: 'text', - data: 'This is a response from LLM', - }, - { - type: 'text', - data: 'This is a second response from LLM', - }, - ])}`, - ); - expect(setHeaderCalls.length).to.be.eql(1); - expect(setHeaderCalls[0].args[0]).to.be.eql('Content-Type'); - expect(setHeaderCalls[0].args[1]).to.be.eql('application/json'); - - expect(statusCalls.length).to.be.eql(1); - expect(statusCalls[0].args[0]).to.be.eql(200); - expect(endCalls.length).to.be.eql(1); }); - it('should handle error gracyfully', async () => { - const dummyStream = new PassThrough({objectMode: true}); - graph.stubs.execute.callsFake(async () => { - return dummyStream as unknown as IterableReadableStream; - }); - dummyStream.push({ - type: 'text', - data: 'This is a response from LLM', - }); + it('should handle error gracefully', async () => { const errorToThrow = new Error('Something went wrong!'); - setTimeout(() => { - dummyStream.destroy(errorToThrow); - }, 100); + runner.stubs.executeChatWorkflow.returns( + throwingEventStream(errorToThrow), + ); + await service.generate('test prompt', []).catch(err => { expect(err.message).to.be.eql('Something went wrong!'); }); - const writeCalls = (dummyResponse.write as sinon.SinonStub).getCalls(); - const setHeaderCalls = ( - dummyResponse.setHeader as sinon.SinonStub - ).getCalls(); - const statusCalls = (dummyResponse.status as sinon.SinonStub).getCalls(); - const endCalls = (dummyResponse.end as sinon.SinonStub).getCalls(); - expect(writeCalls.length).to.be.eql(1); - - expect(writeCalls[0].args[0]).to.deepEqual( - `${JSON.stringify({ - error: errorToThrow, - })}`, - ); - expect(setHeaderCalls.length).to.be.eql(1); - expect(setHeaderCalls[0].args[0]).to.be.eql('Content-Type'); - expect(setHeaderCalls[0].args[1]).to.be.eql('application/json'); - - expect(statusCalls.length).to.be.eql(1); - expect(statusCalls[0].args[0]).to.be.eql(500); + const endCalls = (dummyResponse.end as sinon.SinonStub).getCalls(); expect(endCalls.length).to.be.eql(1); }); }); diff --git a/src/component.ts b/src/component.ts index a6f1e1d..469d7c1 100644 --- a/src/component.ts +++ b/src/component.ts @@ -57,6 +57,7 @@ import {TokenCounter} from './services/token-counter.service'; import {SSETransport} from './transports'; import {AIIntegrationConfig} from './types'; import {PgVectorStore} from './sub-modules/db/postgresql'; +import {WorkflowRunner} from './mastra/bridge/workflow-runner'; const debug = require('debug')('ai-integration:log-events:component'); export class AiIntegrationsComponent implements Component { @@ -88,6 +89,8 @@ export class AiIntegrationsComponent implements Component { TokenCounter, GenerationService, ChatStore, + // mastra migration + WorkflowRunner, // graph ChatGraph, // nodes diff --git a/src/graphs/chat/chat.store.ts b/src/graphs/chat/chat.store.ts index 7c7119b..747904f 100644 --- a/src/graphs/chat/chat.store.ts +++ b/src/graphs/chat/chat.store.ts @@ -15,6 +15,7 @@ import {ChatRepository} from '../../repositories'; import {ChannelType, TokenMetadata} from '../../types'; import {getTextContent, mergeAttachments} from '../../utils'; import {SavedMessage} from '../types'; +import {CoreMessageLike} from '../../mastra/bridge/context-window-manager'; import { MessageMetadata, MessageMetadataType, @@ -112,12 +113,106 @@ export class ChatStore { return newMessage; } + /** + * Find a message entity by its ID within a chat session. + * Used by FileProcessingStep to retrieve the user Message for addAttachmentMessage. + */ + async findMessageById( + chatId: string, + messageId: string, + ): Promise { + try { + return await this.chatRepository + .messages(chatId) + .find({ + where: {id: messageId}, + limit: 1, + }) + .then(results => results[0]); + } catch { + return undefined; + } + } + + /** + * Load all messages for a session, including nested sub-messages. + * Used by PrepareContextStep to build the full conversation context. + */ + async getMessages(chatId: string): Promise { + const chat = await this.chatRepository.findById(chatId, { + include: [ + { + relation: 'messages', + scope: { + include: ['messages'], + order: ['createdOn ASC'], + }, + }, + ], + }); + return chat.messages ?? []; + } + async addHumanMessage(chatId: string, message: HumanMessage) { return this.addMessage(chatId, getTextContent(message.content), { type: MessageMetadataType.User, }); } + /** + * Mastra-compatible variant of addHumanMessage that accepts a plain string. + * Used by the ChatWorkflow's InitSessionStep without LangChain dependencies. + */ + async addHumanMessageText(chatId: string, text: string) { + return this.addMessage(chatId, text, { + type: MessageMetadataType.User, + }); + } + + /** + * Mastra-compatible variant of addAIMessage that accepts a plain string. + * Used by the ChatWorkflow's PersistConversationStep without LangChain dependencies. + */ + async addAIMessageText(chatId: string, text: string) { + const body = text.trim() || ' '; + return this.addMessage( + chatId, + body, + { + type: MessageMetadataType.AI, + }, + true, + ); + } + + /** + * Mastra-compatible variant of addToolMessage that accepts plain strings/objects. + * Used by the ChatWorkflow's PersistConversationStep without LangChain dependencies. + */ + async addToolMessageText( + chatId: string, + toolCallId: string, + toolName: string, + content: string, + metadata: AnyObject, + aiMessage: Message, + args?: AnyObject, + ) { + return this.addMessage( + chatId, + content, + { + type: MessageMetadataType.Tool, + toolName, + id: toolCallId, + args, + ...metadata, + }, + true, + aiMessage.id, + ); + } + async addAttachmentMessage( chatId: string, userMessage: Message, @@ -223,6 +318,66 @@ export class ChatStore { } } + /** + * Convert a persisted Message entity to a CoreMessage-compatible object. + * Used by PrepareContextStep to build the agent's conversation history. + * Avoids LangChain types — compatible with Vercel AI SDK CoreMessage format. + */ + async toCoreMessage(message: Message): Promise { + if (message.metadata?.type === MessageMetadataType.User) { + let messageContent = message.body; + for (const fileMessage of message.messages ?? []) { + if (fileMessage.metadata?.type === MessageMetadataType.Attachment) { + messageContent = mergeAttachments( + messageContent, + fileMessage.metadata.fileName, + fileMessage.body, + ); + } + } + return {role: 'user', content: messageContent}; + } else if (message.metadata?.type === MessageMetadataType.AI) { + const toolCalls = message.messages + ?.filter( + (v): v is Message & {metadata: ToolMessageMetadata} => + v.metadata.type === MessageMetadataType.Tool, + ) + .map(msg => ({ + type: 'tool-call' as const, + toolCallId: msg.metadata.id, + toolName: msg.metadata.toolName, + args: msg.metadata.args ?? {}, + })); + + if (toolCalls?.length) { + return { + role: 'assistant', + content: [ + ...(message.body.trim() + ? [{type: 'text' as const, text: message.body.trim()}] + : []), + ...toolCalls, + ], + }; + } + return {role: 'assistant', content: message.body.trim() || ' '}; + } else if (message.metadata?.type === MessageMetadataType.Tool) { + const toolMeta = message.metadata as ToolMessageMetadata; + return { + role: 'tool', + content: [ + { + type: 'tool-result' as const, + toolCallId: toolMeta.id, + toolName: toolMeta.toolName, + result: message.body, + }, + ], + }; + } + return undefined; + } + private mergeCountMap(metadata: TokenMetadata, newData: TokenMetadata) { const result: TokenMetadata = {...metadata}; for (const key of Object.keys(newData)) { diff --git a/src/graphs/event.types.ts b/src/graphs/event.types.ts index 84def04..9847029 100644 --- a/src/graphs/event.types.ts +++ b/src/graphs/event.types.ts @@ -61,6 +61,13 @@ export type LLMStreamInitEvent = { }; }; +export type LLMStreamErrorEvent = { + type: LLMStreamEventType.Error; + data: { + message: string; + }; +}; + export type LLMStreamEvent = | LLMStreamInitEvent | LLMStreamMessageEvent @@ -68,4 +75,5 @@ export type LLMStreamEvent = | LLMStreamToolEvent | LLMStreamToolStatusEvent | LLMStreamLogEvent - | LLMStreamTokenCountEvent; + | LLMStreamTokenCountEvent + | LLMStreamErrorEvent; diff --git a/src/keys.ts b/src/keys.ts index 22371a1..de94bb6 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -1,6 +1,7 @@ import {VectorStore as VectorStoreType} from '@langchain/core/vectorstores'; import {BaseCheckpointSaver} from '@langchain/langgraph'; import {BindingKey} from '@loopback/context'; +import type {MastraLanguageModel} from '@mastra/core/agent'; import {ITransport} from './transports/types'; import { AIIntegrationConfig, @@ -55,6 +56,28 @@ export namespace AiIntegrationBindings { export const SystemContext = BindingKey.create( `services.ai-reporting.system-context`, ); + + // ── Mastra LLM bindings (Phase 1 migration) ────────────────────────────── + /** + * Mastra-compatible chat LLM. + * Bind a `MastraLanguageModel` (e.g. from @mastra/openai, @mastra/anthropic, etc.) + * to this key in your application's `application.ts`. + * + * Example: + * app.bind(AiIntegrationBindings.MastraChatLLM).to(openai('gpt-4o')); + */ + export const MastraChatLLM = BindingKey.create( + 'services.ai-reporting.mastraChatLLMProvider', + ); + + /** + * Mastra-compatible file/document processing LLM (optional). + * Used by FileProcessingStep to summarise uploaded files. + * Falls back to MastraChatLLM if not bound. + */ + export const MastraFileLLM = BindingKey.create( + 'services.ai-reporting.mastraFileLLMProvider', + ); } export const WriterDB = 'writerdb'; export const ReaderDB = 'readerdb'; diff --git a/src/mastra/agents/chat-reasoning.agent.ts b/src/mastra/agents/chat-reasoning.agent.ts new file mode 100644 index 0000000..e2a0599 --- /dev/null +++ b/src/mastra/agents/chat-reasoning.agent.ts @@ -0,0 +1,217 @@ +import {Agent} from '@mastra/core/agent'; +import {RequestContext} from '@mastra/core/request-context'; +import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {MastraModelConfig} from '@mastra/core/llm'; +import {createTool} from '@mastra/core/tools'; +import {z} from 'zod'; +import {AnyObject} from '@loopback/repository'; +import {IGraphTool, ToolStatus} from '../../graphs/types'; +import {LLMStreamEvent, LLMStreamEventType} from '../../graphs/event.types'; +import type {AsyncEventQueue} from '../bridge/async-event-queue'; +import type {ToolStore} from '../../types'; +import {asWorkflowContext} from '../bridge/workflow-request-context'; + +const debug = require('debug')('ai-integration:mastra:chat-agent'); + +/** + * Typed interface for the LangChain tool extracted via igraphTool.build(). + * Only the fields we need are declared. + */ +interface LangChainToolLike { + schema?: z.ZodTypeAny; + description?: string; + invoke( + input: Record, + config?: Record, + ): Promise; +} + +/** + * Build a Mastra-compatible tool from an IGraphTool instance. + * + * The adapter: + * 1. Builds the LangChain tool once (to extract the Zod schema and description). + * 2. Returns a Mastra createTool() with the same schema. + * 3. At execution time, re-builds with a config that routes config.writer → AsyncEventQueue. + */ +async function buildMastraToolFromIGraphTool( + igraphTool: IGraphTool, +): Promise> { + // Build once with a minimal config to extract the schema and description. + // IGraphTool.build() is typed to accept LangGraphRunnableConfig — we pass the + // minimum required shape. + const lcTool = (await igraphTool.build({ + configurable: {}, + } as Parameters[0])) as unknown as LangChainToolLike; + + const toolSchema: z.ZodTypeAny = lcTool.schema ?? z.record(z.unknown()); + const toolDescription: string = lcTool.description ?? igraphTool.key; + + return createTool({ + id: igraphTool.key, + description: toolDescription, + inputSchema: toolSchema, + execute: async (inputData, context) => { + // Retrieve the event queue from RequestContext (typed access via asWorkflowContext). + const eventQueue: AsyncEventQueue | undefined = context?.requestContext + ? asWorkflowContext(context.requestContext as RequestContext).get( + 'eventQueue', + ) + : undefined; + + debug(`Executing tool: ${igraphTool.key}`, inputData); + + // Build a LangGraph-compatible config so tool sub-graphs can emit SSE events + // via config.writer — those events are routed into the AsyncEventQueue. + const lgConfig: Parameters[0] = { + configurable: {}, + writer: (event: LLMStreamEvent) => { + eventQueue?.push(event); + }, + } as unknown as Parameters[0]; + + // Re-build with writer so sub-graphs can emit events during tool execution. + const freshLcTool = (await igraphTool.build( + lgConfig, + )) as unknown as LangChainToolLike; + + const result = await freshLcTool.invoke( + inputData as Record, + lgConfig as Record, + ); + + return result as AnyObject; + }, + }); +} + +/** + * Build the Mastra tools map for the ChatReasoningAgent. + * Called once per request from AgentReasoningStep. + */ +export async function buildChatAgentTools( + toolStore: ToolStore, +): Promise>> { + const toolsMap: Record> = {}; + + for (const igraphTool of toolStore.list) { + if (igraphTool.needsReview === true) { + debug(`Skipping tool ${igraphTool.key}: requires user review`); + continue; + } + try { + toolsMap[igraphTool.key] = + await buildMastraToolFromIGraphTool(igraphTool); + } catch (err) { + debug(`Failed to build Mastra tool wrapper for ${igraphTool.key}:`, err); + } + } + + return toolsMap; +} + +/** + * ChatReasoningAgent — the Mastra Agent that drives the multi-turn tool-calling loop. + * + * Replaces the CallLLM → RunTool → TrimMessages cycle in the original ChatGraph. + * The model and tools are resolved dynamically from RequestContext at runtime. + * + * Architecture: + * - model: resolved from RequestContext at each call (supports per-request model injection) + * - tools: built from the ToolStore in RequestContext (adapts IGraphTool → Mastra tools) + * - maxSteps: 20 (equivalent to recursionLimit: 60 / 3 nodes per cycle) + * + * RequestContext access uses `asWorkflowContext()` for fully typed, zero-any access. + */ +export const chatReasoningAgent = new Agent({ + id: 'chat-reasoning-agent', + name: 'Chat Reasoning Agent', + instructions: async ({requestContext}: {requestContext: RequestContext}) => { + const ctx = asWorkflowContext(requestContext); + const systemCtx = ctx.get('systemContext'); + const additionalContext = systemCtx?.join('\n') ?? ''; + return [ + `You are a helpful AI assistant. You MUST always use one of the available tools to handle the user's request. Never respond with just text on the first message — always call the closest matching tool, even if you are unsure.`, + `Only use a single tool in a single message, but you can use multiple tools over subsequent messages if it could help with the user's requirements.`, + `If the user provides feedback, you can use that feedback to improve the result.`, + `Do not write any redundant messages before or after tool calls, be as concise as possible.`, + `Do not hallucinate details or make up information.`, + `Do not make assumptions about user's intent beyond what is explicitly provided in the prompt.`, + `Current date is ${new Date().toDateString()}`, + additionalContext, + ] + .filter(Boolean) + .join('\n'); + }, + model: ({ + requestContext, + }: { + requestContext: RequestContext; + }): MastraModelConfig => { + const ctx = asWorkflowContext(requestContext); + const llm: MastraLanguageModel = ctx.get('mastraChatLlm'); + if (!llm) { + throw new Error( + 'MastraChatLLM not found in RequestContext. ' + + 'Bind AiIntegrationBindings.MastraChatLLM in your LoopBack application.', + ); + } + return llm; + }, + tools: async ({requestContext}: {requestContext: RequestContext}) => { + const ctx = asWorkflowContext(requestContext); + const toolStore: ToolStore = ctx.get('toolStore'); + if (!toolStore?.list?.length) { + return {}; + } + return buildChatAgentTools(toolStore); + }, +}); + +/** + * Emit a Tool event to the AsyncEventQueue. + * Called when the agent starts executing a tool call. + */ +export function emitToolStartEvent( + eventQueue: AsyncEventQueue, + toolCallId: string, + toolName: string, + args: AnyObject, +): void { + eventQueue.push({ + type: LLMStreamEventType.Tool, + data: { + id: toolCallId, + tool: toolName, + data: args, + }, + }); +} + +/** + * Emit a ToolStatus event to the AsyncEventQueue. + * Called after a tool call completes (or fails). + */ +export function emitToolStatusEvent( + eventQueue: AsyncEventQueue, + toolCallId: string, + toolStore: ToolStore, + toolName: string, + result: AnyObject, +): void { + const igraphTool = toolStore.map[toolName]; + const metadata = + igraphTool?.getMetadata?.(result as Record) ?? {}; + const status = + (metadata['status'] as string) ?? + (result ? ToolStatus.Completed : ToolStatus.Failed); + + eventQueue.push({ + type: LLMStreamEventType.ToolStatus, + data: { + id: toolCallId, + status, + data: metadata, + }, + }); +} diff --git a/src/mastra/bridge/async-event-queue.ts b/src/mastra/bridge/async-event-queue.ts new file mode 100644 index 0000000..97b261d --- /dev/null +++ b/src/mastra/bridge/async-event-queue.ts @@ -0,0 +1,72 @@ +import {LLMStreamEvent} from '../../graphs/event.types'; + +/** + * AsyncEventQueue — a lightweight in-process event queue for real-time SSE delivery. + * + * Used to bridge between Mastra agent callbacks (which run inside agent internals and + * cannot access the step's `writer`) and the WorkflowRunner's event-forwarding loop. + * + * Architecture: + * 1. WorkflowRunner stores an AsyncEventQueue in RequestContext before workflow run. + * 2. AgentReasoningStep's onStepFinish / tool-call callbacks push events to the queue. + * 3. WorkflowRunner reads from the queue concurrently and sends events to ITransport. + * 4. After workflow completes, `close()` signals the consumer to stop iterating. + */ +export class AsyncEventQueue implements AsyncIterable { + private readonly _queue: LLMStreamEvent[] = []; + private _resolve: (() => void) | null = null; + private _closed = false; + + /** + * Push an event into the queue. + * Synchronous; wakes any waiting consumer. + */ + push(event: LLMStreamEvent): void { + if (this._closed) return; + this._queue.push(event); + this._resolve?.(); + this._resolve = null; + } + + /** + * Signal that no more events will be pushed. + * The async iterator will complete after all buffered events are consumed. + */ + close(): void { + this._closed = true; + this._resolve?.(); + this._resolve = null; + } + + /** + * Returns true when the queue is closed and all events have been consumed. + */ + get isDrained(): boolean { + return this._closed && this._queue.length === 0; + } + + /** + * Async iterator — yields events as they arrive. + * Suspends (awaits) when the queue is empty and not yet closed. + */ + [Symbol.asyncIterator](): AsyncIterator { + return { + next: async (): Promise> => { + // Spin until an event is available or the queue is closed + // eslint-disable-next-line no-constant-condition + while (true) { + if (this._queue.length > 0) { + return {value: this._queue.shift()!, done: false}; + } + if (this._closed) { + return {value: undefined as unknown as LLMStreamEvent, done: true}; + } + // Wait for next push or close + await new Promise(resolve => { + this._resolve = resolve; + }); + } + }, + }; + } +} diff --git a/src/mastra/bridge/context-window-manager.ts b/src/mastra/bridge/context-window-manager.ts new file mode 100644 index 0000000..9320e0c --- /dev/null +++ b/src/mastra/bridge/context-window-manager.ts @@ -0,0 +1,102 @@ +import {DEFAULT_MAX_TOKEN_COUNT} from '../../constant'; +import {approxTokenCounter} from '../../utils'; + +/** + * A message in CoreMessage-compatible format (role + content). + * We avoid importing directly from Vercel AI SDK to keep the dependency boundary clean; + * we only use what we need for context trimming. + */ +export type CoreMessageLike = { + role: string; + content: string | Array>; +}; + +/** + * ContextWindowManager — manages context compression for the ChatWorkflow. + * + * Preserves the exact behaviour of the existing `ContextCompressionNode`: + * - strategy: 'last' (keep the most recent messages when trimming) + * - includeSystem: true (system message always preserved at position 0) + * - Approximate token counting (1 token ≈ 4 characters) + * + * This is a pure utility class (no LB4 / Mastra dependencies). + */ +export class ContextWindowManager { + /** Default token budget, mirrors DEFAULT_MAX_TOKEN_COUNT from constants. */ + static readonly DEFAULT_MAX_TOKENS: number = DEFAULT_MAX_TOKEN_COUNT; + + /** + * Trim a messages array to fit within `maxTokens`. + * + * @param messages - Full conversation history (system + prior turns + new turn) + * @param maxTokens - Token budget (defaults to DEFAULT_MAX_TOKEN_COUNT = 8192) + * @returns The trimmed messages array, always keeping the system message first. + */ + static trim( + messages: CoreMessageLike[], + maxTokens: number = DEFAULT_MAX_TOKEN_COUNT, + ): CoreMessageLike[] { + const totalTokens = messages.reduce( + (sum, m) => sum + ContextWindowManager._countMessageTokens(m), + 0, + ); + + if (totalTokens <= maxTokens) { + return messages; + } + + // Separate system message (always kept) from the rest + const systemMessages = messages.filter(m => m.role === 'system'); + const nonSystemMessages = messages.filter(m => m.role !== 'system'); + + const systemTokens = systemMessages.reduce( + (sum, m) => sum + ContextWindowManager._countMessageTokens(m), + 0, + ); + + const budget = maxTokens - systemTokens; + if (budget <= 0) { + // Even the system message exceeds the budget; return it as-is + return systemMessages; + } + + // Keep messages from the END (strategy: 'last') + const kept: CoreMessageLike[] = []; + let usedTokens = 0; + + for (let i = nonSystemMessages.length - 1; i >= 0; i--) { + const msg = nonSystemMessages[i]; + const tokens = ContextWindowManager._countMessageTokens(msg); + if (usedTokens + tokens > budget) break; + kept.unshift(msg); + usedTokens += tokens; + } + + return [...systemMessages, ...kept]; + } + + /** + * Return the approximate token count for a single message. + */ + static countTokens(messages: CoreMessageLike[]): number { + return messages.reduce( + (sum, m) => sum + ContextWindowManager._countMessageTokens(m), + 0, + ); + } + + private static _countMessageTokens(msg: CoreMessageLike): number { + if (typeof msg.content === 'string') { + return approxTokenCounter(msg.content); + } + if (Array.isArray(msg.content)) { + return msg.content.reduce((sum, part) => { + if (typeof part.text === 'string') { + return sum + approxTokenCounter(part.text); + } + return sum; + }, 0); + } + return 0; + } +} diff --git a/src/mastra/bridge/token-usage-accumulator.ts b/src/mastra/bridge/token-usage-accumulator.ts new file mode 100644 index 0000000..7b2663c --- /dev/null +++ b/src/mastra/bridge/token-usage-accumulator.ts @@ -0,0 +1,68 @@ +import {TokenMetadata} from '../../types'; + +/** + * TokenUsageAccumulator — per-request token usage tracker for Mastra workflows. + * + * Replaces the LangChain callback-based `TokenCounter`. In the Mastra architecture, + * token usage is captured from the `step-finish` events emitted by `agent.stream()`. + * + * Lifecycle: Created per HTTP request by WorkflowRunner, stored in RequestContext, + * read by EndSessionStep to persist final counts. + */ +export class TokenUsageAccumulator { + private _inputs = 0; + private _outputs = 0; + private readonly _countMap = new Map< + string, + {inputTokens: number; outputTokens: number} + >(); + + /** + * Accumulate token usage for a given model. + * + * @param modelName - LLM model identifier (e.g. "gpt-4o", "claude-3-5-sonnet") + * @param inputTokens - Prompt / input token count + * @param outputTokens - Completion / output token count + */ + accumulate( + modelName: string, + inputTokens: number, + outputTokens: number, + ): void { + this._inputs += inputTokens; + this._outputs += outputTokens; + + const prev = this._countMap.get(modelName) ?? { + inputTokens: 0, + outputTokens: 0, + }; + this._countMap.set(modelName, { + inputTokens: prev.inputTokens + inputTokens, + outputTokens: prev.outputTokens + outputTokens, + }); + } + + /** + * Get the accumulated counts. + */ + getCounts(): { + inputs: number; + outputs: number; + map: TokenMetadata; + } { + return { + inputs: this._inputs, + outputs: this._outputs, + map: Object.fromEntries(this._countMap.entries()), + }; + } + + /** + * Reset all counters (used in tests). + */ + clear(): void { + this._inputs = 0; + this._outputs = 0; + this._countMap.clear(); + } +} diff --git a/src/mastra/bridge/workflow-request-context.ts b/src/mastra/bridge/workflow-request-context.ts new file mode 100644 index 0000000..9e95e2b --- /dev/null +++ b/src/mastra/bridge/workflow-request-context.ts @@ -0,0 +1,56 @@ +import type {RequestContext} from '@mastra/core/request-context'; +import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {IAuthUserWithPermissions} from '@sourceloop/core'; +import type {ChatStore} from '../../graphs/chat/chat.store'; +import type {AIIntegrationConfig, ToolStore} from '../../types'; +import type {AsyncEventQueue} from './async-event-queue'; +import type {TokenUsageAccumulator} from './token-usage-accumulator'; + +/** + * Typed interface for all values stored in Mastra RequestContext. + * + * Using `RequestContext` enables fully typed `.get()` and `.set()` + * calls throughout all workflow steps and the ChatReasoningAgent — zero `any` casts needed. + * + * All keys follow the snake_case convention matching the RequestContext.set() calls in + * WorkflowRunner.executeChatWorkflow(). + */ +export interface WorkflowRequestContext { + /** Primary LLM used for chat reasoning (Agent reasoning loop) */ + mastraChatLlm: MastraLanguageModel; + /** LLM used for file summarisation (falls back to mastraChatLlm if not set) */ + mastraFileLlm: MastraLanguageModel; + /** Chat session store — request-scoped */ + chatStore: ChatStore; + /** Tool registry for the chat Agent */ + toolStore: ToolStore; + /** AI integration config (optional — may be undefined if not bound) */ + aiConfig: AIIntegrationConfig | Record; + /** System context strings to prepend to the system prompt */ + systemContext: string[] | undefined; + /** Per-request token usage accumulator */ + tokenUsageAccumulator: TokenUsageAccumulator; + /** + * Async event queue used EXCLUSIVELY by AgentReasoningStep to forward + * Tool and ToolStatus events that originate inside agent callbacks + * (which do not have access to the step's writer parameter). + */ + eventQueue: AsyncEventQueue; + /** AbortSignal propagated from the HTTP request's abort controller */ + abortSignal: AbortSignal; + /** Authenticated user resolved from LoopBack auth middleware */ + currentUser: IAuthUserWithPermissions | undefined; +} + +/** + * Helper: cast an untyped Mastra RequestContext to our fully-typed variant. + * + * Usage: + * const ctx = asWorkflowContext(requestContext); + * const chatStore = ctx.get('chatStore'); // typed as ChatStore + */ +export function asWorkflowContext( + requestContext: RequestContext, +): RequestContext { + return requestContext as RequestContext; +} diff --git a/src/mastra/bridge/workflow-runner.ts b/src/mastra/bridge/workflow-runner.ts new file mode 100644 index 0000000..5bfe019 --- /dev/null +++ b/src/mastra/bridge/workflow-runner.ts @@ -0,0 +1,222 @@ +import { + BindingScope, + Getter, + inject, + injectable, + service, +} from '@loopback/core'; +import {repository} from '@loopback/repository'; +import {IAuthUserWithPermissions} from '@sourceloop/core'; +import {AuthenticationBindings} from 'loopback4-authentication'; +import {RequestContext} from '@mastra/core/request-context'; +import {ChatStore} from '../../graphs/chat/chat.store'; +import {LLMStreamEvent, LLMStreamEventType} from '../../graphs/event.types'; +import {AiIntegrationBindings} from '../../keys'; +import {ChatRepository} from '../../repositories'; +import {AIIntegrationConfig, ToolStore} from '../../types'; +import {chatWorkflow} from '../workflows/chat/chat.workflow'; +import {AsyncEventQueue} from './async-event-queue'; +import {TokenUsageAccumulator} from './token-usage-accumulator'; +import type {MastraLanguageModel} from '@mastra/core/agent'; + +const debug = require('debug')('ai-integration:mastra:workflow-runner'); + +/** + * Type guard: checks if an unknown value is an LLMStreamEvent. + * Used to extract typed events from workflow-step-output stream chunks. + */ +function isLLMStreamEvent(value: unknown): value is LLMStreamEvent { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + 'data' in value && + typeof (value as {type: unknown}).type === 'string' + ); +} + +/** + * WorkflowRunner — the LoopBack 4 ↔ Mastra bridge. + * + * Responsibilities: + * 1. Resolve all REQUEST-scoped LoopBack services (ChatStore, LLMs, etc.) + * 2. Build a typed RequestContext and inject it into the Mastra ChatWorkflow + * 3. Stream the workflow via run.stream() and concurrently drain the AsyncEventQueue + * 4. Yield LLMStreamEvents to the caller (GenerationService forwards to ITransport) + * + * Event sources: + * - Workflow stream: steps emit Init/Status/Log/TokenCount/Message via writer.write() + * → surfaced as workflow-step-output chunks; extracted via isLLMStreamEvent() + * - AsyncEventQueue: agent callbacks emit Tool/ToolStatus events + * → drained concurrently via _mergeStreams() + * + * Scope: REQUEST — one instance per HTTP request, discarded after the request ends. + */ +@injectable({scope: BindingScope.REQUEST}) +export class WorkflowRunner { + constructor( + @service(ChatStore) + private readonly chatStore: ChatStore, + @inject(AiIntegrationBindings.MastraChatLLM) + private readonly mastraChatLlm: MastraLanguageModel, + @inject(AiIntegrationBindings.MastraFileLLM, {optional: true}) + private readonly mastraFileLlm: MastraLanguageModel | undefined, + @inject(AiIntegrationBindings.Tools) + private readonly toolStore: ToolStore, + @inject(AiIntegrationBindings.Config, {optional: true}) + private readonly aiConfig: AIIntegrationConfig | undefined, + @inject(AiIntegrationBindings.SystemContext, {optional: true}) + private readonly systemContext: string[] | undefined, + @inject.getter(AuthenticationBindings.CURRENT_USER) + private readonly getCurrentUser: Getter, + @repository(ChatRepository) + private readonly chatRepository: ChatRepository, + ) {} + + /** + * Execute the ChatWorkflow and yield LLMStreamEvents as they are produced. + * + * Callers (GenerationService) iterate this generator and forward each event + * to ITransport. WorkflowRunner does NOT hold a reference to ITransport. + */ + async *executeChatWorkflow( + prompt: string, + files: Express.Multer.File[], + abortController: AbortController, + sessionId?: string, + ): AsyncGenerator { + const eventQueue = new AsyncEventQueue(); + const tokenAccumulator = new TokenUsageAccumulator(); + + const requestContext = new RequestContext(); + + requestContext.set('abortSignal', abortController.signal); + requestContext.set('eventQueue', eventQueue); + requestContext.set('mastraChatLlm', this.mastraChatLlm); + requestContext.set( + 'mastraFileLlm', + this.mastraFileLlm ?? this.mastraChatLlm, + ); + requestContext.set('chatStore', this.chatStore); + requestContext.set('toolStore', this.toolStore); + requestContext.set('aiConfig', this.aiConfig ?? {}); + requestContext.set('systemContext', this.systemContext); + requestContext.set('tokenUsageAccumulator', tokenAccumulator); + + const run = await chatWorkflow.createRun(); + + // run.stream() executes the workflow lazily as we consume the returned iterator. + // The iterator yields WorkflowStreamEvent — steps emit via writer.write() which + // surfaces as {type: 'workflow-step-output', payload: {output: }}. + const workflowStream = run.stream({ + inputData: {prompt, files, sessionId}, + requestContext, + }); + + // Merge the workflow stream (writer.write events) and AsyncEventQueue (agent callbacks) + // concurrently. Yield all LLMStreamEvents to GenerationService in arrival order. + yield* this._mergeStreams(workflowStream, eventQueue, abortController); + } + + /** + * Merge the Mastra workflow stream and the AsyncEventQueue into a single + * LLMStreamEvent generator using Promise.race() for fair interleaving. + * + * - Workflow stream yields WorkflowStreamEvent; we extract LLMStreamEvents + * from `workflow-step-output` chunks via isLLMStreamEvent(). + * - AsyncEventQueue yields LLMStreamEvents directly (Tool/ToolStatus from agent callbacks). + * + * The generator completes when BOTH sources are exhausted. + */ + private async *_mergeStreams( + workflowStream: AsyncIterable, + queue: AsyncEventQueue, + abortController: AbortController, + ): AsyncGenerator { + const wsIter = workflowStream[Symbol.asyncIterator](); + const qIter = queue[Symbol.asyncIterator](); + + type SlotResult = {done?: boolean; value: unknown; source: 'ws' | 'queue'}; + + // Kick off the first read from both sources before entering the race loop + let wsPromise: Promise = wsIter + .next() + .then(r => ({done: r.done, value: r.value, source: 'ws' as const})); + let qPromise: Promise = qIter + .next() + .then(r => ({done: r.done, value: r.value, source: 'queue' as const})); + + let wsDone = false; + let qDone = false; + + while (!wsDone || !qDone) { + if (abortController.signal.aborted) { + debug('WorkflowRunner: abort signal received, stopping merge'); + break; + } + + // Build the list of active (not yet done) promises + const active: Promise[] = []; + if (!wsDone) active.push(wsPromise); + if (!qDone) active.push(qPromise); + + if (!active.length) break; + + const result = await Promise.race(active); + + if (result.source === 'ws') { + if (result.done) { + wsDone = true; + debug('WorkflowRunner: workflow stream exhausted'); + } else { + // Extract LLMStreamEvent from workflow-step-output chunks + const chunk = result.value as { + type?: string; + payload?: {output?: unknown}; + }; + if (chunk?.type === 'workflow-step-output') { + const output = chunk.payload?.output; + if (isLLMStreamEvent(output)) { + if (output.type !== LLMStreamEventType.Log) { + yield output; + } else { + debug( + 'WorkflowRunner: Log event (not forwarded):', + output.data, + ); + } + } + } + // Schedule the next read from the workflow stream + wsPromise = wsIter + .next() + .then(r => ({done: r.done, value: r.value, source: 'ws' as const})); + } + } else { + // source === 'queue' + if (result.done) { + qDone = true; + debug('WorkflowRunner: AsyncEventQueue exhausted'); + } else { + const event = result.value as LLMStreamEvent; + if (event.type !== LLMStreamEventType.Log) { + yield event; + } else { + debug( + 'WorkflowRunner: Log event from queue (not forwarded):', + event.data, + ); + } + // Schedule the next read from the queue + qPromise = qIter.next().then(r => ({ + done: r.done, + value: r.value, + source: 'queue' as const, + })); + } + } + } + + debug('WorkflowRunner: merge complete'); + } +} diff --git a/src/mastra/index.ts b/src/mastra/index.ts new file mode 100644 index 0000000..d5d7a75 --- /dev/null +++ b/src/mastra/index.ts @@ -0,0 +1,27 @@ +/** + * Mastra migration layer barrel export. + * + * Phase 1: Foundation Layer + ChatWorkflow + * Phase 2 (future): DBQueryWorkflow + * Phase 3 (future): VisualizationWorkflow + */ + +// Bridge utilities +export {AsyncEventQueue} from './bridge/async-event-queue'; +export {TokenUsageAccumulator} from './bridge/token-usage-accumulator'; +export {ContextWindowManager} from './bridge/context-window-manager'; +export {WorkflowRunner} from './bridge/workflow-runner'; + +// Types +export type { + ChatWorkflowRequestContext, + IMastraTool, + AgentReasoningOutput, + ToolCallRecord, +} from './types'; + +// Chat workflow +export {chatWorkflow} from './workflows/chat/chat.workflow'; + +// Agent +export {chatReasoningAgent} from './agents/chat-reasoning.agent'; diff --git a/src/mastra/types.ts b/src/mastra/types.ts new file mode 100644 index 0000000..2f6649a --- /dev/null +++ b/src/mastra/types.ts @@ -0,0 +1,97 @@ +import {AnyObject} from '@loopback/repository'; +import {z} from 'zod'; +import {LLMStreamEvent} from '../graphs/event.types'; +import type {AsyncEventQueue} from './bridge/async-event-queue'; +import type {TokenUsageAccumulator} from './bridge/token-usage-accumulator'; +import type {ChatStore} from '../graphs/chat/chat.store'; +import type {ToolStore} from '../types'; +import type {AIIntegrationConfig} from '../types'; +import type {MastraLanguageModel} from '@mastra/core/agent'; + +/** + * Type-safe key map for the RequestContext used by the ChatWorkflow. + * All request-scoped values are passed through this context. + */ +export type ChatWorkflowRequestContext = { + /** Abort signal from the HTTP request lifecycle */ + abortSignal: AbortSignal; + /** Queue for real-time SSE event delivery */ + eventQueue: AsyncEventQueue; + /** Mastra-compatible LLM for primary chat reasoning */ + mastraChatLlm: MastraLanguageModel; + /** Mastra-compatible LLM for file summarization */ + mastraFileLlm: MastraLanguageModel; + /** Per-request chat data store */ + chatStore: ChatStore; + /** Available tools for the agent */ + toolStore: ToolStore; + /** AI integration configuration */ + aiConfig: AIIntegrationConfig; + /** Optional system context additions */ + systemContext: string[] | undefined; + /** Token usage accumulator for the request */ + tokenUsageAccumulator: TokenUsageAccumulator; +}; + +/** + * IMastraTool — Mastra-native tool interface. + * Implementors provide explicit inputSchema so Mastra tools are fully typed. + */ +export interface IMastraTool { + /** Tool key (used as tool name by the LLM) */ + key: string; + /** Human-readable description for the LLM */ + description: string; + /** Zod schema for the tool's input */ + inputSchema: z.ZodTypeAny; + /** + * Execute the tool. + * @param args - Input data validated against inputSchema + * @param requestContext - RequestContext for accessing services + */ + execute( + args: AnyObject, + requestContext: {get: (key: string) => unknown}, + ): Promise; + /** Extract the human-readable value from the raw result */ + getValue?(result: AnyObject): string; + /** Extract metadata for DB persistence */ + getMetadata?(result: AnyObject): AnyObject; + /** Whether this tool requires human review before execution */ + needsReview?: boolean; +} + +/** + * Output produced by the AgentReasoningStep. + * Contains full conversation context needed for persistence. + */ +export type AgentReasoningOutput = { + /** Final text response from the agent */ + finalText: string; + /** All tool calls made during the agent loop */ + toolCalls: ToolCallRecord[]; + /** Total token usage across all agent iterations */ + totalInputTokens: number; + totalOutputTokens: number; + /** Per-model token breakdown */ + tokenMap: Record; +}; + +/** + * A single tool call record with its result. + */ +export type ToolCallRecord = { + /** LLM-assigned call ID */ + toolCallId: string; + /** Tool name */ + toolName: string; + /** Arguments passed to the tool */ + args: AnyObject; + /** Raw result returned by the tool */ + rawResult: AnyObject; +}; + +/** + * Events emitted by the SSE transport. Re-exported for convenience. + */ +export type {LLMStreamEvent}; diff --git a/src/mastra/workflows/chat/chat-workflow-schemas.ts b/src/mastra/workflows/chat/chat-workflow-schemas.ts new file mode 100644 index 0000000..4a41d72 --- /dev/null +++ b/src/mastra/workflows/chat/chat-workflow-schemas.ts @@ -0,0 +1,150 @@ +import {z} from 'zod'; + +/** + * Input schema for the ChatWorkflow. + * Matches the existing ChatGraph.execute() signature. + */ +export const ChatWorkflowInputSchema = z.object({ + prompt: z.string().describe('The user prompt or message'), + files: z + .array( + z + .object({ + originalname: z.string(), + buffer: z.instanceof(Buffer).optional(), + mimetype: z.string().optional(), + size: z.number().optional(), + fieldname: z.string().optional(), + encoding: z.string().optional(), + // Allow arbitrary additional fields from Multer + }) + .catchall(z.unknown()), + ) + .default([]) + .describe('Uploaded files to process'), + sessionId: z + .string() + .optional() + .describe('Existing chat session ID for resuming a conversation'), +}); + +export type ChatWorkflowInput = z.infer; + +/** + * Output schema for the ChatWorkflow. + * Events are streamed via the AsyncEventQueue — not accumulated in output. + */ +export const ChatWorkflowOutputSchema = z.object({ + sessionId: z.string().describe('The chat session ID (new or existing)'), +}); + +export type ChatWorkflowOutput = z.infer; + +// ── Step-level schemas ──────────────────────────────────────────────────────── + +/** + * InitSessionStep output + */ +export const InitSessionOutputSchema = z.object({ + sessionId: z.string(), + isNewSession: z.boolean(), + userMessageId: z.string().optional(), + prompt: z.string(), + files: z.array(z.object({}).catchall(z.unknown())).default([]), +}); +export type InitSessionOutput = z.infer; + +/** + * PrepareContextStep output + */ +export const PrepareContextOutputSchema = z.object({ + sessionId: z.string(), + messages: z + .array( + z + .object({ + role: z.string(), + content: z.union([ + z.string(), + z.array(z.object({}).catchall(z.unknown())), + ]), + }) + .catchall(z.unknown()), + ) + .describe('Full conversation context (CoreMessage[])'), + userMessageId: z.string().optional(), + prompt: z.string(), + files: z.array(z.object({}).catchall(z.unknown())).default([]), +}); +export type PrepareContextOutput = z.infer; + +/** + * FileProcessingStep output + */ +export const FileProcessingOutputSchema = z.object({ + sessionId: z.string(), + messages: z + .array( + z + .object({ + role: z.string(), + content: z.union([ + z.string(), + z.array(z.object({}).catchall(z.unknown())), + ]), + }) + .catchall(z.unknown()), + ) + .describe('Updated context after file processing'), + userMessageId: z.string().optional(), + prompt: z.string(), +}); +export type FileProcessingOutput = z.infer; + +/** + * AgentReasoningStep output + */ +export const AgentReasoningOutputSchema = z.object({ + sessionId: z.string(), + finalText: z.string().describe('Final text response from the agent'), + toolCalls: z + .array( + z.object({ + toolCallId: z.string(), + toolName: z.string(), + args: z.record(z.unknown()), + rawResult: z.unknown(), + }), + ) + .default([]), + totalInputTokens: z.number().default(0), + totalOutputTokens: z.number().default(0), + tokenMap: z + .record( + z.object({ + inputTokens: z.number(), + outputTokens: z.number(), + }), + ) + .default({}), + userMessageId: z.string().optional(), +}); +export type AgentReasoningOutput = z.infer; + +/** + * PersistConversationStep output + */ +export const PersistConversationOutputSchema = z.object({ + sessionId: z.string(), + totalInputTokens: z.number(), + totalOutputTokens: z.number(), + tokenMap: z.record( + z.object({ + inputTokens: z.number(), + outputTokens: z.number(), + }), + ), +}); +export type PersistConversationOutput = z.infer< + typeof PersistConversationOutputSchema +>; diff --git a/src/mastra/workflows/chat/chat.workflow.ts b/src/mastra/workflows/chat/chat.workflow.ts new file mode 100644 index 0000000..75ac7a0 --- /dev/null +++ b/src/mastra/workflows/chat/chat.workflow.ts @@ -0,0 +1,48 @@ +import {createWorkflow} from '@mastra/core/workflows'; +import { + ChatWorkflowInputSchema, + ChatWorkflowOutputSchema, +} from './chat-workflow-schemas'; +import {initSessionStep} from './steps/init-session.step'; +import {prepareContextStep} from './steps/prepare-context.step'; +import {fileProcessingStep} from './steps/file-processing.step'; +import {agentReasoningStep} from './steps/agent-reasoning.step'; +import {persistConversationStep} from './steps/persist-conversation.step'; +import {endSessionStep} from './steps/end-session.step'; + +/** + * ChatWorkflow — Mastra replacement for the LangGraph ChatGraph. + * + * Step pipeline: + * initSession → prepareContext → fileProcessing → agentReasoning → persistConversation → endSession + * + * All SSE events are routed through the AsyncEventQueue stored in RequestContext. + * Token usage is accumulated in TokenUsageAccumulator stored in RequestContext. + * + * The workflow does NOT directly interact with the SSE transport — that is the + * responsibility of WorkflowRunner, which runs the workflow concurrently with + * the event forwarding loop. + * + * RequestContext keys (injected by WorkflowRunner): + * - chatStore: ChatStore (REQUEST-scoped) + * - eventQueue: AsyncEventQueue (per-request) + * - tokenUsageAccumulator: TokenUsageAccumulator (per-request) + * - mastraChatLlm: MastraLanguageModel (bound in LB4 DI) + * - mastraFileLlm: MastraLanguageModel (optional, bound in LB4 DI) + * - toolStore: ToolStore (REQUEST-scoped via ToolsProvider) + * - aiConfig: { maxTokens?, maxSteps?, modelName? } (optional, from LB4 config) + * - systemContext: string[] (optional, from LB4 SystemContext binding) + * - abortSignal: AbortSignal (from AbortController in GenerationService) + */ +export const chatWorkflow = createWorkflow({ + id: 'chat-workflow', + inputSchema: ChatWorkflowInputSchema, + outputSchema: ChatWorkflowOutputSchema, +}) + .then(initSessionStep) + .then(prepareContextStep) + .then(fileProcessingStep) + .then(agentReasoningStep) + .then(persistConversationStep) + .then(endSessionStep) + .commit(); diff --git a/src/mastra/workflows/chat/steps/agent-reasoning.step.ts b/src/mastra/workflows/chat/steps/agent-reasoning.step.ts new file mode 100644 index 0000000..fc1358a --- /dev/null +++ b/src/mastra/workflows/chat/steps/agent-reasoning.step.ts @@ -0,0 +1,201 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import { + chatReasoningAgent, + emitToolStatusEvent, +} from '../../../agents/chat-reasoning.agent'; +import {asWorkflowContext} from '../../../bridge/workflow-request-context'; +import { + FileProcessingOutputSchema, + AgentReasoningOutputSchema, +} from '../chat-workflow-schemas'; +import type {AnyObject} from '@loopback/repository'; + +const debug = require('debug')('ai-integration:mastra:agent-reasoning.step'); + +/** + * AgentReasoningStep — the core agentic loop. + * + * LangGraph equivalent: `CallLLMNode` + `RunToolNode` + the graph loop edge. + * + * Event architecture (per TDD): + * - Tool / ToolStatus events → AsyncEventQueue (agent callbacks cannot use writer) + * - Message event → writer.write() AFTER agent completes (workflow-native streaming) + * - Token accumulation → step-finish chunks in fullStream (V2 naming: inputTokens/outputTokens) + * + * Stream chunk typing: + * Mastra's AgentChunkType is a discriminated union — we use switch/case narrowing. + * No unsafe casts. Each branch has fully typed payload access. + */ +export const agentReasoningStep = createStep({ + id: 'agent-reasoning', + description: + 'Run the ChatReasoningAgent in a multi-step tool-calling loop; stream events to the client', + inputSchema: FileProcessingOutputSchema, + outputSchema: AgentReasoningOutputSchema, + execute: async ({inputData, requestContext, writer}) => { + const ctx = asWorkflowContext(requestContext); + + const {sessionId, messages, userMessageId} = inputData; + + const eventQueue = ctx.get('eventQueue'); + const tokenAccumulator = ctx.get('tokenUsageAccumulator'); + const toolStore = ctx.get('toolStore'); + const abortSignal = ctx.get('abortSignal'); + const aiConfig = ctx.get('aiConfig') as + | {maxSteps?: number; modelName?: string} + | undefined; + + debug( + `AgentReasoning: streaming agent with ${messages.length} messages, session=${sessionId}`, + ); + + const toolCallRecords: z.infer< + typeof AgentReasoningOutputSchema + >['toolCalls'] = []; + + let finalText = ''; + + const agentOutput = await chatReasoningAgent.stream( + messages as Parameters[0], + { + maxSteps: (aiConfig as {maxSteps?: number} | undefined)?.maxSteps ?? 20, + abortSignal, + requestContext: ctx, + }, + ); + + // Consume the full stream using discriminated union narrowing. + // AgentChunkType is: tool-call | tool-result | text-delta | step-finish | error | ... + // Each case uses the narrowed payload type — no unsafe casts. + for await (const chunk of agentOutput.fullStream) { + if (abortSignal?.aborted) { + debug('AgentReasoning: abort signal received, stopping stream'); + break; + } + + switch (chunk.type) { + case 'tool-call': { + // chunk.payload is ToolCallPayload (typed via discriminated union) + const {toolCallId, toolName, args} = chunk.payload; + debug(`AgentReasoning: tool call → ${toolName}`); + eventQueue.push({ + type: LLMStreamEventType.Tool, + data: { + id: toolCallId, + tool: toolName, + data: (args ?? {}) as AnyObject, + }, + }); + break; + } + + case 'tool-result': { + // chunk.payload is ToolResultPayload (typed via discriminated union) + const {toolCallId, toolName, args, result} = chunk.payload; + debug(`AgentReasoning: tool result → ${toolName}`); + + toolCallRecords.push({ + toolCallId, + toolName, + args: (args ?? {}) as AnyObject, + rawResult: (result ?? {}) as AnyObject, + }); + + // IGraphTool sub-graphs emit ToolStatus internally via config.writer → eventQueue. + // For plain Mastra tools (not IGraphTool), emit a generic ToolStatus here. + const igraphTool = toolStore?.map?.[toolName]; + if (!igraphTool) { + emitToolStatusEvent( + eventQueue, + toolCallId, + toolStore, + toolName, + (result ?? {}) as AnyObject, + ); + } + break; + } + + case 'text-delta': { + // chunk.payload is TextDeltaPayload (typed via discriminated union) + // Accumulate text — emit ONE Message event after agent completes (TDD §6A.6) + finalText += chunk.payload.text; + break; + } + + case 'step-finish': { + // chunk.payload is StepFinishPayload — typed, no cast needed + // LanguageModelUsage uses V2 naming: inputTokens / outputTokens + const usage = chunk.payload.output?.usage; + if (usage) { + const modelId = + (aiConfig as {modelName?: string} | undefined)?.modelName ?? + 'chat-llm'; + tokenAccumulator?.accumulate( + modelId, + usage.inputTokens ?? 0, + usage.outputTokens ?? 0, + ); + } + break; + } + + case 'error': { + // chunk.payload is ErrorPayload (typed via discriminated union) + const errMsg = + chunk.payload.error instanceof Error + ? chunk.payload.error.message + : 'Agent stream error'; + debug('AgentReasoning: stream error chunk:', errMsg); + eventQueue.push({ + type: LLMStreamEventType.Error, + data: {message: errMsg}, + }); + break; + } + + default: + // All other chunk types (reasoning-delta, file, source, etc.) are ignored. + break; + } + } + + // After agent completes: emit the full Message event via writer (workflow-native streaming). + // This ensures Message arrives through the workflow stream, not the AsyncEventQueue. + // ⚠️ MUST be awaited — writer is a WritableStream; concurrent writes cause errors. + if (finalText) { + await writer.write({ + type: LLMStreamEventType.Message, + data: {message: finalText}, + }); + } + + // Close the AsyncEventQueue — signals WorkflowRunner that no more agent callbacks will arrive. + // WorkflowRunner's concurrent queue drainer will complete after all enqueued events are forwarded. + eventQueue.close(); + + const counts = tokenAccumulator?.getCounts() ?? { + inputs: 0, + outputs: 0, + map: {}, + }; + + debug( + `AgentReasoning: done. finalTextLength=${finalText.length}, toolCalls=${toolCallRecords.length}`, + ); + + return { + sessionId, + finalText, + toolCalls: toolCallRecords, + totalInputTokens: counts.inputs, + totalOutputTokens: counts.outputs, + tokenMap: counts.map as z.infer< + typeof AgentReasoningOutputSchema + >['tokenMap'], + userMessageId, + }; + }, +}); diff --git a/src/mastra/workflows/chat/steps/end-session.step.ts b/src/mastra/workflows/chat/steps/end-session.step.ts new file mode 100644 index 0000000..90cec1a --- /dev/null +++ b/src/mastra/workflows/chat/steps/end-session.step.ts @@ -0,0 +1,70 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asWorkflowContext} from '../../../bridge/workflow-request-context'; +import { + PersistConversationOutputSchema, + ChatWorkflowOutputSchema, +} from '../chat-workflow-schemas'; + +const debug = require('debug')('ai-integration:mastra:end-session.step'); + +/** + * EndSessionStep — finalise the chat turn and emit the TokenCount event. + * + * LangGraph equivalent: `EndSessionNode`. + * + * Responsibilities: + * - Update the session's cumulative token counts in the database + * - Emit the TokenCount SSE event via writer.write() (workflow-native streaming) + * + * The AsyncEventQueue is NOT closed here — it is closed by AgentReasoningStep + * after agent.stream() completes. EndSession only handles DB updates and the + * TokenCount event, which flows through the workflow stream (writer), not the queue. + */ +export const endSessionStep = createStep({ + id: 'end-session', + description: + 'Update token counts in the DB; emit TokenCount event via writer', + inputSchema: PersistConversationOutputSchema, + outputSchema: ChatWorkflowOutputSchema, + execute: async ({inputData, requestContext, writer}) => { + const ctx = asWorkflowContext(requestContext); + const chatStore = ctx.get('chatStore'); + + const {sessionId, totalInputTokens, totalOutputTokens, tokenMap} = + inputData; + + debug( + `EndSession: session=${sessionId}, in=${totalInputTokens}, out=${totalOutputTokens}`, + ); + + // Update cumulative token counts in the database + try { + await chatStore.updateCounts( + sessionId, + totalInputTokens, + totalOutputTokens, + tokenMap, + ); + } catch (err) { + // Non-fatal — log and continue + debug('EndSession: failed to update token counts:', err); + } + + // Emit TokenCount via writer (workflow-native streaming, not AsyncEventQueue) + await writer.write({ + type: LLMStreamEventType.TokenCount, + data: { + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + }, + }); + + debug('EndSession: TokenCount event written, step complete'); + + return { + sessionId, + } satisfies z.infer; + }, +}); diff --git a/src/mastra/workflows/chat/steps/file-processing.step.ts b/src/mastra/workflows/chat/steps/file-processing.step.ts new file mode 100644 index 0000000..402df57 --- /dev/null +++ b/src/mastra/workflows/chat/steps/file-processing.step.ts @@ -0,0 +1,185 @@ +// needed for z.infer in function signatures +import {z} from 'zod'; +import {createStep} from '@mastra/core/workflows'; +import {Agent} from '@mastra/core/agent'; +import type {MastraLanguageModel} from '@mastra/core/agent'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asWorkflowContext} from '../../../bridge/workflow-request-context'; +import {mergeAttachments} from '../../../../utils'; +import {Message} from '../../../../models'; +import { + PrepareContextOutputSchema, + FileProcessingOutputSchema, +} from '../chat-workflow-schemas'; + +const debug = require('debug')('ai-integration:mastra:file-processing.step'); + +const FILE_SUMMARY_SYSTEM_PROMPT = `You are an AI assistant that summarizes file content keeping all the important details in mind. +Make sure that you don't miss any important details and summarize the content in a concise manner. +While summarizing the content, make sure that you keep the user's prompt in mind and summarize the content in a way that it can be used to answer the user's query. +You will be provided with user's original prompt and one file among the files that user provided. +You will summarize the one file at a time so don't worry about the other files mentioned in the user's prompt. +The summary should be relatively short and only contain the important details that are relevant to the user's query. +The output should just be a plain text string without any additional markdown syntax or any special formatting.`; + +/** + * FileProcessingStep — summarise uploaded files using the file LLM. + * + * LangGraph equivalent: `SummariseFileNode` (handles the full iteration loop). + * + * Responsibilities: + * - For each uploaded file, call the file LLM for a summary + * - Persist each summary as an Attachment message in the database + * - Replace the last user message in the context with an enhanced version + * that merges the original prompt with all file summaries + * + * If no files are present, messages and prompt pass through unchanged. + */ +export const fileProcessingStep = createStep({ + id: 'file-processing', + description: + 'Summarise uploaded files and merge summaries into the conversation context', + inputSchema: PrepareContextOutputSchema, + outputSchema: FileProcessingOutputSchema, + execute: async ({inputData, requestContext, writer}) => { + const ctx = asWorkflowContext(requestContext); + + const {sessionId, messages, userMessageId, prompt, files} = inputData; + + if (!files?.length) { + debug('FileProcessing: no files to process, passing through'); + return {sessionId, messages, userMessageId, prompt}; + } + + const chatStore = ctx.get('chatStore'); + const fileLlm = ctx.get('mastraFileLlm') as MastraLanguageModel | undefined; + + if (!fileLlm) { + throw new Error( + 'MastraFileLLM not bound. Bind AiIntegrationBindings.MastraFileLLM to process files.', + ); + } + + // Retrieve the saved user Message entity for addAttachmentMessage + const userMessageRecord = userMessageId + ? await chatStore.findMessageById(sessionId, userMessageId) + : undefined; + + let mergedPrompt = prompt; + + for (const file of files) { + const multerFile = file as unknown as Express.Multer.File; + debug(`FileProcessing: processing file ${multerFile.originalname}`); + + // Emit Status via writer (workflow-native streaming, not AsyncEventQueue) + await writer.write({ + type: LLMStreamEventType.Status, + data: `Reading file: ${multerFile.originalname}`, + }); + + // Build the file content part for the LLM message + const fileContentPart = buildFileContentPart(multerFile, fileLlm); + + // Use a one-shot Agent to summarise the file with the file LLM + const summaryAgent = new Agent({ + id: 'file-summary-agent', + name: 'File Summary Agent', + instructions: `${FILE_SUMMARY_SYSTEM_PROMPT}\nHere is the user's prompt:\n${prompt}`, + model: fileLlm, + }); + + const agentResult = await summaryAgent.generate([ + { + role: 'user', + content: [{type: 'text', text: prompt}, fileContentPart], + }, + ]); + + const rawText = agentResult.text ?? ''; + const summary = typeof rawText === 'string' ? rawText : ''; + + debug(`FileProcessing: file summary length=${summary.length}`); + + // Persist the attachment message to the database + if (userMessageRecord) { + await chatStore.addAttachmentMessage( + sessionId, + userMessageRecord as Message, + multerFile, + summary, + ); + } + + // Merge the file summary into the running prompt + mergedPrompt = mergeAttachments( + mergedPrompt, + multerFile.originalname, + summary, + ); + } + + // Replace the last user message in the context with the enhanced version + const updatedMessages = replaceLastUserMessage(messages, mergedPrompt); + + return { + sessionId, + messages: updatedMessages as z.infer< + typeof FileProcessingOutputSchema + >['messages'], + userMessageId, + prompt: mergedPrompt, + }; + }, +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Build a file content part compatible with the Vercel AI SDK message format. + * Mirrors `SummariseFileNode.buildFileContent()`. + */ +function buildFileContentPart( + file: Express.Multer.File, + llm: MastraLanguageModel, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any { + // Some LLM providers have a custom getFile() helper on the provider instance + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const provider = llm as any; + if (typeof provider?.getFile === 'function') { + return provider.getFile(file); + } + return { + type: 'file', + // eslint-disable-next-line @typescript-eslint/naming-convention + source_type: 'base64', + data: file.buffer?.toString('base64') ?? '', + // eslint-disable-next-line @typescript-eslint/naming-convention + mime_type: file.mimetype ?? 'application/pdf', + }; +} + +/** + * Replace the last user message with an enhanced version containing file summaries. + */ +function replaceLastUserMessage( + messages: z.infer['messages'], + enhancedPrompt: string, +): z.infer['messages'] { + // Find the last user message index + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'user') { + lastUserIdx = i; + break; + } + } + + if (lastUserIdx < 0) { + return [...messages, {role: 'user', content: enhancedPrompt}]; + } + + const updated = [...messages]; + updated[lastUserIdx] = {role: 'user', content: enhancedPrompt}; + return updated; +} diff --git a/src/mastra/workflows/chat/steps/init-session.step.ts b/src/mastra/workflows/chat/steps/init-session.step.ts new file mode 100644 index 0000000..509f576 --- /dev/null +++ b/src/mastra/workflows/chat/steps/init-session.step.ts @@ -0,0 +1,69 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asWorkflowContext} from '../../../bridge/workflow-request-context'; +import { + ChatWorkflowInputSchema, + InitSessionOutputSchema, +} from '../chat-workflow-schemas'; + +const debug = require('debug')('ai-integration:mastra:init-session.step'); + +/** + * InitSessionStep — initialises or resumes a chat session. + * + * LangGraph equivalent: `InitSessionNode` + * + * Responsibilities: + * - Call `chatStore.init()` to create or fetch the session + * - Persist the user's message to the database + * - Emit the `Init` SSE event for new sessions via writer.write() (workflow-native streaming) + * + * Retry: 2 attempts (DB availability issues) + * Error: Throws if chatStore.init() fails after retries + */ +export const initSessionStep = createStep({ + id: 'init-session', + description: + 'Initialise or resume a chat session; persist the user message; emit Init event', + inputSchema: ChatWorkflowInputSchema, + outputSchema: InitSessionOutputSchema, + retries: 2, + execute: async ({inputData, requestContext, writer}) => { + const ctx = asWorkflowContext(requestContext); + const chatStore = ctx.get('chatStore'); + + const {prompt, files, sessionId} = inputData; + const isNewSession = !sessionId; + + debug( + `InitSession: isNew=${isNewSession}, sessionId=${sessionId ?? 'none'}`, + ); + + // Create or resume the session + const chat = await chatStore.init(prompt, sessionId); + + // Emit Init event via writer (workflow-native streaming, not AsyncEventQueue) + if (isNewSession) { + debug(`Emitting Init event for new session ${chat.id}`); + await writer.write({ + type: LLMStreamEventType.Init, + data: {sessionId: chat.id}, + }); + } + + // Persist the human message to the database + const savedUserMessage = await chatStore.addHumanMessageText( + chat.id, + prompt, + ); + + return { + sessionId: chat.id, + isNewSession, + userMessageId: savedUserMessage?.id, + prompt, + files: files as z.infer['files'], + }; + }, +}); diff --git a/src/mastra/workflows/chat/steps/persist-conversation.step.ts b/src/mastra/workflows/chat/steps/persist-conversation.step.ts new file mode 100644 index 0000000..d7ffe52 --- /dev/null +++ b/src/mastra/workflows/chat/steps/persist-conversation.step.ts @@ -0,0 +1,89 @@ +import {createStep} from '@mastra/core/workflows'; +import {asWorkflowContext} from '../../../bridge/workflow-request-context'; +import { + AgentReasoningOutputSchema, + PersistConversationOutputSchema, +} from '../chat-workflow-schemas'; + +const debug = require('debug')( + 'ai-integration:mastra:persist-conversation.step', +); + +/** + * PersistConversationStep — save the AI response and tool results to the database. + * + * LangGraph equivalent: the persistence part of `CallLLMNode` + `RunToolNode`. + * + * Responsibilities: + * - Persist the AI's final text response as an AI-type message + * - For each tool call, persist a Tool-type message linked to the AI message + * - Retrieve per-tool metadata (e.g. report IDs) via IGraphTool.getMetadata() + * + * Tool message metadata enrichment: + * `IGraphTool.getMetadata(rawResult)` returns application-specific metadata + * (e.g. `{ reportId: '...' }`) that gets stored alongside the tool message. + * `IGraphTool.getValue(rawResult)` returns the human-readable content to store. + */ +export const persistConversationStep = createStep({ + id: 'persist-conversation', + description: 'Persist AI response and tool call results to the database', + inputSchema: AgentReasoningOutputSchema, + outputSchema: PersistConversationOutputSchema, + execute: async ({inputData, requestContext}) => { + const ctx = asWorkflowContext(requestContext); + const chatStore = ctx.get('chatStore'); + const toolStore = ctx.get('toolStore'); + + const { + sessionId, + finalText, + toolCalls, + totalInputTokens, + totalOutputTokens, + tokenMap, + } = inputData; + + debug( + `PersistConversation: session=${sessionId}, textLen=${finalText.length}, tools=${toolCalls.length}`, + ); + + // 1. Persist the AI's text response + const aiMessage = await chatStore.addAIMessageText(sessionId, finalText); + + if (!aiMessage) { + debug('PersistConversation: addAIMessageText returned undefined'); + } + + // 2. Persist each tool call as a linked Tool message + for (const toolCall of toolCalls) { + const igraphTool = toolStore?.map?.[toolCall.toolName]; + + const content = + igraphTool?.getValue?.(toolCall.rawResult as Record) ?? + JSON.stringify(toolCall.rawResult); + + const metadata = + igraphTool?.getMetadata?.( + toolCall.rawResult as Record, + ) ?? {}; + + if (aiMessage) { + await chatStore.addToolMessageText( + sessionId, + toolCall.toolCallId, + toolCall.toolName, + content, + metadata, + aiMessage, + toolCall.args, + ); + } + } + + debug( + `PersistConversation: saved AI message (${toolCalls.length} tool messages)`, + ); + + return {sessionId, totalInputTokens, totalOutputTokens, tokenMap}; + }, +}); diff --git a/src/mastra/workflows/chat/steps/prepare-context.step.ts b/src/mastra/workflows/chat/steps/prepare-context.step.ts new file mode 100644 index 0000000..e664c5f --- /dev/null +++ b/src/mastra/workflows/chat/steps/prepare-context.step.ts @@ -0,0 +1,79 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {ContextWindowManager} from '../../../bridge/context-window-manager'; +import {asWorkflowContext} from '../../../bridge/workflow-request-context'; +import { + InitSessionOutputSchema, + PrepareContextOutputSchema, +} from '../chat-workflow-schemas'; + +const debug = require('debug')('ai-integration:mastra:prepare-context.step'); + +/** + * PrepareContextStep — build the conversation history for the agent. + * + * LangGraph equivalent: combines `InitSessionNode`'s message loading and + * `ContextCompressionNode`'s trimming logic. + * + * Responsibilities: + * - Fetch all messages for the session from the database + * - Convert to CoreMessage format (Vercel AI SDK / Mastra-compatible) + * - Trim the history to fit within the context window + * + * Note: The current user message (just saved by InitSessionStep) IS included + * in the history. FileProcessingStep will replace the last user message with + * an enhanced version (prompt + file summaries) if files were uploaded. + */ +export const prepareContextStep = createStep({ + id: 'prepare-context', + description: + 'Load conversation history from the database and trim to context window', + inputSchema: InitSessionOutputSchema, + outputSchema: PrepareContextOutputSchema, + execute: async ({inputData, requestContext}) => { + const ctx = asWorkflowContext(requestContext); + const chatStore = ctx.get('chatStore'); + const aiConfig = ctx.get('aiConfig') as {maxTokens?: number} | undefined; + + const {sessionId, prompt, files, userMessageId} = inputData; + + debug(`PrepareContext: loading history for session=${sessionId}`); + + const rawMessages = await chatStore.getMessages(sessionId); + debug(`PrepareContext: loaded ${rawMessages.length} messages`); + + const coreMessages: z.infer['messages'] = + []; + for (const msg of rawMessages) { + const coreMsg = await chatStore.toCoreMessage(msg); + if (coreMsg) { + coreMessages.push( + coreMsg as z.infer< + typeof PrepareContextOutputSchema + >['messages'][number], + ); + } + } + + const maxTokens = + (aiConfig as {maxTokens?: number} | undefined)?.maxTokens ?? + ContextWindowManager.DEFAULT_MAX_TOKENS; + const trimmedMessages = ContextWindowManager.trim(coreMessages, maxTokens); + + debug( + `PrepareContext: ${coreMessages.length} → ${trimmedMessages.length} messages after trim`, + ); + + return { + sessionId, + messages: trimmedMessages as z.infer< + typeof PrepareContextOutputSchema + >['messages'], + userMessageId, + prompt, + files: (files ?? []) as z.infer< + typeof PrepareContextOutputSchema + >['files'], + }; + }, +}); diff --git a/src/services/generation.service.ts b/src/services/generation.service.ts index 7d5f1c4..bf1ad25 100644 --- a/src/services/generation.service.ts +++ b/src/services/generation.service.ts @@ -1,19 +1,20 @@ import {BindingScope, inject, injectable, service} from '@loopback/core'; -import {ChatGraph} from '../graphs/chat/chat.graph'; import {AiIntegrationBindings} from '../keys'; import {ITransport} from '../transports/types'; import {ILimitStrategy} from './limit-strategies/types'; +import {WorkflowRunner} from '../mastra/bridge/workflow-runner'; @injectable({scope: BindingScope.REQUEST}) export class GenerationService { constructor( - @service(ChatGraph) - private readonly chatGraph: ChatGraph, + @service(WorkflowRunner) + private readonly workflowRunner: WorkflowRunner, @inject(AiIntegrationBindings.Transport) private readonly transport: ITransport, @inject(AiIntegrationBindings.LimitStrategy, {optional: true}) private readonly limiter?: ILimitStrategy, ) {} + async generate(prompt: string, files: Express.Multer.File[], id?: string) { await this.limiter?.check(); const abortController = new AbortController(); @@ -21,16 +22,15 @@ export class GenerationService { this.transport.onCancel(() => { abortController.abort(); }); - const stream = await this.chatGraph.execute( - prompt, - files, - abortController.signal, - id, - ); try { - for await (const chunk of stream) { - await this.transport.send(chunk); + for await (const event of this.workflowRunner.executeChatWorkflow( + prompt, + files, + abortController, + id, + )) { + await this.transport.send(event); } await this.transport.end(); } catch (error) { From 6c86e461a36feedb3f125c832907b4a91a6abebb Mon Sep 17 00:00:00 2001 From: Piyush Singh Gaur Date: Mon, 18 May 2026 14:04:06 +0530 Subject: [PATCH 2/6] feat(mastra): migrate DBQuery workflow from LangGraph to Mastra-native orchestration --- src/component.ts | 3 +- src/components/db-query/keys.ts | 15 +- src/keys.ts | 31 ++ src/mastra/agents/chat-reasoning.agent.ts | 129 +----- src/mastra/bridge/async-event-queue.ts | 6 +- src/mastra/bridge/workflow-request-context.ts | 6 +- src/mastra/bridge/workflow-runner.ts | 246 ++++++++++- src/mastra/index.ts | 10 +- src/mastra/types.ts | 26 +- .../workflows/chat/chat-workflow-schemas.ts | 36 +- src/mastra/workflows/chat/chat.workflow.ts | 2 +- .../chat/steps/agent-reasoning.step.ts | 46 +- .../chat/steps/file-processing.step.ts | 65 ++- .../chat/steps/persist-conversation.step.ts | 27 +- .../db-query/contracts/branch.contract.ts | 20 + .../contracts/step-outputs.contract.ts | 142 +++++++ .../db-query/db-query-request-context.ts | 87 ++++ .../db-query/db-query-workflow-schemas.ts | 143 +++++++ .../workflows/db-query/db-query.workflow.ts | 168 ++++++++ src/mastra/workflows/db-query/index.ts | 27 ++ src/mastra/workflows/db-query/llm-helpers.ts | 45 ++ .../db-query/steps/cache-check.step.ts | 263 ++++++++++++ .../steps/change-classification.step.ts | 84 ++++ .../db-query/steps/column-selection.step.ts | 368 ++++++++++++++++ .../steps/dataset-persistence.step.ts | 133 ++++++ .../db-query/steps/dataset-resolution.step.ts | 42 ++ .../steps/description-generation.step.ts | 96 +++++ .../db-query/steps/discovery-routing.step.ts | 77 ++++ .../workflows/db-query/steps/failure.step.ts | 32 ++ .../db-query/steps/generate-checklist.step.ts | 174 ++++++++ src/mastra/workflows/db-query/steps/index.ts | 23 + .../db-query/steps/query-repair.step.ts | 190 +++++++++ .../steps/semantic-validation.step.ts | 218 ++++++++++ .../db-query/steps/sql-generation.step.ts | 221 ++++++++++ .../steps/syntactic-validation.step.ts | 101 +++++ .../db-query/steps/table-selection.step.ts | 283 +++++++++++++ .../db-query/steps/template-match.step.ts | 174 ++++++++ .../db-query/steps/validation-cycle.step.ts | 392 ++++++++++++++++++ .../db-query/steps/validation-merge.step.ts | 173 ++++++++ .../db-query/steps/verify-checklist.step.ts | 213 ++++++++++ .../db-query/tools/ask-about-dataset.tool.ts | 129 ++++++ .../tools/get-data-as-dataset.tool.ts | 209 ++++++++++ .../db-query/tools/improve-dataset.tool.ts | 213 ++++++++++ src/mastra/workflows/db-query/tools/index.ts | 15 + .../db-query/workflows/discovery.workflow.ts | 83 ++++ .../workflows/full-generation.workflow.ts | 224 ++++++++++ src/providers/index.ts | 1 + src/providers/mastra-tools.provider.ts | 223 ++++++++++ src/types.ts | 25 ++ 49 files changed, 5460 insertions(+), 199 deletions(-) create mode 100644 src/mastra/workflows/db-query/contracts/branch.contract.ts create mode 100644 src/mastra/workflows/db-query/contracts/step-outputs.contract.ts create mode 100644 src/mastra/workflows/db-query/db-query-request-context.ts create mode 100644 src/mastra/workflows/db-query/db-query-workflow-schemas.ts create mode 100644 src/mastra/workflows/db-query/db-query.workflow.ts create mode 100644 src/mastra/workflows/db-query/index.ts create mode 100644 src/mastra/workflows/db-query/llm-helpers.ts create mode 100644 src/mastra/workflows/db-query/steps/cache-check.step.ts create mode 100644 src/mastra/workflows/db-query/steps/change-classification.step.ts create mode 100644 src/mastra/workflows/db-query/steps/column-selection.step.ts create mode 100644 src/mastra/workflows/db-query/steps/dataset-persistence.step.ts create mode 100644 src/mastra/workflows/db-query/steps/dataset-resolution.step.ts create mode 100644 src/mastra/workflows/db-query/steps/description-generation.step.ts create mode 100644 src/mastra/workflows/db-query/steps/discovery-routing.step.ts create mode 100644 src/mastra/workflows/db-query/steps/failure.step.ts create mode 100644 src/mastra/workflows/db-query/steps/generate-checklist.step.ts create mode 100644 src/mastra/workflows/db-query/steps/index.ts create mode 100644 src/mastra/workflows/db-query/steps/query-repair.step.ts create mode 100644 src/mastra/workflows/db-query/steps/semantic-validation.step.ts create mode 100644 src/mastra/workflows/db-query/steps/sql-generation.step.ts create mode 100644 src/mastra/workflows/db-query/steps/syntactic-validation.step.ts create mode 100644 src/mastra/workflows/db-query/steps/table-selection.step.ts create mode 100644 src/mastra/workflows/db-query/steps/template-match.step.ts create mode 100644 src/mastra/workflows/db-query/steps/validation-cycle.step.ts create mode 100644 src/mastra/workflows/db-query/steps/validation-merge.step.ts create mode 100644 src/mastra/workflows/db-query/steps/verify-checklist.step.ts create mode 100644 src/mastra/workflows/db-query/tools/ask-about-dataset.tool.ts create mode 100644 src/mastra/workflows/db-query/tools/get-data-as-dataset.tool.ts create mode 100644 src/mastra/workflows/db-query/tools/improve-dataset.tool.ts create mode 100644 src/mastra/workflows/db-query/tools/index.ts create mode 100644 src/mastra/workflows/db-query/workflows/discovery.workflow.ts create mode 100644 src/mastra/workflows/db-query/workflows/full-generation.workflow.ts create mode 100644 src/providers/mastra-tools.provider.ts diff --git a/src/component.ts b/src/component.ts index 469d7c1..d6e702f 100644 --- a/src/component.ts +++ b/src/component.ts @@ -44,7 +44,7 @@ import { } from './graphs/chat'; import {WriterDB, AiIntegrationBindings, ReaderDB} from './keys'; import {Chat, Message} from './models'; -import {CacheModel, ToolsProvider} from './providers'; +import {CacheModel, MastraToolsProvider, ToolsProvider} from './providers'; import {RedisCache, RedisCacheRepository} from './providers/cache/redis'; import {ChatRepository, MessageRepository} from './repositories'; import { @@ -82,6 +82,7 @@ export class AiIntegrationsComponent implements Component { this.providers = { [AiIntegrationBindings.VectorStore.key]: PgVectorStore, [AiIntegrationBindings.Tools.key]: ToolsProvider, + [AiIntegrationBindings.MastraTools.key]: MastraToolsProvider, }; this.services = [ diff --git a/src/components/db-query/keys.ts b/src/components/db-query/keys.ts index b55fd09..3354fa6 100644 --- a/src/components/db-query/keys.ts +++ b/src/components/db-query/keys.ts @@ -1,10 +1,13 @@ import {BindingKey} from '@loopback/context'; +import {BaseRetriever} from '@langchain/core/retrievers'; import { DatasetServiceConfig, DbQueryConfig, IDataSetStore, IDbConnector, IQueryTemplateStore, + QueryCacheMetadata, + QueryTemplateMetadata, } from './types'; import {AnyObject} from '@loopback/repository'; @@ -21,9 +24,9 @@ export namespace DbQueryAIExtensionBindings { `services.ai-integration.db-query.config`, ); - export const QueryCache = BindingKey.create( - 'services.ai-integration.db-query.query-cache', - ); + export const QueryCache = BindingKey.create< + BaseRetriever + >('services.ai-integration.db-query.query-cache'); export const Connector = BindingKey.create( 'services.ai-integration.db-query.connector', @@ -37,9 +40,9 @@ export namespace DbQueryAIExtensionBindings { 'services.ai-integration.db-query.default-conditions', ); - export const TemplateCache = BindingKey.create( - 'services.ai-integration.db-query.template-cache', - ); + export const TemplateCache = BindingKey.create< + BaseRetriever + >('services.ai-integration.db-query.template-cache'); export const TemplateStore = BindingKey.create( 'services.ai-integration.db-query.template-store', diff --git a/src/keys.ts b/src/keys.ts index de94bb6..8290687 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -8,6 +8,7 @@ import { EmbeddingProvider, ICache, LLMProvider, + MastraToolStore, ToolStore, } from './types'; import {ILimitStrategy} from './services/limit-strategies/types'; @@ -40,6 +41,9 @@ export namespace AiIntegrationBindings { export const Tools = BindingKey.create( 'services.ai-reporting.tool-store', ); + export const MastraTools = BindingKey.create( + 'services.ai-reporting.mastra-tool-store', + ); export const Transport = BindingKey.create( 'services.ai-reporting.transport', ); @@ -78,6 +82,33 @@ export namespace AiIntegrationBindings { export const MastraFileLLM = BindingKey.create( 'services.ai-reporting.mastraFileLLMProvider', ); + + // ── Mastra DBQuery LLM bindings (Phase 2 migration) ────────────────────── + + /** + * Mastra-compatible cheap/fast LLM for DBQuery workflow. + * Used for table selection, column selection, classification, etc. + */ + export const MastraCheapLLM = BindingKey.create( + 'services.ai-reporting.mastraCheapLLMProvider', + ); + + /** + * Mastra-compatible smart/powerful LLM for DBQuery workflow. + * Used for SQL generation and complex validation. + */ + export const MastraSmartLLM = BindingKey.create( + 'services.ai-reporting.mastraSmartLLMProvider', + ); + + /** + * Mastra-compatible smart non-thinking LLM (optional). + * Used for checklist verification. Falls back to MastraSmartLLM. + */ + export const MastraSmartNonThinkingLLM = + BindingKey.create( + 'services.ai-reporting.mastraSmartNonThinkingLLMProvider', + ); } export const WriterDB = 'writerdb'; export const ReaderDB = 'readerdb'; diff --git a/src/mastra/agents/chat-reasoning.agent.ts b/src/mastra/agents/chat-reasoning.agent.ts index e2a0599..bea500f 100644 --- a/src/mastra/agents/chat-reasoning.agent.ts +++ b/src/mastra/agents/chat-reasoning.agent.ts @@ -2,114 +2,11 @@ import {Agent} from '@mastra/core/agent'; import {RequestContext} from '@mastra/core/request-context'; import type {MastraLanguageModel} from '@mastra/core/agent'; import type {MastraModelConfig} from '@mastra/core/llm'; -import {createTool} from '@mastra/core/tools'; -import {z} from 'zod'; -import {AnyObject} from '@loopback/repository'; -import {IGraphTool, ToolStatus} from '../../graphs/types'; -import {LLMStreamEvent, LLMStreamEventType} from '../../graphs/event.types'; +import {LLMStreamEventType} from '../../graphs/event.types'; import type {AsyncEventQueue} from '../bridge/async-event-queue'; -import type {ToolStore} from '../../types'; +import type {JsonObject, MastraToolStore} from '../../types'; import {asWorkflowContext} from '../bridge/workflow-request-context'; -const debug = require('debug')('ai-integration:mastra:chat-agent'); - -/** - * Typed interface for the LangChain tool extracted via igraphTool.build(). - * Only the fields we need are declared. - */ -interface LangChainToolLike { - schema?: z.ZodTypeAny; - description?: string; - invoke( - input: Record, - config?: Record, - ): Promise; -} - -/** - * Build a Mastra-compatible tool from an IGraphTool instance. - * - * The adapter: - * 1. Builds the LangChain tool once (to extract the Zod schema and description). - * 2. Returns a Mastra createTool() with the same schema. - * 3. At execution time, re-builds with a config that routes config.writer → AsyncEventQueue. - */ -async function buildMastraToolFromIGraphTool( - igraphTool: IGraphTool, -): Promise> { - // Build once with a minimal config to extract the schema and description. - // IGraphTool.build() is typed to accept LangGraphRunnableConfig — we pass the - // minimum required shape. - const lcTool = (await igraphTool.build({ - configurable: {}, - } as Parameters[0])) as unknown as LangChainToolLike; - - const toolSchema: z.ZodTypeAny = lcTool.schema ?? z.record(z.unknown()); - const toolDescription: string = lcTool.description ?? igraphTool.key; - - return createTool({ - id: igraphTool.key, - description: toolDescription, - inputSchema: toolSchema, - execute: async (inputData, context) => { - // Retrieve the event queue from RequestContext (typed access via asWorkflowContext). - const eventQueue: AsyncEventQueue | undefined = context?.requestContext - ? asWorkflowContext(context.requestContext as RequestContext).get( - 'eventQueue', - ) - : undefined; - - debug(`Executing tool: ${igraphTool.key}`, inputData); - - // Build a LangGraph-compatible config so tool sub-graphs can emit SSE events - // via config.writer — those events are routed into the AsyncEventQueue. - const lgConfig: Parameters[0] = { - configurable: {}, - writer: (event: LLMStreamEvent) => { - eventQueue?.push(event); - }, - } as unknown as Parameters[0]; - - // Re-build with writer so sub-graphs can emit events during tool execution. - const freshLcTool = (await igraphTool.build( - lgConfig, - )) as unknown as LangChainToolLike; - - const result = await freshLcTool.invoke( - inputData as Record, - lgConfig as Record, - ); - - return result as AnyObject; - }, - }); -} - -/** - * Build the Mastra tools map for the ChatReasoningAgent. - * Called once per request from AgentReasoningStep. - */ -export async function buildChatAgentTools( - toolStore: ToolStore, -): Promise>> { - const toolsMap: Record> = {}; - - for (const igraphTool of toolStore.list) { - if (igraphTool.needsReview === true) { - debug(`Skipping tool ${igraphTool.key}: requires user review`); - continue; - } - try { - toolsMap[igraphTool.key] = - await buildMastraToolFromIGraphTool(igraphTool); - } catch (err) { - debug(`Failed to build Mastra tool wrapper for ${igraphTool.key}:`, err); - } - } - - return toolsMap; -} - /** * ChatReasoningAgent — the Mastra Agent that drives the multi-turn tool-calling loop. * @@ -118,7 +15,7 @@ export async function buildChatAgentTools( * * Architecture: * - model: resolved from RequestContext at each call (supports per-request model injection) - * - tools: built from the ToolStore in RequestContext (adapts IGraphTool → Mastra tools) + * - tools: resolved from the MastraToolStore in RequestContext (native createTool definitions) * - maxSteps: 20 (equivalent to recursionLimit: 60 / 3 nodes per cycle) * * RequestContext access uses `asWorkflowContext()` for fully typed, zero-any access. @@ -160,11 +57,11 @@ export const chatReasoningAgent = new Agent({ }, tools: async ({requestContext}: {requestContext: RequestContext}) => { const ctx = asWorkflowContext(requestContext); - const toolStore: ToolStore = ctx.get('toolStore'); - if (!toolStore?.list?.length) { + const mastraTools: MastraToolStore = ctx.get('mastraTools'); + if (!mastraTools?.list?.length) { return {}; } - return buildChatAgentTools(toolStore); + return mastraTools.tools; }, }); @@ -176,7 +73,7 @@ export function emitToolStartEvent( eventQueue: AsyncEventQueue, toolCallId: string, toolName: string, - args: AnyObject, + args: JsonObject, ): void { eventQueue.push({ type: LLMStreamEventType.Tool, @@ -195,16 +92,14 @@ export function emitToolStartEvent( export function emitToolStatusEvent( eventQueue: AsyncEventQueue, toolCallId: string, - toolStore: ToolStore, + toolStore: MastraToolStore, toolName: string, - result: AnyObject, + result: JsonObject, ): void { - const igraphTool = toolStore.map[toolName]; - const metadata = - igraphTool?.getMetadata?.(result as Record) ?? {}; + const toolDefinition = toolStore.map[toolName]; + const metadata = toolDefinition?.getMetadata?.(result) ?? {}; const status = - (metadata['status'] as string) ?? - (result ? ToolStatus.Completed : ToolStatus.Failed); + typeof metadata['status'] === 'string' ? metadata['status'] : 'completed'; eventQueue.push({ type: LLMStreamEventType.ToolStatus, diff --git a/src/mastra/bridge/async-event-queue.ts b/src/mastra/bridge/async-event-queue.ts index 97b261d..0eefd6b 100644 --- a/src/mastra/bridge/async-event-queue.ts +++ b/src/mastra/bridge/async-event-queue.ts @@ -49,9 +49,9 @@ export class AsyncEventQueue implements AsyncIterable { * Async iterator — yields events as they arrive. * Suspends (awaits) when the queue is empty and not yet closed. */ - [Symbol.asyncIterator](): AsyncIterator { + [Symbol.asyncIterator](): AsyncIterator { return { - next: async (): Promise> => { + next: async (): Promise> => { // Spin until an event is available or the queue is closed // eslint-disable-next-line no-constant-condition while (true) { @@ -59,7 +59,7 @@ export class AsyncEventQueue implements AsyncIterable { return {value: this._queue.shift()!, done: false}; } if (this._closed) { - return {value: undefined as unknown as LLMStreamEvent, done: true}; + return {value: undefined, done: true}; } // Wait for next push or close await new Promise(resolve => { diff --git a/src/mastra/bridge/workflow-request-context.ts b/src/mastra/bridge/workflow-request-context.ts index 9e95e2b..832b484 100644 --- a/src/mastra/bridge/workflow-request-context.ts +++ b/src/mastra/bridge/workflow-request-context.ts @@ -2,7 +2,7 @@ import type {RequestContext} from '@mastra/core/request-context'; import type {MastraLanguageModel} from '@mastra/core/agent'; import type {IAuthUserWithPermissions} from '@sourceloop/core'; import type {ChatStore} from '../../graphs/chat/chat.store'; -import type {AIIntegrationConfig, ToolStore} from '../../types'; +import type {AIIntegrationConfig, MastraToolStore} from '../../types'; import type {AsyncEventQueue} from './async-event-queue'; import type {TokenUsageAccumulator} from './token-usage-accumulator'; @@ -22,8 +22,8 @@ export interface WorkflowRequestContext { mastraFileLlm: MastraLanguageModel; /** Chat session store — request-scoped */ chatStore: ChatStore; - /** Tool registry for the chat Agent */ - toolStore: ToolStore; + /** Mastra-native tool registry for the chat Agent */ + mastraTools: MastraToolStore; /** AI integration config (optional — may be undefined if not bound) */ aiConfig: AIIntegrationConfig | Record; /** System context strings to prepend to the system prompt */ diff --git a/src/mastra/bridge/workflow-runner.ts b/src/mastra/bridge/workflow-runner.ts index 5bfe019..8b54f6b 100644 --- a/src/mastra/bridge/workflow-runner.ts +++ b/src/mastra/bridge/workflow-runner.ts @@ -9,15 +9,36 @@ import {repository} from '@loopback/repository'; import {IAuthUserWithPermissions} from '@sourceloop/core'; import {AuthenticationBindings} from 'loopback4-authentication'; import {RequestContext} from '@mastra/core/request-context'; +import {BaseRetriever} from '@langchain/core/retrievers'; import {ChatStore} from '../../graphs/chat/chat.store'; import {LLMStreamEvent, LLMStreamEventType} from '../../graphs/event.types'; import {AiIntegrationBindings} from '../../keys'; import {ChatRepository} from '../../repositories'; -import {AIIntegrationConfig, ToolStore} from '../../types'; +import {AIIntegrationConfig, MastraToolStore} from '../../types'; import {chatWorkflow} from '../workflows/chat/chat.workflow'; +import {dbQueryWorkflow} from '../workflows/db-query/db-query.workflow'; import {AsyncEventQueue} from './async-event-queue'; import {TokenUsageAccumulator} from './token-usage-accumulator'; import type {MastraLanguageModel} from '@mastra/core/agent'; +import {DbQueryAIExtensionBindings} from '../../components/db-query/keys'; +import type { + DatabaseSchema, + DbQueryConfig, + IDataSetStore, + IDbConnector, + QueryCacheMetadata, + QueryTemplateMetadata, +} from '../../components/db-query/types'; +import {DbSchemaHelperService} from '../../components/db-query/services/db-schema-helper.service'; +import {PermissionHelper} from '../../components/db-query/services/permission-helper.service'; +import {TableSearchService} from '../../components/db-query/services/search/table-search.service'; +import {TemplateHelper} from '../../components/db-query/services/template-helper.service'; +import {DataSetHelper} from '../../components/db-query/services/dataset-helper.service'; +import {SchemaStore} from '../../components/db-query/services/schema.store'; +import type { + CacheDocument, + TemplateDocument, +} from '../workflows/db-query/db-query-request-context'; const debug = require('debug')('ai-integration:mastra:workflow-runner'); @@ -61,8 +82,8 @@ export class WorkflowRunner { private readonly mastraChatLlm: MastraLanguageModel, @inject(AiIntegrationBindings.MastraFileLLM, {optional: true}) private readonly mastraFileLlm: MastraLanguageModel | undefined, - @inject(AiIntegrationBindings.Tools) - private readonly toolStore: ToolStore, + @inject(AiIntegrationBindings.MastraTools) + private readonly mastraTools: MastraToolStore, @inject(AiIntegrationBindings.Config, {optional: true}) private readonly aiConfig: AIIntegrationConfig | undefined, @inject(AiIntegrationBindings.SystemContext, {optional: true}) @@ -71,6 +92,41 @@ export class WorkflowRunner { private readonly getCurrentUser: Getter, @repository(ChatRepository) private readonly chatRepository: ChatRepository, + // ── DBQuery bindings (optional — only present when DB Query component is loaded) + @inject(AiIntegrationBindings.MastraCheapLLM, {optional: true}) + private readonly mastraCheapLlm: MastraLanguageModel | undefined, + @inject(AiIntegrationBindings.MastraSmartLLM, {optional: true}) + private readonly mastraSmartLlm: MastraLanguageModel | undefined, + @inject(AiIntegrationBindings.MastraSmartNonThinkingLLM, {optional: true}) + private readonly mastraSmartNonThinkingLlm: MastraLanguageModel | undefined, + @inject(DbQueryAIExtensionBindings.Config, {optional: true}) + private readonly dbQueryConfig: DbQueryConfig | undefined, + @inject(DbQueryAIExtensionBindings.DatasetStore, {optional: true}) + private readonly datasetStore: IDataSetStore | undefined, + @inject(DbQueryAIExtensionBindings.Connector, {optional: true}) + private readonly connector: IDbConnector | undefined, + @inject(DbQueryAIExtensionBindings.GlobalContext, {optional: true}) + private readonly dbGlobalContext: string[] | undefined, + @service(SchemaStore, {optional: true}) + private readonly schemaStore: SchemaStore | undefined, + @service(DbSchemaHelperService, {optional: true}) + private readonly schemaHelper: DbSchemaHelperService | undefined, + @service(PermissionHelper, {optional: true}) + private readonly permissionHelper: PermissionHelper | undefined, + @service(TableSearchService, {optional: true}) + private readonly tableSearchService: TableSearchService | undefined, + @service(TemplateHelper, {optional: true}) + private readonly templateHelper: TemplateHelper | undefined, + @service(DataSetHelper, {optional: true}) + private readonly datasetHelper: DataSetHelper | undefined, + @inject(DbQueryAIExtensionBindings.QueryCache, {optional: true}) + private readonly queryCacheRetriever: + | BaseRetriever + | undefined, + @inject(DbQueryAIExtensionBindings.TemplateCache, {optional: true}) + private readonly templateCacheRetriever: + | BaseRetriever + | undefined, ) {} /** @@ -87,6 +143,7 @@ export class WorkflowRunner { ): AsyncGenerator { const eventQueue = new AsyncEventQueue(); const tokenAccumulator = new TokenUsageAccumulator(); + const currentUser = await this.resolveOptionalCurrentUser(); const requestContext = new RequestContext(); @@ -98,10 +155,21 @@ export class WorkflowRunner { this.mastraFileLlm ?? this.mastraChatLlm, ); requestContext.set('chatStore', this.chatStore); - requestContext.set('toolStore', this.toolStore); + requestContext.set('mastraTools', this.mastraTools); requestContext.set('aiConfig', this.aiConfig ?? {}); requestContext.set('systemContext', this.systemContext); requestContext.set('tokenUsageAccumulator', tokenAccumulator); + requestContext.set('currentUser', currentUser); + + const chatDbQuerySchema = this.resolveDbQueryChatSchema(); + if (chatDbQuerySchema) { + this.bindDbQueryContext(requestContext, { + schema: chatDbQuerySchema, + abortSignal: abortController.signal, + currentUser, + directCall: false, + }); + } const run = await chatWorkflow.createRun(); @@ -118,6 +186,176 @@ export class WorkflowRunner { yield* this._mergeStreams(workflowStream, eventQueue, abortController); } + /** + * Execute the DBQueryWorkflow and yield LLMStreamEvents as they are produced. + * + * Callers invoke this for database query generation. The workflow is entirely + * deterministic (no Agent involved — only LLM calls via step handlers). + */ + async *executeDbQueryWorkflow( + prompt: string, + schema: DatabaseSchema, + abortController: AbortController, + options?: {datasetId?: string; directCall?: boolean}, + ): AsyncGenerator { + if ( + !this.mastraCheapLlm || + !this.mastraSmartLlm || + !this.dbQueryConfig || + !this.datasetStore || + !this.connector || + !this.schemaStore || + !this.schemaHelper || + !this.tableSearchService || + !this.templateHelper || + !this.datasetHelper || + !this.queryCacheRetriever || + !this.templateCacheRetriever + ) { + throw new Error( + 'DBQuery workflow requires DB Query component bindings. ' + + 'Ensure MastraCheapLLM, MastraSmartLLM, cache retrievers, and all DB Query services are bound.', + ); + } + + const currentUser = await this.getCurrentUser(); + + const requestContext = new RequestContext(); + this.bindDbQueryContext(requestContext, { + schema, + abortSignal: abortController.signal, + currentUser, + directCall: options?.directCall ?? false, + }); + + const run = await dbQueryWorkflow.createRun(); + + const workflowStream = run.stream({ + inputData: { + prompt, + schema, + datasetId: options?.datasetId, + directCall: options?.directCall, + }, + requestContext, + }); + + // DBQuery doesn't use AsyncEventQueue (no Agent/tool callbacks) + // but we still use _mergeStreams for consistency with the abort logic + const emptyQueue = new AsyncEventQueue(); + emptyQueue.close(); // immediately close since no events will come from it + + yield* this._mergeStreams(workflowStream, emptyQueue, abortController); + } + + private async resolveOptionalCurrentUser(): Promise< + IAuthUserWithPermissions | undefined + > { + try { + return await this.getCurrentUser(); + } catch { + return undefined; + } + } + + private resolveDbQueryChatSchema(): DatabaseSchema | undefined { + if (!this.mastraCheapLlm) { + return undefined; + } + if (!this.schemaStore) { + throw new Error( + 'SchemaStore is required for DBQuery tool execution in ChatWorkflow.', + ); + } + return this.schemaStore.get(); + } + + private bindDbQueryContext( + requestContext: RequestContext, + params: { + schema: DatabaseSchema; + abortSignal: AbortSignal; + currentUser: IAuthUserWithPermissions | undefined; + directCall: boolean; + }, + ): void { + if ( + !this.mastraCheapLlm || + !this.mastraSmartLlm || + !this.dbQueryConfig || + !this.datasetStore || + !this.connector || + !this.schemaStore || + !this.schemaHelper || + !this.tableSearchService || + !this.templateHelper || + !this.datasetHelper || + !this.queryCacheRetriever || + !this.templateCacheRetriever + ) { + throw new Error( + 'DBQuery context binding requires all DBQuery dependencies and retrievers to be configured.', + ); + } + + const queryCacheRetriever = this.queryCacheRetriever; + const templateCacheRetriever = this.templateCacheRetriever; + + requestContext.set('cheapLlm', this.mastraCheapLlm); + requestContext.set('smartLlm', this.mastraSmartLlm); + requestContext.set('smartNonThinkingLlm', this.mastraSmartNonThinkingLlm); + requestContext.set('dbQueryConfig', this.dbQueryConfig); + requestContext.set('datasetStore', this.datasetStore); + requestContext.set('connector', this.connector); + requestContext.set('schemaStore', this.schemaStore); + requestContext.set('schemaHelper', this.schemaHelper); + requestContext.set('permissionHelper', this.permissionHelper); + requestContext.set('tableSearchService', this.tableSearchService); + requestContext.set('templateHelper', this.templateHelper); + requestContext.set('datasetHelper', this.datasetHelper); + requestContext.set('globalContext', this.dbGlobalContext ?? []); + requestContext.set('abortSignal', params.abortSignal); + requestContext.set('currentUser', params.currentUser); + requestContext.set('fullSchema', params.schema); + requestContext.set('directCall', params.directCall); + requestContext.set('queryCache', { + invoke: async (query: string): Promise => { + const docs = await queryCacheRetriever.invoke(query); + return docs + .map(doc => ({ + pageContent: doc.pageContent, + metadata: { + datasetId: doc.metadata.datasetId, + query: doc.metadata.query, + description: doc.metadata.description, + votes: doc.metadata.votes, + }, + })) + .filter(doc => !!doc.metadata.datasetId && !!doc.metadata.query); + }, + }); + requestContext.set('templateCache', { + invoke: async (query: string): Promise => { + const docs = await templateCacheRetriever.invoke(query); + return docs + .map(doc => ({ + pageContent: doc.pageContent, + metadata: { + templateId: doc.metadata.templateId, + template: doc.metadata.template, + type: doc.metadata.type, + description: doc.metadata.description, + votes: doc.metadata.votes, + placeholders: doc.metadata.placeholders, + tables: doc.metadata.tables, + schemaHash: doc.metadata.schemaHash, + }, + })) + .filter(doc => !!doc.metadata.templateId && !!doc.metadata.template); + }, + }); + } + /** * Merge the Mastra workflow stream and the AsyncEventQueue into a single * LLMStreamEvent generator using Promise.race() for fair interleaving. diff --git a/src/mastra/index.ts b/src/mastra/index.ts index d5d7a75..26c1a54 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -2,7 +2,7 @@ * Mastra migration layer barrel export. * * Phase 1: Foundation Layer + ChatWorkflow - * Phase 2 (future): DBQueryWorkflow + * Phase 2: DBQueryWorkflow * Phase 3 (future): VisualizationWorkflow */ @@ -23,5 +23,13 @@ export type { // Chat workflow export {chatWorkflow} from './workflows/chat/chat.workflow'; +// DB Query workflow +export {dbQueryWorkflow} from './workflows/db-query/db-query.workflow'; +export { + getDataAsDatasetTool, + improveDatasetTool, + askAboutDatasetTool, +} from './workflows/db-query/tools'; + // Agent export {chatReasoningAgent} from './agents/chat-reasoning.agent'; diff --git a/src/mastra/types.ts b/src/mastra/types.ts index 2f6649a..ef86d4b 100644 --- a/src/mastra/types.ts +++ b/src/mastra/types.ts @@ -1,11 +1,9 @@ -import {AnyObject} from '@loopback/repository'; import {z} from 'zod'; import {LLMStreamEvent} from '../graphs/event.types'; import type {AsyncEventQueue} from './bridge/async-event-queue'; import type {TokenUsageAccumulator} from './bridge/token-usage-accumulator'; import type {ChatStore} from '../graphs/chat/chat.store'; -import type {ToolStore} from '../types'; -import type {AIIntegrationConfig} from '../types'; +import type {AIIntegrationConfig, JsonObject, MastraToolStore} from '../types'; import type {MastraLanguageModel} from '@mastra/core/agent'; /** @@ -23,8 +21,8 @@ export type ChatWorkflowRequestContext = { mastraFileLlm: MastraLanguageModel; /** Per-request chat data store */ chatStore: ChatStore; - /** Available tools for the agent */ - toolStore: ToolStore; + /** Available Mastra-native tools for the agent */ + mastraTools: MastraToolStore; /** AI integration configuration */ aiConfig: AIIntegrationConfig; /** Optional system context additions */ @@ -43,20 +41,22 @@ export interface IMastraTool { /** Human-readable description for the LLM */ description: string; /** Zod schema for the tool's input */ - inputSchema: z.ZodTypeAny; + inputSchema: z.ZodType; /** * Execute the tool. * @param args - Input data validated against inputSchema * @param requestContext - RequestContext for accessing services */ execute( - args: AnyObject, - requestContext: {get: (key: string) => unknown}, - ): Promise; + args: JsonObject, + requestContext: { + get: (key: string) => string | number | boolean | object | undefined; + }, + ): Promise; /** Extract the human-readable value from the raw result */ - getValue?(result: AnyObject): string; + getValue?(result: JsonObject): string; /** Extract metadata for DB persistence */ - getMetadata?(result: AnyObject): AnyObject; + getMetadata?(result: JsonObject): JsonObject; /** Whether this tool requires human review before execution */ needsReview?: boolean; } @@ -86,9 +86,9 @@ export type ToolCallRecord = { /** Tool name */ toolName: string; /** Arguments passed to the tool */ - args: AnyObject; + args: JsonObject; /** Raw result returned by the tool */ - rawResult: AnyObject; + rawResult: JsonObject; }; /** diff --git a/src/mastra/workflows/chat/chat-workflow-schemas.ts b/src/mastra/workflows/chat/chat-workflow-schemas.ts index 4a41d72..430d1b2 100644 --- a/src/mastra/workflows/chat/chat-workflow-schemas.ts +++ b/src/mastra/workflows/chat/chat-workflow-schemas.ts @@ -1,4 +1,16 @@ import {z} from 'zod'; +import type {JsonValue} from '../../../types'; + +const JsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(JsonValueSchema), + z.record(JsonValueSchema), + ]), +); /** * Input schema for the ChatWorkflow. @@ -18,7 +30,7 @@ export const ChatWorkflowInputSchema = z.object({ encoding: z.string().optional(), // Allow arbitrary additional fields from Multer }) - .catchall(z.unknown()), + .passthrough(), ) .default([]) .describe('Uploaded files to process'), @@ -50,7 +62,7 @@ export const InitSessionOutputSchema = z.object({ isNewSession: z.boolean(), userMessageId: z.string().optional(), prompt: z.string(), - files: z.array(z.object({}).catchall(z.unknown())).default([]), + files: z.array(z.object({}).passthrough()).default([]), }); export type InitSessionOutput = z.infer; @@ -64,17 +76,14 @@ export const PrepareContextOutputSchema = z.object({ z .object({ role: z.string(), - content: z.union([ - z.string(), - z.array(z.object({}).catchall(z.unknown())), - ]), + content: z.union([z.string(), z.array(z.object({}).passthrough())]), }) - .catchall(z.unknown()), + .passthrough(), ) .describe('Full conversation context (CoreMessage[])'), userMessageId: z.string().optional(), prompt: z.string(), - files: z.array(z.object({}).catchall(z.unknown())).default([]), + files: z.array(z.object({}).passthrough()).default([]), }); export type PrepareContextOutput = z.infer; @@ -88,12 +97,9 @@ export const FileProcessingOutputSchema = z.object({ z .object({ role: z.string(), - content: z.union([ - z.string(), - z.array(z.object({}).catchall(z.unknown())), - ]), + content: z.union([z.string(), z.array(z.object({}).passthrough())]), }) - .catchall(z.unknown()), + .passthrough(), ) .describe('Updated context after file processing'), userMessageId: z.string().optional(), @@ -112,8 +118,8 @@ export const AgentReasoningOutputSchema = z.object({ z.object({ toolCallId: z.string(), toolName: z.string(), - args: z.record(z.unknown()), - rawResult: z.unknown(), + args: z.record(JsonValueSchema), + rawResult: JsonValueSchema, }), ) .default([]), diff --git a/src/mastra/workflows/chat/chat.workflow.ts b/src/mastra/workflows/chat/chat.workflow.ts index 75ac7a0..7ac5941 100644 --- a/src/mastra/workflows/chat/chat.workflow.ts +++ b/src/mastra/workflows/chat/chat.workflow.ts @@ -29,7 +29,7 @@ import {endSessionStep} from './steps/end-session.step'; * - tokenUsageAccumulator: TokenUsageAccumulator (per-request) * - mastraChatLlm: MastraLanguageModel (bound in LB4 DI) * - mastraFileLlm: MastraLanguageModel (optional, bound in LB4 DI) - * - toolStore: ToolStore (REQUEST-scoped via ToolsProvider) + * - mastraTools: MastraToolStore (REQUEST-scoped via MastraToolsProvider) * - aiConfig: { maxTokens?, maxSteps?, modelName? } (optional, from LB4 config) * - systemContext: string[] (optional, from LB4 SystemContext binding) * - abortSignal: AbortSignal (from AbortController in GenerationService) diff --git a/src/mastra/workflows/chat/steps/agent-reasoning.step.ts b/src/mastra/workflows/chat/steps/agent-reasoning.step.ts index fc1358a..7561e51 100644 --- a/src/mastra/workflows/chat/steps/agent-reasoning.step.ts +++ b/src/mastra/workflows/chat/steps/agent-reasoning.step.ts @@ -10,10 +10,27 @@ import { FileProcessingOutputSchema, AgentReasoningOutputSchema, } from '../chat-workflow-schemas'; -import type {AnyObject} from '@loopback/repository'; +import type {JsonObject, JsonValue} from '../../../../types'; const debug = require('debug')('ai-integration:mastra:agent-reasoning.step'); +function toJsonObject(value: JsonValue | undefined): JsonObject { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value; + } + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ) { + return {value}; + } + + return {}; +} + /** * AgentReasoningStep — the core agentic loop. * @@ -41,7 +58,7 @@ export const agentReasoningStep = createStep({ const eventQueue = ctx.get('eventQueue'); const tokenAccumulator = ctx.get('tokenUsageAccumulator'); - const toolStore = ctx.get('toolStore'); + const mastraTools = ctx.get('mastraTools'); const abortSignal = ctx.get('abortSignal'); const aiConfig = ctx.get('aiConfig') as | {maxSteps?: number; modelName?: string} @@ -85,7 +102,7 @@ export const agentReasoningStep = createStep({ data: { id: toolCallId, tool: toolName, - data: (args ?? {}) as AnyObject, + data: toJsonObject(args as JsonValue), }, }); break; @@ -99,22 +116,17 @@ export const agentReasoningStep = createStep({ toolCallRecords.push({ toolCallId, toolName, - args: (args ?? {}) as AnyObject, - rawResult: (result ?? {}) as AnyObject, + args: toJsonObject(args as JsonValue), + rawResult: toJsonObject(result as JsonValue), }); - // IGraphTool sub-graphs emit ToolStatus internally via config.writer → eventQueue. - // For plain Mastra tools (not IGraphTool), emit a generic ToolStatus here. - const igraphTool = toolStore?.map?.[toolName]; - if (!igraphTool) { - emitToolStatusEvent( - eventQueue, - toolCallId, - toolStore, - toolName, - (result ?? {}) as AnyObject, - ); - } + emitToolStatusEvent( + eventQueue, + toolCallId, + mastraTools, + toolName, + toJsonObject(result as JsonValue), + ); break; } diff --git a/src/mastra/workflows/chat/steps/file-processing.step.ts b/src/mastra/workflows/chat/steps/file-processing.step.ts index 402df57..29a5578 100644 --- a/src/mastra/workflows/chat/steps/file-processing.step.ts +++ b/src/mastra/workflows/chat/steps/file-processing.step.ts @@ -3,6 +3,7 @@ import {z} from 'zod'; import {createStep} from '@mastra/core/workflows'; import {Agent} from '@mastra/core/agent'; import type {MastraLanguageModel} from '@mastra/core/agent'; +import {Readable} from 'stream'; import {LLMStreamEventType} from '../../../../graphs/event.types'; import {asWorkflowContext} from '../../../bridge/workflow-request-context'; import {mergeAttachments} from '../../../../utils'; @@ -22,6 +23,19 @@ You will summarize the one file at a time so don't worry about the other files m The summary should be relatively short and only contain the important details that are relevant to the user's query. The output should just be a plain text string without any additional markdown syntax or any special formatting.`; +type FileContentPart = { + type: 'file'; + // eslint-disable-next-line @typescript-eslint/naming-convention + source_type: 'base64'; + data: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + mime_type: string; +}; + +type FileModelWithAdapter = MastraLanguageModel & { + getFile?: (file: Express.Multer.File) => FileContentPart; +}; + /** * FileProcessingStep — summarise uploaded files using the file LLM. * @@ -68,7 +82,7 @@ export const fileProcessingStep = createStep({ let mergedPrompt = prompt; for (const file of files) { - const multerFile = file as unknown as Express.Multer.File; + const multerFile = toMulterFile(file); debug(`FileProcessing: processing file ${multerFile.originalname}`); // Emit Status via writer (workflow-native streaming, not AsyncEventQueue) @@ -134,6 +148,49 @@ export const fileProcessingStep = createStep({ // ── Helpers ─────────────────────────────────────────────────────────────────── +function toMulterFile( + file: z.infer['files'][number], +): Express.Multer.File { + const source = file as Record; + + const buffer = Buffer.isBuffer(source.buffer) + ? source.buffer + : Buffer.alloc(0); + const originalname = + typeof source.originalname === 'string' && source.originalname.length > 0 + ? source.originalname + : 'attachment'; + const mimetype = + typeof source.mimetype === 'string' && source.mimetype.length > 0 + ? source.mimetype + : 'application/pdf'; + const fieldname = + typeof source.fieldname === 'string' && source.fieldname.length > 0 + ? source.fieldname + : 'file'; + const encoding = + typeof source.encoding === 'string' && source.encoding.length > 0 + ? source.encoding + : '7bit'; + const size = + typeof source.size === 'number' && Number.isFinite(source.size) + ? source.size + : buffer.length; + + return { + fieldname, + originalname, + encoding, + mimetype, + size, + destination: '', + filename: originalname, + path: '', + buffer, + stream: Readable.from(buffer), + }; +} + /** * Build a file content part compatible with the Vercel AI SDK message format. * Mirrors `SummariseFileNode.buildFileContent()`. @@ -141,11 +198,9 @@ export const fileProcessingStep = createStep({ function buildFileContentPart( file: Express.Multer.File, llm: MastraLanguageModel, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): any { +): FileContentPart { // Some LLM providers have a custom getFile() helper on the provider instance - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const provider = llm as any; + const provider = llm as FileModelWithAdapter; if (typeof provider?.getFile === 'function') { return provider.getFile(file); } diff --git a/src/mastra/workflows/chat/steps/persist-conversation.step.ts b/src/mastra/workflows/chat/steps/persist-conversation.step.ts index d7ffe52..d15a2e7 100644 --- a/src/mastra/workflows/chat/steps/persist-conversation.step.ts +++ b/src/mastra/workflows/chat/steps/persist-conversation.step.ts @@ -4,6 +4,7 @@ import { AgentReasoningOutputSchema, PersistConversationOutputSchema, } from '../chat-workflow-schemas'; +import type {JsonObject} from '../../../../types'; const debug = require('debug')( 'ai-integration:mastra:persist-conversation.step', @@ -17,12 +18,12 @@ const debug = require('debug')( * Responsibilities: * - Persist the AI's final text response as an AI-type message * - For each tool call, persist a Tool-type message linked to the AI message - * - Retrieve per-tool metadata (e.g. report IDs) via IGraphTool.getMetadata() + * - Retrieve per-tool metadata via MastraToolStore.getMetadata() * * Tool message metadata enrichment: - * `IGraphTool.getMetadata(rawResult)` returns application-specific metadata - * (e.g. `{ reportId: '...' }`) that gets stored alongside the tool message. - * `IGraphTool.getValue(rawResult)` returns the human-readable content to store. + * `MastraToolDefinition.getMetadata(rawResult)` returns application-specific metadata + * stored alongside each persisted tool message. + * `MastraToolDefinition.formatResult(rawResult)` returns the human-readable content. */ export const persistConversationStep = createStep({ id: 'persist-conversation', @@ -32,7 +33,7 @@ export const persistConversationStep = createStep({ execute: async ({inputData, requestContext}) => { const ctx = asWorkflowContext(requestContext); const chatStore = ctx.get('chatStore'); - const toolStore = ctx.get('toolStore'); + const mastraTools = ctx.get('mastraTools'); const { sessionId, @@ -56,16 +57,16 @@ export const persistConversationStep = createStep({ // 2. Persist each tool call as a linked Tool message for (const toolCall of toolCalls) { - const igraphTool = toolStore?.map?.[toolCall.toolName]; + const toolDefinition = mastraTools?.map?.[toolCall.toolName]; + const result = toolCall.rawResult as JsonObject; - const content = - igraphTool?.getValue?.(toolCall.rawResult as Record) ?? - JSON.stringify(toolCall.rawResult); + const content = toolDefinition + ? toolDefinition.formatResult(result) + : JSON.stringify(result); - const metadata = - igraphTool?.getMetadata?.( - toolCall.rawResult as Record, - ) ?? {}; + const metadata = toolDefinition + ? toolDefinition.getMetadata(result) + : ({status: 'completed'} as JsonObject); if (aiMessage) { await chatStore.addToolMessageText( diff --git a/src/mastra/workflows/db-query/contracts/branch.contract.ts b/src/mastra/workflows/db-query/contracts/branch.contract.ts new file mode 100644 index 0000000..97eedab --- /dev/null +++ b/src/mastra/workflows/db-query/contracts/branch.contract.ts @@ -0,0 +1,20 @@ +/** + * Branch condition context types for DBQuery workflow branches. + * + * Mastra's `.branch()` and `.dountil()` condition functions receive the full + * step execution context, not just the step output. The step output is in + * the `inputData` field of this context. + */ + +/** + * The execution context passed to `.branch()` and `.dountil()` conditions. + * + * `inputData` contains the output of the preceding step or workflow. + * `iterationCount` is available in `.dountil()` loops (1-based). + */ +export interface BranchContext { + inputData: TInputData; + iterationCount?: number; + runId: string; + workflowId: string; +} diff --git a/src/mastra/workflows/db-query/contracts/step-outputs.contract.ts b/src/mastra/workflows/db-query/contracts/step-outputs.contract.ts new file mode 100644 index 0000000..353ba32 --- /dev/null +++ b/src/mastra/workflows/db-query/contracts/step-outputs.contract.ts @@ -0,0 +1,142 @@ +/** + * Step output interfaces for the DBQuery workflow. + * + * Extracted from the workflow file to provide a single source of truth for + * step I/O contracts used across sub-workflows and composition boundaries. + */ + +import type {AnyObject} from '@loopback/repository'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; + +// ── Dataset resolution ──────────────────────────────────────────────────── + +export interface DatasetResolutionOut { + prompt: string; + sampleSql?: string; + sampleSqlPrompt?: string; +} + +// ── Discovery steps ──────────────────────────────────────────────────────── + +export interface CacheCheckOut { + fromCache?: boolean; + sampleSql?: string; + sampleSqlPrompt?: string; + datasetId?: string; + replyToUser?: string; +} + +export interface TableSelectionOut { + schema?: DatabaseSchema; + status?: string; + replyToUser?: string; +} + +export interface TemplateMatchOut { + sql?: string; + description?: string; + fromTemplate?: boolean; + templateId?: string; +} + +export interface ChangeClassificationOut { + changeType?: 'minor' | 'major' | 'rewrite'; +} + +export interface DiscoveryRoutingOut { + route: 'from-cache' | 'from-template' | 'continue' | 'failed'; + prompt: string; + schema?: DatabaseSchema; + sql?: string; + description?: string; + sampleSql?: string; + sampleSqlPrompt?: string; + changeType?: 'minor' | 'major' | 'rewrite'; + datasetId?: string; + replyToUser?: string; + templateId?: string; + directCall?: boolean; +} + +// ── Column selection and checklist ───────────────────────────────────────── + +export interface ColumnSelectionOut { + schema: DatabaseSchema; + status?: string; + replyToUser?: string; +} + +export interface ChecklistOut { + validationChecklist?: string; +} + +// ── SQL generation and repair ────────────────────────────────────────────── + +export interface SqlGenerationOut { + sql?: string; + status?: string; + replyToUser?: string; +} + +export interface QueryRepairOut { + sql?: string; + status?: string; + replyToUser?: string; +} + +// ── Validation steps ─────────────────────────────────────────────────────── + +export interface SyntacticValidationOut { + syntacticStatus: string; + syntacticFeedback?: string; + syntacticErrorTables?: string[]; +} + +export interface SemanticValidationOut { + semanticStatus: string; + semanticFeedback?: string; + semanticErrorTables?: string[]; +} + +export interface DescriptionGenerationOut { + description?: string; +} + +export interface ValidationMergeOut { + route: 'accepted' | 'fix-query' | 'reselect-tables' | 'failed'; + status: string; + feedbacks: string[]; + syntacticErrorTables?: string[]; + semanticErrorTables?: string[]; + description?: string; + sql?: string; + prompt: string; + schema: DatabaseSchema; + validationChecklist?: string; + directCall?: boolean; +} + +// ── Persistence ─────────────────────────────────────────────────────────── + +export interface DatasetPersistenceOut { + datasetId?: string; + replyToUser?: string; + done?: boolean; + resultArray?: AnyObject[]; +} + +export interface FailureOut { + replyToUser: string; +} + +// ── Validation cycle (composite) ────────────────────────────────────────── + +/** Output of one iteration of the SQL validation cycle (ValidationMergeOut + loop state). */ +export interface ValidationCycleOut extends ValidationMergeOut { + fixAttempts: number; + changeType?: 'minor' | 'major' | 'rewrite'; + sampleSql?: string; + sampleSqlPrompt?: string; + fromCache?: boolean; + replyToUser?: string; +} diff --git a/src/mastra/workflows/db-query/db-query-request-context.ts b/src/mastra/workflows/db-query/db-query-request-context.ts new file mode 100644 index 0000000..58ab0d2 --- /dev/null +++ b/src/mastra/workflows/db-query/db-query-request-context.ts @@ -0,0 +1,87 @@ +import type {RequestContext} from '@mastra/core/request-context'; +import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {IAuthUserWithPermissions} from '@sourceloop/core'; +import type { + DatabaseSchema, + DbQueryConfig, + IDataSetStore, + IDbConnector, + QueryTemplateMetadata, +} from '../../../components/db-query/types'; +import type {DbSchemaHelperService} from '../../../components/db-query/services/db-schema-helper.service'; +import type {PermissionHelper} from '../../../components/db-query/services/permission-helper.service'; +import type {TableSearchService} from '../../../components/db-query/services/search/table-search.service'; +import type {TemplateHelper} from '../../../components/db-query/services/template-helper.service'; +import type {DataSetHelper} from '../../../components/db-query/services/dataset-helper.service'; +import type {SchemaStore} from '../../../components/db-query/services/schema.store'; + +/** + * Typed interface for all values stored in Mastra RequestContext + * for the DBQueryWorkflow. + */ +export interface DbQueryRequestContext { + /** Cheap/fast LLM for most DBQuery nodes */ + cheapLlm: MastraLanguageModel; + /** Smart/powerful LLM for complex SQL generation */ + smartLlm: MastraLanguageModel; + /** Smart non-thinking LLM (for checklist verification) */ + smartNonThinkingLlm: MastraLanguageModel | undefined; + /** DBQuery configuration */ + dbQueryConfig: DbQueryConfig; + /** Dataset store for persistence */ + datasetStore: IDataSetStore; + /** Database connector for SQL validation/execution */ + connector: IDbConnector; + /** Schema store (in-memory cached schema) */ + schemaStore: SchemaStore; + /** Schema helper (DDL generation, hash, context extraction) */ + schemaHelper: DbSchemaHelperService; + /** Permission helper for table access control */ + permissionHelper: PermissionHelper | undefined; + /** Table search service (knowledge graph + vector) */ + tableSearchService: TableSearchService; + /** Template helper for query template resolution */ + templateHelper: TemplateHelper; + /** Dataset helper for permissions and data access */ + datasetHelper: DataSetHelper; + /** Query cache retriever */ + queryCache: {invoke(query: string): Promise}; + /** Template cache retriever */ + templateCache: {invoke(query: string): Promise}; + /** Global context rules/checks */ + globalContext: string[]; + /** Abort signal from HTTP request */ + abortSignal: AbortSignal; + /** Authenticated user */ + currentUser: IAuthUserWithPermissions; + /** Full database schema (unfiltered) */ + fullSchema: DatabaseSchema; + /** Whether this is a direct internal call (not from chat tool) */ + directCall: boolean; +} + +/** Document returned by the query cache retriever */ +export interface CacheDocument { + pageContent: string; + metadata: { + datasetId: string; + query: string; + description: string; + votes: number; + }; +} + +/** Document returned by the template cache retriever */ +export interface TemplateDocument { + pageContent: string; + metadata: QueryTemplateMetadata; +} + +/** + * Helper: cast an untyped Mastra RequestContext to the DbQuery typed variant. + */ +export function asDbQueryContext( + requestContext: RequestContext, +): RequestContext { + return requestContext as RequestContext; +} diff --git a/src/mastra/workflows/db-query/db-query-workflow-schemas.ts b/src/mastra/workflows/db-query/db-query-workflow-schemas.ts new file mode 100644 index 0000000..3ca8f77 --- /dev/null +++ b/src/mastra/workflows/db-query/db-query-workflow-schemas.ts @@ -0,0 +1,143 @@ +import {z} from 'zod'; + +const looseObjectSchema = z.object({}).passthrough(); + +// ─── Enums as Zod literals ───────────────────────────────────────────────── + +export const EvaluationResultSchema = z.enum([ + 'pass', + 'query_error', + 'table_not_found', +]); + +export const GenerationErrorSchema = z.literal('failed'); + +export const ChangeTypeSchema = z.enum(['minor', 'major', 'rewrite']); + +export const CacheResultSchema = z.enum(['as-is', 'similar', 'not-relevant']); + +export const StatusSchema = z.union([ + EvaluationResultSchema, + GenerationErrorSchema, + z.literal('permission_error'), + z.literal('accept'), + z.literal('query_issue'), + z.literal('other_issue'), +]); + +// ─── Database schema Zod types (validation only at workflow boundary) ─────── + +export const ColumnSchemaZ = z.object({ + type: z.string(), + required: z.boolean(), + description: z.string().optional(), + id: z.boolean(), + metadata: z.record(z.string(), looseObjectSchema).optional(), +}); + +export const TableSchemaZ = z.object({ + columns: z.record(ColumnSchemaZ), + primaryKey: z.array(z.string()), + description: z.string(), + context: z.array(z.union([z.string(), z.record(z.string())])), + hash: z.string(), +}); + +export const ForeignKeyZ = z.object({ + table: z.string(), + column: z.string(), + referencedTable: z.string(), + referencedColumn: z.string(), + type: z.string(), + description: z.string().optional(), +}); + +export const DatabaseSchemaZ = z.object({ + tables: z.record(TableSchemaZ), + relations: z.array(ForeignKeyZ), +}); + +// ─── Workflow Input Schema ────────────────────────────────────────────────── + +export const dbQueryWorkflowInputSchema = z.object({ + prompt: z.string().describe('User natural language query'), + schema: DatabaseSchemaZ.describe('Available database schema'), + datasetId: z.string().optional().describe('Existing dataset ID if improving'), + directCall: z + .boolean() + .optional() + .describe('True when invoked internally (not from chat tool)'), +}); + +export type DbQueryWorkflowInput = z.infer; + +// ─── Workflow Output Schema ───────────────────────────────────────────────── + +export const dbQueryWorkflowOutputSchema = z.object({ + datasetId: z.string().optional(), + sql: z.string().optional(), + description: z.string().optional(), + resultArray: z.array(looseObjectSchema).optional(), + replyToUser: z.string().optional(), + fromCache: z.boolean().optional(), + done: z.boolean().optional(), +}); + +export type DbQueryWorkflowOutput = z.infer; + +// ─── Internal Workflow State ──────────────────────────────────────────────── + +export const dbQueryWorkflowStateSchema = z.object({ + // Input fields + prompt: z.string(), + schema: DatabaseSchemaZ, + datasetId: z.string().optional(), + directCall: z.boolean().optional(), + + // SQL generation + sql: z.string().optional(), + status: StatusSchema.optional(), + description: z.string().optional(), + + // Discovery results + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + fromCache: z.boolean().optional(), + fromTemplate: z.boolean().optional(), + templateId: z.string().optional(), + changeType: ChangeTypeSchema.optional(), + + // Validation + validationChecklist: z.string().optional(), + syntacticStatus: StatusSchema.optional(), + syntacticFeedback: z.string().optional(), + semanticStatus: StatusSchema.optional(), + semanticFeedback: z.string().optional(), + syntacticErrorTables: z.array(z.string()).optional(), + semanticErrorTables: z.array(z.string()).optional(), + + // Retry tracking + feedbacks: z.array(z.string()).optional(), + fixAttempts: z.number().default(0), + + // Result + replyToUser: z.string().optional(), + done: z.boolean().optional(), + resultArray: z.array(looseObjectSchema).optional(), +}); + +export type DbQueryWorkflowState = z.infer; + +// ─── Routing Decision ─────────────────────────────────────────────────────── + +export type DiscoveryRoutingDecision = + | 'from-cache' + | 'from-template' + | 'continue' + | 'failed'; + +export type ValidationRoutingDecision = + | 'accepted' + | 'fix-query' + | 'reselect-tables' + | 'failed'; diff --git a/src/mastra/workflows/db-query/db-query.workflow.ts b/src/mastra/workflows/db-query/db-query.workflow.ts new file mode 100644 index 0000000..dabf1f1 --- /dev/null +++ b/src/mastra/workflows/db-query/db-query.workflow.ts @@ -0,0 +1,168 @@ +import {createWorkflow, createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import { + dbQueryWorkflowInputSchema, + dbQueryWorkflowOutputSchema, + DatabaseSchemaZ, +} from './db-query-workflow-schemas'; +import type {DbQueryWorkflowInput} from './db-query-workflow-schemas'; +import {datasetResolutionStep} from './steps/dataset-resolution.step'; +import {discoveryWorkflow} from './workflows/discovery.workflow'; +import {fullGenerationWorkflow} from './workflows/full-generation.workflow'; +import {failureStep} from './steps/failure.step'; +import {datasetPersistenceStep} from './steps/dataset-persistence.step'; +import type {BranchContext} from './contracts/branch.contract'; +import type {DiscoveryRoutingOut} from './contracts/step-outputs.contract'; + +/** + * DBQueryWorkflow — Mastra replacement for the LangGraph DbQueryGraph. + * + * Implements the complete DB query generation pipeline using Mastra-native + * workflow composition: + * + * 1. `workflowInitStep`: + * Resolves the existing dataset (if improving) and carries the DB schema + * and directCall flag forward — fields that datasetResolutionStep does + * not pass through. + * + * 2. `discoveryWorkflow` (sub-workflow): + * Runs cache-check, table-selection, template-match, and change- + * classification in PARALLEL, merges the results, and determines route. + * + * 3. `.branch()` on discovery route: + * - `from-cache` → fromCacheDoneStep (return cached result) + * - `from-template` → templatePersistenceStep (save template SQL) + * - `failed` → failureStep (emit error) + * - `continue` → fullGenerationWorkflow (column-select + SQL gen loop) + * + * `fullGenerationWorkflow` (sub-workflow): + * - Runs column selection + checklist generation + * - Loops SQL generation + validation with `.dountil()` (max 4 iterations) + * - Branches on loop result: accepted → save dataset, failed → emit error + * + * All LLM calls use Agent.generate() via the individual step implementations. + * All events are streamed via the Mastra writer (workflow-native streaming). + */ + +/** Returns the cached result directly (no SQL generation needed). */ +const fromCacheDoneStep = createStep({ + id: 'from-cache-done', + inputSchema: z.object({ + route: z.string(), + datasetId: z.string().optional(), + replyToUser: z.string().optional(), + }), + outputSchema: dbQueryWorkflowOutputSchema, + execute: async ({inputData}) => ({ + datasetId: inputData.datasetId, + replyToUser: inputData.replyToUser, + fromCache: true, + done: true, + }), +}); + +const templatePersistenceInputSchema = z.object({ + route: z.string(), + prompt: z.string(), + sql: z.string().optional(), + schema: DatabaseSchemaZ.optional(), + description: z.string().optional(), + directCall: z.boolean().optional(), +}); + +type TemplatePersistenceInput = z.infer; + +const datasetResolutionOutputSchema = z.object({ + prompt: z.string(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), +}); + +const datasetPersistenceOutputSchema = z.object({ + datasetId: z.string().optional(), + replyToUser: z.string().optional(), + done: z.boolean().optional(), + resultArray: z.array(z.object({}).passthrough()).optional(), +}); + +/** Saves template-matched SQL as a dataset using native workflow chaining. */ +const templatePersistenceWorkflow = createWorkflow({ + id: 'template-persistence-branch', + inputSchema: templatePersistenceInputSchema, + outputSchema: dbQueryWorkflowOutputSchema, +}) + .map(async ({inputData}) => { + const data = templatePersistenceInputSchema.parse(inputData); + return { + prompt: data.prompt, + sql: data.sql ?? '', + schema: data.schema, + description: data.description, + directCall: data.directCall, + }; + }) + .then(datasetPersistenceStep) + .map(async ({inputData, getInitData}) => { + const data = datasetPersistenceOutputSchema.parse(inputData); + const initData = getInitData(); + return { + datasetId: data.datasetId, + sql: initData.sql, + description: initData.description ?? data.replyToUser, + replyToUser: data.replyToUser, + resultArray: data.resultArray, + done: true, + }; + }) + .commit(); + +// ── Branch context types ─────────────────────────────────────────────────────── +type DiscoveryCtx = BranchContext; + +// ── Main workflow ───────────────────────────────────────────────────────────── + +export const dbQueryWorkflow = createWorkflow({ + id: 'db-query-workflow', + inputSchema: dbQueryWorkflowInputSchema, + outputSchema: dbQueryWorkflowOutputSchema, +}) + // 1. Resolve existing dataset prompt context + .then(datasetResolutionStep) + + // 2. Carry required workflow input fields forward via native map + .map(async ({inputData, getInitData}) => { + const data = datasetResolutionOutputSchema.parse(inputData); + const initData = getInitData(); + return { + prompt: data.prompt, + schema: initData.schema, + sampleSql: data.sampleSql, + sampleSqlPrompt: data.sampleSqlPrompt, + directCall: initData.directCall, + datasetId: initData.datasetId, + }; + }) + + // 3. Parallel discovery → routing decision + .then(discoveryWorkflow) + + // 4. Route to terminal or generation paths + .branch([ + [ + async (ctx: DiscoveryCtx) => ctx.inputData?.route === 'from-cache', + fromCacheDoneStep, + ], + [ + async (ctx: DiscoveryCtx) => ctx.inputData?.route === 'from-template', + templatePersistenceWorkflow, + ], + [ + async (ctx: DiscoveryCtx) => ctx.inputData?.route === 'failed', + failureStep, + ], + [ + async (ctx: DiscoveryCtx) => ctx.inputData?.route === 'continue', + fullGenerationWorkflow, + ], + ]) + .commit(); diff --git a/src/mastra/workflows/db-query/index.ts b/src/mastra/workflows/db-query/index.ts new file mode 100644 index 0000000..a5065d7 --- /dev/null +++ b/src/mastra/workflows/db-query/index.ts @@ -0,0 +1,27 @@ +export {dbQueryWorkflow} from './db-query.workflow'; +export {asDbQueryContext} from './db-query-request-context'; +export type { + DbQueryRequestContext, + CacheDocument, + TemplateDocument, +} from './db-query-request-context'; +export { + dbQueryWorkflowInputSchema, + dbQueryWorkflowOutputSchema, + dbQueryWorkflowStateSchema, +} from './db-query-workflow-schemas'; +export type { + DbQueryWorkflowInput, + DbQueryWorkflowOutput, + DbQueryWorkflowState, + DiscoveryRoutingDecision, + ValidationRoutingDecision, +} from './db-query-workflow-schemas'; +export * from './steps'; +export * from './tools'; +// Sub-workflows +export {discoveryWorkflow} from './workflows/discovery.workflow'; +export {fullGenerationWorkflow} from './workflows/full-generation.workflow'; +// Contracts +export type * from './contracts/step-outputs.contract'; +export type {BranchContext} from './contracts/branch.contract'; diff --git a/src/mastra/workflows/db-query/llm-helpers.ts b/src/mastra/workflows/db-query/llm-helpers.ts new file mode 100644 index 0000000..98e8f50 --- /dev/null +++ b/src/mastra/workflows/db-query/llm-helpers.ts @@ -0,0 +1,45 @@ +import {Agent} from '@mastra/core/agent'; +import type {MastraLanguageModel} from '@mastra/core/agent'; + +/** + * Invoke an LLM with a prompt string and return the text response. + * Uses Mastra Agent.generate() as the project does not depend on the `ai` package directly. + * + * @param llm - Mastra language model + * @param prompt - Formatted prompt string + * @returns Raw text response from the LLM + */ +export async function invokeLlm( + llm: MastraLanguageModel, + prompt: string, +): Promise { + const agent = new Agent({ + id: 'db-query-llm-agent', + name: 'DB Query LLM', + instructions: 'You are a helpful assistant.', + model: llm, + }); + const result = await agent.generate([{role: 'user', content: prompt}]); + return result.text ?? ''; +} + +/** + * Strip `...` or `...` tags from LLM output. + * Handles incomplete opening tags at the start of the response. + */ +export function stripThinkingTokens(text: string): string { + let cleaned = text.replace(/[\s\S]*?<\/think(ing)?>/g, ''); + // Handle case where response starts mid-thinking block (no opening tag) + cleaned = cleaned.replace(/^[\s\S]*?<\/think(ing)?>/g, ''); + return cleaned.trim(); +} + +/** + * Strip markdown code block fences from SQL output. + */ +export function stripCodeBlock(text: string): string { + return text + .replace(/^```(?:sql)?\s*/i, '') + .replace(/```\s*$/, '') + .trim(); +} diff --git a/src/mastra/workflows/db-query/steps/cache-check.step.ts b/src/mastra/workflows/db-query/steps/cache-check.step.ts new file mode 100644 index 0000000..08e00a4 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/cache-check.step.ts @@ -0,0 +1,263 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {DatasetActionType} from '../../../../components/db-query/constant'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; + +const CACHE_CHECK_PROMPT = ` + +You are an expert Semantic analyser, you will be given a prompt from the user and a list of past prompts that were handled successfully, along with description of the sql generated from those prompts. +You need to return the most relevant prompt from the list and in which of the following ways is it relevant - +- return 'as-is' if the prompt's result would contain the information the user is looking for without any changes in the result, and can be used as it is. +- return 'similar' if the prompt's result would be similar to the question in the new prompt but not exactly, and can be modified to get the data user needs. +- return 'not-relevant' if the prompt is not relevant to the new prompt at all. +Remember that if the cached prompt has extra information, then still the old prompt could be considered exactly same as long as it does not contradict the new prompt. + + +{prompt} + + +{queries} + + +format - +relevant index-of-query-starting-from-1 +examples - +as-is 2 + +similar 1 + +not-relevant + + + +Do not return any other text or explanation, just the output in the above format. +If no queries are relevant, return 'not-relevant' and nothing else. +`; + +/** + * CacheCheckStep — replaces CheckCacheNode. + * + * Searches the query cache for semantically similar past queries. + * If an exact match is found (as-is), returns it directly. + * If a similar query is found, provides it as a sample for SQL generation. + */ +export const cacheCheckStep = createStep({ + id: 'cache-check', + inputSchema: z.object({ + prompt: z.string(), + sampleSql: z.string().optional(), + directCall: z.boolean().optional(), + }), + outputSchema: z.object({ + fromCache: z.boolean().optional(), + datasetId: z.string().optional(), + replyToUser: z.string().optional(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const queryCache = ctx.get('queryCache'); + const cheapLlm = ctx.get('cheapLlm'); + const datasetHelper = ctx.get('datasetHelper'); + const directCall = inputData.directCall ?? false; + + if (inputData.sampleSql) { + return {}; + } + + const relevantDocs = await queryCache.invoke(inputData.prompt); + if (relevantDocs.length === 0) { + return {}; + } + + const prompt = CACHE_CHECK_PROMPT.replace( + '{prompt}', + inputData.prompt, + ).replace('{queries}', buildQueriesText(relevantDocs)); + + const rawResponse = await invokeLlm(cheapLlm, prompt); + const decision = parseCacheDecision( + stripThinkingTokens(rawResponse), + relevantDocs.length, + ); + + if (decision.status === 'not-relevant') { + await log(writer, 'No relevant queries found in cache for this prompt'); + return {}; + } + + if (decision.status === 'invalid-index') { + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Cache returned an invalid result index. Falling back to generation.', + }); + return {}; + } + + if (decision.status === 'as-is') { + return handleAsIsDecision({ + decisionIndex: decision.index, + relevantDocs, + datasetHelper, + writer, + directCall, + }); + } + + if (decision.status === 'similar') { + return handleSimilarDecision(decision.index, relevantDocs, writer); + } + + return {}; + }, +}); + +function buildQueriesText( + relevantDocs: Array<{ + pageContent: string; + metadata: {description: string}; + }>, +): string { + return relevantDocs + .map( + (doc, index) => + `\n\n${doc.pageContent}\n\n${doc.metadata.description}`, + ) + .join('\n'); +} + +function parseCacheDecision( + response: string, + maxIndex: number, +): + | {status: 'not-relevant'} + | {status: 'invalid-index'} + | {status: 'as-is'; index: number} + | {status: 'similar'; index: number} { + const [relevance, indexValue] = response.split(' '); + if (relevance === 'not-relevant') { + return {status: 'not-relevant'}; + } + + const index = Number.parseInt(indexValue, 10) - 1; + if (Number.isNaN(index) || index < 0 || index >= maxIndex) { + return {status: 'invalid-index'}; + } + + if (relevance === 'as-is') { + return {status: 'as-is', index}; + } + + return {status: 'similar', index}; +} + +async function handleAsIsDecision(params: { + decisionIndex: number; + relevantDocs: Array<{ + pageContent: string; + metadata: {datasetId: string}; + }>; + datasetHelper: { + checkPermissions(datasetId: string): Promise; + find(filter: { + where: {id: string}; + include: Array<{relation: string}>; + }): Promise}>>; + }; + writer: { + write: (event: { + type: LLMStreamEventType; + data: string | {status: string; data?: {datasetId: string}}; + }) => Promise; + }; + directCall: boolean; +}): Promise<{} | {fromCache: boolean; datasetId: string; replyToUser: string}> { + const datasetId = + params.relevantDocs[params.decisionIndex].metadata.datasetId; + const missingPermissions = + await params.datasetHelper.checkPermissions(datasetId); + if (missingPermissions.length > 0) { + await log( + params.writer, + `Found relevant query in cache, but missing permissions: ${missingPermissions.join(', ')} so generating new query`, + ); + return {}; + } + + await log(params.writer, 'Found relevant query in cache, using it as is'); + await params.writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'Found relevant query in cache'}, + }); + + const [dataset] = await params.datasetHelper.find({ + where: {id: datasetId}, + include: [{relation: 'actions'}], + }); + + const disliked = + !!dataset?.actions?.length && + dataset.actions.some( + action => action.action === DatasetActionType.Disliked, + ); + if (!dataset || disliked) { + await log( + params.writer, + 'Found relevant query in cache, but the dataset was not found or was disliked by the user, so generating new query', + ); + return {}; + } + + if (!params.directCall) { + await params.writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'completed', data: {datasetId}}, + }); + } + + return { + fromCache: true, + datasetId, + replyToUser: `I found this dataset in the cache - ${params.relevantDocs[params.decisionIndex].pageContent}`, + }; +} + +async function handleSimilarDecision( + decisionIndex: number, + relevantDocs: Array<{ + pageContent: string; + metadata: {query: string}; + }>, + writer: { + write: (event: { + type: LLMStreamEventType; + data: string | {status: string}; + }) => Promise; + }, +): Promise<{sampleSql: string; sampleSqlPrompt: string}> { + await log(writer, 'Found similar query in cache, using it as example'); + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'Found similar query in cache, using it as example'}, + }); + + return { + sampleSql: relevantDocs[decisionIndex].metadata.query, + sampleSqlPrompt: relevantDocs[decisionIndex].pageContent, + }; +} + +async function log( + writer: { + write: (event: {type: LLMStreamEventType; data: string}) => Promise; + }, + data: string, +): Promise { + await writer.write({ + type: LLMStreamEventType.Log, + data, + }); +} diff --git a/src/mastra/workflows/db-query/steps/change-classification.step.ts b/src/mastra/workflows/db-query/steps/change-classification.step.ts new file mode 100644 index 0000000..868a467 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/change-classification.step.ts @@ -0,0 +1,84 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; + +const CLASSIFY_CHANGE_PROMPT = ` + +You are given the original description of a SQL query and a new description that includes user feedback. +Your task is to classify the level of change required to transform the original query into the new one. + +Classify as one of: +- **minor**: Small tweaks such as changing a filter value, adjusting a limit, adding/removing a single condition, or renaming an alias. +- **major**: Structural changes like adding/removing joins, changing grouping logic, adding subqueries, or significantly altering the WHERE clause. +- **rewrite**: The intent of the query has fundamentally changed, requiring a completely new query from scratch. + + + +{originalDescription} + + + +{newDescription} + + + +Return ONLY one of: minor, major, rewrite +Do not include any other text, explanation, or formatting. +`; + +/** + * ChangeClassificationStep — replaces ClassifyChangeNode. + * + * When improving an existing query (sampleSql exists), classifies + * the level of change needed: minor, major, or rewrite. + * This determines which LLM to use for SQL generation. + */ +export const changeClassificationStep = createStep({ + id: 'change-classification', + inputSchema: z.object({ + prompt: z.string(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + }), + outputSchema: z.object({ + changeType: z.enum(['minor', 'major', 'rewrite']).optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + if (!inputData.sampleSql) { + return {}; + } + + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Classifying the level of change required for the query.', + }); + + const prompt = CLASSIFY_CHANGE_PROMPT.replace( + '{originalDescription}', + inputData.sampleSqlPrompt ?? '', + ).replace('{newDescription}', inputData.prompt); + + const rawOutput = await invokeLlm(cheapLlm, prompt); + const response = stripThinkingTokens(rawOutput).trim().toLowerCase(); + + const changeType = parseChangeType(response); + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Change classified as: ${changeType}`, + }); + + return {changeType}; + }, +}); + +function parseChangeType(response: string): 'minor' | 'major' | 'rewrite' { + if (response.includes('minor')) return 'minor'; + if (response.includes('rewrite')) return 'rewrite'; + return 'major'; +} diff --git a/src/mastra/workflows/db-query/steps/column-selection.step.ts b/src/mastra/workflows/db-query/steps/column-selection.step.ts new file mode 100644 index 0000000..5c1273f --- /dev/null +++ b/src/mastra/workflows/db-query/steps/column-selection.step.ts @@ -0,0 +1,368 @@ +import {createStep} from '@mastra/core/workflows'; +import type {MastraLanguageModel} from '@mastra/core/agent'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +const COLUMN_SELECTION_PROMPT = ` + +You are an AI assistant that identifies relevant columns from database tables based on a user's query. +Given a set of tables with their columns, you need to identify which columns are relevant to answer the user's query. + +For each table, return only the column names that are relevant to the query. Include: +1. Columns directly mentioned or implied in the query +2. Primary key columns (always needed for joins and identification) +3. Foreign key columns (needed for relationships) +4. Columns that might be needed for filtering, sorting, or calculations +5. It is better to include a few extra relevant columns than to miss important ones. + +Do not include: +- Columns that are clearly irrelevant to the query +- Descriptions, types, or any other metadata about the columns + +Return the result as a JSON object where each table name is a key and the value is an array of relevant column names. +If you are not sure about which columns to select, return your doubt asking the user for more details in the following format: +failed attempt: + + + +{tablesWithColumns} + + + +{query} + + +{checks} + +{feedbacks} + + +Return a valid JSON object with table names as keys and arrays of column names as values. +Example format (do not copy these exact values): +{{ + "table_name1": ["column1", "column2", "column3"], + "table_name2": ["column1", "column2"] +}} + +In case of failure, return the failure message in the format: +failed attempt: +`; + +const COLUMN_FEEDBACK_PROMPT = ` + +We also need to consider the errors from last attempt at query generation. + +In the last attempt, these were the columns selected: +{lastColumns} + +But it was rejected with the following errors: +{feedback} + +Use these errors to refine your column selection. Consider if you need additional columns for joins, filtering, or calculations. + +`; + +/** + * ColumnSelectionStep — replaces GetColumnsNode. + * + * Selects relevant columns from the chosen tables to reduce schema + * complexity for SQL generation. Includes a 3-attempt internal retry loop. + */ +export const columnSelectionStep = createStep({ + id: 'column-selection', + inputSchema: z.object({ + prompt: z.string(), + schema: DatabaseSchemaZ, + feedbacks: z.array(z.string()).optional(), + }), + outputSchema: z.object({ + schema: DatabaseSchemaZ, + status: z.string().optional(), + replyToUser: z.string().optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + const dbQueryConfig = ctx.get('dbQueryConfig'); + const schemaHelper = ctx.get('schemaHelper'); + const globalContext = ctx.get('globalContext'); + const schema = inputData.schema as DatabaseSchema; + + if (!dbQueryConfig.columnSelection) { + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Skipping column selection as per configuration', + }); + return {schema: inputData.schema}; + } + + if (!schema?.tables || Object.keys(schema.tables).length === 0) { + throw new Error( + 'No tables found in the schema. Please ensure the get-tables step was completed successfully.', + ); + } + + const tablesWithColumns = getTablesWithColumns(schema); + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Selecting relevant columns from ${Object.keys(schema.tables).length} tables`, + }); + + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'Extracting relevant columns from the schema'}, + }); + + const feedbacksText = buildColumnFeedbackText(inputData.feedbacks, schema); + + const checks = [ + '', + ...(globalContext ?? []), + ...schemaHelper.getTablesContext(schema), + '', + ].join('\n'); + + const selectionResult = await selectColumnsWithRetries({ + llm: cheapLlm, + prompt: inputData.prompt, + tablesWithColumns, + feedbacksText, + checks, + schema, + writer, + maxAttempts: 3, + }); + + if (selectionResult.status === 'failed') { + return { + schema: inputData.schema, + status: 'failed', + replyToUser: selectionResult.replyToUser, + }; + } + + const selectedColumns = selectionResult.columns; + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Selected columns: ${JSON.stringify(selectedColumns, null, 2)}`, + }); + + const filteredSchema = createFilteredSchema(schema, selectedColumns); + return {schema: filteredSchema}; + }, +}); + +function getTablesWithColumns(schema: DatabaseSchema): string[] { + return Object.entries(schema.tables).map(([tableName, table]) => { + const columnDescriptions = Object.entries(table.columns).map( + ([columnName, column]) => { + const details = [ + `${columnName} (${column.type})`, + column.required ? 'NOT NULL' : 'NULL', + column.id ? 'PRIMARY KEY' : '', + column.description ? `- ${column.description}` : '', + ] + .filter(Boolean) + .join(' '); + return ` - ${details}`; + }, + ); + return `${tableName}: ${table.description}\nColumns:\n${columnDescriptions.join('\n')}`; + }); +} + +function validateColumns( + selectedColumns: Record, + schema: DatabaseSchema, +): boolean { + for (const tableName of Object.keys(selectedColumns)) { + if (!schema.tables[tableName]) return false; + const tableColumns = Object.keys(schema.tables[tableName].columns); + for (const columnName of selectedColumns[tableName]) { + if (!tableColumns.includes(columnName)) return false; + } + } + return true; +} + +function createFilteredSchema( + originalSchema: DatabaseSchema, + selectedColumns: Record, +): DatabaseSchema { + const filteredTables: DatabaseSchema['tables'] = {}; + + for (const [tableName, columnNames] of Object.entries(selectedColumns)) { + if (originalSchema.tables[tableName]) { + const originalTable = originalSchema.tables[tableName]; + const filteredColumns: Record< + string, + (typeof originalTable.columns)[string] + > = {}; + + for (const columnName of columnNames) { + if (originalTable.columns[columnName]) { + filteredColumns[columnName] = originalTable.columns[columnName]; + } + } + + // Always include primary key columns + for (const pkColumn of originalTable.primaryKey) { + if (!filteredColumns[pkColumn] && originalTable.columns[pkColumn]) { + filteredColumns[pkColumn] = originalTable.columns[pkColumn]; + } + } + + filteredTables[tableName] = {...originalTable, columns: filteredColumns}; + } + } + + const filteredRelations = originalSchema.relations.filter( + relation => + filteredTables[relation.table] && + filteredTables[relation.referencedTable], + ); + + return {tables: filteredTables, relations: filteredRelations}; +} + +function getSelectedColumnsFromSchema( + schema: DatabaseSchema, +): Record { + const result: Record = {}; + for (const [tableName, table] of Object.entries(schema.tables)) { + result[tableName] = Object.keys(table.columns); + } + return result; +} + +function buildColumnFeedbackText( + feedbacks: string[] | undefined, + schema: DatabaseSchema, +): string { + if (!feedbacks?.length) { + return ''; + } + return COLUMN_FEEDBACK_PROMPT.replace( + '{lastColumns}', + JSON.stringify(getSelectedColumnsFromSchema(schema), null, 2), + ).replace('{feedback}', feedbacks.join('\n')); +} + +function parseSelectedColumns( + output: string, +): + | {status: 'failed'; reason: string} + | {status: 'retry'} + | {status: 'success'; columns: Record} { + if (output.startsWith('failed attempt:')) { + return { + status: 'failed', + reason: output.replace('failed attempt: ', ''), + }; + } + + const jsonMatch = output.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + return {status: 'retry'}; + } + + return { + status: 'success', + columns: JSON.parse(jsonMatch[0]) as Record, + }; +} + +async function selectColumnsWithRetries(params: { + llm: MastraLanguageModel; + prompt: string; + tablesWithColumns: string[]; + feedbacksText: string; + checks: string; + schema: DatabaseSchema; + writer: { + write: (event: { + type: LLMStreamEventType; + data: string | {status: string}; + }) => Promise; + }; + maxAttempts: number; +}): Promise< + | {status: 'failed'; replyToUser: string} + | {status: 'success'; columns: Record} +> { + let attempts = 0; + while (attempts < params.maxAttempts) { + attempts++; + const prompt = COLUMN_SELECTION_PROMPT.replace( + '{tablesWithColumns}', + params.tablesWithColumns.join('\n\n'), + ) + .replace('{query}', params.prompt) + .replace('{feedbacks}', params.feedbacksText) + .replace('{checks}', params.checks); + + const rawResult = await invokeLlm(params.llm, prompt); + const output = stripThinkingTokens(rawResult); + + try { + const parsed = parseSelectedColumns(output); + + if (parsed.status === 'failed') { + await params.writer.write({ + type: LLMStreamEventType.Log, + data: `Column selection failed: ${output}`, + }); + return {status: 'failed', replyToUser: parsed.reason}; + } + + if (parsed.status === 'retry') { + await params.writer.write({ + type: LLMStreamEventType.Log, + data: `Failed to find JSON in LLM response, trying again (attempt ${attempts})`, + }); + continue; + } + + if (validateColumns(parsed.columns, params.schema)) { + return {status: 'success', columns: parsed.columns}; + } + + if (attempts === params.maxAttempts) { + return { + status: 'failed', + replyToUser: + 'Not able to select relevant columns from the schema. Please rephrase the question or provide more details.', + }; + } + + await params.writer.write({ + type: LLMStreamEventType.Log, + data: `LLM returned invalid columns, trying again (attempt ${attempts})`, + }); + } catch (error) { + if (attempts === params.maxAttempts) { + return { + status: 'failed', + replyToUser: + 'Failed to parse column selection response. Please try again.', + }; + } + + await params.writer.write({ + type: LLMStreamEventType.Log, + data: `Failed to parse LLM response: ${error}, trying again (attempt ${attempts})`, + }); + } + } + + return { + status: 'failed', + replyToUser: 'Failed to parse column selection response. Please try again.', + }; +} diff --git a/src/mastra/workflows/db-query/steps/dataset-persistence.step.ts b/src/mastra/workflows/db-query/steps/dataset-persistence.step.ts new file mode 100644 index 0000000..0451b53 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/dataset-persistence.step.ts @@ -0,0 +1,133 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import type {AnyObject} from '@loopback/repository'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; +import {createHash} from 'crypto'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +const rowObjectSchema = z.object({}).passthrough(); + +const DESCRIPTION_PROMPT = `You are an AI assitant that generates a short description of a query based on a given schema, providing a summary of the query's intent and user's demand in a way that is short but does not miss any importance detail. + + Here is the query that you need to describe - {query} + + And here is the schema that was used to generate the query - + {schema} + + + {checks} + The output should be a valid description of the query that is easy to understand by the user in plain text, without any formatting`; + +/** + * DatasetPersistenceStep — replaces SaveDataSetNode. + * + * Saves the generated SQL as a dataset, optionally generates a description, + * and returns the result to the user. + */ +export const datasetPersistenceStep = createStep({ + id: 'dataset-persistence', + inputSchema: z.object({ + prompt: z.string(), + sql: z.string(), + schema: DatabaseSchemaZ, + description: z.string().optional(), + directCall: z.boolean().optional(), + }), + outputSchema: z.object({ + datasetId: z.string().optional(), + replyToUser: z.string().optional(), + done: z.boolean().optional(), + resultArray: z.array(rowObjectSchema).optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + const datasetStore = ctx.get('datasetStore'); + const dbQueryConfig = ctx.get('dbQueryConfig'); + const schemaHelper = ctx.get('schemaHelper'); + const globalContext = ctx.get('globalContext'); + const currentUser = ctx.get('currentUser'); + const schema = inputData.schema as DatabaseSchema; + + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Dataset generated', + }); + + const tenantId = currentUser.tenantId; + if (!tenantId) { + throw new Error('User does not have a tenantId'); + } + + let description = inputData.description; + + if (!description) { + const checks = [ + 'You must keep these additional details in consideration while describing the query -', + ...(globalContext ?? []), + ].join('\n'); + + const prompt = DESCRIPTION_PROMPT.replace('{query}', inputData.sql) + .replace('{schema}', schemaHelper.asString(schema)) + .replace('{checks}', checks); + + const rawOutput = await invokeLlm(cheapLlm, prompt); + description = stripThinkingTokens(rawOutput); + } + + const dataset = await datasetStore.create({ + query: inputData.sql, + tenantId, + description, + prompt: inputData.prompt, + tables: getTableList(schema), + schemaHash: hashSchema(schema), + votes: 0, + }); + + if (!inputData.directCall) { + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'completed', data: {datasetId: dataset.id}}, + }); + } + + let resultArray: AnyObject[] | undefined; + if (dbQueryConfig.readAccessForAI && dataset.id) { + resultArray = await datasetStore.getData( + dataset.id, + dbQueryConfig.maxRowsForAI ?? 5, + ); + } + + return { + datasetId: dataset.id, + replyToUser: description, + done: true, + resultArray, + }; + }, +}); + +function hashSchema(schema: DatabaseSchema): string { + const hash = createHash('sha256'); + const tableList = getTableList(schema).sort((a, b) => a.localeCompare(b)); + tableList.forEach(table => { + hash.update(table); + const columns = schema.tables[table]?.columns ?? {}; + Object.keys(columns) + .sort((a, b) => a.localeCompare(b)) + .forEach(column => { + hash.update(`${column}:${columns[column].type}`); + }); + }); + return hash.digest('hex'); +} + +function getTableList(schema: DatabaseSchema): string[] { + if (!schema?.tables) return []; + return Object.keys(schema.tables); +} diff --git a/src/mastra/workflows/db-query/steps/dataset-resolution.step.ts b/src/mastra/workflows/db-query/steps/dataset-resolution.step.ts new file mode 100644 index 0000000..78feb70 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/dataset-resolution.step.ts @@ -0,0 +1,42 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {asDbQueryContext} from '../db-query-request-context'; + +/** + * DatasetResolutionStep — replaces IsImprovementNode. + * + * Checks if this is an improvement of an existing dataset. + * If improving, loads the original SQL and merges prompts. + */ +export const datasetResolutionStep = createStep({ + id: 'dataset-resolution', + inputSchema: z.object({ + prompt: z.string(), + datasetId: z.string().optional(), + }), + outputSchema: z.object({ + prompt: z.string(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + }), + execute: async ({inputData, requestContext}) => { + const ctx = asDbQueryContext(requestContext!); + const datasetStore = ctx.get('datasetStore'); + const datasetId = inputData.datasetId; + + if (datasetId) { + const dataset = await datasetStore.findById(datasetId); + return { + prompt: `${dataset.prompt}\n also consider following feedback given by user -\n ${inputData.prompt}\n`, + sampleSql: dataset.query, + sampleSqlPrompt: dataset.prompt, + }; + } + + return { + prompt: inputData.prompt, + sampleSql: undefined, + sampleSqlPrompt: undefined, + }; + }, +}); diff --git a/src/mastra/workflows/db-query/steps/description-generation.step.ts b/src/mastra/workflows/db-query/steps/description-generation.step.ts new file mode 100644 index 0000000..4c86230 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/description-generation.step.ts @@ -0,0 +1,96 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +const DESCRIPTION_PROMPT = ` + +You are an AI assistant that describes what a SQL query does in plain english. +Analyze the actual query below and write a concise, bulleted summary of the data it retrieves and any filters/conditions it applies. +Write in plain english. No SQL, no technical jargon, no table/column names. + + + +{prompt} + + + +{sql} + + + +{schema} + + +{checks} + + +Return a short bulleted list where each bullet is one condition, filter, or piece of data the query retrieves. +- Use plain, non-technical language a business user would understand. +- Do NOT mention tables, columns, joins, CTEs, enums, or any DB concepts. +- Keep each bullet to one line. +- Do not add any preamble, heading, or closing text — just the bullets. +`; + +/** + * DescriptionGenerationStep — replaces GenerateDescriptionNode. + * + * Generates a plain-language description of the SQL query. + * Emits tokens as ToolStatus for frontend streaming. + */ +export const descriptionGenerationStep = createStep({ + id: 'description-generation', + inputSchema: z.object({ + prompt: z.string(), + sql: z.string().optional(), + schema: DatabaseSchemaZ, + }), + outputSchema: z.object({ + description: z.string().optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + const dbQueryConfig = ctx.get('dbQueryConfig'); + const schemaHelper = ctx.get('schemaHelper'); + const globalContext = ctx.get('globalContext'); + const schema = inputData.schema as DatabaseSchema; + + const generateDesc = + dbQueryConfig.nodes?.sqlGenerationNode?.generateDescription !== false; + + if (!generateDesc || !inputData.sql) { + return {}; + } + + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Generating query description.', + }); + + const checks = [ + '', + ...(globalContext ?? []), + ...schemaHelper.getTablesContext(schema), + '', + ].join('\n'); + + const prompt = DESCRIPTION_PROMPT.replace('{prompt}', inputData.prompt) + .replace('{sql}', inputData.sql) + .replace('{schema}', schemaHelper.asString(schema)) + .replace('{checks}', checks); + + const rawOutput = await invokeLlm(cheapLlm, prompt); + const description = stripThinkingTokens(rawOutput); + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Query description: ${description}`, + }); + + return {description}; + }, +}); diff --git a/src/mastra/workflows/db-query/steps/discovery-routing.step.ts b/src/mastra/workflows/db-query/steps/discovery-routing.step.ts new file mode 100644 index 0000000..983fab6 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/discovery-routing.step.ts @@ -0,0 +1,77 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import type {DiscoveryRoutingDecision} from '../db-query-workflow-schemas'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +/** + * DiscoveryRoutingStep — replaces PostCacheAndTables routing logic. + * + * Examines the merged results from cache-check, table-selection, + * template-match, and change-classification to determine the next path: + * - 'from-cache': exact cache hit → workflow complete (return dataset) + * - 'from-template': template matched → go to save dataset + * - 'failed': table selection failed → go to failure + * - 'continue': proceed with column selection and SQL generation + */ +export const discoveryRoutingStep = createStep({ + id: 'discovery-routing', + inputSchema: z.object({ + fromCache: z.boolean().optional(), + fromTemplate: z.boolean().optional(), + status: z.string().optional(), + // Pass-through fields needed by subsequent steps + prompt: z.string(), + schema: DatabaseSchemaZ.optional(), + sql: z.string().optional(), + description: z.string().optional(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + changeType: z.enum(['minor', 'major', 'rewrite']).optional(), + datasetId: z.string().optional(), + replyToUser: z.string().optional(), + templateId: z.string().optional(), + directCall: z.boolean().optional(), + }), + outputSchema: z.object({ + route: z.enum(['from-cache', 'from-template', 'continue', 'failed']), + prompt: z.string(), + schema: DatabaseSchemaZ.optional(), + sql: z.string().optional(), + description: z.string().optional(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + changeType: z.enum(['minor', 'major', 'rewrite']).optional(), + datasetId: z.string().optional(), + replyToUser: z.string().optional(), + templateId: z.string().optional(), + directCall: z.boolean().optional(), + }), + execute: async ({inputData}) => { + let route: DiscoveryRoutingDecision; + + if (inputData.fromTemplate) { + route = 'from-template'; + } else if (inputData.fromCache) { + route = 'from-cache'; + } else if (inputData.status === 'failed') { + route = 'failed'; + } else { + route = 'continue'; + } + + return { + route, + prompt: inputData.prompt, + schema: inputData.schema, + sql: inputData.sql, + description: inputData.description, + sampleSql: inputData.sampleSql, + sampleSqlPrompt: inputData.sampleSqlPrompt, + changeType: inputData.changeType, + datasetId: inputData.datasetId, + replyToUser: inputData.replyToUser, + templateId: inputData.templateId, + directCall: inputData.directCall, + }; + }, +}); diff --git a/src/mastra/workflows/db-query/steps/failure.step.ts b/src/mastra/workflows/db-query/steps/failure.step.ts new file mode 100644 index 0000000..301127b --- /dev/null +++ b/src/mastra/workflows/db-query/steps/failure.step.ts @@ -0,0 +1,32 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; + +/** + * FailureStep — replaces FailedNode. + * + * Emits a ToolStatus.Failed event and returns a user-facing error message. + */ +export const failureStep = createStep({ + id: 'failure', + inputSchema: z.object({ + replyToUser: z.string().optional(), + feedbacks: z.array(z.string()).optional(), + }), + outputSchema: z.object({ + replyToUser: z.string(), + }), + execute: async ({inputData, writer}) => { + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'failed'}, + }); + + const replyToUser = + inputData.replyToUser ?? + `I am sorry, I was not able to generate a valid SQL query for your request. Please try again with a more detailed or a more specific prompt.\n` + + `These were the errors I encountered:\n${inputData.feedbacks?.join('\n') ?? 'No errors reported.'}`; + + return {replyToUser}; + }, +}); diff --git a/src/mastra/workflows/db-query/steps/generate-checklist.step.ts b/src/mastra/workflows/db-query/steps/generate-checklist.step.ts new file mode 100644 index 0000000..cf57df0 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/generate-checklist.step.ts @@ -0,0 +1,174 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +const CHECKLIST_PROMPT = ` + +You are given a user question, the tables selected for SQL generation, the relevant database schema, and a numbered list of rules/checks. +Return ONLY the indexes of the rules that are relevant to the user's question, the selected tables, and the given schema. + +A rule is relevant if: +- It directly affects how a correct SQL query should be written for this question. +- It is a dependency of another relevant rule (e.g. if rule 3 requires a currency conversion, and rule 5 defines how currency conversion works, both must be included). +- It applies to any of the selected tables or their relationships. + +After selecting relevant rules, review your selection and ensure: +- Any rule that is referenced by, or is a prerequisite for, another selected rule is also included. +- Do not include rules that are completely unrelated to the question, schema, or selected tables. + + + +{prompt} + + + +{tables} + + + +{schema} + + + +{indexedChecks} + + + +Return only a comma-separated list of the relevant rule indexes. +Do not include any other text, explanation, or formatting. +Example: 1,3,5 +If no rules are relevant, return: none +`; + +/** + * GenerateChecklistStep — replaces GenerateChecklistNode. + * + * Filters the global validation rules/checks to only those relevant + * to the current query and schema. Used by semantic validation. + */ +export const generateChecklistStep = createStep({ + id: 'generate-checklist', + inputSchema: z.object({ + prompt: z.string(), + schema: DatabaseSchemaZ, + validationChecklist: z.string().optional(), + }), + outputSchema: z.object({ + validationChecklist: z.string().optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + const dbQueryConfig = ctx.get('dbQueryConfig'); + const schemaHelper = ctx.get('schemaHelper'); + const globalContext = ctx.get('globalContext'); + const schema = inputData.schema as DatabaseSchema; + + const allChecks = collectChecklistRules( + globalContext, + schemaHelper.getTablesContext(schema), + ); + if ( + shouldSkipChecklistGeneration({ + checklistGenerationEnabled: + dbQueryConfig.nodes?.generateChecklistNode?.enabled !== false, + existingChecklist: inputData.validationChecklist, + tableCount: Object.keys(schema.tables).length, + availableRuleCount: allChecks.length, + }) + ) { + return {}; + } + + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Filtering validation checklist for semantic validation.', + }); + + const indexedChecks = toIndexedChecklist(allChecks); + + const parallelism = + dbQueryConfig.nodes?.generateChecklistNode?.parallelism ?? 1; + + const invokePrompt = buildChecklistPrompt( + inputData.prompt, + Object.keys(schema.tables).join(', '), + schemaHelper.asString(schema), + indexedChecks, + ); + + const results = await Promise.all( + Array.from({length: parallelism}, () => + invokeLlm(cheapLlm, invokePrompt), + ), + ); + + const mergedIndexes = new Set(); + for (const output of results) { + parseIndexes(stripThinkingTokens(output), allChecks.length).forEach(n => + mergedIndexes.add(n), + ); + } + + if (mergedIndexes.size === 0) { + return {}; + } + + const validationChecklist = Array.from(mergedIndexes) + .sort((a, b) => a - b) + .map(i => allChecks[i - 1]) + .join('\n'); + + return {validationChecklist}; + }, +}); + +function parseIndexes(response: string, maxIndex: number): number[] { + const trimmed = response.trim(); + if (!trimmed || trimmed === 'none') return []; + return trimmed + .split(',') + .map(s => Number.parseInt(s.trim(), 10)) + .filter(n => !Number.isNaN(n) && n >= 1 && n <= maxIndex); +} + +function collectChecklistRules( + globalContext: string[] | undefined, + tableContext: string[], +): string[] { + return [...(globalContext ?? []), ...tableContext]; +} + +function shouldSkipChecklistGeneration(params: { + checklistGenerationEnabled: boolean; + existingChecklist: string | undefined; + tableCount: number; + availableRuleCount: number; +}): boolean { + return ( + !params.checklistGenerationEnabled || + !!params.existingChecklist || + params.tableCount <= 2 || + params.availableRuleCount === 0 + ); +} + +function toIndexedChecklist(checks: string[]): string { + return checks.map((check, i) => `${i + 1}. ${check}`).join('\n'); +} + +function buildChecklistPrompt( + prompt: string, + tables: string, + schema: string, + indexedChecks: string, +): string { + return CHECKLIST_PROMPT.replace('{prompt}', prompt) + .replace('{tables}', tables) + .replace('{schema}', schema) + .replace('{indexedChecks}', indexedChecks); +} diff --git a/src/mastra/workflows/db-query/steps/index.ts b/src/mastra/workflows/db-query/steps/index.ts new file mode 100644 index 0000000..546c7a4 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/index.ts @@ -0,0 +1,23 @@ +export {datasetResolutionStep} from './dataset-resolution.step'; +export {cacheCheckStep} from './cache-check.step'; +export {tableSelectionStep} from './table-selection.step'; +export {templateMatchStep} from './template-match.step'; +export {changeClassificationStep} from './change-classification.step'; +export {discoveryRoutingStep} from './discovery-routing.step'; +export {columnSelectionStep} from './column-selection.step'; +export {generateChecklistStep} from './generate-checklist.step'; +export {verifyChecklistStep} from './verify-checklist.step'; +export {sqlGenerationStep} from './sql-generation.step'; +export {syntacticValidationStep} from './syntactic-validation.step'; +export {semanticValidationStep} from './semantic-validation.step'; +export {descriptionGenerationStep} from './description-generation.step'; +export {validationMergeStep} from './validation-merge.step'; +export {queryRepairStep} from './query-repair.step'; +export {datasetPersistenceStep} from './dataset-persistence.step'; +export {failureStep} from './failure.step'; +export { + validationCycleWorkflow, + validationCycleStep, + validationCycleSchema, +} from './validation-cycle.step'; +export type {ValidationCycleState} from './validation-cycle.step'; diff --git a/src/mastra/workflows/db-query/steps/query-repair.step.ts b/src/mastra/workflows/db-query/steps/query-repair.step.ts new file mode 100644 index 0000000..d9b750e --- /dev/null +++ b/src/mastra/workflows/db-query/steps/query-repair.step.ts @@ -0,0 +1,190 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens, stripCodeBlock} from '../llm-helpers'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +const FIX_QUERY_PROMPT = ` + +You are an expert AI assistant that fixes SQL query errors. +You are given a SQL query that has validation errors related to specific tables. +Your task is to fix ONLY the parts of the query related to the listed error tables. +DO NOT change any part of the query that does not involve the error tables. +Preserve the overall structure, logic, and all other table references exactly as they are. + +Rules: +- Only modify clauses, joins, columns, or conditions that involve the error tables. +- Do not add, remove, or reorder columns or tables that are not related to the error. +- Do not change aliases, formatting, or logic for unrelated parts of the query. +- **DO NOT make any DML statements** (INSERT, UPDATE, DELETE, DROP etc.) to the database. +- Use the provided schema for the error-related tables to write correct SQL. +- The dialect is {dialect}. + + + +{question} + + + +{currentQuery} + + + +{errorSchema} + + + +{errorFeedback} + + +{checks} + +{historicalErrors} + + +Output should only be a valid SQL query with no other special character or formatting. +Contains the required valid SQL with the error fixed. +It should have no other character or symbol or character that is not part of SQLs. +`; + +/** + * QueryRepairStep — replaces FixQueryNode. + * + * Fixes SQL errors by providing the LLM with just the error-related + * table schemas and asking it to fix only those parts. + */ +export const queryRepairStep = createStep({ + id: 'query-repair', + inputSchema: z.object({ + prompt: z.string(), + sql: z.string(), + schema: DatabaseSchemaZ, + feedbacks: z.array(z.string()).optional(), + syntacticErrorTables: z.array(z.string()).optional(), + semanticErrorTables: z.array(z.string()).optional(), + validationChecklist: z.string().optional(), + }), + outputSchema: z.object({ + sql: z.string().optional(), + status: z.string().optional(), + replyToUser: z.string().optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + const dbQueryConfig = ctx.get('dbQueryConfig'); + const schemaHelper = ctx.get('schemaHelper'); + const schema = inputData.schema as DatabaseSchema; + + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'Fixing SQL query based on validation errors'}, + }); + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Fixing SQL query based on validation errors', + }); + + const errorTables = [ + ...(inputData.syntacticErrorTables ?? []), + ...(inputData.semanticErrorTables ?? []), + ]; + + const trimmedSchema = trimSchema(schema, errorTables); + const errorSchemaString = schemaHelper.asString(trimmedSchema); + + const feedbacks = inputData.feedbacks ?? []; + const lastFeedback = feedbacks[feedbacks.length - 1] ?? ''; + const historicalErrors = feedbacks.slice(0, -1); + + const checks = buildChecks(inputData, trimmedSchema, schemaHelper); + const dialect = dbQueryConfig.db?.dialect ?? 'PostgreSQL'; + + const prompt = FIX_QUERY_PROMPT.replace('{dialect}', dialect) + .replace('{question}', inputData.prompt) + .replace('{currentQuery}', inputData.sql) + .replace('{errorSchema}', errorSchemaString) + .replace('{errorFeedback}', lastFeedback) + .replace('{checks}', checks) + .replace( + '{historicalErrors}', + historicalErrors.length + ? [ + '', + 'You already faced following issues in the past -', + historicalErrors.join('\n'), + '', + ].join('\n') + : '', + ); + + const rawOutput = await invokeLlm(cheapLlm, prompt); + const response = stripThinkingTokens(rawOutput); + const sql = stripCodeBlock(response) || undefined; + + if (!sql) { + await writer.write({ + type: LLMStreamEventType.Log, + data: `SQL fix failed: ${response}`, + }); + return { + status: 'failed', + replyToUser: + 'Failed to fix SQL query. Please try rephrasing your question or provide more details.', + }; + } + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Fixed SQL query: ${sql}`, + }); + + return {sql, status: 'pass'}; + }, +}); + +function trimSchema( + fullSchema: DatabaseSchema, + errorTables: string[], +): DatabaseSchema { + const errorTableSet = new Set(errorTables); + const trimmedTables: DatabaseSchema['tables'] = {}; + + for (const tableName of Object.keys(fullSchema.tables)) { + if (errorTableSet.has(tableName)) { + trimmedTables[tableName] = fullSchema.tables[tableName]; + } + } + + const trimmedRelations = fullSchema.relations.filter( + rel => + errorTableSet.has(rel.table) || errorTableSet.has(rel.referencedTable), + ); + + return {tables: trimmedTables, relations: trimmedRelations}; +} + +function buildChecks( + inputData: {validationChecklist?: string}, + trimmedSchema: DatabaseSchema, + schemaHelper: {getTablesContext(schema: DatabaseSchema): string[]}, +): string { + if (inputData.validationChecklist) { + return [ + '', + 'You must keep these additional details in mind while fixing the query -', + ...inputData.validationChecklist.split('\n').map(check => `- ${check}`), + '', + ].join('\n'); + } + const context = schemaHelper.getTablesContext(trimmedSchema); + if (context.length === 0) return ''; + return [ + '', + 'You must keep these additional details in mind while fixing the query -', + ...context.map(check => `- ${check}`), + '', + ].join('\n'); +} diff --git a/src/mastra/workflows/db-query/steps/semantic-validation.step.ts b/src/mastra/workflows/db-query/steps/semantic-validation.step.ts new file mode 100644 index 0000000..a256b01 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/semantic-validation.step.ts @@ -0,0 +1,218 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +const SEMANTIC_VALIDATION_PROMPT = ` + +You are an AI assistant that validates whether a SQL query satisfies a given checklist. +The query has already been validated for syntax and correctness. +Go through each checklist item and verify it against the SQL query. +DO NOT make up issues that do not exist in the query. + + + +{userPrompt} + + + +{query} + + + +{schema} + + + +{tableNames} + + + +{checklist} + + +{feedbacks} + + +If the query satisfies ALL checklist items, return ONLY a valid tag with no other text: + + + + +If any checklist item is NOT satisfied, return your response in two sections: +1. An invalid tag containing each failed item with a detailed explanation of what is wrong and how it should be fixed. +2. A tables tag listing ALL table names from the available tables that are related to the errors. Be generous - include tables directly involved in the error, tables that need to be joined to fix the issue, and any tables that could be relevant. It is better to include extra tables than to miss any. + + + +- Salary values are not converted to USD. The query should join the exchange_rates table using currency_id and multiply salary by the rate. +- Lost and hold deals are not excluded. Add a WHERE condition to filter out deals with status 0 and 2. + +exchange_rates, deals, employees + + +`; + +const SEMANTIC_FEEDBACK_PROMPT = ` + +We also need to consider the users feedback on the last attempt at query generation. + +But was rejected by validator with the following errors - +{feedback} + +Keep these feedbacks in mind while validating the new query. +`; + +/** + * SemanticValidationStep — replaces SemanticValidatorNode. + * + * Uses an LLM to validate the SQL against the filtered checklist. + * Checks for logical correctness beyond syntax. + */ +export const semanticValidationStep = createStep({ + id: 'semantic-validation', + inputSchema: z.object({ + prompt: z.string(), + sql: z.string(), + schema: DatabaseSchemaZ, + validationChecklist: z.string().optional(), + feedbacks: z.array(z.string()).optional(), + }), + outputSchema: z.object({ + semanticStatus: z.string(), + semanticFeedback: z.string().optional(), + semanticErrorTables: z.array(z.string()).optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + const smartLlm = ctx.get('smartLlm'); + const dbQueryConfig = ctx.get('dbQueryConfig'); + const tableSearchService = ctx.get('tableSearchService'); + const schemaHelper = ctx.get('schemaHelper'); + const permissionHelper = ctx.get('permissionHelper'); + const schema = inputData.schema as DatabaseSchema; + + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: { + status: "Verifying if the query fully satisfies the user's requirement", + }, + }); + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Validating the query semantically.', + }); + + const llm = selectSemanticValidationModel( + dbQueryConfig.nodes?.semanticValidatorNode?.useSmartLLM ?? false, + smartLlm, + cheapLlm, + ); + + const tableList = + (await tableSearchService.getTables(inputData.prompt)) ?? []; + const accessibleTables = filterByPermissions(tableList, permissionHelper); + + const prompt = buildSemanticValidationPrompt({ + userPrompt: inputData.prompt, + sql: inputData.sql, + schema: schemaHelper.asString(schema), + tableNames: accessibleTables, + checklist: inputData.validationChecklist, + feedbacks: inputData.feedbacks, + }); + + const rawOutput = await invokeLlm(llm, prompt); + const response = stripThinkingTokens(rawOutput); + + const parsed = parseSemanticValidationResponse(response); + if (parsed.isValid) { + return {semanticStatus: 'pass'}; + } + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Query Validation Failed by LLM: ${parsed.reason}`, + }); + + return { + semanticStatus: 'query_error', + semanticFeedback: `Query Validation Failed by LLM: ${parsed.reason}`, + semanticErrorTables: parsed.errorTables, + }; + }, +}); + +function selectSemanticValidationModel( + useSmartModel: boolean, + smartModel: TModel, + cheapModel: TModel, +): TModel { + return useSmartModel ? smartModel : cheapModel; +} + +function buildSemanticValidationPrompt(params: { + userPrompt: string; + sql: string; + schema: string; + tableNames: string[]; + checklist: string | undefined; + feedbacks: string[] | undefined; +}): string { + const feedbacksText = params.feedbacks?.length + ? SEMANTIC_FEEDBACK_PROMPT.replace( + '{feedback}', + params.feedbacks.join('\n'), + ) + : ''; + + return SEMANTIC_VALIDATION_PROMPT.replace('{userPrompt}', params.userPrompt) + .replace('{query}', params.sql) + .replace('{schema}', params.schema) + .replace('{tableNames}', params.tableNames.join(', ')) + .replace('{checklist}', params.checklist ?? 'No checklist provided.') + .replace('{feedbacks}', feedbacksText); +} + +function parseSemanticValidationResponse(response: string): { + isValid: boolean; + reason: string; + errorTables: string[]; +} { + const invalidMatch = /(.*?)<\/invalid>/s.exec(response); + const tablesMatch = /(.*?)<\/tables>/s.exec(response); + const isValid = + response.includes('') || response.includes(''); + + if (isValid && !invalidMatch) { + return {isValid: true, reason: '', errorTables: []}; + } + + return { + isValid: false, + reason: invalidMatch ? invalidMatch[1].trim() : response.trim(), + errorTables: tablesMatch + ? tablesMatch[1] + .split(',') + .map(tableName => tableName.trim()) + .filter(tableName => tableName.length > 0) + : [], + }; +} + +function filterByPermissions( + tables: string[], + permissionHelper: + | {findMissingPermissions(tables: string[]): string[]} + | undefined, +): string[] { + if (!permissionHelper) return tables; + return tables.filter(t => { + const name = t.toLowerCase().slice(t.indexOf('.') + 1); + return permissionHelper.findMissingPermissions([name]).length === 0; + }); +} diff --git a/src/mastra/workflows/db-query/steps/sql-generation.step.ts b/src/mastra/workflows/db-query/steps/sql-generation.step.ts new file mode 100644 index 0000000..a00f303 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/sql-generation.step.ts @@ -0,0 +1,221 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens, stripCodeBlock} from '../llm-helpers'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +const SQL_GENERATION_PROMPT = ` + +You are an expert AI assistant that generates SQL queries based on user questions and a given database schema. +You try to following the instructions carefully to generate the SQL query that answers the question. +Do not hallucinate details or make up information. +Your task is to convert a question into a SQL query, given a {dialect} database schema. +Adhere to these rules: +- **Deliberately go through the question and database schema word by word** to appropriately answer the question +- **DO NOT make any DML statements** (INSERT, UPDATE, DELETE, DROP etc.) to the database. +- Never query for all the columns from a specific table, only ask for the relevant columns for the given the question. +- You can only generate a single query, so if you need multiple results you can use JOINs, subqueries, CTEs or UNIONS. +- Do not make any assumptions about the user's intent beyond what is explicitly provided in the prompt. +- Ensure proper grouping with brackets for where clauses with multiple conditions using AND and OR. +- Follow each and every single rule in the "must-follow-rules" section carefully while writing the query. DO NOT SKIP ANY RULE. + + +{question} + + + +{dbschema} + + +{checks} + +{exampleQueries} + +{feedbacks} + + +Output should only be a valid SQL query with no other special character or formatting. +Contains the required valid SQL satisfying all the constraints. +It should have no other character or symbol or character that is not part of SQLs. +`; + +const FEEDBACK_PROMPT = ` + +We also need to consider the users feedback on the last attempt at query generation. +Make sure you fix the provided error without introducing any new or past errors. +In the last attempt, you generated this SQL query - + +{query} + + + +{feedback} + + +{historicalErrors} +`; + +/** + * SqlGenerationStep — replaces SqlGenerationNode. + * + * Generates SQL from the user prompt and filtered schema. + * Selects cheap vs smart LLM based on changeType, table count, and retry status. + */ +export const sqlGenerationStep = createStep({ + id: 'sql-generation', + inputSchema: z.object({ + prompt: z.string(), + schema: DatabaseSchemaZ, + changeType: z.enum(['minor', 'major', 'rewrite']).optional(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + fromCache: z.boolean().optional(), + validationChecklist: z.string().optional(), + feedbacks: z.array(z.string()).optional(), + sql: z.string().optional(), + }), + outputSchema: z.object({ + sql: z.string().optional(), + status: z.string().optional(), + replyToUser: z.string().optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + const smartLlm = ctx.get('smartLlm'); + const dbQueryConfig = ctx.get('dbQueryConfig'); + const schemaHelper = ctx.get('schemaHelper'); + const globalContext = ctx.get('globalContext'); + const schema = inputData.schema as DatabaseSchema; + + const isSingleTable = + schema?.tables && Object.keys(schema.tables).length === 1; + + // Use cheap LLM for validation fix retries + const isValidationFixRetry = + inputData.feedbacks?.length && + inputData.feedbacks[inputData.feedbacks.length - 1].startsWith( + 'Query Validation Failed', + ); + + const llm = + inputData.changeType === 'minor' || isSingleTable || isValidationFixRetry + ? cheapLlm + : smartLlm; + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Generating SQL query from the prompt - ${inputData.prompt}`, + }); + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'Generating SQL query from the prompt'}, + }); + + const feedbacksText = buildFeedbacks(inputData); + const exampleQueries = inputData.feedbacks?.length + ? '' + : buildSampleQueries(inputData); + const checks = buildChecks(inputData, schema, schemaHelper, globalContext); + + const dialect = dbQueryConfig.db?.dialect ?? 'PostgreSQL'; + + const prompt = SQL_GENERATION_PROMPT.replace('{dialect}', dialect) + .replace('{question}', inputData.prompt) + .replace('{dbschema}', schemaHelper.asString(schema)) + .replace('{checks}', checks) + .replace('{exampleQueries}', exampleQueries) + .replace('{feedbacks}', feedbacksText); + + const rawOutput = await invokeLlm(llm, prompt); + const response = stripThinkingTokens(rawOutput); + const sql = stripCodeBlock(response) || undefined; + + if (!sql) { + await writer.write({ + type: LLMStreamEventType.Log, + data: `SQL generation failed: ${response}`, + }); + return { + status: 'failed', + replyToUser: + 'Failed to generate SQL query. Please try rephrasing your question or provide more details.', + }; + } + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Generated SQL query: ${sql}`, + }); + + return {sql, status: 'pass'}; + }, +}); + +function buildFeedbacks(inputData: { + feedbacks?: string[]; + sql?: string; +}): string { + if (!inputData.feedbacks?.length) return ''; + const lastFeedback = inputData.feedbacks[inputData.feedbacks.length - 1]; + const otherFeedbacks = inputData.feedbacks.slice(0, -1); + return FEEDBACK_PROMPT.replace('{query}', inputData.sql ?? '') + .replace( + '{feedback}', + `This was the error in the latest query you generated - \n${lastFeedback}`, + ) + .replace( + '{historicalErrors}', + otherFeedbacks.length + ? [ + '', + 'You already faced following issues in the past -', + otherFeedbacks.join('\n'), + '', + ].join('\n') + : '', + ); +} + +function buildSampleQueries(inputData: { + sampleSql?: string; + sampleSqlPrompt?: string; + fromCache?: boolean; +}): string { + if (!inputData.sampleSql) return ''; + const startTag = inputData.fromCache + ? '' + : ''; + const endTag = inputData.fromCache + ? '' + : ''; + const baseLine = inputData.fromCache + ? 'Here is an example query for reference that is similar to the question asked and has been validated by the user' + : 'Here is the last valid SQL query that was generated for the user that is supposed to be used as the base line for the next query generation.'; + return `${startTag}\n${baseLine} -\n${inputData.sampleSql}\nThis was generated for the following question - \n${inputData.sampleSqlPrompt}\n${endTag}`; +} + +function buildChecks( + inputData: {validationChecklist?: string}, + schema: DatabaseSchema, + schemaHelper: {getTablesContext(schema: DatabaseSchema): string[]}, + globalContext: string[], +): string { + if (inputData.validationChecklist) { + return [ + '', + 'You must keep these additional details in mind while writing the query -', + ...inputData.validationChecklist.split('\n').map(check => `- ${check}`), + '', + ].join('\n'); + } + return [ + '', + 'You must keep these additional details in mind while writing the query -', + ...(globalContext ?? []).map(check => `- ${check}`), + ...schemaHelper.getTablesContext(schema).map(check => `- ${check}`), + '', + ].join('\n'); +} diff --git a/src/mastra/workflows/db-query/steps/syntactic-validation.step.ts b/src/mastra/workflows/db-query/steps/syntactic-validation.step.ts new file mode 100644 index 0000000..ffd4cdb --- /dev/null +++ b/src/mastra/workflows/db-query/steps/syntactic-validation.step.ts @@ -0,0 +1,101 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +const SYNTACTIC_ERROR_PROMPT = `You are an AI assistant that categorizes the SQL query error and identifies related tables. + +Here is the SQL query error that you need to categorize - +{error} + +Here is the query that resulted in the error - +{query} + +Here are all the available tables in the database - +{tableNames} + +Categorize the error into one of these two categories: +- table_not_found: Any error that indicates a table or column is missing +- query_error: All other errors + +Also identify ALL tables that are related to the error. Be generous - include tables that are directly involved in the error, tables referenced in the failing part of the query, and tables that might need to be joined or referenced to fix the error. It is better to include extra tables than to miss any. + +Return your response in exactly this format with no other text: +table_not_found or query_error +comma, separated, table, names +`; + +/** + * SyntacticValidationStep — replaces SyntacticValidatorNode. + * + * Validates SQL by executing it against the database connector (EXPLAIN/dry-run). + * If it fails, classifies the error type and identifies related tables. + */ +export const syntacticValidationStep = createStep({ + id: 'syntactic-validation', + inputSchema: z.object({ + sql: z.string(), + schema: DatabaseSchemaZ, + }), + outputSchema: z.object({ + syntacticStatus: z.string(), + syntacticFeedback: z.string().optional(), + syntacticErrorTables: z.array(z.string()).optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + const connector = ctx.get('connector'); + + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'Validating generated SQL query'}, + }); + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Validating the query syntactically.', + }); + + try { + if (!inputData.sql) { + throw new Error('No SQL query generated to validate'); + } + await connector.validate(inputData.sql); + return {syntacticStatus: 'pass'}; + } catch (error) { + const tableNames = Object.keys(inputData.schema.tables); + const errorMessage = (error as Error).message; + + const prompt = SYNTACTIC_ERROR_PROMPT.replace('{error}', errorMessage) + .replace('{query}', inputData.sql) + .replace('{tableNames}', tableNames.join(', ')); + + const rawOutput = await invokeLlm(cheapLlm, prompt); + const result = stripThinkingTokens(rawOutput); + + const categoryMatch = /(.*?)<\/category>/s.exec(result); + const tablesMatch = /(.*?)<\/tables>/s.exec(result); + + const category = categoryMatch ? categoryMatch[1].trim() : 'query_error'; + const errorTables = tablesMatch + ? tablesMatch[1] + .split(',') + .map(t => t.trim()) + .filter(t => t.length > 0) + : []; + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Query Validation Failed by DB: ${category} with error ${errorMessage}`, + }); + + return { + syntacticStatus: category, + syntacticFeedback: `Query Validation Failed by DB: ${category} with error ${errorMessage}`, + syntacticErrorTables: errorTables, + }; + } + }, +}); diff --git a/src/mastra/workflows/db-query/steps/table-selection.step.ts b/src/mastra/workflows/db-query/steps/table-selection.step.ts new file mode 100644 index 0000000..8f72f84 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/table-selection.step.ts @@ -0,0 +1,283 @@ +import {createStep} from '@mastra/core/workflows'; +import type {MastraLanguageModel} from '@mastra/core/agent'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; + +const TABLE_SELECTION_PROMPT = ` + +You are an AI assistant that extracts table names that are relevant to the users query that will be used to generate an SQL query later. +- Consider not just the user query but also the context and the table descriptions while selecting the tables. +- Carefully consider each and every table before including or excluding it. +- If doubtful about a table's relevance, include it anyway to give the SQL generation step more options to choose from. +- Assume that the table would have appropriate columns for relating them to any other table even if the description does not mention it. +- If you are not sure about the tables to select from the given schema, just return your doubt asking the user for more details or to rephrase the question in the following format - +failed attempt: reason for failure + + + +{tables} + + + +{query} + + +{checks} + +{feedbacks} + + +The output should be just a comma separated list of table names with no other text, comments or formatting. +Ensure that table names are exact and match the names in the input including schema if given. + +public.employees, public.departments + +In case of failure, return the failure message in the format - +failed attempt: + +failed attempt: reason for failure + +`; + +const FEEDBACK_PROMPT = ` + +We also need to consider the errors from last attempt at query generation. + +In the last attempt, these were the last tables selected: +{lastTables} + +But it was rejected with the following errors: +{feedback} + +Use these if they are relevant to the table selection, otherwise ignore them, they would be considered again during the SQL generation step. + +`; + +/** + * TableSelectionStep — replaces GetTablesNode. + * + * Uses knowledge graph + vector search to find candidate tables, + * then asks an LLM to pick the relevant ones. + * Includes a 2-attempt internal retry loop. + */ +export const tableSelectionStep = createStep({ + id: 'table-selection', + inputSchema: z.object({ + prompt: z.string(), + feedbacks: z.array(z.string()).optional(), + schema: DatabaseSchemaZ.optional(), + }), + outputSchema: z.object({ + schema: DatabaseSchemaZ.optional(), + status: z.string().optional(), + replyToUser: z.string().optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const cheapLlm = ctx.get('cheapLlm'); + const smartLlm = ctx.get('smartLlm'); + const dbQueryConfig = ctx.get('dbQueryConfig'); + const schemaStore = ctx.get('schemaStore'); + const schemaHelper = ctx.get('schemaHelper'); + const tableSearchService = ctx.get('tableSearchService'); + const permissionHelper = ctx.get('permissionHelper'); + const globalContext = ctx.get('globalContext'); + + const tableList = await tableSearchService.getTables(inputData.prompt, 10); + const accessibleTables = filterByPermissions(tableList, permissionHelper); + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Selecting from tables: ${accessibleTables}`, + }); + + const dbSchema = schemaStore.filteredSchema(accessibleTables); + const allTables = getTablesFromSchema(dbSchema); + if (allTables.length === 0) { + throw new Error( + 'No tables found in the provided database schema. Please ensure the schema is valid.', + ); + } + + const useSmartLLM = + dbQueryConfig.nodes?.getTablesNode?.useSmartLLM ?? false; + const llm = useSmartLLM ? smartLlm : cheapLlm; + + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'Extracting relevant tables from the schema'}, + }); + + const feedbacksText = buildFeedbackText( + inputData.feedbacks, + inputData.schema, + ); + + const checks = [ + '', + ...(globalContext ?? []).map(check => `- ${check}`), + ...schemaHelper.getTablesContext(dbSchema).map(check => `- ${check}`), + '', + ].join('\n'); + + const selectionResult = await selectTablesWithRetries({ + llm, + prompt: inputData.prompt, + allTables, + feedbacksText, + checks, + dbSchema, + writer, + maxAttempts: 2, + }); + + if (selectionResult.status === 'failed') { + return { + status: 'failed', + replyToUser: selectionResult.replyToUser, + }; + } + + const requiredTables = selectionResult.tables; + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Picked tables - ${requiredTables.join(', ')}`, + }); + + if (requiredTables.length === 0) { + throw new Error( + 'LLM did not return a valid comma separated string response.', + ); + } + + return { + schema: schemaStore.filteredSchema(requiredTables), + }; + }, +}); + +function getTablesFromSchema(schema: DatabaseSchema): string[] { + return Object.keys(schema.tables).map( + tableName => `${tableName}: ${schema.tables[tableName].description ?? ''}`, + ); +} + +function filterByPermissions( + tables: string[], + permissionHelper: + | {findMissingPermissions(tables: string[]): string[]} + | undefined, +): string[] { + if (!permissionHelper) return tables; + return tables.filter(t => { + const name = t.toLowerCase().slice(t.indexOf('.') + 1); + return permissionHelper.findMissingPermissions([name]).length === 0; + }); +} + +function validateTables(tables: string[], schema: DatabaseSchema): boolean { + return tables.every(t => schema.tables[t] !== undefined); +} + +function buildFeedbackText( + feedbacks: string[] | undefined, + schema: z.infer | undefined, +): string { + if (!feedbacks?.length) { + return ''; + } + + const lastTables = schema ? Object.keys(schema.tables).join(', ') : ''; + return FEEDBACK_PROMPT.replace('{lastTables}', lastTables).replace( + '{feedback}', + feedbacks.join('\n'), + ); +} + +function parseTableSelectionOutput( + output: string, +): {status: 'failed'; reason: string} | {status: 'success'; tables: string[]} { + if (output.startsWith('failed attempt:')) { + return { + status: 'failed', + reason: output.replace('failed attempt: ', ''), + }; + } + + const lastLine = output.split('\n').pop() ?? ''; + return { + status: 'success', + tables: lastLine.split(',').map(tableName => tableName.trim()), + }; +} + +async function selectTablesWithRetries(params: { + llm: MastraLanguageModel; + prompt: string; + allTables: string[]; + feedbacksText: string; + checks: string; + dbSchema: DatabaseSchema; + writer: { + write: (event: { + type: LLMStreamEventType; + data: string | {status: string}; + }) => Promise; + }; + maxAttempts: number; +}): Promise< + | {status: 'failed'; replyToUser: string} + | {status: 'success'; tables: string[]} +> { + let attempts = 0; + while (attempts < params.maxAttempts) { + attempts++; + const prompt = TABLE_SELECTION_PROMPT.replace( + '{tables}', + params.allTables.join('\n\n'), + ) + .replace('{query}', params.prompt) + .replace('{feedbacks}', params.feedbacksText) + .replace('{checks}', params.checks); + + const rawResult = await invokeLlm(params.llm, prompt); + const output = stripThinkingTokens(rawResult); + const parsed = parseTableSelectionOutput(output); + + if (parsed.status === 'failed') { + await params.writer.write({ + type: LLMStreamEventType.Log, + data: `Table selection failed: ${output}`, + }); + return {status: 'failed', replyToUser: parsed.reason}; + } + + if (validateTables(parsed.tables, params.dbSchema)) { + return {status: 'success', tables: parsed.tables}; + } + + if (attempts === params.maxAttempts) { + return { + status: 'failed', + replyToUser: + 'Not able to select relevant tables from the schema. Please rephrase the question or provide more details.', + }; + } + + await params.writer.write({ + type: LLMStreamEventType.Log, + data: `LLM returned invalid tables, trying again`, + }); + } + + return { + status: 'failed', + replyToUser: + 'Not able to select relevant tables from the schema. Please rephrase the question or provide more details.', + }; +} diff --git a/src/mastra/workflows/db-query/steps/template-match.step.ts b/src/mastra/workflows/db-query/steps/template-match.step.ts new file mode 100644 index 0000000..086cfa1 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/template-match.step.ts @@ -0,0 +1,174 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; + +const TEMPLATE_MATCH_PROMPT = ` + +You are an expert at matching user prompts to query templates. +Given a user prompt and a list of query templates with their canonical prompts and placeholders, determine if any template can EXACTLY fulfill the user's request. + +A template is a match ONLY if ALL of the following are true: +- The template produces exactly the data the user is asking for — not more, not less +- The user's intent is identical to the template's purpose, just with different parameter values +- All non-optional placeholders can be filled from the user's prompt or have defaults +- The template does not include extra filters, columns, or logic that the user did not ask for +- The template does not omit any filters, columns, or logic that the user is asking for + +Do NOT match if: +- The template is only similar or partially relevant +- The template would need structural changes beyond placeholder substitution to answer the question +- The user is asking for something the template cannot express through its placeholders alone + + +{prompt} + + +{templates} + + +If a template is an exact match, return: match +If no template exactly matches, return: no_match + +Do not return any other text or explanation. +`; + +/** + * TemplateMatchStep — replaces CheckTemplatesNode. + * + * Searches the template cache for matching query templates. + * If a template matches exactly, resolves placeholders and returns the SQL. + */ +export const templateMatchStep = createStep({ + id: 'template-match', + inputSchema: z.object({ + prompt: z.string(), + }), + outputSchema: z.object({ + sql: z.string().optional(), + description: z.string().optional(), + fromTemplate: z.boolean().optional(), + templateId: z.string().optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const templateCache = ctx.get('templateCache'); + const cheapLlm = ctx.get('cheapLlm'); + const permissionHelper = ctx.get('permissionHelper'); + const templateHelper = ctx.get('templateHelper'); + const schemaStore = ctx.get('schemaStore'); + + const relevantDocs = await templateCache.invoke(inputData.prompt); + if (relevantDocs.length === 0) { + await writer.write({ + type: LLMStreamEventType.Log, + data: 'No templates found for this prompt', + }); + return {}; + } + + const templatesText = relevantDocs + .map((doc, index) => { + const metadata = doc.metadata; + const placeholders = JSON.parse(metadata.placeholders); + const placeholderText = placeholders + .map( + (p: {name: string; type: string; description: string}) => + ` - {{${p.name}}} (${p.type}): ${p.description}`, + ) + .join('\n'); + return ` +${doc.pageContent} + +${placeholderText} + +`; + }) + .join('\n'); + + const prompt = TEMPLATE_MATCH_PROMPT.replace( + '{prompt}', + inputData.prompt, + ).replace('{templates}', templatesText); + + const rawResponse = await invokeLlm(cheapLlm, prompt); + const trimmed = stripThinkingTokens(rawResponse).trim(); + + if (trimmed === 'no_match') { + await writer.write({ + type: LLMStreamEventType.Log, + data: 'No matching template found for this prompt', + }); + return {}; + } + + const matchResult = trimmed.match(/^match\s+(\d+)$/); + if (!matchResult) { + await writer.write({ + type: LLMStreamEventType.Log, + data: `Unexpected template match response: ${trimmed}`, + }); + return {}; + } + + const matchIndex = Number.parseInt(matchResult[1], 10) - 1; + if (matchIndex < 0 || matchIndex >= relevantDocs.length) { + await writer.write({ + type: LLMStreamEventType.Log, + data: `Template match index ${matchResult[1]} out of bounds`, + }); + return {}; + } + + const matchedDoc = relevantDocs[matchIndex]; + const template = templateHelper.parseTemplateMetadata(matchedDoc.metadata); + + // Permission check + if (permissionHelper) { + const missingPermissions = permissionHelper.findMissingPermissions( + template.tables, + ); + if (missingPermissions.length > 0) { + await writer.write({ + type: LLMStreamEventType.Log, + data: `Template matched but missing permissions: ${missingPermissions.join(', ')}`, + }); + return {}; + } + } + + // Resolve placeholders with column context from schema + try { + const schema = schemaStore.filteredSchema(template.tables); + const resolved = await templateHelper.resolveTemplate( + template, + inputData.prompt, + {configurable: {}}, + schema, + ); + + await writer.write({ + type: LLMStreamEventType.Log, + data: `Template matched: ${template.description}`, + }); + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: {status: 'Matched query template'}, + }); + + return { + sql: resolved.sql, + description: resolved.description, + fromTemplate: true, + templateId: template.id, + }; + } catch (error) { + await writer.write({ + type: LLMStreamEventType.Log, + data: `Template resolution failed: ${(error as Error).message}`, + }); + return {}; + } + }, +}); diff --git a/src/mastra/workflows/db-query/steps/validation-cycle.step.ts b/src/mastra/workflows/db-query/steps/validation-cycle.step.ts new file mode 100644 index 0000000..2da2f22 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/validation-cycle.step.ts @@ -0,0 +1,392 @@ +import {createStep, createWorkflow} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; +import {tableSelectionStep} from './table-selection.step'; +import {sqlGenerationStep} from './sql-generation.step'; +import {queryRepairStep} from './query-repair.step'; +import {syntacticValidationStep} from './syntactic-validation.step'; +import {semanticValidationStep} from './semantic-validation.step'; +import {descriptionGenerationStep} from './description-generation.step'; +import {validationMergeStep} from './validation-merge.step'; + +/** Shared schema for both input and output of the validation cycle (enables dountil loop). */ +export const validationCycleSchema = z.object({ + prompt: z.string(), + schema: DatabaseSchemaZ, + sql: z.string().optional(), + description: z.string().optional(), + changeType: z.enum(['minor', 'major', 'rewrite']).optional(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + fromCache: z.boolean().optional(), + validationChecklist: z.string().optional(), + feedbacks: z.array(z.string()).optional(), + syntacticErrorTables: z.array(z.string()).optional(), + semanticErrorTables: z.array(z.string()).optional(), + directCall: z.boolean().optional(), + fixAttempts: z.number().default(0), + route: z + .enum(['accepted', 'fix-query', 'reselect-tables', 'failed']) + .optional(), + status: z.string().optional(), + replyToUser: z.string().optional(), +}); + +export type ValidationCycleState = z.infer; + +function asValidationCycleState(inputData: unknown): ValidationCycleState { + return validationCycleSchema.parse(inputData); +} + +const tableSelectionOutputSchema = z.object({ + schema: DatabaseSchemaZ.optional(), + status: z.string().optional(), + replyToUser: z.string().optional(), +}); + +const sqlPreparationOutputSchema = z.object({ + sql: z.string().optional(), + status: z.string().optional(), + replyToUser: z.string().optional(), +}); + +const validationParallelOutputSchema = z.object({ + 'syntactic-validation': z.object({ + syntacticStatus: z.string(), + syntacticFeedback: z.string().optional(), + syntacticErrorTables: z.array(z.string()).optional(), + }), + 'semantic-validation': z.object({ + semanticStatus: z.string(), + semanticFeedback: z.string().optional(), + semanticErrorTables: z.array(z.string()).optional(), + }), + 'description-generation': z.object({ + description: z.string().optional(), + }), +}); + +const validationMergeInputSchema = z.object({ + syntacticStatus: z.string().optional(), + syntacticFeedback: z.string().optional(), + syntacticErrorTables: z.array(z.string()).optional(), + semanticStatus: z.string().optional(), + semanticFeedback: z.string().optional(), + semanticErrorTables: z.array(z.string()).optional(), + description: z.string().optional(), + feedbacks: z.array(z.string()).optional(), + sql: z.string().optional(), + prompt: z.string(), + schema: DatabaseSchemaZ, + validationChecklist: z.string().optional(), + directCall: z.boolean().optional(), +}); + +const validationMergeOutputSchema = z.object({ + route: z.enum(['accepted', 'fix-query', 'reselect-tables', 'failed']), + status: z.string(), + feedbacks: z.array(z.string()), + syntacticErrorTables: z.array(z.string()).optional(), + semanticErrorTables: z.array(z.string()).optional(), + description: z.string().optional(), + sql: z.string().optional(), + prompt: z.string(), + schema: DatabaseSchemaZ, + validationChecklist: z.string().optional(), + directCall: z.boolean().optional(), +}); + +const validationCyclePassthroughWorkflow = createWorkflow({ + id: 'validation-cycle-passthrough', + inputSchema: validationCycleSchema, + outputSchema: validationCycleSchema, +}) + .map(async ({inputData}) => validationCycleSchema.parse(inputData)) + .commit(); + +const reselectTablesWorkflow = createWorkflow({ + id: 'validation-cycle-reselect-tables', + inputSchema: validationCycleSchema, + outputSchema: validationCycleSchema, +}) + .map(async ({inputData}) => { + const data = validationCycleSchema.parse(inputData); + return { + prompt: data.prompt, + feedbacks: data.feedbacks, + schema: data.schema, + }; + }) + .then(tableSelectionStep) + .map(async ({inputData, getInitData}) => { + const initData = getInitData(); + const selection = tableSelectionOutputSchema.parse(inputData); + + if (selection.status === 'failed') { + return buildFailedCycleState(initData, { + replyToUser: selection.replyToUser, + }); + } + + return { + ...initData, + schema: selection.schema ?? initData.schema, + route: undefined, + status: undefined, + replyToUser: undefined, + }; + }) + .commit(); + +const generateSqlWorkflow = createWorkflow({ + id: 'validation-cycle-generate-sql', + inputSchema: validationCycleSchema, + outputSchema: validationCycleSchema, +}) + .map(async ({inputData}) => { + const data = validationCycleSchema.parse(inputData); + return { + prompt: data.prompt, + schema: data.schema, + changeType: data.changeType, + sampleSql: data.sampleSql, + sampleSqlPrompt: data.sampleSqlPrompt, + fromCache: data.fromCache, + validationChecklist: data.validationChecklist, + feedbacks: data.feedbacks, + sql: data.sql, + }; + }) + .then(sqlGenerationStep) + .map(async ({inputData, getInitData}) => { + const initData = getInitData(); + const generated = sqlPreparationOutputSchema.parse(inputData); + + if (generated.status === 'failed') { + return buildFailedCycleState(initData, { + sql: undefined, + replyToUser: generated.replyToUser, + }); + } + + return { + ...initData, + sql: generated.sql, + route: undefined, + status: undefined, + replyToUser: undefined, + }; + }) + .commit(); + +const repairSqlWorkflow = createWorkflow({ + id: 'validation-cycle-repair-sql', + inputSchema: validationCycleSchema, + outputSchema: validationCycleSchema, +}) + .map(async ({inputData}) => { + const data = validationCycleSchema.parse(inputData); + return { + prompt: data.prompt, + sql: data.sql ?? '', + schema: data.schema, + feedbacks: data.feedbacks, + syntacticErrorTables: data.syntacticErrorTables, + semanticErrorTables: data.semanticErrorTables, + validationChecklist: data.validationChecklist, + }; + }) + .then(queryRepairStep) + .map(async ({inputData, getInitData}) => { + const initData = getInitData(); + const repaired = sqlPreparationOutputSchema.parse(inputData); + + if (repaired.status === 'failed') { + return buildFailedCycleState(initData, { + sql: initData.sql, + }); + } + + return { + ...initData, + sql: repaired.sql ?? initData.sql, + route: undefined, + status: undefined, + replyToUser: undefined, + }; + }) + .commit(); + +const prepareSqlWorkflow = createWorkflow({ + id: 'validation-cycle-prepare-sql', + inputSchema: validationCycleSchema, + outputSchema: validationCycleSchema, +}) + .branch([ + [ + async ({inputData}) => { + const state = asValidationCycleState(inputData); + return state.fixAttempts > 0 && !!state.sql; + }, + repairSqlWorkflow, + ], + [async () => true, generateSqlWorkflow], + ]) + .commit(); + +const runValidationWorkflow = createWorkflow({ + id: 'validation-cycle-run-validation', + inputSchema: validationCycleSchema, + outputSchema: validationMergeInputSchema, +}) + .parallel([ + syntacticValidationStep, + semanticValidationStep, + descriptionGenerationStep, + ]) + .map(async ({inputData, getInitData}) => { + const state = getInitData(); + const results = validationParallelOutputSchema.parse(inputData); + + return { + syntacticStatus: results['syntactic-validation'].syntacticStatus, + syntacticFeedback: results['syntactic-validation'].syntacticFeedback, + syntacticErrorTables: + results['syntactic-validation'].syntacticErrorTables, + semanticStatus: results['semantic-validation'].semanticStatus, + semanticFeedback: results['semantic-validation'].semanticFeedback, + semanticErrorTables: results['semantic-validation'].semanticErrorTables, + description: results['description-generation'].description, + feedbacks: state.feedbacks, + sql: state.sql, + prompt: state.prompt, + schema: state.schema, + validationChecklist: state.validationChecklist, + directCall: state.directCall, + }; + }) + .commit(); + +const validationMergeWorkflow = createWorkflow({ + id: 'validation-cycle-merge', + inputSchema: validationCycleSchema, + outputSchema: validationCycleSchema, +}) + .then(runValidationWorkflow) + .then(validationMergeStep) + .map(async ({inputData, getInitData}) => { + const merged = validationMergeOutputSchema.parse(inputData); + const initData = getInitData(); + + return { + ...merged, + changeType: initData.changeType, + sampleSql: initData.sampleSql, + sampleSqlPrompt: initData.sampleSqlPrompt, + fromCache: initData.fromCache, + fixAttempts: initData.fixAttempts + 1, + }; + }) + .commit(); + +const isFailedState = async ({ + inputData, +}: { + inputData: unknown; +}): Promise => { + const parsed = validationCycleSchema.safeParse(inputData); + return ( + parsed.success && + (parsed.data.route === 'failed' || parsed.data.status === 'failed') + ); +}; + +/** + * ValidationCycleWorkflow — one complete iteration of the SQL validation loop. + * + * Each call performs: + * - (if `route === 'reselect-tables'`) Re-runs table selection with feedbacks + * - (if `fixAttempts > 0`) Repairs the SQL using validation feedbacks + * - (else) Generates new SQL from scratch + * - Runs syntactic validation, semantic validation, and description generation in parallel + * - Merges validation results to produce the next iteration route decision + */ +export const validationCycleWorkflow = createWorkflow({ + id: 'validation-cycle', + inputSchema: validationCycleSchema, + outputSchema: validationCycleSchema, +}) + .branch([ + [ + async ({inputData}) => { + const state = asValidationCycleState(inputData); + return state.route === 'reselect-tables'; + }, + reselectTablesWorkflow, + ], + [async () => true, validationCyclePassthroughWorkflow], + ]) + .branch([ + [isFailedState, validationCyclePassthroughWorkflow], + [async () => true, prepareSqlWorkflow], + ]) + .branch([ + [isFailedState, validationCyclePassthroughWorkflow], + [async () => true, validationMergeWorkflow], + ]) + .commit(); + +/** + * Step wrapper for dountil(): delegates execution to the workflow-composed + * validation cycle while preserving outer workflow writer propagation. + */ +export const validationCycleStep = createStep({ + id: 'validation-cycle-step', + inputSchema: validationCycleSchema, + outputSchema: validationCycleSchema, + execute: async ({inputData, requestContext, writer}) => { + const run = await validationCycleWorkflow.createRun(); + const result = await run.start({ + inputData, + requestContext, + outputWriter: async (output: unknown) => { + await writer.write(output); + }, + }); + + if (result.status !== 'success') { + throw new Error( + 'Validation cycle workflow did not complete successfully.', + ); + } + + return validationCycleSchema.parse(result.result); + }, +}); + +function buildFailedCycleState( + inputData: ValidationCycleState, + overrides: Partial, +): ValidationCycleState { + return { + ...inputData, + ...overrides, + schema: resolveOverride(inputData, overrides, 'schema') ?? inputData.schema, + sql: resolveOverride(inputData, overrides, 'sql'), + replyToUser: resolveOverride(inputData, overrides, 'replyToUser'), + route: 'failed', + status: 'failed', + feedbacks: inputData.feedbacks ?? [], + fixAttempts: inputData.fixAttempts + 1, + }; +} + +function resolveOverride( + inputData: ValidationCycleState, + overrides: Partial, + key: T, +): ValidationCycleState[T] { + return Object.prototype.hasOwnProperty.call(overrides, key) + ? (overrides[key] as ValidationCycleState[T]) + : inputData[key]; +} diff --git a/src/mastra/workflows/db-query/steps/validation-merge.step.ts b/src/mastra/workflows/db-query/steps/validation-merge.step.ts new file mode 100644 index 0000000..cdffef5 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/validation-merge.step.ts @@ -0,0 +1,173 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import type {ValidationRoutingDecision} from '../db-query-workflow-schemas'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +/** + * ValidationMergeStep — replaces PostValidation merge logic + routing. + * + * Merges syntactic + semantic validation results and determines routing: + * - 'accepted': both pass → save dataset + * - 'fix-query': query_error → attempt repair + * - 'reselect-tables': table_not_found → re-run table selection + * - 'failed': max attempts exceeded → failure + */ +export const validationMergeStep = createStep({ + id: 'validation-merge', + inputSchema: z.object({ + // Validation results + syntacticStatus: z.string().optional(), + syntacticFeedback: z.string().optional(), + syntacticErrorTables: z.array(z.string()).optional(), + semanticStatus: z.string().optional(), + semanticFeedback: z.string().optional(), + semanticErrorTables: z.array(z.string()).optional(), + // Description from parallel step + description: z.string().optional(), + // Existing state + feedbacks: z.array(z.string()).optional(), + sql: z.string().optional(), + prompt: z.string(), + schema: DatabaseSchemaZ, + validationChecklist: z.string().optional(), + directCall: z.boolean().optional(), + }), + outputSchema: z.object({ + route: z.enum(['accepted', 'fix-query', 'reselect-tables', 'failed']), + status: z.string(), + feedbacks: z.array(z.string()), + syntacticErrorTables: z.array(z.string()).optional(), + semanticErrorTables: z.array(z.string()).optional(), + description: z.string().optional(), + sql: z.string().optional(), + prompt: z.string(), + schema: DatabaseSchemaZ, + validationChecklist: z.string().optional(), + directCall: z.boolean().optional(), + }), + execute: async ({inputData}) => { + const MAX_ATTEMPTS = 3; + + const hasSyntacticFailure = isValidationFailure(inputData.syntacticStatus); + const hasSemanticFailure = isValidationFailure(inputData.semanticStatus); + + if (!hasSyntacticFailure && !hasSemanticFailure) { + return buildAcceptedOutput(inputData); + } + + const errorTables = mergeErrorTables( + inputData.syntacticErrorTables, + inputData.semanticErrorTables, + ); + const allFeedbacks = buildFeedbacks( + inputData.feedbacks, + inputData.syntacticFeedback, + inputData.semanticFeedback, + hasSyntacticFailure, + ); + const status = resolveValidationStatus( + hasSyntacticFailure, + inputData.syntacticStatus, + inputData.semanticStatus, + ); + const route = resolveValidationRoute( + status, + allFeedbacks.length, + MAX_ATTEMPTS, + ); + + return { + route, + status, + feedbacks: allFeedbacks, + syntacticErrorTables: errorTables, + semanticErrorTables: errorTables, + description: inputData.description, + sql: inputData.sql, + prompt: inputData.prompt, + schema: inputData.schema, + validationChecklist: inputData.validationChecklist, + directCall: inputData.directCall, + }; + }, +}); + +function buildAcceptedOutput(inputData: { + feedbacks?: string[]; + description?: string; + sql?: string; + prompt: string; + schema: z.infer; + validationChecklist?: string; + directCall?: boolean; +}) { + return { + route: 'accepted' as ValidationRoutingDecision, + status: 'pass', + feedbacks: (inputData.feedbacks ?? []).filter( + feedback => !feedback.startsWith('Query Validation Failed'), + ), + description: inputData.description, + sql: inputData.sql, + prompt: inputData.prompt, + schema: inputData.schema, + validationChecklist: inputData.validationChecklist, + directCall: inputData.directCall, + }; +} + +function mergeErrorTables( + syntacticErrorTables?: string[], + semanticErrorTables?: string[], +): string[] | undefined { + const mergedErrorTables = [ + ...new Set([ + ...(syntacticErrorTables ?? []), + ...(semanticErrorTables ?? []), + ]), + ]; + return mergedErrorTables.length > 0 ? mergedErrorTables : undefined; +} + +function buildFeedbacks( + baseFeedbacks: string[] | undefined, + syntacticFeedback: string | undefined, + semanticFeedback: string | undefined, + hasSyntacticFailure: boolean, +): string[] { + const syntactic = + hasSyntacticFailure && syntacticFeedback ? [syntacticFeedback] : []; + const semantic = semanticFeedback ? [semanticFeedback] : []; + return [...(baseFeedbacks ?? []), ...syntactic, ...semantic]; +} + +function resolveValidationStatus( + hasSyntacticFailure: boolean, + syntacticStatus: string | undefined, + semanticStatus: string | undefined, +): string { + return hasSyntacticFailure + ? (syntacticStatus ?? 'query_error') + : (semanticStatus ?? 'query_error'); +} + +function resolveValidationRoute( + status: string, + feedbackCount: number, + maxAttempts: number, +): ValidationRoutingDecision { + if (feedbackCount >= maxAttempts) { + return 'failed'; + } + if (status === 'table_not_found') { + return 'reselect-tables'; + } + if (status === 'query_error') { + return 'fix-query'; + } + return 'failed'; +} + +function isValidationFailure(status: string | undefined): boolean { + return !!status && status !== 'pass'; +} diff --git a/src/mastra/workflows/db-query/steps/verify-checklist.step.ts b/src/mastra/workflows/db-query/steps/verify-checklist.step.ts new file mode 100644 index 0000000..df63929 --- /dev/null +++ b/src/mastra/workflows/db-query/steps/verify-checklist.step.ts @@ -0,0 +1,213 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; +import type {DatabaseSchema} from '../../../../components/db-query/types'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; + +const VERIFY_BASE_PROMPT = ` + +You are given a user question, the tables selected for SQL generation, the relevant database schema, and a numbered list of rules/checks. +Return ONLY the indexes of the rules that are relevant to the user's question, the selected tables, and the given schema. + +A rule is relevant if: +- It directly affects how a correct SQL query should be written for this question. +- It is a dependency of another relevant rule (e.g. if rule 3 requires a currency conversion, and rule 5 defines how currency conversion works, both must be included). +- It applies to any of the selected tables or their relationships. + +Ensure: +- Any rule that is referenced by, or is a prerequisite for, another selected rule is also included. +- Do not include rules that are completely unrelated to the question, schema, or selected tables. + + + +{prompt} + + + +{tables} + + + +{schema} + + + +{indexedChecks} + + +`; + +const EVALUATION_OUTPUT = ` +First, evaluate each rule inside an evaluation tag. For each rule, repeat the full rule text exactly as given, followed by " — Include" or " — Exclude" with a brief reason. +Then, return only the comma-separated list of included rule indexes inside a result tag. + +Example: + +1. When matching names, use ilike with wildcards — Include, query involves name matching +2. Format dates using to_char — Exclude, no date fields in this query +3. Always exclude lost deals — Include, query involves deals + +1,3 + +If no rules are relevant: none +`; + +const SIMPLE_OUTPUT = ` +Return ONLY the comma-separated list of relevant rule indexes inside a result tag. +Do NOT include any reasoning, analysis, or explanation — only the result tag. +Example: +1,3,5 +If no rules are relevant: +none +`; + +/** + * VerifyChecklistStep — replaces VerifyChecklistNode. + * + * Uses a smart LLM to verify/refine the checklist with chain-of-thought. + * Runs only on first attempt (no feedbacks yet) with 3+ tables. + */ +export const verifyChecklistStep = createStep({ + id: 'verify-checklist', + inputSchema: z.object({ + prompt: z.string(), + schema: DatabaseSchemaZ, + validationChecklist: z.string().optional(), + feedbacks: z.array(z.string()).optional(), + }), + outputSchema: z.object({ + validationChecklist: z.string().optional(), + }), + execute: async ({inputData, requestContext, writer}) => { + const ctx = asDbQueryContext(requestContext!); + const smartLlm = ctx.get('smartLlm'); + const smartNonThinkingLlm = ctx.get('smartNonThinkingLlm'); + const dbQueryConfig = ctx.get('dbQueryConfig'); + const schemaHelper = ctx.get('schemaHelper'); + const globalContext = ctx.get('globalContext'); + const schema = inputData.schema as DatabaseSchema; + + const allChecks = collectChecklistRules( + globalContext, + schemaHelper.getTablesContext(schema), + ); + if ( + shouldSkipChecklistVerification({ + checklistVerificationEnabled: + dbQueryConfig.nodes?.verifyChecklistNode?.enabled !== false, + hasFeedbacks: !!inputData.feedbacks?.length, + tableCount: Object.keys(schema.tables).length, + availableRuleCount: allChecks.length, + }) + ) { + return {}; + } + + await writer.write({ + type: LLMStreamEventType.Log, + data: 'Verifying validation checklist with chain-of-thought.', + }); + + const llm = smartNonThinkingLlm ?? smartLlm; + const indexedChecks = toIndexedChecklist(allChecks); + + const useEvaluation = + dbQueryConfig.nodes?.verifyChecklistNode?.evaluation ?? false; + const outputInstructions = useEvaluation + ? EVALUATION_OUTPUT + : SIMPLE_OUTPUT; + + const prompt = buildVerificationPrompt( + inputData.prompt, + Object.keys(schema.tables).join(', '), + schemaHelper.asString(schema), + indexedChecks, + outputInstructions, + ); + + const rawOutput = await invokeLlm(llm, prompt); + const verifiedIndexes = parseVerifiedIndexes( + stripThinkingTokens(rawOutput), + allChecks.length, + ); + + if (verifiedIndexes.length === 0) { + return {}; + } + + const validationChecklist = mergeWithExisting( + inputData.validationChecklist, + verifiedIndexes, + allChecks, + ); + + return {validationChecklist}; + }, +}); + +function parseVerifiedIndexes(response: string, maxIndex: number): number[] { + const resultMatch = /(.*?)<\/result>/s.exec(response); + const indexStr = resultMatch ? resultMatch[1].trim() : response.trim(); + + if (!indexStr || indexStr === 'none') return []; + + return indexStr + .split(',') + .map(s => Number.parseInt(s.trim(), 10)) + .filter(n => !Number.isNaN(n) && n >= 1 && n <= maxIndex); +} + +function mergeWithExisting( + existing: string | undefined, + verifiedIndexes: number[], + allChecks: string[], +): string { + const existingChecks = new Set( + (existing ?? '').split('\n').filter(c => c.length > 0), + ); + for (const check of verifiedIndexes.map(i => allChecks[i - 1])) { + existingChecks.add(check); + } + return Array.from(existingChecks).join('\n'); +} + +function collectChecklistRules( + globalContext: string[] | undefined, + tableContext: string[], +): string[] { + return [...(globalContext ?? []), ...tableContext]; +} + +function shouldSkipChecklistVerification(params: { + checklistVerificationEnabled: boolean; + hasFeedbacks: boolean; + tableCount: number; + availableRuleCount: number; +}): boolean { + return ( + !params.checklistVerificationEnabled || + params.hasFeedbacks || + params.tableCount <= 2 || + params.availableRuleCount === 0 + ); +} + +function toIndexedChecklist(checks: string[]): string { + return checks.map((check, i) => `${i + 1}. ${check}`).join('\n'); +} + +function buildVerificationPrompt( + prompt: string, + tables: string, + schema: string, + indexedChecks: string, + outputInstructions: string, +): string { + return (VERIFY_BASE_PROMPT + outputInstructions) + .replace('{prompt}', prompt) + .replace('{tables}', tables) + .replace('{schema}', schema) + .replace('{indexedChecks}', indexedChecks); +} diff --git a/src/mastra/workflows/db-query/tools/ask-about-dataset.tool.ts b/src/mastra/workflows/db-query/tools/ask-about-dataset.tool.ts new file mode 100644 index 0000000..b50947f --- /dev/null +++ b/src/mastra/workflows/db-query/tools/ask-about-dataset.tool.ts @@ -0,0 +1,129 @@ +import {createTool} from '@mastra/core/tools'; +import {z} from 'zod'; +import type {RequestContext} from '@mastra/core/request-context'; +import {asDbQueryContext} from '../db-query-request-context'; +import {invokeLlm, stripThinkingTokens} from '../llm-helpers'; +import type {JsonObject, JsonValue} from '../../../../types'; + +const ASK_ABOUT_DATASET_PROMPT = `You are an AI assistant that answers questions about a query, without revealing any technical details, you need to answer the question the user's question. +Make sure you don't reveal the original query to the user, just answer the question based on the query. +Here is the query that the question was for - +{query} + +and here is the schema the query was generated for - +{schema} + +and here is the context that was provided for the query - +{context} + +and here is the user's question - +{question}`; + +const askAboutDatasetResultSchema = z.object({ + status: z.enum(['completed', 'failed']), + done: z.boolean(), + datasetId: z.string().optional(), + replyToUser: z.string(), +}); + +type AskAboutDatasetToolResult = z.infer; + +function toJsonObject(value: JsonValue): JsonObject { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as JsonObject; + } + return { + value: + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ? value + : String(value), + }; +} + +export const askAboutDatasetTool = createTool({ + id: 'ask-about-dataset', + description: + 'Tool for answering questions about an existing dataset. It can only answer questions about dataset/query definition and intent, not raw row-level data. Call this only if you have a valid dataset ID.', + inputSchema: z.object({ + datasetId: z + .string() + .describe('UUID ID of the existing dataset to answer a question about'), + question: z + .string() + .describe( + 'The user question about the dataset definition or query intent.', + ), + }), + outputSchema: askAboutDatasetResultSchema, + execute: async ( + inputData: {datasetId: string; question: string}, + {requestContext}: {requestContext?: RequestContext}, + ): Promise => { + if (!requestContext) { + throw new Error( + 'RequestContext is required for ask-about-dataset tool execution.', + ); + } + + const ctx = asDbQueryContext(requestContext); + const datasetStore = ctx.get('datasetStore'); + const schemaStore = ctx.get('schemaStore'); + const schemaHelper = ctx.get('schemaHelper'); + const cheapLlm = ctx.get('cheapLlm'); + const globalContext = ctx.get('globalContext'); + + try { + const dataset = await datasetStore.findById(inputData.datasetId); + const filteredSchema = schemaStore.filteredSchema(dataset.tables); + const schemaContext = schemaHelper.getTablesContext(filteredSchema); + const prompt = ASK_ABOUT_DATASET_PROMPT.replace('{query}', dataset.query) + .replace('{schema}', JSON.stringify(filteredSchema)) + .replace('{context}', [...globalContext, ...schemaContext].join('\n')) + .replace('{question}', inputData.question); + + const llmResponse = await invokeLlm(cheapLlm, prompt); + const reply = stripThinkingTokens(llmResponse).trim(); + + return { + status: 'completed', + done: true, + datasetId: inputData.datasetId, + replyToUser: reply || 'I could not derive an answer for this dataset.', + }; + } catch (error) { + return { + status: 'failed', + done: false, + datasetId: inputData.datasetId, + replyToUser: + error instanceof Error + ? error.message + : 'Unable to answer dataset question.', + }; + } + }, +}); + +export function formatAskAboutDatasetResult(result: JsonObject): string { + const parsed = askAboutDatasetResultSchema.safeParse(toJsonObject(result)); + if (!parsed.success) { + return JSON.stringify(result); + } + + return parsed.data.replyToUser; +} + +export function getAskAboutDatasetMetadata(result: JsonObject): JsonObject { + const parsed = askAboutDatasetResultSchema.safeParse(toJsonObject(result)); + if (!parsed.success) { + return {status: 'failed'}; + } + + return { + status: parsed.data.status, + existingDatasetId: parsed.data.datasetId ?? null, + }; +} diff --git a/src/mastra/workflows/db-query/tools/get-data-as-dataset.tool.ts b/src/mastra/workflows/db-query/tools/get-data-as-dataset.tool.ts new file mode 100644 index 0000000..8038b85 --- /dev/null +++ b/src/mastra/workflows/db-query/tools/get-data-as-dataset.tool.ts @@ -0,0 +1,209 @@ +import {createTool} from '@mastra/core/tools'; +import {z} from 'zod'; +import type {RequestContext} from '@mastra/core/request-context'; +import {dbQueryWorkflow} from '../db-query.workflow'; +import {asDbQueryContext} from '../db-query-request-context'; +import { + dbQueryWorkflowOutputSchema, + type DbQueryWorkflowOutput, +} from '../db-query-workflow-schemas'; +import type {LLMStreamEvent} from '../../../../graphs/event.types'; +import type {AsyncEventQueue} from '../../../bridge/async-event-queue'; +import type {JsonObject, JsonValue} from '../../../../types'; + +const DEFAULT_MAX_READ_ROWS_FOR_AI = 25; + +const datasetToolResultSchema = z.object({ + status: z.enum(['completed', 'failed']), + done: z.boolean(), + datasetId: z.string().optional(), + replyToUser: z.string(), + resultArray: z.array(z.object({}).passthrough()).optional(), +}); + +type DatasetToolResult = z.infer; + +/** + * Type guard for LLMStreamEvent extracted from workflow chunks. + */ +function isLLMStreamEvent( + value: object | null | undefined, +): value is LLMStreamEvent { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + 'data' in value + ); +} + +function toJsonObject(value: JsonValue): JsonObject { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as JsonObject; + } + return { + value: + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ? value + : String(value), + }; +} + +/** + * Mastra-native tool: get-data-as-dataset + * + * Replaces the LangChain `GetDataAsDatasetTool` by running the + * `dbQueryWorkflow` directly, streaming events to the AsyncEventQueue. + * + * Used by the ChatWorkflow Agent when the user requests tabular data. + */ +export const getDataAsDatasetTool = createTool({ + id: 'get-data-as-dataset', + description: `Query tool for generating SQL queries for a users request. Use it only when the user needs raw tabular data from the database. +Do not use this tool if the user's request involves trends, growth, decline, comparisons, distributions, patterns, or any form of analytical insight — use the 'generate-visualization' tool instead. +Note that it does not return the query, instead only a dataset ID that is not relevant to the user. +It internally fires an event that renders a grid for the dataset on the UI for the user to see.`, + inputSchema: z.object({ + prompt: z + .string() + .describe( + 'Prompt from the user that will be used for generating an SQL query and create a dataset from it.', + ), + }), + outputSchema: datasetToolResultSchema, + execute: async ( + inputData: {prompt: string}, + {requestContext}: {requestContext?: RequestContext}, + ): Promise => { + if (!requestContext) { + throw new Error( + 'RequestContext is required for get-data-as-dataset tool execution.', + ); + } + + const ctx = asDbQueryContext(requestContext); + const eventQueue = requestContext.get('eventQueue') as + | AsyncEventQueue + | undefined; + const schema = ctx.get('fullSchema'); + const abortSignal = ctx.get('abortSignal'); + + if (!schema) { + throw new Error( + 'fullSchema not found in RequestContext. ' + + 'Ensure the DB Query component is properly configured.', + ); + } + + const run = await dbQueryWorkflow.createRun(); + const stream = run.stream({ + inputData: {prompt: inputData.prompt, schema}, + requestContext, + }); + + for await (const chunk of stream) { + if (abortSignal?.aborted) { + return { + status: 'failed', + done: false, + replyToUser: + 'Request was cancelled before dataset generation finished.', + }; + } + + if (chunk.type === 'workflow-step-output') { + const output = chunk.payload?.output; + if (eventQueue && isLLMStreamEvent(output)) { + eventQueue.push(output); + } + } + } + + const finalResult = await stream.result; + if (finalResult.status !== 'success') { + return { + status: 'failed', + done: false, + replyToUser: 'Unable to generate dataset.', + }; + } + + const parsedOutput = dbQueryWorkflowOutputSchema.safeParse( + finalResult.result, + ); + if (!parsedOutput.success) { + return { + status: 'failed', + done: false, + replyToUser: 'Unable to parse DBQuery workflow output.', + }; + } + + return formatResult(parsedOutput.data, ctx.get('dbQueryConfig')); + }, +}); + +function formatResult( + result: DbQueryWorkflowOutput, + config?: {maxRowsForAI?: number}, +): DatasetToolResult { + const status = result.done ? 'completed' : 'failed'; + + if (!result.done || !result.datasetId) { + return { + status, + done: false, + replyToUser: result.replyToUser ?? 'Unable to generate dataset.', + }; + } + + return { + status, + datasetId: result.datasetId, + done: true, + resultArray: result.resultArray, + replyToUser: buildDatasetReadyMessage( + result.datasetId, + result.resultArray, + config, + ), + }; +} + +function buildDatasetReadyMessage( + datasetId: string, + resultArray: DbQueryWorkflowOutput['resultArray'], + config?: {maxRowsForAI?: number}, +): string { + let resultSetString = ''; + if (resultArray) { + const maxRows = config?.maxRowsForAI ?? DEFAULT_MAX_READ_ROWS_FOR_AI; + resultSetString = ` First ${maxRows} results from the dataset are: ${JSON.stringify(resultArray)}`; + } + + return `Dataset generated and has been rendered for the user. The dataset ID is ${datasetId}. Just tell the user that it is done.${resultSetString}`; +} + +export function formatGetDataAsDatasetResult(result: JsonObject): string { + const parsed = datasetToolResultSchema.safeParse(toJsonObject(result)); + if (!parsed.success) { + return JSON.stringify(result); + } + + return parsed.data.replyToUser; +} + +export function getDataAsDatasetMetadata(result: JsonObject): JsonObject { + const parsed = datasetToolResultSchema.safeParse(toJsonObject(result)); + if (!parsed.success) { + return {status: 'failed'}; + } + + return { + status: parsed.data.status, + existingDatasetId: parsed.data.datasetId ?? null, + }; +} diff --git a/src/mastra/workflows/db-query/tools/improve-dataset.tool.ts b/src/mastra/workflows/db-query/tools/improve-dataset.tool.ts new file mode 100644 index 0000000..6a831b6 --- /dev/null +++ b/src/mastra/workflows/db-query/tools/improve-dataset.tool.ts @@ -0,0 +1,213 @@ +import {createTool} from '@mastra/core/tools'; +import {z} from 'zod'; +import type {RequestContext} from '@mastra/core/request-context'; +import {dbQueryWorkflow} from '../db-query.workflow'; +import {asDbQueryContext} from '../db-query-request-context'; +import { + dbQueryWorkflowOutputSchema, + type DbQueryWorkflowOutput, +} from '../db-query-workflow-schemas'; +import type {LLMStreamEvent} from '../../../../graphs/event.types'; +import type {AsyncEventQueue} from '../../../bridge/async-event-queue'; +import type {JsonObject, JsonValue} from '../../../../types'; + +const DEFAULT_MAX_READ_ROWS_FOR_AI = 25; + +const improveDatasetToolResultSchema = z.object({ + status: z.enum(['completed', 'failed']), + done: z.boolean(), + datasetId: z.string().optional(), + replyToUser: z.string(), + resultArray: z.array(z.object({}).passthrough()).optional(), +}); + +type ImproveDatasetToolResult = z.infer; + +/** + * Type guard for LLMStreamEvent extracted from workflow chunks. + */ +function isLLMStreamEvent( + value: object | null | undefined, +): value is LLMStreamEvent { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + 'data' in value + ); +} + +function toJsonObject(value: JsonValue): JsonObject { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as JsonObject; + } + return { + value: + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ? value + : String(value), + }; +} + +/** + * Mastra-native tool: improve-dataset + * + * Replaces the LangChain `ImproveDatasetTool` by running the + * `dbQueryWorkflow` with an existing datasetId, streaming events + * to the AsyncEventQueue. + * + * Used by the ChatWorkflow Agent when the user wants to modify an existing dataset. + */ +export const improveDatasetTool = createTool({ + id: 'improve-dataset', + description: + 'Tool for improving an existing dataset based on user feedback. It takes a dataset ID and a prompt describing the desired changes, and returns an updated dataset. Call this only if you have a valid dataset ID available.', + inputSchema: z.object({ + datasetId: z + .string() + .describe('UUID ID of the existing dataset to improve'), + prompt: z + .string() + .describe( + 'A description of what changes or improvements the user wants in the existing dataset.', + ), + }), + outputSchema: improveDatasetToolResultSchema, + execute: async ( + inputData: {datasetId: string; prompt: string}, + {requestContext}: {requestContext?: RequestContext}, + ): Promise => { + if (!requestContext) { + throw new Error( + 'RequestContext is required for improve-dataset tool execution.', + ); + } + + const ctx = asDbQueryContext(requestContext); + const eventQueue = requestContext.get('eventQueue') as + | AsyncEventQueue + | undefined; + const schema = ctx.get('fullSchema'); + const abortSignal = ctx.get('abortSignal'); + + if (!schema) { + throw new Error( + 'fullSchema not found in RequestContext. ' + + 'Ensure the DB Query component is properly configured.', + ); + } + + const run = await dbQueryWorkflow.createRun(); + const stream = run.stream({ + inputData: { + prompt: inputData.prompt, + schema, + datasetId: inputData.datasetId, + }, + requestContext, + }); + + for await (const chunk of stream) { + if (abortSignal?.aborted) { + return { + status: 'failed', + done: false, + replyToUser: + 'Request was cancelled before dataset improvement finished.', + }; + } + + if (chunk.type === 'workflow-step-output') { + const output = chunk.payload?.output; + if (eventQueue && isLLMStreamEvent(output)) { + eventQueue.push(output); + } + } + } + + const finalResult = await stream.result; + if (finalResult.status !== 'success') { + return { + status: 'failed', + done: false, + replyToUser: 'Unable to improve dataset.', + }; + } + + const parsedOutput = dbQueryWorkflowOutputSchema.safeParse( + finalResult.result, + ); + if (!parsedOutput.success) { + return { + status: 'failed', + done: false, + replyToUser: 'Unable to parse DBQuery workflow output.', + }; + } + + return formatResult(parsedOutput.data, ctx.get('dbQueryConfig')); + }, +}); + +function formatResult( + result: DbQueryWorkflowOutput, + config?: {maxRowsForAI?: number}, +): ImproveDatasetToolResult { + if (!result.done || !result.datasetId) { + return { + status: 'failed', + done: false, + replyToUser: result.replyToUser ?? 'Unable to improve dataset.', + }; + } + + return { + status: 'completed', + datasetId: result.datasetId, + done: true, + resultArray: result.resultArray, + replyToUser: buildDatasetImprovedMessage( + result.datasetId, + result.resultArray, + config, + ), + }; +} + +function buildDatasetImprovedMessage( + datasetId: string, + resultArray: DbQueryWorkflowOutput['resultArray'], + config?: {maxRowsForAI?: number}, +): string { + let resultSetString = ''; + if (resultArray) { + const maxRows = config?.maxRowsForAI ?? DEFAULT_MAX_READ_ROWS_FOR_AI; + resultSetString = ` First ${maxRows} results from the dataset are: ${JSON.stringify(resultArray)}`; + } + + return `Dataset improved and has been rendered for the user. The dataset ID is ${datasetId}. Just tell the user that it is done.${resultSetString}`; +} + +export function formatImproveDatasetResult(result: JsonObject): string { + const parsed = improveDatasetToolResultSchema.safeParse(toJsonObject(result)); + if (!parsed.success) { + return JSON.stringify(result); + } + + return parsed.data.replyToUser; +} + +export function getImproveDatasetMetadata(result: JsonObject): JsonObject { + const parsed = improveDatasetToolResultSchema.safeParse(toJsonObject(result)); + if (!parsed.success) { + return {status: 'failed'}; + } + + return { + status: parsed.data.status, + existingDatasetId: parsed.data.datasetId ?? null, + }; +} diff --git a/src/mastra/workflows/db-query/tools/index.ts b/src/mastra/workflows/db-query/tools/index.ts new file mode 100644 index 0000000..8cfcc89 --- /dev/null +++ b/src/mastra/workflows/db-query/tools/index.ts @@ -0,0 +1,15 @@ +export { + getDataAsDatasetTool, + formatGetDataAsDatasetResult, + getDataAsDatasetMetadata, +} from './get-data-as-dataset.tool'; +export { + improveDatasetTool, + formatImproveDatasetResult, + getImproveDatasetMetadata, +} from './improve-dataset.tool'; +export { + askAboutDatasetTool, + formatAskAboutDatasetResult, + getAskAboutDatasetMetadata, +} from './ask-about-dataset.tool'; diff --git a/src/mastra/workflows/db-query/workflows/discovery.workflow.ts b/src/mastra/workflows/db-query/workflows/discovery.workflow.ts new file mode 100644 index 0000000..65c6c41 --- /dev/null +++ b/src/mastra/workflows/db-query/workflows/discovery.workflow.ts @@ -0,0 +1,83 @@ +import {createWorkflow} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {DatabaseSchemaZ} from '../db-query-workflow-schemas'; +import {cacheCheckStep} from '../steps/cache-check.step'; +import {tableSelectionStep} from '../steps/table-selection.step'; +import {templateMatchStep} from '../steps/template-match.step'; +import {changeClassificationStep} from '../steps/change-classification.step'; +import {discoveryRoutingStep} from '../steps/discovery-routing.step'; + +/** + * DiscoveryWorkflow — determines how to proceed for a given prompt. + * + * Runs four independent discovery steps in parallel, then merges and routes: + * - CacheCheck: checks if a cached dataset matches the request + * - TableSelection: selects relevant DB tables from the schema + * - TemplateMatch: checks for a pre-existing SQL template + * - ChangeClassification: classifies the type of change (minor/major/rewrite) + * + * Routes: + * - `from-cache` → cached SQL found, skip generation + * - `from-template` → SQL template matched, skip generation + * - `failed` → table selection failed, cannot continue + * - `continue` → proceed with column selection + SQL generation + */ +const discoveryInputSchema = z.object({ + prompt: z.string(), + schema: DatabaseSchemaZ, + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + directCall: z.boolean().optional(), + datasetId: z.string().optional(), +}); + +type DiscoveryInput = z.infer; + +export const discoveryWorkflow = createWorkflow({ + id: 'discovery', + inputSchema: discoveryInputSchema, + outputSchema: z.object({ + route: z.enum(['from-cache', 'from-template', 'continue', 'failed']), + prompt: z.string(), + schema: DatabaseSchemaZ.optional(), + sql: z.string().optional(), + description: z.string().optional(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + changeType: z.enum(['minor', 'major', 'rewrite']).optional(), + datasetId: z.string().optional(), + replyToUser: z.string().optional(), + templateId: z.string().optional(), + directCall: z.boolean().optional(), + }), +}) + .parallel([ + cacheCheckStep, + tableSelectionStep, + templateMatchStep, + changeClassificationStep, + ]) + .map(async ({inputData, getInitData}) => { + const initData = getInitData(); + return { + fromCache: inputData['cache-check'].fromCache, + fromTemplate: inputData['template-match'].fromTemplate, + status: inputData['table-selection'].status, + prompt: initData.prompt, + schema: inputData['table-selection'].schema ?? initData.schema, + sql: inputData['template-match'].sql, + description: inputData['template-match'].description, + sampleSql: inputData['cache-check'].sampleSql ?? initData.sampleSql, + sampleSqlPrompt: + inputData['cache-check'].sampleSqlPrompt ?? initData.sampleSqlPrompt, + changeType: inputData['change-classification'].changeType, + datasetId: inputData['cache-check'].datasetId ?? initData.datasetId, + replyToUser: + inputData['cache-check'].replyToUser ?? + inputData['table-selection'].replyToUser, + templateId: inputData['template-match'].templateId, + directCall: initData.directCall, + }; + }) + .then(discoveryRoutingStep) + .commit(); diff --git a/src/mastra/workflows/db-query/workflows/full-generation.workflow.ts b/src/mastra/workflows/db-query/workflows/full-generation.workflow.ts new file mode 100644 index 0000000..96ab677 --- /dev/null +++ b/src/mastra/workflows/db-query/workflows/full-generation.workflow.ts @@ -0,0 +1,224 @@ +import {createWorkflow} from '@mastra/core/workflows'; +import {z} from 'zod'; +import { + dbQueryWorkflowOutputSchema, + DatabaseSchemaZ, +} from '../db-query-workflow-schemas'; +import { + columnSelectionStep, + generateChecklistStep, + verifyChecklistStep, + validationCycleStep, + validationCycleSchema, + datasetPersistenceStep, + failureStep, +} from '../steps'; +import type {BranchContext} from '../contracts/branch.contract'; +import type {ValidationCycleState} from '../steps'; + +/** + * FullGenerationWorkflow — runs the complete SQL generation pipeline. + * + * Receives the routing decision from the discovery phase and executes: + * 1. Column selection + checklist generation (pre-generation) + * → Branches to failure if column selection fails + * 2. SQL generation + validation loop (dountil up to MAX_CYCLE_ITERATIONS) + * → Loops until accepted, failed, or max iterations reached + * 3. Branches on final route: + * → accepted: save dataset and return result + * → failed/max: emit failure message + */ + +/** Maximum validation+repair iterations before giving up. */ +const MAX_CYCLE_ITERATIONS = 4; + +/** Input is the discoveryRoutingStep output for the 'continue' route. */ +const fullGenerationInputSchema = z.object({ + prompt: z.string(), + schema: DatabaseSchemaZ, + changeType: z.enum(['minor', 'major', 'rewrite']).optional(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + fromCache: z.boolean().optional(), + directCall: z.boolean().optional(), + datasetId: z.string().optional(), + route: z.string().optional(), +}); + +type FullGenerationInput = z.infer; + +const postColumnSelectionSchema = z.object({ + prompt: z.string(), + schema: DatabaseSchemaZ, + changeType: z.enum(['minor', 'major', 'rewrite']).optional(), + sampleSql: z.string().optional(), + sampleSqlPrompt: z.string().optional(), + fromCache: z.boolean().optional(), + directCall: z.boolean().optional(), + feedbacks: z.array(z.string()).optional(), + fixAttempts: z.number(), + status: z.string().optional(), + replyToUser: z.string().optional(), +}); + +type PostColumnSelectionState = z.infer; + +const failureOutputSchema = z.object({ + replyToUser: z.string(), +}); + +const datasetPersistenceOutputSchema = z.object({ + datasetId: z.string().optional(), + replyToUser: z.string().optional(), + done: z.boolean().optional(), + resultArray: z.array(z.object({}).passthrough()).optional(), +}); + +const columnSelectionOutputSchema = z.object({ + schema: DatabaseSchemaZ, + status: z.string().optional(), + replyToUser: z.string().optional(), +}); + +type CycleCtx = BranchContext; + +/** Condition for the dountil loop: stop when a terminal route is reached or cap exceeded. */ +const isTerminalRoute = async (ctx: CycleCtx): Promise => { + const route = ctx.inputData?.route; + return ( + route === 'accepted' || + route === 'failed' || + (ctx.iterationCount ?? 0) >= MAX_CYCLE_ITERATIONS + ); +}; + +/** Type alias for column-and-checklist output used in branch conditions. */ +type ColCheckCtx = BranchContext; + +const fullGenerationFailureWorkflow = createWorkflow({ + id: 'full-generation-failure', + inputSchema: postColumnSelectionSchema, + outputSchema: dbQueryWorkflowOutputSchema, +}) + .map(async ({inputData}) => { + const data = postColumnSelectionSchema.parse(inputData); + return { + replyToUser: data.replyToUser, + feedbacks: data.feedbacks, + }; + }) + .then(failureStep) + .map(async ({inputData}) => { + const data = failureOutputSchema.parse(inputData); + return { + replyToUser: data.replyToUser, + done: true, + }; + }) + .commit(); + +const acceptedPersistenceWorkflow = createWorkflow({ + id: 'accepted-persistence', + inputSchema: validationCycleSchema, + outputSchema: dbQueryWorkflowOutputSchema, +}) + .map(async ({inputData}) => { + const data = validationCycleSchema.parse(inputData); + return { + prompt: data.prompt, + sql: data.sql ?? '', + schema: data.schema, + description: data.description, + directCall: data.directCall, + }; + }) + .then(datasetPersistenceStep) + .map(async ({inputData, getInitData}) => { + const data = datasetPersistenceOutputSchema.parse(inputData); + const initData = getInitData(); + return { + datasetId: data.datasetId, + sql: initData.sql, + description: initData.description ?? data.replyToUser, + replyToUser: data.replyToUser, + resultArray: data.resultArray, + done: true, + }; + }) + .commit(); + +const fullGenerationContinueWorkflow = createWorkflow({ + id: 'full-generation-continue', + inputSchema: postColumnSelectionSchema, + outputSchema: dbQueryWorkflowOutputSchema, +}) + .parallel([generateChecklistStep, verifyChecklistStep]) + .map(async ({inputData, getInitData}) => { + const initData = getInitData(); + return { + prompt: initData.prompt, + schema: initData.schema, + changeType: initData.changeType, + sampleSql: initData.sampleSql, + sampleSqlPrompt: initData.sampleSqlPrompt, + fromCache: initData.fromCache, + validationChecklist: + inputData['verify-checklist'].validationChecklist ?? + inputData['generate-checklist'].validationChecklist, + feedbacks: initData.feedbacks, + directCall: initData.directCall, + fixAttempts: initData.fixAttempts, + }; + }) + .dountil(validationCycleStep, isTerminalRoute) + .branch([ + [ + async (ctx: CycleCtx) => ctx.inputData?.route === 'accepted', + acceptedPersistenceWorkflow, + ], + [ + async (ctx: CycleCtx) => ctx.inputData?.route !== 'accepted', + fullGenerationFailureWorkflow, + ], + ]) + .commit(); + +export const fullGenerationWorkflow = createWorkflow({ + id: 'full-generation', + inputSchema: fullGenerationInputSchema, + outputSchema: dbQueryWorkflowOutputSchema, +}) + // Step 1: column selection + .then(columnSelectionStep) + + // Step 2: re-attach carried fields from workflow input for downstream steps + .map(async ({inputData, getInitData}) => { + const data = columnSelectionOutputSchema.parse(inputData); + const initData = getInitData(); + return { + prompt: initData.prompt, + schema: data.schema, + changeType: initData.changeType, + sampleSql: initData.sampleSql, + sampleSqlPrompt: initData.sampleSqlPrompt, + fromCache: initData.fromCache, + directCall: initData.directCall, + feedbacks: undefined, + fixAttempts: 0, + status: data.status, + replyToUser: data.replyToUser, + }; + }) + + // Step 3: branch on column selection failure + .branch([ + [ + async (ctx: ColCheckCtx) => ctx.inputData?.status === 'failed', + fullGenerationFailureWorkflow, + ], + [ + async (ctx: ColCheckCtx) => ctx.inputData?.status !== 'failed', + fullGenerationContinueWorkflow, + ], + ]) + .commit(); diff --git a/src/providers/index.ts b/src/providers/index.ts index aea156b..8eac6e6 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,3 +1,4 @@ export * from './cache'; +export * from './mastra-tools.provider'; export * from './tools.provider'; export * from './vector-stores'; diff --git a/src/providers/mastra-tools.provider.ts b/src/providers/mastra-tools.provider.ts new file mode 100644 index 0000000..0a70422 --- /dev/null +++ b/src/providers/mastra-tools.provider.ts @@ -0,0 +1,223 @@ +import {BindingScope, inject, injectable, Provider} from '@loopback/core'; +import {StructuredToolInterface} from '@langchain/core/tools'; +import {RunnableToolLike} from '@langchain/core/runnables'; +import {createTool} from '@mastra/core/tools'; +import {z} from 'zod'; +import {AiIntegrationBindings} from '../keys'; +import type {IGraphTool} from '../graphs/types'; +import type {LLMStreamEvent} from '../graphs/event.types'; +import type { + JsonObject, + JsonValue, + MastraToolDefinition, + MastraToolStore, + ToolStore, +} from '../types'; +import {asWorkflowContext} from '../mastra/bridge/workflow-request-context'; +import { + askAboutDatasetTool, + formatAskAboutDatasetResult, + formatGetDataAsDatasetResult, + formatImproveDatasetResult, + getAskAboutDatasetMetadata, + getDataAsDatasetMetadata, + getDataAsDatasetTool, + getImproveDatasetMetadata, + improveDatasetTool, +} from '../mastra/workflows/db-query/tools'; + +const debug = require('debug')('ai-integration:provider:mastra-tools'); + +type LegacyTool = StructuredToolInterface | RunnableToolLike; + +type LegacyInvokeConfig = { + configurable?: Record; + writer?: (event: LLMStreamEvent) => void; +}; + +type InvokableLegacyTool = { + invoke( + input: JsonObject, + config?: LegacyInvokeConfig, + ): Promise; +}; + +function isInvokableLegacyTool( + tool: LegacyTool, +): tool is LegacyTool & InvokableLegacyTool { + return 'invoke' in tool && typeof tool.invoke === 'function'; +} + +function resolveLegacyDescription(tool: LegacyTool, fallback: string): string { + if ('description' in tool && typeof tool.description === 'string') { + return tool.description; + } + return fallback; +} + +function resolveLegacyInputSchema(tool: LegacyTool): z.ZodType { + if ('schema' in tool && tool.schema instanceof z.ZodType) { + return tool.schema; + } + return z.object({}).passthrough(); +} + +function toJsonObject(value: JsonValue | JsonObject | undefined): JsonObject { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value; + } + + if (typeof value === 'string' || typeof value === 'number') { + return {value}; + } + + if (typeof value === 'boolean') { + return {value}; + } + + if (value === null) { + return {value: null}; + } + + return {}; +} + +function toLegacyRecord(result: JsonObject): Record { + const output: Record = {}; + for (const [key, value] of Object.entries(result)) { + output[key] = + typeof value === 'string' ? value : JSON.stringify(value ?? null); + } + return output; +} + +function createNativeDefinitions(): MastraToolDefinition[] { + return [ + { + id: getDataAsDatasetTool.id, + tool: getDataAsDatasetTool, + source: 'native', + formatResult: formatGetDataAsDatasetResult, + getMetadata: getDataAsDatasetMetadata, + }, + { + id: improveDatasetTool.id, + tool: improveDatasetTool, + source: 'native', + formatResult: formatImproveDatasetResult, + getMetadata: getImproveDatasetMetadata, + }, + { + id: askAboutDatasetTool.id, + tool: askAboutDatasetTool, + source: 'native', + formatResult: formatAskAboutDatasetResult, + getMetadata: getAskAboutDatasetMetadata, + }, + ]; +} + +async function createLegacyCompatibilityDefinition( + legacyTool: IGraphTool, +): Promise { + const builtTool = await legacyTool.build({configurable: {}}); + const description = resolveLegacyDescription(builtTool, legacyTool.key); + const inputSchema = resolveLegacyInputSchema(builtTool); + + const wrappedTool = createTool({ + id: legacyTool.key, + description, + inputSchema, + execute: async (inputData, context) => { + const eventQueue = context?.requestContext + ? asWorkflowContext(context.requestContext).get('eventQueue') + : undefined; + + const runtimeTool = await legacyTool.build({configurable: {}}); + if (!isInvokableLegacyTool(runtimeTool)) { + throw new Error(`Legacy tool ${legacyTool.key} is not invokable.`); + } + + const invokeConfig: LegacyInvokeConfig = { + configurable: {}, + writer: event => { + eventQueue?.push(event); + }, + }; + + const result = await runtimeTool.invoke( + toJsonObject(inputData), + invokeConfig, + ); + return toJsonObject(result); + }, + }); + + return { + id: legacyTool.key, + tool: wrappedTool, + source: 'legacy-compat', + formatResult: result => { + if (legacyTool.getValue) { + return legacyTool.getValue(toLegacyRecord(result)); + } + return JSON.stringify(result); + }, + getMetadata: result => { + if (legacyTool.getMetadata) { + const metadata = legacyTool.getMetadata(toLegacyRecord(result)); + return toJsonObject(metadata as JsonObject); + } + return {status: 'completed'}; + }, + }; +} + +@injectable({scope: BindingScope.REQUEST}) +export class MastraToolsProvider implements Provider { + constructor( + @inject(AiIntegrationBindings.Tools) + private readonly legacyToolStore: ToolStore, + ) {} + + async value(): Promise { + const nativeDefinitions = createNativeDefinitions(); + const definitions: MastraToolDefinition[] = [...nativeDefinitions]; + const nativeIds = new Set( + nativeDefinitions.map(definition => definition.id), + ); + + for (const legacyTool of this.legacyToolStore.list) { + if (legacyTool.needsReview) { + continue; + } + if (nativeIds.has(legacyTool.key)) { + continue; + } + + try { + const compatibilityDefinition = + await createLegacyCompatibilityDefinition(legacyTool); + definitions.push(compatibilityDefinition); + } catch (error) { + debug( + `Failed to register legacy compatibility tool ${legacyTool.key}:`, + error, + ); + } + } + + const map: Record = {}; + const tools: Record> = {}; + for (const definition of definitions) { + map[definition.id] = definition; + tools[definition.id] = definition.tool; + } + + return { + list: definitions, + map, + tools, + }; + } +} diff --git a/src/types.ts b/src/types.ts index bc3d776..a64adb9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,7 @@ import { import {BaseCheckpointSaver} from '@langchain/langgraph'; import {ChatOllama, OllamaEmbeddings} from '@langchain/ollama'; import {ChatOpenAI, OpenAIEmbeddings} from '@langchain/openai'; +import {createTool} from '@mastra/core/tools'; import {Provider} from '@loopback/core'; import {AnyObject} from '@loopback/repository'; import {IGraphTool} from './graphs/types'; @@ -64,6 +65,30 @@ export type ToolStore = { map: Record; }; +export type JsonPrimitive = string | number | boolean | null; + +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; + +export type JsonObject = { + [key: string]: JsonValue; +}; + +export type MastraTool = ReturnType; + +export type MastraToolDefinition = { + id: string; + tool: MastraTool; + source: 'native' | 'legacy-compat'; + formatResult: (result: JsonObject) => string; + getMetadata: (result: JsonObject) => JsonObject; +}; + +export type MastraToolStore = { + list: MastraToolDefinition[]; + map: Record; + tools: Record; +}; + export enum ChannelType { Chat = 'chat', } From aa576185d638d102db027d958aaf4cf49a81b9d2 Mon Sep 17 00:00:00 2001 From: Piyush Singh Gaur Date: Tue, 19 May 2026 11:25:39 +0530 Subject: [PATCH 3/6] fix(deps):fix trivy Vulnerabilities --- package-lock.json | 289 +++++++++++++++++++++++++++++++--------------- package.json | 7 +- 2 files changed, 200 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe54024..1568a3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1445,13 +1445,14 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz", - "integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", "license": "Apache-2.0", "dependencies": { + "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.5.8", + "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, "engines": { @@ -3748,9 +3749,9 @@ } }, "node_modules/@langchain/core/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -3778,9 +3779,9 @@ } }, "node_modules/@langchain/google-genai/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -3918,9 +3919,9 @@ } }, "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -4105,24 +4106,24 @@ } }, "node_modules/@loopback/build": { - "version": "12.0.11", - "resolved": "https://registry.npmjs.org/@loopback/build/-/build-12.0.11.tgz", - "integrity": "sha512-Qr3j1tF20YQ7W/0Ont8Fv5xAroXdkEwlOOn+tqv8K7D3aTGeSehMkCnrVpOWllbWmiMAdqtVJXlNNqFne8j3+w==", + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/@loopback/build/-/build-12.0.12.tgz", + "integrity": "sha512-ufmzogGEHvKX4Phaab0du8W35vlIktLbiCHjMnqWrOzzzL35N61p4QnIsDV5ImfSEu4XmouZNLwwLuBuqlthWQ==", "dev": true, "license": "MIT", "dependencies": { "@loopback/eslint-config": "^16.0.1", "@types/mocha": "^10.0.10", - "@types/node": "^20.19.39", + "@types/node": "^20.19.40", "cross-spawn": "^7.0.6", "debug": "^4.4.3", "eslint": "^8.57.1", - "fs-extra": "^11.3.4", + "fs-extra": "^11.3.5", "glob": "^13.0.6", "lodash": "^4.18.1", "mocha": "^11.7.5", "nyc": "^18.0.0", - "prettier": "^3.8.2", + "prettier": "^3.8.3", "rimraf": "^5.0.10", "source-map-support": "^0.5.21", "typescript": "~5.2.2" @@ -4140,6 +4141,16 @@ "node": "20 || 22 || 24" } }, + "node_modules/@loopback/build/node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@loopback/build/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -5035,6 +5046,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6877,9 +6900,9 @@ } }, "node_modules/@sourceloop/chat-service/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -7048,9 +7071,9 @@ } }, "node_modules/@sourceloop/core/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -7617,9 +7640,9 @@ } }, "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==", "license": "MIT", "peer": true }, @@ -8323,9 +8346,9 @@ "peer": true }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -8558,9 +8581,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -11791,12 +11814,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -12028,9 +12051,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -12044,9 +12067,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", - "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", @@ -12055,7 +12078,8 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { @@ -12552,9 +12576,9 @@ "license": "MIT" }, "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", "dev": true, "license": "MIT", "dependencies": { @@ -13615,47 +13639,88 @@ } }, "node_modules/ibm-cloud-sdk-core": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.4.11.tgz", - "integrity": "sha512-UYm6i3OCcQ1sBOVIJh0gcwCNltiGCf7QBCPaDtqCXuHIPbn8m9sKqVBqfrgFuQpenAak/Yv/450Vw+tC59XVIQ==", + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.4.19.tgz", + "integrity": "sha512-BPeSnFP1qRxLinqnfl2BnKGp5z2+OvZXxuwMdSAdS9eAT0kTdk33A2n5TBnLTzE42pVLc7YC4CSG5XF6x9vpDg==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@types/debug": "^4.1.12", - "@types/node": "^18.19.80", - "@types/tough-cookie": "^4.0.0", - "axios": "1.15.0", - "camelcase": "^6.3.0", - "debug": "^4.3.4", - "dotenv": "^16.4.5", + "@types/debug": "4.1.12", + "@types/node": "18.19.80", + "@types/tough-cookie": "4.0.0", + "axios": "1.15.2", + "camelcase": "6.3.0", + "debug": "4.3.4", + "dotenv": "16.4.5", "extend": "3.0.2", - "file-type": "^21.3.2", - "form-data": "^4.0.4", + "file-type": "21.3.2", + "form-data": "4.0.4", "isstream": "0.1.2", - "jsonwebtoken": "^9.0.3", - "load-esm": "^1.0.3", + "jsonwebtoken": "9.0.3", + "load-esm": "1.0.3", "mime-types": "2.1.35", - "retry-axios": "^2.6.0", - "tough-cookie": "^4.1.3" + "retry-axios": "2.6.0", + "tough-cookie": "4.1.3" }, "engines": { "node": ">=20" } }, + "node_modules/ibm-cloud-sdk-core/node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/ibm-cloud-sdk-core/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "18.19.80", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.80.tgz", + "integrity": "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ==", "license": "MIT", "peer": true, "dependencies": { "undici-types": "~5.26.4" } }, + "node_modules/ibm-cloud-sdk-core/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ibm-cloud-sdk-core/node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/ibm-cloud-sdk-core/node_modules/file-type": { - "version": "21.3.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", - "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "version": "21.3.2", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz", + "integrity": "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==", "license": "MIT", "peer": true, "dependencies": { @@ -13671,6 +13736,30 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/ibm-cloud-sdk-core/node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "peer": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ibm-cloud-sdk-core/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT", + "peer": true + }, "node_modules/ibm-cloud-sdk-core/node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -13949,9 +14038,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -15100,9 +15189,9 @@ } }, "node_modules/langchain/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -15113,13 +15202,12 @@ } }, "node_modules/langsmith": { - "version": "0.5.20", - "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.20.tgz", - "integrity": "sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.7.1.tgz", + "integrity": "sha512-Wjk90UjNoY5cBHMlNAC/eZx5clI8jnjBOBW8uJu8+MWBtx0QesNjsUiLtjI+I3UnrpxFFpDqGXcnhBjH654Mqg==", "license": "MIT", "dependencies": { - "p-queue": "6.6.2", - "uuid": "10.0.0" + "p-queue": "6.6.2" }, "peerDependencies": { "@opentelemetry/api": "*", @@ -15664,9 +15752,9 @@ } }, "node_modules/loopback-connector-postgresql/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -16138,9 +16226,9 @@ } }, "node_modules/loopback-datasource-juggler/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -25883,9 +25971,9 @@ "license": "MIT" }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -27089,9 +27177,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -27109,6 +27197,21 @@ } } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlcreate": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", diff --git a/package.json b/package.json index fa679f8..e078968 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,7 @@ "jws": "3.2.3", "node-forge": "1.4.0", "validator": "13.15.22", - "axios": "1.15.0", + "axios": "1.15.2", "fast-xml-parser": "5.5.8", "simple-git": "3.33.0", "flatted": "3.4.2", @@ -224,7 +224,8 @@ }, "commitizen": { "inquirer": "^12.9.6" - } + }, + "fast-uri": "^3.1.2" }, "config": { "commitizen": { @@ -279,4 +280,4 @@ ], "repositoryUrl": "https://github.com/sourcefuse/loopback4-llm-chat-extension.git" } -} +} \ No newline at end of file From 5cbbfb4338ba4d78bfd63774badf065f784dd62c Mon Sep 17 00:00:00 2001 From: Piyush Singh Gaur Date: Tue, 19 May 2026 16:45:15 +0530 Subject: [PATCH 4/6] feat(mastra): migrate Visualization workflow from LangGraph to Mastra-native orchestration --- .../tools/generate-visualization.tool.unit.ts | 64 ++++ .../workflows/visualization.workflow.unit.ts | 276 ++++++++++++++++++ src/components/visualization/types.ts | 5 + src/mastra/bridge/workflow-runner.ts | 45 +++ src/mastra/index.ts | 6 +- src/mastra/workflows/visualization/index.ts | 18 ++ .../visualization/steps/data-fetch.step.ts | 43 +++ .../workflows/visualization/steps/index.ts | 4 + .../steps/query-generation.step.ts | 131 +++++++++ .../visualization/steps/render-config.step.ts | 81 +++++ .../steps/visualization-selection.step.ts | 148 ++++++++++ .../tools/generate-visualization.tool.ts | 218 ++++++++++++++ .../workflows/visualization/tools/index.ts | 5 + .../visualization-request-context.ts | 15 + .../visualization-workflow-schemas.ts | 54 ++++ .../visualization/visualization.workflow.ts | 28 ++ src/providers/mastra-tools.provider.ts | 12 + 17 files changed, 1152 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/visualization/unit/tools/generate-visualization.tool.unit.ts create mode 100644 src/__tests__/visualization/unit/workflows/visualization.workflow.unit.ts create mode 100644 src/mastra/workflows/visualization/index.ts create mode 100644 src/mastra/workflows/visualization/steps/data-fetch.step.ts create mode 100644 src/mastra/workflows/visualization/steps/index.ts create mode 100644 src/mastra/workflows/visualization/steps/query-generation.step.ts create mode 100644 src/mastra/workflows/visualization/steps/render-config.step.ts create mode 100644 src/mastra/workflows/visualization/steps/visualization-selection.step.ts create mode 100644 src/mastra/workflows/visualization/tools/generate-visualization.tool.ts create mode 100644 src/mastra/workflows/visualization/tools/index.ts create mode 100644 src/mastra/workflows/visualization/visualization-request-context.ts create mode 100644 src/mastra/workflows/visualization/visualization-workflow-schemas.ts create mode 100644 src/mastra/workflows/visualization/visualization.workflow.ts diff --git a/src/__tests__/visualization/unit/tools/generate-visualization.tool.unit.ts b/src/__tests__/visualization/unit/tools/generate-visualization.tool.unit.ts new file mode 100644 index 0000000..22c684f --- /dev/null +++ b/src/__tests__/visualization/unit/tools/generate-visualization.tool.unit.ts @@ -0,0 +1,64 @@ +import {expect} from '@loopback/testlab'; +import { + formatGenerateVisualizationResult, + getGenerateVisualizationMetadata, +} from '../../../../mastra/workflows/visualization/tools/generate-visualization.tool'; + +describe('GenerateVisualizationTool Unit', function () { + it('formats successful visualization output message', () => { + const formatted = formatGenerateVisualizationResult({ + status: 'completed', + done: true, + datasetId: 'dataset-1', + visualizerName: 'bar', + visualizerConfig: { + categoryColumn: 'month', + valueColumn: 'revenue', + }, + replyToUser: + 'Visualization rendered for the user with the following config: {}', + }); + + expect(formatted).to.equal( + 'Visualization rendered for the user with the following config: {}', + ); + }); + + it('formats failed visualization output message', () => { + const formatted = formatGenerateVisualizationResult({ + status: 'failed', + done: false, + error: 'No suitable visualization found', + replyToUser: + 'Visualization could not be generated. Reason: No suitable visualization found', + }); + + expect(formatted).to.equal( + 'Visualization could not be generated. Reason: No suitable visualization found', + ); + }); + + it('extracts metadata with visualization payload', () => { + const metadata = getGenerateVisualizationMetadata({ + status: 'completed', + done: true, + datasetId: 'dataset-42', + visualizerName: 'line', + visualizerConfig: { + xAxisColumn: 'month', + yAxisColumn: 'sales', + }, + replyToUser: 'ok', + }); + + expect(metadata).to.deepEqual({ + status: 'completed', + existingDatasetId: 'dataset-42', + config: { + xAxisColumn: 'month', + yAxisColumn: 'sales', + }, + visualization: 'line', + }); + }); +}); diff --git a/src/__tests__/visualization/unit/workflows/visualization.workflow.unit.ts b/src/__tests__/visualization/unit/workflows/visualization.workflow.unit.ts new file mode 100644 index 0000000..b71433d --- /dev/null +++ b/src/__tests__/visualization/unit/workflows/visualization.workflow.unit.ts @@ -0,0 +1,276 @@ +import {RequestContext} from '@mastra/core/request-context'; +import {expect, sinon} from '@loopback/testlab'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {dbQueryWorkflow} from '../../../../mastra/workflows/db-query/db-query.workflow'; +import * as llmHelpers from '../../../../mastra/workflows/db-query/llm-helpers'; +import {visualizationWorkflow} from '../../../../mastra/workflows/visualization/visualization.workflow'; +import type {IVisualizer} from '../../../../components/visualization/types'; + +type WorkflowChunk = { + type: string; + payload?: { + output?: { + type?: string; + data?: unknown; + }; + }; +}; + +function createMockStream( + chunks: TChunk[], + result: unknown, +): AsyncIterable & {result: Promise} { + return { + [Symbol.asyncIterator]: async function* () { + for (const chunk of chunks) { + yield chunk; + } + }, + result: Promise.resolve(result), + }; +} + +describe('VisualizationWorkflow Unit', function () { + afterEach(() => { + sinon.restore(); + }); + + it('renders visualization using explicit type and existing dataset', async () => { + const getConfig = sinon.stub().resolves({ + categoryColumn: 'department', + valueColumn: 'salary', + }); + + const visualizer: IVisualizer = { + name: 'bar', + description: 'Bar chart visualizer', + context: 'requires category and value columns', + getConfig, + }; + + const datasetStore = { + findById: sinon.stub().resolves({ + query: 'SELECT department, salary FROM employees', + description: 'Department salary distribution', + }), + }; + + const requestContext = new RequestContext(); + requestContext.set('visualizerStore', { + list: [visualizer], + map: {bar: visualizer}, + }); + requestContext.set('datasetStore', datasetStore); + requestContext.set('cheapLlm', {}); + + const run = await visualizationWorkflow.createRun(); + const stream = run.stream({ + inputData: { + prompt: 'Show salary by department', + datasetId: 'dataset-1', + type: 'bar', + }, + requestContext, + }); + + const statuses: string[] = []; + for await (const chunk of stream) { + const typedChunk = chunk as WorkflowChunk; + const output = typedChunk.payload?.output; + if ( + typedChunk.type === 'workflow-step-output' && + output?.type === LLMStreamEventType.ToolStatus + ) { + const status = output.data as {status?: string}; + if (typeof status.status === 'string') { + statuses.push(status.status); + } + } + } + + const result = await stream.result; + + expect(result.status).to.equal('success'); + if (result.status !== 'success') { + return; + } + + expect(result.result).to.deepEqual({ + datasetId: 'dataset-1', + visualizerName: 'bar', + visualizerConfig: { + categoryColumn: 'department', + valueColumn: 'salary', + }, + done: true, + }); + expect(datasetStore.findById.calledOnceWith('dataset-1')).to.be.true(); + expect(getConfig.calledOnce).to.be.true(); + expect(statuses).to.containEql('Preparing visualization'); + expect(statuses).to.containEql('Configuring bar'); + expect(statuses).to.containEql('completed'); + }); + + it('delegates query generation to dbQueryWorkflow when dataset is not provided', async () => { + const getConfig = sinon.stub().resolves({ + categoryColumn: 'month', + valueColumn: 'revenue', + }); + + const visualizer: IVisualizer = { + name: 'bar', + description: 'Bar chart visualizer', + context: 'must include category and numeric value columns', + getConfig, + }; + + const datasetStore = { + findById: sinon.stub().resolves({ + query: 'SELECT month, revenue FROM monthly_revenue', + description: 'Monthly revenue data', + }), + }; + + const subWorkflowChunks = [ + { + type: 'workflow-step-output', + payload: { + output: { + type: LLMStreamEventType.Status, + data: 'db-query-running', + }, + }, + }, + ]; + + const dbQueryStream = createMockStream(subWorkflowChunks, { + status: 'success', + result: { + datasetId: 'generated-dataset', + done: true, + replyToUser: 'Dataset generated', + }, + }); + + const subWorkflowRun = { + stream: sinon.stub().returns(dbQueryStream), + }; + + const createRunStub = sinon + .stub(dbQueryWorkflow, 'createRun') + .resolves( + subWorkflowRun as unknown as Awaited< + ReturnType + >, + ); + + const requestContext = new RequestContext(); + requestContext.set('visualizerStore', { + list: [visualizer], + map: {bar: visualizer}, + }); + requestContext.set('datasetStore', datasetStore); + requestContext.set('cheapLlm', {}); + requestContext.set('fullSchema', { + tables: {}, + relations: [], + }); + + const run = await visualizationWorkflow.createRun(); + const stream = run.stream({ + inputData: { + prompt: 'Show monthly revenue trend', + type: 'bar', + }, + requestContext, + }); + + const forwardedEvents: string[] = []; + for await (const chunk of stream) { + const typedChunk = chunk as WorkflowChunk; + const output = typedChunk.payload?.output; + if ( + typedChunk.type === 'workflow-step-output' && + output?.type === LLMStreamEventType.Status + ) { + const data = output.data; + if (typeof data === 'string') { + forwardedEvents.push(data); + } + } + } + + const result = await stream.result; + + expect(result.status).to.equal('success'); + if (result.status !== 'success') { + return; + } + + expect(result.result).to.deepEqual({ + datasetId: 'generated-dataset', + visualizerName: 'bar', + visualizerConfig: { + categoryColumn: 'month', + valueColumn: 'revenue', + }, + done: true, + }); + + expect(createRunStub.calledOnce).to.be.true(); + expect(subWorkflowRun.stream.calledOnce).to.be.true(); + const dbQueryInput = subWorkflowRun.stream.firstCall.args[0].inputData; + + expect(dbQueryInput.datasetId).to.be.undefined(); + expect(dbQueryInput.directCall).to.be.true(); + expect(dbQueryInput.prompt).to.equal( + 'Generate a query to fetch data for visualization based on the following user prompt: Show monthly revenue trend. Ensure that the query structure satisfies the following context: must include category and numeric value columns', + ); + expect(forwardedEvents).to.containEql('db-query-running'); + }); + + it('returns workflow output with error when selection step resolves to none', async () => { + const visualizer: IVisualizer = { + name: 'bar', + description: 'Bar chart visualizer', + context: 'requires category and value columns', + getConfig: sinon.stub().resolves({}), + }; + + const datasetStore = { + findById: sinon.stub(), + }; + + sinon + .stub(llmHelpers, 'invokeLlm') + .resolves('none: this request cannot be represented by available charts'); + + const requestContext = new RequestContext(); + requestContext.set('visualizerStore', { + list: [visualizer], + map: {bar: visualizer}, + }); + requestContext.set('datasetStore', datasetStore); + requestContext.set('cheapLlm', {}); + + const run = await visualizationWorkflow.createRun(); + const result = await run.start({ + inputData: { + prompt: 'Render a scatter matrix with clustering details', + }, + requestContext, + }); + + expect(result.status).to.equal('success'); + if (result.status !== 'success') { + return; + } + + const workflowOutput = result.result as {done?: boolean; error?: string}; + expect(workflowOutput.done).to.equal(false); + expect(workflowOutput.error).to.equal( + ': this request cannot be represented by available charts', + ); + expect(datasetStore.findById.called).to.be.false(); + }); +}); diff --git a/src/components/visualization/types.ts b/src/components/visualization/types.ts index 65fd036..12013b8 100644 --- a/src/components/visualization/types.ts +++ b/src/components/visualization/types.ts @@ -7,3 +7,8 @@ export interface IVisualizer { context?: string; getConfig(state: VisualizationGraphState): Promise | AnyObject; } + +export type VisualizerStore = { + list: IVisualizer[]; + map: Record; +}; diff --git a/src/mastra/bridge/workflow-runner.ts b/src/mastra/bridge/workflow-runner.ts index 8b54f6b..d0bdb16 100644 --- a/src/mastra/bridge/workflow-runner.ts +++ b/src/mastra/bridge/workflow-runner.ts @@ -1,5 +1,6 @@ import { BindingScope, + Context, Getter, inject, injectable, @@ -39,6 +40,11 @@ import type { CacheDocument, TemplateDocument, } from '../workflows/db-query/db-query-request-context'; +import {VISUALIZATION_KEY} from '../../components/visualization/keys'; +import type { + IVisualizer, + VisualizerStore, +} from '../../components/visualization/types'; const debug = require('debug')('ai-integration:mastra:workflow-runner'); @@ -76,6 +82,8 @@ function isLLMStreamEvent(value: unknown): value is LLMStreamEvent { @injectable({scope: BindingScope.REQUEST}) export class WorkflowRunner { constructor( + @inject.context() + private readonly lbContext: Context, @service(ChatStore) private readonly chatStore: ChatStore, @inject(AiIntegrationBindings.MastraChatLLM) @@ -160,6 +168,7 @@ export class WorkflowRunner { requestContext.set('systemContext', this.systemContext); requestContext.set('tokenUsageAccumulator', tokenAccumulator); requestContext.set('currentUser', currentUser); + requestContext.set('visualizerStore', await this.resolveVisualizerStore()); const chatDbQuerySchema = this.resolveDbQueryChatSchema(); if (chatDbQuerySchema) { @@ -258,6 +267,42 @@ export class WorkflowRunner { } } + private async resolveVisualizerStore(): Promise { + const bindings = this.lbContext.findByTag({ + [VISUALIZATION_KEY]: true, + }); + + if (!bindings.length) { + return { + list: [], + map: {}, + }; + } + + const visualizers: IVisualizer[] = []; + for (const binding of bindings) { + try { + const visualizer = await this.lbContext.get(binding.key); + visualizers.push(visualizer); + } catch (error) { + debug( + `WorkflowRunner: failed to resolve visualizer binding ${binding.key}:`, + error, + ); + } + } + + const visualizerMap: Record = {}; + for (const visualizer of visualizers) { + visualizerMap[visualizer.name] = visualizer; + } + + return { + list: visualizers, + map: visualizerMap, + }; + } + private resolveDbQueryChatSchema(): DatabaseSchema | undefined { if (!this.mastraCheapLlm) { return undefined; diff --git a/src/mastra/index.ts b/src/mastra/index.ts index 26c1a54..11829cd 100644 --- a/src/mastra/index.ts +++ b/src/mastra/index.ts @@ -3,7 +3,7 @@ * * Phase 1: Foundation Layer + ChatWorkflow * Phase 2: DBQueryWorkflow - * Phase 3 (future): VisualizationWorkflow + * Phase 3: VisualizationWorkflow */ // Bridge utilities @@ -31,5 +31,9 @@ export { askAboutDatasetTool, } from './workflows/db-query/tools'; +// Visualization workflow +export {visualizationWorkflow} from './workflows/visualization/visualization.workflow'; +export {generateVisualizationTool} from './workflows/visualization/tools'; + // Agent export {chatReasoningAgent} from './agents/chat-reasoning.agent'; diff --git a/src/mastra/workflows/visualization/index.ts b/src/mastra/workflows/visualization/index.ts new file mode 100644 index 0000000..d8ffd7d --- /dev/null +++ b/src/mastra/workflows/visualization/index.ts @@ -0,0 +1,18 @@ +export {visualizationWorkflow} from './visualization.workflow'; +export {asVisualizationContext} from './visualization-request-context'; +export type { + VisualizationRequestContext, + VisualizerStore, +} from './visualization-request-context'; +export { + visualizationWorkflowInputSchema, + visualizationWorkflowOutputSchema, + visualizationWorkflowStateSchema, +} from './visualization-workflow-schemas'; +export type { + VisualizationWorkflowInput, + VisualizationWorkflowOutput, + VisualizationWorkflowState, +} from './visualization-workflow-schemas'; +export * from './steps'; +export * from './tools'; diff --git a/src/mastra/workflows/visualization/steps/data-fetch.step.ts b/src/mastra/workflows/visualization/steps/data-fetch.step.ts new file mode 100644 index 0000000..4480f00 --- /dev/null +++ b/src/mastra/workflows/visualization/steps/data-fetch.step.ts @@ -0,0 +1,43 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {asVisualizationContext} from '../visualization-request-context'; +import {visualizationWorkflowStateSchema} from '../visualization-workflow-schemas'; + +export const dataFetchStep = createStep({ + id: 'data-fetch', + inputSchema: z.object({ + prompt: z.string(), + datasetId: z.string().optional(), + visualizerName: z.string().optional(), + visualizerContext: z.string().optional(), + type: z.string().optional(), + error: z.string().optional(), + }), + outputSchema: visualizationWorkflowStateSchema, + execute: async ({inputData, requestContext, writer}) => { + if (inputData.error) { + return inputData; + } + + if (!inputData.datasetId) { + throw new Error('Invalid State'); + } + + const ctx = asVisualizationContext(requestContext!); + const dataset = await ctx.get('datasetStore').findById(inputData.datasetId); + + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: { + status: 'Preparing visualization', + }, + }); + + return { + ...inputData, + sql: dataset.query, + queryDescription: dataset.description, + }; + }, +}); diff --git a/src/mastra/workflows/visualization/steps/index.ts b/src/mastra/workflows/visualization/steps/index.ts new file mode 100644 index 0000000..f237086 --- /dev/null +++ b/src/mastra/workflows/visualization/steps/index.ts @@ -0,0 +1,4 @@ +export {visualizationSelectionStep} from './visualization-selection.step'; +export {queryGenerationStep} from './query-generation.step'; +export {dataFetchStep} from './data-fetch.step'; +export {renderConfigStep} from './render-config.step'; diff --git a/src/mastra/workflows/visualization/steps/query-generation.step.ts b/src/mastra/workflows/visualization/steps/query-generation.step.ts new file mode 100644 index 0000000..9e605eb --- /dev/null +++ b/src/mastra/workflows/visualization/steps/query-generation.step.ts @@ -0,0 +1,131 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import type {LLMStreamEvent} from '../../../../graphs/event.types'; +import {dbQueryWorkflow} from '../../db-query/db-query.workflow'; +import { + dbQueryWorkflowOutputSchema, + type DbQueryWorkflowOutput, +} from '../../db-query/db-query-workflow-schemas'; +import {asVisualizationContext} from '../visualization-request-context'; +import {visualizationWorkflowStateSchema} from '../visualization-workflow-schemas'; + +function isLLMStreamEvent(value: unknown): value is LLMStreamEvent { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + 'data' in value + ); +} + +function buildDatasetGenerationPrompt(inputData: { + prompt: string; + visualizerContext?: string; +}): string { + return `Generate a query to fetch data for visualization based on the following user prompt: ${inputData.prompt}.${inputData.visualizerContext ? ` Ensure that the query structure satisfies the following context: ${inputData.visualizerContext}` : ''}`; +} + +function buildDatasetFailureMessage(result: DbQueryWorkflowOutput): string { + return result.replyToUser ?? 'Failed to create dataset for visualization'; +} + +function resolveRunFailureMessage(result: unknown): string { + if ( + typeof result === 'object' && + result !== null && + 'error' in result && + result.error instanceof Error + ) { + return result.error.message; + } + + return 'Failed to create dataset for visualization'; +} + +export const queryGenerationStep = createStep({ + id: 'query-generation', + inputSchema: z.object({ + prompt: z.string(), + datasetId: z.string().optional(), + visualizerName: z.string().optional(), + visualizerContext: z.string().optional(), + type: z.string().optional(), + error: z.string().optional(), + }), + outputSchema: visualizationWorkflowStateSchema, + execute: async ({inputData, requestContext, writer}) => { + if (inputData.error !== undefined || inputData.datasetId !== undefined) { + return inputData; + } + + const ctx = asVisualizationContext(requestContext!); + const schema = ctx.get('fullSchema'); + + if (!schema) { + throw new Error( + 'fullSchema not found in RequestContext. Ensure DB Query context is bound before visualization execution.', + ); + } + + const run = await dbQueryWorkflow.createRun(); + const stream = run.stream({ + inputData: { + datasetId: inputData.datasetId, + directCall: true, + prompt: buildDatasetGenerationPrompt(inputData), + schema, + }, + requestContext, + }); + + for await (const chunk of stream) { + if (chunk.type === 'workflow-step-output') { + const output = chunk.payload?.output; + if (isLLMStreamEvent(output)) { + await writer.write(output); + } + } + } + + const finalResult = await stream.result; + if (finalResult.status !== 'success') { + const failureMessage = resolveRunFailureMessage(finalResult); + await writer.write({ + type: LLMStreamEventType.Error, + data: { + status: `Failed to create dataset for visualization: ${failureMessage}`, + }, + }); + return { + ...inputData, + error: failureMessage, + }; + } + + const parsedOutput = dbQueryWorkflowOutputSchema.safeParse( + finalResult.result, + ); + + if (!parsedOutput.success || !parsedOutput.data.datasetId) { + const fallbackResult = parsedOutput.success + ? buildDatasetFailureMessage(parsedOutput.data) + : 'Failed to create dataset for visualization'; + await writer.write({ + type: LLMStreamEventType.Error, + data: { + status: `Failed to create dataset for visualization: ${parsedOutput.success ? (parsedOutput.data.replyToUser ?? 'Unknown error') : 'Unknown error'}`, + }, + }); + return { + ...inputData, + error: fallbackResult, + }; + } + + return { + ...inputData, + datasetId: parsedOutput.data.datasetId, + }; + }, +}); diff --git a/src/mastra/workflows/visualization/steps/render-config.step.ts b/src/mastra/workflows/visualization/steps/render-config.step.ts new file mode 100644 index 0000000..15723b6 --- /dev/null +++ b/src/mastra/workflows/visualization/steps/render-config.step.ts @@ -0,0 +1,81 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {ToolStatus} from '../../../../graphs/types'; +import type {VisualizationGraphState} from '../../../../components/visualization/state'; +import {asVisualizationContext} from '../visualization-request-context'; +import {visualizationWorkflowOutputSchema} from '../visualization-workflow-schemas'; + +export const renderConfigStep = createStep({ + id: 'render-config', + inputSchema: z.object({ + prompt: z.string(), + datasetId: z.string().optional(), + visualizerName: z.string().optional(), + sql: z.string().optional(), + queryDescription: z.string().optional(), + type: z.string().optional(), + error: z.string().optional(), + }), + outputSchema: visualizationWorkflowOutputSchema, + execute: async ({inputData, requestContext, writer}) => { + if (inputData.error) { + return { + datasetId: inputData.datasetId, + visualizerName: inputData.visualizerName, + done: false, + error: inputData.error, + }; + } + + const ctx = asVisualizationContext(requestContext!); + const visualizerStore = ctx.get('visualizerStore'); + const visualizer = inputData.visualizerName + ? visualizerStore.map[inputData.visualizerName] + : undefined; + + if ( + !visualizer || + !inputData.sql || + !inputData.queryDescription || + !inputData.datasetId + ) { + throw new Error('Invalid State'); + } + + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: { + status: `Configuring ${visualizer.name}`, + }, + }); + + const settings = await visualizer.getConfig({ + prompt: inputData.prompt, + datasetId: inputData.datasetId, + sql: inputData.sql, + queryDescription: inputData.queryDescription, + visualizerName: visualizer.name, + type: inputData.type, + } as VisualizationGraphState); + + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: { + status: ToolStatus.Completed, + data: { + datasetId: inputData.datasetId, + visualization: visualizer.name, + config: settings || {}, + }, + }, + }); + + return { + datasetId: inputData.datasetId, + visualizerName: visualizer.name, + visualizerConfig: settings || {}, + done: true, + }; + }, +}); diff --git a/src/mastra/workflows/visualization/steps/visualization-selection.step.ts b/src/mastra/workflows/visualization/steps/visualization-selection.step.ts new file mode 100644 index 0000000..3b87574 --- /dev/null +++ b/src/mastra/workflows/visualization/steps/visualization-selection.step.ts @@ -0,0 +1,148 @@ +import {createStep} from '@mastra/core/workflows'; +import {z} from 'zod'; +import {LLMStreamEventType} from '../../../../graphs/event.types'; +import {VISUALIZATION_KEY} from '../../../../components/visualization/keys'; +import {invokeLlm, stripThinkingTokens} from '../../db-query/llm-helpers'; +import {asVisualizationContext} from '../visualization-request-context'; +import {visualizationWorkflowStateSchema} from '../visualization-workflow-schemas'; + +const VISUALIZATION_SELECTION_PROMPT = ` + +You are expert Data Analysis Agent whose job is to suggest visualisations that would be best suited to display the results for a particular user prompt and the data extracted based on that prompt. +You are provided with 2 inputs - +- user prompt +- A list of visualization names with their descriptions that are supported. + +You need to suggest a visualisation from a list of visualisation that would best fit the user's request. + + + +{prompt} + + + +{sql} + + +{description} + + + +{visualizations} + + + + +The output should be a single string that has the name from the visualizations list and nothing else. +If none of the visualizations fit the requirement, return "none" followed by the changes required in the data to be able to render the visualization. +Do not try to force fit the prompt to any visualization if it does not make sense. Prefer to returning none with appropriate reason instead. + + +type-of-visualization + + +none: reason why the visualization is not possible with the current prompt. + + +`; + +function buildSelectionPrompt(inputData: { + prompt: string; + sql?: string; + queryDescription?: string; + visualizations: string; +}): string { + return VISUALIZATION_SELECTION_PROMPT.replace('{prompt}', inputData.prompt) + .replace('{sql}', inputData.sql ?? '') + .replace('{description}', inputData.queryDescription ?? '') + .replace('{visualizations}', inputData.visualizations); +} + +function buildUnknownVisualizerError( + name: string, + availableVisualizers: string[], +): string { + return `No visualizer found with name ${name}, available visualizers are ${availableVisualizers.join(', ')}`; +} + +export const visualizationSelectionStep = createStep({ + id: 'visualization-selection', + inputSchema: z.object({ + prompt: z.string(), + datasetId: z.string().optional(), + type: z.string().optional(), + sql: z.string().optional(), + queryDescription: z.string().optional(), + error: z.string().optional(), + }), + outputSchema: visualizationWorkflowStateSchema, + execute: async ({inputData, requestContext, writer}) => { + const ctx = asVisualizationContext(requestContext!); + const visualizerStore = ctx.get('visualizerStore'); + const visualizations = visualizerStore.list; + + if (!visualizations.length) { + throw new Error(`Node with key ${VISUALIZATION_KEY} not found`); + } + + if (inputData.type) { + const selected = visualizerStore.map[inputData.type]; + if (!selected) { + throw new Error( + buildUnknownVisualizerError( + inputData.type, + visualizations.map(v => v.name), + ), + ); + } + + return { + ...inputData, + visualizerName: selected.name, + visualizerContext: selected.context, + }; + } + + await writer.write({ + type: LLMStreamEventType.ToolStatus, + data: { + status: 'Selecting best visualization for the data', + }, + }); + + const selectionPrompt = buildSelectionPrompt({ + prompt: inputData.prompt, + sql: inputData.sql, + queryDescription: inputData.queryDescription, + visualizations: visualizations + .map(v => `- ${v.name}: ${v.description}`) + .join('\n'), + }); + + const rawOutput = await invokeLlm(ctx.get('cheapLlm'), selectionPrompt); + const output = stripThinkingTokens(rawOutput); + + if (output.trim().startsWith('none')) { + return { + ...inputData, + error: output.trim().substring(4).trim(), + }; + } + + const selected = visualizerStore.map[output.trim()]; + if (!selected) { + throw new Error( + buildUnknownVisualizerError( + output.trim(), + visualizations.map(v => v.name), + ), + ); + } + + return { + ...inputData, + visualizerName: selected.name, + visualizerContext: selected.context, + }; + }, +}); diff --git a/src/mastra/workflows/visualization/tools/generate-visualization.tool.ts b/src/mastra/workflows/visualization/tools/generate-visualization.tool.ts new file mode 100644 index 0000000..212297a --- /dev/null +++ b/src/mastra/workflows/visualization/tools/generate-visualization.tool.ts @@ -0,0 +1,218 @@ +import {createTool} from '@mastra/core/tools'; +import {z} from 'zod'; +import type {RequestContext} from '@mastra/core/request-context'; +import {visualizationWorkflow} from '../visualization.workflow'; +import { + visualizationWorkflowInputSchema, + visualizationWorkflowOutputSchema, + type VisualizationWorkflowOutput, +} from '../visualization-workflow-schemas'; +import type {LLMStreamEvent} from '../../../../graphs/event.types'; +import type {AsyncEventQueue} from '../../../bridge/async-event-queue'; +import type {JsonObject, JsonValue} from '../../../../types'; + +const looseObjectSchema = z.object({}).passthrough(); + +const visualizationToolResultSchema = z.object({ + status: z.enum(['completed', 'failed']), + done: z.boolean(), + datasetId: z.string().optional(), + visualizerName: z.string().optional(), + visualizerConfig: looseObjectSchema.optional(), + error: z.string().optional(), + replyToUser: z.string(), +}); + +type VisualizationToolResult = z.infer; + +function isLLMStreamEvent( + value: object | null | undefined, +): value is LLMStreamEvent { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + 'data' in value + ); +} + +function toJsonObject(value: JsonValue): JsonObject { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as JsonObject; + } + return { + value: + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ? value + : String(value), + }; +} + +function buildFailureMessage(error?: string): string { + return `Visualization could not be generated. Reason: ${error ?? 'Unknown reason'}`; +} + +function buildSuccessMessage(config: JsonObject | undefined): string { + return `Visualization rendered for the user with the following config: ${JSON.stringify( + config ?? {}, + undefined, + 2, + )}`; +} + +function resolveRunFailureMessage(result: unknown): string { + if ( + typeof result === 'object' && + result !== null && + 'error' in result && + result.error instanceof Error + ) { + return result.error.message; + } + + return 'Visualization workflow execution failed.'; +} + +function formatResult( + result: VisualizationWorkflowOutput, +): VisualizationToolResult { + if ( + !result.done || + !result.datasetId || + !result.visualizerName || + result.error + ) { + const errorMessage = result.error ?? 'Unknown reason'; + return { + status: 'failed', + done: false, + datasetId: result.datasetId, + visualizerName: result.visualizerName, + visualizerConfig: result.visualizerConfig, + error: errorMessage, + replyToUser: buildFailureMessage(errorMessage), + }; + } + + return { + status: 'completed', + done: true, + datasetId: result.datasetId, + visualizerName: result.visualizerName, + visualizerConfig: result.visualizerConfig ?? {}, + replyToUser: buildSuccessMessage(result.visualizerConfig as JsonObject), + }; +} + +/** + * Mastra-native tool: generate-visualization + * + * Replaces the LangChain GenerateVisualizationTool by running the + * VisualizationWorkflow directly, forwarding step events to AsyncEventQueue. + */ +export const generateVisualizationTool = createTool({ + id: 'generate-visualization', + description: `Generates a visualization for the user's request. It takes in a prompt and an optional dataset ID. +If the user's request involves trends, growth, decline, comparisons, distributions, patterns, correlations, or any analytical insight, ALWAYS use this tool instead of 'get-data-as-dataset'. +No need to call 'get-data-as-dataset' tool before this - if the dataset ID is not provided, this tool will internally fetch the data to be visualized. +It does not return anything, instead it fires an event internally that renders the visualization on the UI for the user to see.`, + inputSchema: visualizationWorkflowInputSchema, + outputSchema: visualizationToolResultSchema, + execute: async ( + inputData: z.infer, + {requestContext}: {requestContext?: RequestContext}, + ): Promise => { + if (!requestContext) { + throw new Error( + 'RequestContext is required for generate-visualization tool execution.', + ); + } + + const eventQueue = requestContext.get('eventQueue') as + | AsyncEventQueue + | undefined; + const abortSignal = requestContext.get('abortSignal') as + | AbortSignal + | undefined; + + const run = await visualizationWorkflow.createRun(); + const stream = run.stream({ + inputData, + requestContext, + }); + + for await (const chunk of stream) { + if (abortSignal?.aborted) { + return { + status: 'failed', + done: false, + replyToUser: + 'Request was cancelled before visualization generation finished.', + }; + } + + if (chunk.type === 'workflow-step-output') { + const output = chunk.payload?.output; + if (eventQueue && isLLMStreamEvent(output)) { + eventQueue.push(output); + } + } + } + + const finalResult = await stream.result; + if (finalResult.status !== 'success') { + const errorMessage = resolveRunFailureMessage(finalResult); + return { + status: 'failed', + done: false, + error: errorMessage, + replyToUser: buildFailureMessage(errorMessage), + }; + } + + const parsedOutput = visualizationWorkflowOutputSchema.safeParse( + finalResult.result, + ); + + if (!parsedOutput.success) { + return { + status: 'failed', + done: false, + error: 'Unable to parse visualization workflow output.', + replyToUser: buildFailureMessage( + 'Unable to parse visualization workflow output.', + ), + }; + } + + return formatResult(parsedOutput.data); + }, +}); + +export function formatGenerateVisualizationResult(result: JsonObject): string { + const parsed = visualizationToolResultSchema.safeParse(toJsonObject(result)); + if (!parsed.success) { + return JSON.stringify(result); + } + + return parsed.data.replyToUser; +} + +export function getGenerateVisualizationMetadata( + result: JsonObject, +): JsonObject { + const parsed = visualizationToolResultSchema.safeParse(toJsonObject(result)); + if (!parsed.success) { + return {status: 'failed'}; + } + + return { + status: parsed.data.status, + existingDatasetId: parsed.data.datasetId ?? null, + config: (parsed.data.visualizerConfig as JsonObject | undefined) ?? null, + visualization: parsed.data.visualizerName ?? null, + }; +} diff --git a/src/mastra/workflows/visualization/tools/index.ts b/src/mastra/workflows/visualization/tools/index.ts new file mode 100644 index 0000000..b5afc67 --- /dev/null +++ b/src/mastra/workflows/visualization/tools/index.ts @@ -0,0 +1,5 @@ +export { + generateVisualizationTool, + formatGenerateVisualizationResult, + getGenerateVisualizationMetadata, +} from './generate-visualization.tool'; diff --git a/src/mastra/workflows/visualization/visualization-request-context.ts b/src/mastra/workflows/visualization/visualization-request-context.ts new file mode 100644 index 0000000..4917e09 --- /dev/null +++ b/src/mastra/workflows/visualization/visualization-request-context.ts @@ -0,0 +1,15 @@ +import type {RequestContext} from '@mastra/core/request-context'; +import type {DbQueryRequestContext} from '../db-query/db-query-request-context'; +import type {VisualizerStore} from '../../../components/visualization/types'; + +export type {VisualizerStore} from '../../../components/visualization/types'; + +export interface VisualizationRequestContext extends DbQueryRequestContext { + visualizerStore: VisualizerStore; +} + +export function asVisualizationContext( + requestContext: RequestContext, +): RequestContext { + return requestContext as RequestContext; +} diff --git a/src/mastra/workflows/visualization/visualization-workflow-schemas.ts b/src/mastra/workflows/visualization/visualization-workflow-schemas.ts new file mode 100644 index 0000000..4241c0b --- /dev/null +++ b/src/mastra/workflows/visualization/visualization-workflow-schemas.ts @@ -0,0 +1,54 @@ +import {z} from 'zod'; + +const looseObjectSchema = z.object({}).passthrough(); + +export const visualizationWorkflowInputSchema = z.object({ + prompt: z + .string() + .describe( + 'Prompt from the user that will be used for generating the visualization.', + ), + datasetId: z + .string() + .optional() + .describe( + "ID of the dataset that needs to be visualized. Use the dataset ID from 'get-data-as-dataset' or 'improve-dataset' if available.", + ), + type: z + .string() + .optional() + .describe( + 'Type of visualization to be generated. If not provided, the system will decide the best visualization based on the prompt.', + ), +}); + +export type VisualizationWorkflowInput = z.infer< + typeof visualizationWorkflowInputSchema +>; + +export const visualizationWorkflowStateSchema = + visualizationWorkflowInputSchema.extend({ + visualizerName: z.string().optional(), + visualizerContext: z.string().optional(), + sql: z.string().optional(), + queryDescription: z.string().optional(), + visualizerConfig: looseObjectSchema.optional(), + done: z.boolean().optional(), + error: z.string().optional(), + }); + +export type VisualizationWorkflowState = z.infer< + typeof visualizationWorkflowStateSchema +>; + +export const visualizationWorkflowOutputSchema = z.object({ + datasetId: z.string().optional(), + visualizerName: z.string().optional(), + visualizerConfig: looseObjectSchema.optional(), + done: z.boolean().optional(), + error: z.string().optional(), +}); + +export type VisualizationWorkflowOutput = z.infer< + typeof visualizationWorkflowOutputSchema +>; diff --git a/src/mastra/workflows/visualization/visualization.workflow.ts b/src/mastra/workflows/visualization/visualization.workflow.ts new file mode 100644 index 0000000..905db32 --- /dev/null +++ b/src/mastra/workflows/visualization/visualization.workflow.ts @@ -0,0 +1,28 @@ +import {createWorkflow} from '@mastra/core/workflows'; +import { + visualizationWorkflowInputSchema, + visualizationWorkflowOutputSchema, +} from './visualization-workflow-schemas'; +import { + visualizationSelectionStep, + queryGenerationStep, + dataFetchStep, + renderConfigStep, +} from './steps'; + +/** + * VisualizationWorkflow — Mastra replacement for the LangGraph VisualizationGraph. + * + * Step pipeline: + * visualization-selection → query-generation → data-fetch → render-config + */ +export const visualizationWorkflow = createWorkflow({ + id: 'visualization-workflow', + inputSchema: visualizationWorkflowInputSchema, + outputSchema: visualizationWorkflowOutputSchema, +}) + .then(visualizationSelectionStep) + .then(queryGenerationStep) + .then(dataFetchStep) + .then(renderConfigStep) + .commit(); diff --git a/src/providers/mastra-tools.provider.ts b/src/providers/mastra-tools.provider.ts index 0a70422..fc063da 100644 --- a/src/providers/mastra-tools.provider.ts +++ b/src/providers/mastra-tools.provider.ts @@ -25,6 +25,11 @@ import { getImproveDatasetMetadata, improveDatasetTool, } from '../mastra/workflows/db-query/tools'; +import { + formatGenerateVisualizationResult, + generateVisualizationTool, + getGenerateVisualizationMetadata, +} from '../mastra/workflows/visualization/tools'; const debug = require('debug')('ai-integration:provider:mastra-tools'); @@ -114,6 +119,13 @@ function createNativeDefinitions(): MastraToolDefinition[] { formatResult: formatAskAboutDatasetResult, getMetadata: getAskAboutDatasetMetadata, }, + { + id: generateVisualizationTool.id, + tool: generateVisualizationTool, + source: 'native', + formatResult: formatGenerateVisualizationResult, + getMetadata: getGenerateVisualizationMetadata, + }, ]; } From 6e19def361c0f76ebbf55c9c49c6e46fdf6feb54 Mon Sep 17 00:00:00 2001 From: Piyush Singh Gaur Date: Thu, 21 May 2026 23:54:25 +0530 Subject: [PATCH 5/6] feat(mastra): migrate providers from LangGraph to ai-sdk. --- package-lock.json | 725 +++++++++++++++++- package.json | 16 +- .../unit/visualizers/bar.visualizer.unit.ts | 131 ++-- .../unit/visualizers/line.visualizer.unit.ts | 194 ++--- .../unit/visualizers/pie.visualizer.unit.ts | 216 ++---- src/component.ts | 23 +- .../services/template-helper.service.ts | 60 +- src/components/visualization/types.ts | 20 +- .../visualization/visualizer.component.ts | 19 +- .../visualizers/bar.visualizer.ts | 47 +- .../visualizers/line.visualizer.ts | 59 +- .../visualizers/pie.visualizer.ts | 47 +- src/keys.ts | 5 +- src/mastra/bridge/workflow-request-context.ts | 10 + src/mastra/bridge/workflow-runner.ts | 118 ++- src/mastra/types.ts | 10 + .../db-query/db-query-request-context.ts | 15 +- src/mastra/workflows/db-query/llm-helpers.ts | 155 +++- .../db-query/steps/cache-check.step.ts | 5 +- .../steps/change-classification.step.ts | 5 +- .../db-query/steps/column-selection.step.ts | 8 +- .../steps/dataset-persistence.step.ts | 7 +- .../steps/description-generation.step.ts | 5 +- .../db-query/steps/generate-checklist.step.ts | 5 +- .../db-query/steps/query-repair.step.ts | 5 +- .../steps/semantic-validation.step.ts | 5 +- .../db-query/steps/sql-generation.step.ts | 5 +- .../steps/syntactic-validation.step.ts | 5 +- .../db-query/steps/table-selection.step.ts | 8 +- .../db-query/steps/template-match.step.ts | 5 +- .../db-query/steps/verify-checklist.step.ts | 5 +- .../db-query/tools/ask-about-dataset.tool.ts | 5 +- .../tools/get-data-as-dataset.tool.ts | 5 +- .../db-query/tools/improve-dataset.tool.ts | 5 +- .../visualization/steps/data-fetch.step.ts | 6 +- .../steps/query-generation.step.ts | 6 +- .../visualization/steps/render-config.step.ts | 28 +- .../steps/visualization-selection.step.ts | 13 +- .../tools/generate-visualization.tool.ts | 11 +- src/providers/mastra-tools.provider.ts | 172 +---- .../obf/langfuse/langfuse.provider.ts | 40 +- .../anthropic/llms/anthropic.provider.ts | 28 +- .../embedding/bedrock-embedding.provider.ts | 48 +- .../providers/aws/llms/bedrock.provider.ts | 64 +- src/sub-modules/providers/aws/types.ts | 4 +- .../cerebras/llm/cerebras.provider.ts | 21 +- .../embedding/gemini-embedding.provider.ts | 49 +- .../providers/google/llms/gemini.provider.ts | 16 +- .../providers/groq/llms/groq.provider.ts | 12 +- .../embedding/ollama-embedding.provider.ts | 38 +- .../providers/ollama/llms/ollama.provider.ts | 21 +- .../providers/openai/llms/openai.provider.ts | 31 +- src/sub-modules/providers/openai/types.ts | 8 +- .../openrouter/llms/openrouter.provider.ts | 24 +- src/sub-modules/providers/openrouter/types.ts | 8 +- src/types.ts | 29 +- 56 files changed, 1742 insertions(+), 893 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1568a3a..483355c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,32 @@ "version": "3.0.0", "license": "MIT", "dependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.107", + "@ai-sdk/anthropic": "^3.0.78", + "@ai-sdk/cerebras": "^2.0.51", + "@ai-sdk/google": "^3.0.75", + "@ai-sdk/groq": "^3.0.39", + "@ai-sdk/openai": "^3.0.64", "@langchain/community": "^1.1.27", "@langchain/core": "^1.1.40", "@langchain/langgraph": "^1.2.9", + "@langfuse/otel": "^5.3.0", + "@langfuse/tracing": "^5.3.0", "@loopback/context": "^8.0.11", "@loopback/repository": "^8.0.11", "@loopback/sequelize": "^0.8.8", "@mastra/core": "^1.32.1", + "@openrouter/ai-sdk-provider": "^2.9.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@sourceloop/chat-service": "^17.0.6", "@sourceloop/core": "^20.0.6", "@sourceloop/file-utils": "^0.5.6", + "ai": "^6.0.185", "langchain": "^1.3.3", "loopback4-authentication": "^13.0.4", "loopback4-authorization": "^8.1.5", + "ollama-ai-provider": "^1.2.0", "prom-client": "^15.1.3", "tslib": "^2.8.1", "winston": "^3.19.0" @@ -40,7 +53,6 @@ "@langchain/openai": "^1.4.4", "@langchain/openrouter": "^0.2.2", "@langfuse/core": "^5.1.0", - "@langfuse/langchain": "^5.1.0", "@loopback/build": "^12.0.11", "@loopback/eslint-config": "^16.0.1", "@loopback/testlab": "^8.0.11", @@ -155,6 +167,372 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/amazon-bedrock": { + "version": "4.0.107", + "resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-4.0.107.tgz", + "integrity": "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/anthropic": "3.0.78", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27", + "@smithy/eventstream-codec": "^4.0.1", + "@smithy/util-utf8": "^4.0.0", + "aws4fetch": "^1.0.20" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.78", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.78.tgz", + "integrity": "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/cerebras": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/@ai-sdk/cerebras/-/cerebras-2.0.51.tgz", + "integrity": "sha512-d48yRR+XUExpsnJ8TQQ5xzEcmPtiLx9KcuFlvd7SaUF3ycJLvNIuRCQfHefHJbaT6GUTDxOfbUyBSNXxeCptQw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.47", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/cerebras/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/cerebras/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.116", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.116.tgz", + "integrity": "sha512-k8P17w7Eho5Y4l3tZrYxqQdffkI4xwtl8GCxkZs+JdMWZhyrLLlozqWkKLaWrCSlEYQOeIhEnQLhqQgYYU86Rw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.75", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.75.tgz", + "integrity": "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "3.0.39", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-3.0.39.tgz", + "integrity": "sha512-BZAr6DjCbzWQ0Qn1/TSsHo/bmCt4JaAMb4A7HCSUZBQCAcOjne/03D0sVjHnQhUC3TpwcmYiv7tHAviK7BluRw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/groq/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.64", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.64.tgz", + "integrity": "sha512-epO4iS6QwktaY2PF6uBcPnDTJ3BxPOfsGS7/OEtBe3GtNj7C8h8gMDVtIe5K8W16HNDbn0tbR4dcQfpfs+XVFg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.47.tgz", + "integrity": "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", @@ -4044,38 +4422,39 @@ } }, "node_modules/@langfuse/core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@langfuse/core/-/core-5.1.0.tgz", - "integrity": "sha512-yFvC67HBtrY4B3tyzF8+RJaIqK79LBVXtAgtmEc2vhpKauecvSW0zevRnRynFX+ajUHqi9TN7tnD91FJszFLgQ==", - "dev": true, + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@langfuse/core/-/core-5.3.0.tgz", + "integrity": "sha512-9JnDpSMBxsy6Mw5YTc0vERpAYJG5jJKxLtgv1mgA4yrNGWOtqV6ShkSSb/mWE7CeyKFnIFM0lRJpqblrMaRfQA==", "license": "MIT", "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, - "node_modules/@langfuse/langchain": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@langfuse/langchain/-/langchain-5.1.0.tgz", - "integrity": "sha512-daBSFV/gzv6oZZHOTAKJtC3BaEGItnuPAAuruE9FaCnKOuzG61caf9OFTi4w0sYgUmE+mZrW/kmnf39kp/SVvg==", - "dev": true, + "node_modules/@langfuse/otel": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@langfuse/otel/-/otel-5.3.0.tgz", + "integrity": "sha512-CJUz3oCD0RMbe+1cPxnuLD/JhsUnLt02DhLP6ZXzhFoi07rHsf8T5oUyCwLRNRH4x2tl87u3aTiOcBJqSK6/fQ==", "license": "MIT", "dependencies": { - "@langfuse/core": "^5.1.0", - "@langfuse/tracing": "^5.1.0" + "@langfuse/core": "^5.3.0" + }, + "engines": { + "node": ">=20" }, "peerDependencies": { - "@langchain/core": ">=0.3.8", - "@opentelemetry/api": "^1.9.0" + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.0.1", + "@opentelemetry/exporter-trace-otlp-http": ">=0.202.0 <1.0.0", + "@opentelemetry/sdk-trace-base": "^2.0.1" } }, "node_modules/@langfuse/tracing": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@langfuse/tracing/-/tracing-5.1.0.tgz", - "integrity": "sha512-ScwYnQzqLZOaMPZkCsWizx139eb02GI8tD5yxs5XVjGNGZxKdw1DfRPTIONSlOhaAYCY9ILGTJdkqAtNTzsbRg==", - "dev": true, + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@langfuse/tracing/-/tracing-5.3.0.tgz", + "integrity": "sha512-Fz6da1O+OqrwG69nF1UAjdaZrYUfR+h+DyVjXJLTNhTrOCqx/jTiIVAP5A32rB07+l4ESgzfO4K222A6cdPW1w==", "license": "MIT", "dependencies": { - "@langfuse/core": "^5.1.0" + "@langfuse/core": "^5.3.0" }, "engines": { "node": ">=20" @@ -5334,6 +5713,19 @@ "node": ">=14.0.0" } }, + "node_modules/@openrouter/ai-sdk-provider": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.9.0.tgz", + "integrity": "sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ai": "^6.0.0", + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -5343,6 +5735,199 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", + "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", + "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.218.0.tgz", + "integrity": "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", + "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-transformer": "0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", + "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", + "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", + "integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.7.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -7873,6 +8458,15 @@ "dev": true, "license": "ISC" }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@workflow/serde": { "version": "4.1.0-beta.2", "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", @@ -7990,6 +8584,53 @@ "node": ">=8" } }, + "node_modules/ai": { + "version": "6.0.185", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.185.tgz", + "integrity": "sha512-oGsqscREaTlo75KHZLtwZxRyI+ZBwHV2wRX9B8smHjgOs13WwoCvUyr5aPUWpIBRz406wmIKy1RzoUEq0/WKJw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.116", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27", + "@opentelemetry/api": "^1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -8345,6 +8986,12 @@ "license": "MIT", "peer": true }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "license": "MIT" + }, "node_modules/axios": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", @@ -21264,6 +21911,40 @@ "whatwg-fetch": "^3.6.20" } }, + "node_modules/ollama-ai-provider": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ollama-ai-provider/-/ollama-ai-provider-1.2.0.tgz", + "integrity": "sha512-jTNFruwe3O/ruJeppI/quoOUxG7NA6blG3ZyQj3lei4+NnJo7bi3eIRWqlVpRlu/mbzbFXeJSBuYQWF6pzGKww==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "^1.0.0", + "@ai-sdk/provider-utils": "^2.0.0", + "partial-json": "0.1.7" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/ollama-ai-provider/node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -22007,6 +22688,12 @@ "node": ">= 0.8" } }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, "node_modules/pascal-case": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", diff --git a/package.json b/package.json index e078968..6fd8642 100644 --- a/package.json +++ b/package.json @@ -134,19 +134,32 @@ "@loopback/core": "^7.0.11" }, "dependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.107", + "@ai-sdk/anthropic": "^3.0.78", + "@ai-sdk/cerebras": "^2.0.51", + "@ai-sdk/google": "^3.0.75", + "@ai-sdk/groq": "^3.0.39", + "@ai-sdk/openai": "^3.0.64", "@langchain/community": "^1.1.27", "@langchain/core": "^1.1.40", "@langchain/langgraph": "^1.2.9", + "@langfuse/otel": "^5.3.0", + "@langfuse/tracing": "^5.3.0", "@loopback/context": "^8.0.11", "@loopback/repository": "^8.0.11", "@loopback/sequelize": "^0.8.8", "@mastra/core": "^1.32.1", + "@openrouter/ai-sdk-provider": "^2.9.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@sourceloop/chat-service": "^17.0.6", "@sourceloop/core": "^20.0.6", "@sourceloop/file-utils": "^0.5.6", + "ai": "^6.0.185", "langchain": "^1.3.3", "loopback4-authentication": "^13.0.4", "loopback4-authorization": "^8.1.5", + "ollama-ai-provider": "^1.2.0", "prom-client": "^15.1.3", "tslib": "^2.8.1", "winston": "^3.19.0" @@ -165,7 +178,6 @@ "@langchain/openai": "^1.4.4", "@langchain/openrouter": "^0.2.2", "@langfuse/core": "^5.1.0", - "@langfuse/langchain": "^5.1.0", "@loopback/build": "^12.0.11", "@loopback/eslint-config": "^16.0.1", "@loopback/testlab": "^8.0.11", @@ -280,4 +292,4 @@ ], "repositoryUrl": "https://github.com/sourcefuse/loopback4-llm-chat-extension.git" } -} \ No newline at end of file +} diff --git a/src/__tests__/visualization/unit/visualizers/bar.visualizer.unit.ts b/src/__tests__/visualization/unit/visualizers/bar.visualizer.unit.ts index 6885692..4a54c83 100644 --- a/src/__tests__/visualization/unit/visualizers/bar.visualizer.unit.ts +++ b/src/__tests__/visualization/unit/visualizers/bar.visualizer.unit.ts @@ -1,22 +1,18 @@ import {expect, sinon} from '@loopback/testlab'; -import {BarVisualizer} from '../../../../components/visualization/visualizers/bar.visualizer'; -import {LLMProvider} from '../../../../types'; +import type {MastraLanguageModel} from '@mastra/core/agent'; import {fail} from 'assert'; -import {VisualizationGraphState} from '../../../../components'; +import {BarVisualizer} from '../../../../components/visualization/visualizers/bar.visualizer'; +import * as llmHelpers from '../../../../mastra/workflows/db-query/llm-helpers'; describe('BarVisualizer Unit', function () { let visualizer: BarVisualizer; - let llmProvider: sinon.SinonStubbedInstance; - let withStructuredOutputStub: sinon.SinonStub; + let llm: MastraLanguageModel; + let invokeLlmObjectStub: sinon.SinonStub; beforeEach(() => { - // Create stub for LLM provider - withStructuredOutputStub = sinon.stub(); - llmProvider = { - withStructuredOutput: withStructuredOutputStub, - } as sinon.SinonStubbedInstance; - - visualizer = new BarVisualizer(llmProvider); + llm = {} as MastraLanguageModel; + visualizer = new BarVisualizer(llm); + invokeLlmObjectStub = sinon.stub(llmHelpers, 'invokeLlmObject'); }); afterEach(() => { @@ -33,7 +29,6 @@ describe('BarVisualizer Unit', function () { const schema = visualizer.schema; expect(schema).to.be.ok(); - // Test schema structure by trying to parse valid data const validData = { categoryColumn: 'category', valueColumn: 'value', @@ -49,13 +44,11 @@ describe('BarVisualizer Unit', function () { }); it('should validate schema with default orientation', () => { - const schema = visualizer.schema; - const dataWithoutOrientation = { + const result = visualizer.schema.safeParse({ categoryColumn: 'category', valueColumn: 'value', - }; + }); - const result = schema.safeParse(dataWithoutOrientation); expect(result.success).to.be.true(); if (result.success) { @@ -64,27 +57,22 @@ describe('BarVisualizer Unit', function () { }); it('should reject invalid orientation values', () => { - const schema = visualizer.schema; - const invalidData = { + const result = visualizer.schema.safeParse({ categoryColumn: 'category', valueColumn: 'value', - orientation: 42, // invalid type - }; + orientation: 42, + }); - const result = schema.safeParse(invalidData); expect(result.success).to.be.false(); }); it('should throw error when state is invalid (missing sql)', async () => { - const invalidState = { - prompt: 'test prompt', - datasetId: 'test-id', - queryDescription: 'test description', - // sql is missing - will be undefined - } as unknown as VisualizationGraphState; - try { - await visualizer.getConfig(invalidState); + await visualizer.getConfig({ + prompt: 'test prompt', + datasetId: 'test-id', + queryDescription: 'test description', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.have.property('message', 'Invalid State'); @@ -92,15 +80,12 @@ describe('BarVisualizer Unit', function () { }); it('should throw error when state is invalid (missing queryDescription)', async () => { - const invalidState = { - prompt: 'test prompt', - datasetId: 'test-id', - sql: 'SELECT * FROM test', - // queryDescription is missing - will be undefined - } as unknown as VisualizationGraphState; - try { - await visualizer.getConfig(invalidState); + await visualizer.getConfig({ + prompt: 'test prompt', + datasetId: 'test-id', + sql: 'SELECT * FROM test', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.have.property('message', 'Invalid State'); @@ -108,15 +93,12 @@ describe('BarVisualizer Unit', function () { }); it('should throw error when state is invalid (missing prompt)', async () => { - const invalidState = { - datasetId: 'test-id', - sql: 'SELECT * FROM test', - queryDescription: 'test description', - // prompt is missing - will be undefined - } as unknown as VisualizationGraphState; - try { - await visualizer.getConfig(invalidState); + await visualizer.getConfig({ + datasetId: 'test-id', + sql: 'SELECT * FROM test', + queryDescription: 'test description', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.have.property('message', 'Invalid State'); @@ -129,52 +111,47 @@ describe('BarVisualizer Unit', function () { valueColumn: 'salary', orientation: 'vertical', }; + invokeLlmObjectStub.resolves(mockLLMResponse); - const mockInvoke = sinon.stub().resolves(mockLLMResponse); - withStructuredOutputStub.returns(mockInvoke); - - const validState = { + const input = { prompt: 'Show me a bar chart of salaries by department', datasetId: 'test-dataset', sql: 'SELECT department, AVG(salary) as avg_salary FROM employees GROUP BY department', queryDescription: 'Average salary by department', - } as unknown as VisualizationGraphState; + }; - const config = await visualizer.getConfig(validState); + const config = await visualizer.getConfig(input); expect(config).to.deepEqual(mockLLMResponse); - expect( - withStructuredOutputStub.calledOnceWith(visualizer.schema), - ).to.be.true(); - expect(mockInvoke.calledOnce).to.be.true(); - - // Check that the mock was called with a StringPromptValue containing our data - const invokeArgs = mockInvoke.getCall(0).args[0]; - expect(invokeArgs).to.have.property('value'); - // Escape special regex characters in SQL - const escapedSQL = - validState.sql?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') ?? ''; - expect(invokeArgs.value).to.match(new RegExp(escapedSQL)); - expect(invokeArgs.value).to.match( - new RegExp(validState.queryDescription ?? ''), + expect(invokeLlmObjectStub.calledOnce).to.be.true(); + + const [modelArg, promptArg, schemaArg, optionsArg] = + invokeLlmObjectStub.getCall(0).args; + expect(modelArg).to.equal(llm); + expect(schemaArg).to.equal(visualizer.schema); + expect(optionsArg).to.deepEqual({ + requestContext: undefined, + functionId: 'visualization.bar.config', + }); + + expect(promptArg).to.match( + new RegExp(input.sql.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); - expect(invokeArgs.value).to.match(new RegExp(validState.prompt)); + expect(promptArg).to.match(new RegExp(input.queryDescription)); + expect(promptArg).to.match(new RegExp(input.prompt)); }); it('should handle LLM errors gracefully', async () => { const mockError = new Error('LLM processing failed'); - const mockInvoke = sinon.stub().rejects(mockError); - withStructuredOutputStub.returns(mockInvoke); - - const validState = { - prompt: 'test prompt', - datasetId: 'test-dataset', - sql: 'SELECT * FROM test', - queryDescription: 'test description', - } as unknown as VisualizationGraphState; + invokeLlmObjectStub.rejects(mockError); try { - await visualizer.getConfig(validState); + await visualizer.getConfig({ + prompt: 'test prompt', + datasetId: 'test-dataset', + sql: 'SELECT * FROM test', + queryDescription: 'test description', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.equal(mockError); diff --git a/src/__tests__/visualization/unit/visualizers/line.visualizer.unit.ts b/src/__tests__/visualization/unit/visualizers/line.visualizer.unit.ts index 9195bc6..8eb11dc 100644 --- a/src/__tests__/visualization/unit/visualizers/line.visualizer.unit.ts +++ b/src/__tests__/visualization/unit/visualizers/line.visualizer.unit.ts @@ -1,22 +1,18 @@ import {expect, sinon} from '@loopback/testlab'; -import {LineVisualizer} from '../../../../components/visualization/visualizers/line.visualizer'; -import {LLMProvider} from '../../../../types'; +import type {MastraLanguageModel} from '@mastra/core/agent'; import {fail} from 'assert'; -import {VisualizationGraphState} from '../../../../components'; +import {LineVisualizer} from '../../../../components/visualization/visualizers/line.visualizer'; +import * as llmHelpers from '../../../../mastra/workflows/db-query/llm-helpers'; describe('LineVisualizer Unit', function () { let visualizer: LineVisualizer; - let llmProvider: sinon.SinonStubbedInstance; - let withStructuredOutputStub: sinon.SinonStub; + let llm: MastraLanguageModel; + let invokeLlmObjectStub: sinon.SinonStub; beforeEach(() => { - // Create stub for LLM provider - withStructuredOutputStub = sinon.stub(); - llmProvider = { - withStructuredOutput: withStructuredOutputStub, - } as sinon.SinonStubbedInstance; - - visualizer = new LineVisualizer(llmProvider); + llm = {} as MastraLanguageModel; + visualizer = new LineVisualizer(llm); + invokeLlmObjectStub = sinon.stub(llmHelpers, 'invokeLlmObject'); }); afterEach(() => { @@ -31,76 +27,57 @@ describe('LineVisualizer Unit', function () { }); it('should have valid schema with required fields', () => { - const schema = visualizer.schema; - expect(schema).to.be.ok(); - - // Test schema structure by trying to parse valid data - const validData = { + const result = visualizer.schema.safeParse({ xAxisColumn: 'date', yAxisColumn: 'value', seriesColumns: 'category', - }; + }); - const result = schema.safeParse(validData); expect(result.success).to.be.true(); - - if (result.success) { - expect(result.data).to.deepEqual(validData); - } }); it('should accept empty string seriesColumn', () => { - const schema = visualizer.schema; - const dataWithNullSeries = { + const result = visualizer.schema.safeParse({ xAxisColumn: 'date', yAxisColumn: 'value', seriesColumns: '', - }; + }); - const result = schema.safeParse(dataWithNullSeries); expect(result.success).to.be.true(); }); it('should reject missing seriesColumn field', () => { - const schema = visualizer.schema; - const dataWithoutSeries = { + const result = visualizer.schema.safeParse({ xAxisColumn: 'date', yAxisColumn: 'value', - }; + }); - const result = schema.safeParse(dataWithoutSeries); - // seriesColumn is nullable but still required - omitting it should fail expect(result.success).to.be.false(); }); it('should reject missing required fields', () => { - const schema = visualizer.schema; - - // Missing xAxisColumn - const missingXAxis = { - yAxisColumn: 'value', - seriesColumn: 'category', - }; - expect(schema.safeParse(missingXAxis).success).to.be.false(); + expect( + visualizer.schema.safeParse({ + yAxisColumn: 'value', + seriesColumn: 'category', + }).success, + ).to.be.false(); - // Missing yAxisColumn - const missingYAxis = { - xAxisColumn: 'date', - seriesColumn: 'category', - }; - expect(schema.safeParse(missingYAxis).success).to.be.false(); + expect( + visualizer.schema.safeParse({ + xAxisColumn: 'date', + seriesColumn: 'category', + }).success, + ).to.be.false(); }); it('should throw error when state is invalid (missing sql)', async () => { - const invalidState = { - prompt: 'test prompt', - datasetId: 'test-id', - queryDescription: 'test description', - // sql is missing - will be undefined - } as unknown as VisualizationGraphState; - try { - await visualizer.getConfig(invalidState); + await visualizer.getConfig({ + prompt: 'test prompt', + datasetId: 'test-id', + queryDescription: 'test description', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.have.property('message', 'Invalid State'); @@ -108,15 +85,12 @@ describe('LineVisualizer Unit', function () { }); it('should throw error when state is invalid (missing queryDescription)', async () => { - const invalidState = { - prompt: 'test prompt', - datasetId: 'test-id', - sql: 'SELECT * FROM test', - // queryDescription is missing - will be undefined - } as unknown as VisualizationGraphState; - try { - await visualizer.getConfig(invalidState); + await visualizer.getConfig({ + prompt: 'test prompt', + datasetId: 'test-id', + sql: 'SELECT * FROM test', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.have.property('message', 'Invalid State'); @@ -124,15 +98,12 @@ describe('LineVisualizer Unit', function () { }); it('should throw error when state is invalid (missing prompt)', async () => { - const invalidState = { - datasetId: 'test-id', - sql: 'SELECT * FROM test', - queryDescription: 'test description', - // prompt is missing - will be undefined - } as unknown as VisualizationGraphState; - try { - await visualizer.getConfig(invalidState); + await visualizer.getConfig({ + datasetId: 'test-id', + sql: 'SELECT * FROM test', + queryDescription: 'test description', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.have.property('message', 'Invalid State'); @@ -146,74 +117,66 @@ describe('LineVisualizer Unit', function () { seriesColumns: 'product_line', }; - const mockInvoke = sinon.stub().resolves(mockLLMResponse); - withStructuredOutputStub.returns(mockInvoke); + invokeLlmObjectStub.resolves(mockLLMResponse); - const validState = { + const input = { prompt: 'Show me a line chart of revenue trends over time by product line', datasetId: 'test-dataset', sql: 'SELECT month, product_line, SUM(revenue) as revenue FROM sales GROUP BY month, product_line', queryDescription: 'Revenue trends by product line over time', - } as unknown as VisualizationGraphState; - - const config = await visualizer.getConfig(validState); + }; - expect(config).to.deepEqual(mockLLMResponse); - expect( - withStructuredOutputStub.calledOnceWith(visualizer.schema), - ).to.be.true(); - expect(mockInvoke.calledOnce).to.be.true(); - - // Check that the mock was called with a StringPromptValue containing our data - const invokeArgs = mockInvoke.getCall(0).args[0]; - expect(invokeArgs).to.have.property('value'); - // Escape special regex characters in SQL - const escapedSQL = validState.sql?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - expect(invokeArgs.value).to.match(new RegExp(escapedSQL ?? '')); - expect(invokeArgs.value).to.match( - new RegExp(validState.queryDescription ?? ''), - ); - expect(invokeArgs.value).to.match(new RegExp(validState.prompt)); + const config = await visualizer.getConfig(input); + + expect(config).to.deepEqual({ + ...mockLLMResponse, + seriesColumns: ['product_line'], + }); + expect(invokeLlmObjectStub.calledOnce).to.be.true(); + + const [modelArg, , schemaArg, optionsArg] = + invokeLlmObjectStub.getCall(0).args; + expect(modelArg).to.equal(llm); + expect(schemaArg).to.equal(visualizer.schema); + expect(optionsArg).to.deepEqual({ + requestContext: undefined, + functionId: 'visualization.line.config', + }); }); it('should successfully generate config without series column', async () => { - const mockLLMResponse = { + invokeLlmObjectStub.resolves({ xAxisColumn: 'month', yAxisColumn: 'total_sales', seriesColumns: null, - }; + }); - const mockInvoke = sinon.stub().resolves(mockLLMResponse); - withStructuredOutputStub.returns(mockInvoke); - - const validState = { + const config = await visualizer.getConfig({ prompt: 'Show me total sales over time', datasetId: 'test-dataset', sql: 'SELECT month, SUM(sales) as total_sales FROM sales GROUP BY month', queryDescription: 'Total sales over time', - } as unknown as VisualizationGraphState; - - const config = await visualizer.getConfig(validState); + }); - expect(config).to.deepEqual(mockLLMResponse); - expect(config.seriesColumns).to.be.null(); + expect(config).to.deepEqual({ + xAxisColumn: 'month', + yAxisColumn: 'total_sales', + seriesColumns: null, + }); }); it('should handle LLM errors gracefully', async () => { const mockError = new Error('LLM processing failed'); - const mockInvoke = sinon.stub().rejects(mockError); - withStructuredOutputStub.returns(mockInvoke); - - const validState = { - prompt: 'test prompt', - datasetId: 'test-dataset', - sql: 'SELECT * FROM test', - queryDescription: 'test description', - } as unknown as VisualizationGraphState; + invokeLlmObjectStub.rejects(mockError); try { - await visualizer.getConfig(validState); + await visualizer.getConfig({ + prompt: 'test prompt', + datasetId: 'test-dataset', + sql: 'SELECT * FROM test', + queryDescription: 'test description', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.equal(mockError); @@ -221,10 +184,7 @@ describe('LineVisualizer Unit', function () { }); it('should contain proper prompt template structure', () => { - const promptTemplate = visualizer.renderPrompt; - expect(promptTemplate).to.be.ok(); - - const templateText = promptTemplate.template; + const templateText = visualizer.renderPrompt.template; expect(templateText).to.match(/line chart/); expect(templateText).to.match(/\{sql\}/); expect(templateText).to.match(/\{description\}/); diff --git a/src/__tests__/visualization/unit/visualizers/pie.visualizer.unit.ts b/src/__tests__/visualization/unit/visualizers/pie.visualizer.unit.ts index c76405b..5625197 100644 --- a/src/__tests__/visualization/unit/visualizers/pie.visualizer.unit.ts +++ b/src/__tests__/visualization/unit/visualizers/pie.visualizer.unit.ts @@ -1,22 +1,18 @@ import {expect, sinon} from '@loopback/testlab'; -import {PieVisualizer} from '../../../../components/visualization/visualizers/pie.visualizer'; -import {LLMProvider} from '../../../../types'; +import type {MastraLanguageModel} from '@mastra/core/agent'; import {fail} from 'assert'; -import {VisualizationGraphState} from '../../../../components'; +import {PieVisualizer} from '../../../../components/visualization/visualizers/pie.visualizer'; +import * as llmHelpers from '../../../../mastra/workflows/db-query/llm-helpers'; describe('PieVisualizer Unit', function () { let visualizer: PieVisualizer; - let llmProvider: sinon.SinonStubbedInstance; - let withStructuredOutputStub: sinon.SinonStub; + let llm: MastraLanguageModel; + let invokeLlmObjectStub: sinon.SinonStub; beforeEach(() => { - // Create stub for LLM provider - withStructuredOutputStub = sinon.stub(); - llmProvider = { - withStructuredOutput: withStructuredOutputStub, - } as sinon.SinonStubbedInstance; - - visualizer = new PieVisualizer(llmProvider); + llm = {} as MastraLanguageModel; + visualizer = new PieVisualizer(llm); + invokeLlmObjectStub = sinon.stub(llmHelpers, 'invokeLlmObject'); }); afterEach(() => { @@ -26,72 +22,38 @@ describe('PieVisualizer Unit', function () { it('should have correct name and description', () => { expect(visualizer.name).to.equal('pie'); expect(visualizer.description).to.match(/pie chart/); - expect(visualizer.description).to.match(/proportions/); - expect(visualizer.description).to.match(/percentages/); }); it('should have valid schema with required fields', () => { - const schema = visualizer.schema; - expect(schema).to.be.ok(); - - // Test schema structure by trying to parse valid data - const validData = { + const result = visualizer.schema.safeParse({ labelColumn: 'category', - valueColumn: 'amount', - }; + valueColumn: 'value', + }); - const result = schema.safeParse(validData); expect(result.success).to.be.true(); - - if (result.success) { - expect(result.data).to.deepEqual(validData); - } }); it('should reject missing required fields', () => { - const schema = visualizer.schema; - - // Missing labelColumn - const missingLabel = { - valueColumn: 'amount', - }; - expect(schema.safeParse(missingLabel).success).to.be.false(); - - // Missing valueColumn - const missingValue = { - labelColumn: 'category', - }; - expect(schema.safeParse(missingValue).success).to.be.false(); - }); - - it('should reject invalid field types', () => { - const schema = visualizer.schema; - - // Non-string labelColumn - const invalidLabel = { - labelColumn: 123, - valueColumn: 'amount', - }; - expect(schema.safeParse(invalidLabel).success).to.be.false(); + expect( + visualizer.schema.safeParse({ + valueColumn: 'value', + }).success, + ).to.be.false(); - // Non-string valueColumn - const invalidValue = { - labelColumn: 'category', - valueColumn: 456, - }; - expect(schema.safeParse(invalidValue).success).to.be.false(); + expect( + visualizer.schema.safeParse({ + labelColumn: 'category', + }).success, + ).to.be.false(); }); it('should throw error when state is invalid (missing sql)', async () => { - const invalidState = { - prompt: 'test prompt', - datasetId: 'test-id', - queryDescription: 'test description', - // sql is missing - will be undefined - } as unknown as VisualizationGraphState; - try { - await visualizer.getConfig(invalidState); + await visualizer.getConfig({ + prompt: 'test prompt', + datasetId: 'test-id', + queryDescription: 'test description', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.have.property('message', 'Invalid State'); @@ -99,15 +61,12 @@ describe('PieVisualizer Unit', function () { }); it('should throw error when state is invalid (missing queryDescription)', async () => { - const invalidState = { - prompt: 'test prompt', - datasetId: 'test-id', - sql: 'SELECT * FROM test', - // queryDescription is missing - will be undefined - } as unknown as VisualizationGraphState; - try { - await visualizer.getConfig(invalidState); + await visualizer.getConfig({ + prompt: 'test prompt', + datasetId: 'test-id', + sql: 'SELECT * FROM test', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.have.property('message', 'Invalid State'); @@ -115,15 +74,12 @@ describe('PieVisualizer Unit', function () { }); it('should throw error when state is invalid (missing prompt)', async () => { - const invalidState = { - datasetId: 'test-id', - sql: 'SELECT * FROM test', - queryDescription: 'test description', - // prompt is missing - will be undefined - } as unknown as VisualizationGraphState; - try { - await visualizer.getConfig(invalidState); + await visualizer.getConfig({ + datasetId: 'test-id', + sql: 'SELECT * FROM test', + queryDescription: 'test description', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.have.property('message', 'Invalid State'); @@ -133,77 +89,43 @@ describe('PieVisualizer Unit', function () { it('should successfully generate config with valid state', async () => { const mockLLMResponse = { labelColumn: 'department', - valueColumn: 'budget_allocation', + valueColumn: 'total_salary', }; + invokeLlmObjectStub.resolves(mockLLMResponse); - const mockInvoke = sinon.stub().resolves(mockLLMResponse); - withStructuredOutputStub.returns(mockInvoke); - - const validState = { - prompt: 'Show me a pie chart of budget allocation by department', + const input = { + prompt: 'Show me salary distribution by department', datasetId: 'test-dataset', - sql: 'SELECT department, SUM(budget) as budget_allocation FROM departments GROUP BY department', - queryDescription: 'Budget allocation by department', - } as unknown as VisualizationGraphState; - - const config = await visualizer.getConfig(validState); - - expect(config).to.deepEqual(mockLLMResponse); - expect( - withStructuredOutputStub.calledOnceWith(visualizer.schema), - ).to.be.true(); - expect(mockInvoke.calledOnce).to.be.true(); - - // Check that the mock was called with a StringPromptValue containing our data - const invokeArgs = mockInvoke.getCall(0).args[0]; - expect(invokeArgs).to.have.property('value'); - // Escape special regex characters in SQL - const escapedSQL = - validState.sql?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') ?? ''; - expect(invokeArgs.value).to.match(new RegExp(escapedSQL)); - expect(invokeArgs.value).to.match( - new RegExp(validState.queryDescription ?? ''), - ); - expect(invokeArgs.value).to.match(new RegExp(validState.prompt)); - }); - - it('should handle LLM response with percentage data', async () => { - const mockLLMResponse = { - labelColumn: 'product_category', - valueColumn: 'sales_percentage', + sql: 'SELECT department, SUM(salary) as total_salary FROM employees GROUP BY department', + queryDescription: 'Salary distribution by department', }; - const mockInvoke = sinon.stub().resolves(mockLLMResponse); - withStructuredOutputStub.returns(mockInvoke); - - const validState = { - prompt: 'Show me sales distribution by product category as percentages', - datasetId: 'test-dataset', - sql: 'SELECT product_category, (sales / total_sales * 100) as sales_percentage FROM sales_summary', - queryDescription: 'Sales distribution by product category', - } as unknown as VisualizationGraphState; - - const config = await visualizer.getConfig(validState); + const config = await visualizer.getConfig(input); expect(config).to.deepEqual(mockLLMResponse); - expect(config.labelColumn).to.equal('product_category'); - expect(config.valueColumn).to.equal('sales_percentage'); + expect(invokeLlmObjectStub.calledOnce).to.be.true(); + + const [modelArg, , schemaArg, optionsArg] = + invokeLlmObjectStub.getCall(0).args; + expect(modelArg).to.equal(llm); + expect(schemaArg).to.equal(visualizer.schema); + expect(optionsArg).to.deepEqual({ + requestContext: undefined, + functionId: 'visualization.pie.config', + }); }); it('should handle LLM errors gracefully', async () => { const mockError = new Error('LLM processing failed'); - const mockInvoke = sinon.stub().rejects(mockError); - withStructuredOutputStub.returns(mockInvoke); - - const validState = { - prompt: 'test prompt', - datasetId: 'test-dataset', - sql: 'SELECT * FROM test', - queryDescription: 'test description', - } as unknown as VisualizationGraphState; + invokeLlmObjectStub.rejects(mockError); try { - await visualizer.getConfig(validState); + await visualizer.getConfig({ + prompt: 'test prompt', + datasetId: 'test-dataset', + sql: 'SELECT * FROM test', + queryDescription: 'test description', + }); fail('Should have thrown an error'); } catch (error) { expect(error).to.equal(mockError); @@ -211,26 +133,12 @@ describe('PieVisualizer Unit', function () { }); it('should contain proper prompt template structure', () => { - const promptTemplate = visualizer.renderPrompt; - expect(promptTemplate).to.be.ok(); - - const templateText = promptTemplate.template; + const templateText = visualizer.renderPrompt.template; expect(templateText).to.match(/pie chart/); expect(templateText).to.match(/\{sql\}/); expect(templateText).to.match(/\{description\}/); expect(templateText).to.match(/\{userPrompt\}/); expect(templateText).to.match(/categories/); - }); - - it('should validate that schema describes columns correctly', () => { - const schema = visualizer.schema; - - // Access the schema shape to check descriptions - const shape = schema._def.shape(); - - expect(shape.labelColumn._def.description).to.match(/labels/); - expect(shape.labelColumn._def.description).to.match(/pie chart/); - expect(shape.valueColumn._def.description).to.match(/values/); - expect(shape.valueColumn._def.description).to.match(/pie chart/); + expect(templateText).to.match(/values/); }); }); diff --git a/src/component.ts b/src/component.ts index d6e702f..2e36a15 100644 --- a/src/component.ts +++ b/src/component.ts @@ -32,19 +32,10 @@ import { } from './components'; import {DEFAULT_FILE_SIZE, MAX_TOTAL_SIZE} from './constant'; import {ChatController, GenerationController} from './controllers'; -import { - CallLLMNode, - ChatGraph, - ChatStore, - ContextCompressionNode, - EndSessionNode, - InitSessionNode, - RunToolNode, - SummariseFileNode, -} from './graphs/chat'; +import {ChatStore} from './graphs/chat'; import {WriterDB, AiIntegrationBindings, ReaderDB} from './keys'; import {Chat, Message} from './models'; -import {CacheModel, MastraToolsProvider, ToolsProvider} from './providers'; +import {CacheModel, MastraToolsProvider} from './providers'; import {RedisCache, RedisCacheRepository} from './providers/cache/redis'; import {ChatRepository, MessageRepository} from './repositories'; import { @@ -81,7 +72,6 @@ export class AiIntegrationsComponent implements Component { this.providers = { [AiIntegrationBindings.VectorStore.key]: PgVectorStore, - [AiIntegrationBindings.Tools.key]: ToolsProvider, [AiIntegrationBindings.MastraTools.key]: MastraToolsProvider, }; @@ -92,15 +82,6 @@ export class AiIntegrationsComponent implements Component { ChatStore, // mastra migration WorkflowRunner, - // graph - ChatGraph, - // nodes - CallLLMNode, - RunToolNode, - InitSessionNode, - SummariseFileNode, - ContextCompressionNode, - EndSessionNode, ]; this.controllers = [GenerationController, ChatController]; diff --git a/src/components/db-query/services/template-helper.service.ts b/src/components/db-query/services/template-helper.service.ts index a1fc058..4698cc3 100644 --- a/src/components/db-query/services/template-helper.service.ts +++ b/src/components/db-query/services/template-helper.service.ts @@ -1,19 +1,23 @@ -import {PromptTemplate} from '@langchain/core/prompts'; -import {RunnableSequence} from '@langchain/core/runnables'; +import type {MastraLanguageModel} from '@mastra/core/agent'; import {inject} from '@loopback/core'; import {AiIntegrationBindings} from '../../../keys'; -import {LLMProvider} from '../../../types'; -import {stripThinkingTokens} from '../../../utils'; +import { + invokeLlm, + stripThinkingTokens, +} from '../../../mastra/workflows/db-query/llm-helpers'; import { DatabaseSchema, QueryTemplate, QueryTemplateMetadata, TemplatePlaceholder, } from '../types'; -import {RunnableConfig} from '../../../graphs'; const MAX_TEMPLATE_RECURSION_DEPTH = 3; +type RunnableConfig = { + configurable?: Record; +}; + type ResolvedTemplate = { sql: string; description: string; @@ -21,11 +25,11 @@ type ResolvedTemplate = { export class TemplateHelper { constructor( - @inject(AiIntegrationBindings.CheapLLM) - private readonly llm: LLMProvider, + @inject(AiIntegrationBindings.MastraCheapLLM) + private readonly llm: MastraLanguageModel, ) {} - extractionPrompt = PromptTemplate.fromTemplate(` + extractionPrompt = ` You are an expert at extracting parameter values from natural language prompts. Given a user prompt, a SQL template, and a list of placeholders with their descriptions and types, extract the value for each placeholder from the prompt. @@ -51,21 +55,26 @@ Rules per type: - sql_expression: Return a complete, valid SQL fragment with proper SQL syntax including quotes where needed. Example: created_at > '2024-01-01' Do not return any other text or explanation, just the XML tags. -`); +`; + + private _buildExtractionPrompt(params: { + prompt: string; + template: string; + placeholders: string; + }): string { + return this.extractionPrompt + .replace('{prompt}', params.prompt) + .replace('{template}', params.template) + .replace('{placeholders}', params.placeholders); + } async extractPlaceholderValues( placeholders: TemplatePlaceholder[], prompt: string, sqlTemplate: string, - config: RunnableConfig, + _config: RunnableConfig, schema?: DatabaseSchema, ): Promise> { - const chain = RunnableSequence.from([ - this.extractionPrompt, - this.llm, - stripThinkingTokens, - ]); - const placeholderDescriptions = placeholders .map(p => { let desc = `- ${p.name} (type: ${p.type}): ${p.description}`; @@ -76,16 +85,17 @@ Do not return any other text or explanation, just the XML tags. }) .join('\n'); - const response = await chain.invoke( - { - prompt, - template: sqlTemplate, - placeholders: placeholderDescriptions, - }, - config, - ); + const extractionPrompt = this._buildExtractionPrompt({ + prompt, + template: sqlTemplate, + placeholders: placeholderDescriptions, + }); + + const response = await invokeLlm(this.llm, extractionPrompt, { + functionId: 'db-query.template-placeholder-extraction', + }); - return this._parseXmlValues(response, placeholders); + return this._parseXmlValues(stripThinkingTokens(response), placeholders); } private _getColumnContext( diff --git a/src/components/visualization/types.ts b/src/components/visualization/types.ts index 12013b8..a6ed8f1 100644 --- a/src/components/visualization/types.ts +++ b/src/components/visualization/types.ts @@ -1,11 +1,27 @@ import {AnyObject} from '@loopback/repository'; -import {VisualizationGraphState} from './state'; +import type {RequestContext} from '@mastra/core/request-context'; + +export type VisualizationConfigInput = { + prompt?: string; + datasetId?: string; + sql?: string; + queryDescription?: string; + visualizerName?: string; + type?: string; +}; + +export type VisualizationConfigOptions = { + requestContext?: RequestContext; +}; export interface IVisualizer { name: string; description: string; context?: string; - getConfig(state: VisualizationGraphState): Promise | AnyObject; + getConfig( + input: VisualizationConfigInput, + options?: VisualizationConfigOptions, + ): Promise | AnyObject; } export type VisualizerStore = { diff --git a/src/components/visualization/visualizer.component.ts b/src/components/visualization/visualizer.component.ts index b734744..df82eb8 100644 --- a/src/components/visualization/visualizer.component.ts +++ b/src/components/visualization/visualizer.component.ts @@ -8,14 +8,6 @@ import { ServiceOrProviderClass, } from '@loopback/core'; import {AnyObject} from '@loopback/repository'; -import {VisualizationGraph} from './visualization.graph'; -import { - CallQueryGenerationNode, - GetDatasetDataNode, - RenderVisualizationNode, - SelectVisualizationNode, -} from './nodes'; -import {GenerateVisualizationTool} from './tools/generate-visualization.tool'; import {PieVisualizer, BarVisualizer, LineVisualizer} from './visualizers'; export class VisualizerComponent implements Component { @@ -32,16 +24,7 @@ export class VisualizerComponent implements Component { this.bindings = []; this.lifeCycleObservers = []; this.services = [ - // graph - VisualizationGraph, - // tools - GenerateVisualizationTool, - // nodes - GetDatasetDataNode, - SelectVisualizationNode, - RenderVisualizationNode, - CallQueryGenerationNode, - // visualizers + // native visualizers PieVisualizer, BarVisualizer, LineVisualizer, diff --git a/src/components/visualization/visualizers/bar.visualizer.ts b/src/components/visualization/visualizers/bar.visualizer.ts index 25fb6f1..a817bed 100644 --- a/src/components/visualization/visualizers/bar.visualizer.ts +++ b/src/components/visualization/visualizers/bar.visualizer.ts @@ -1,13 +1,16 @@ import {PromptTemplate} from '@langchain/core/prompts'; -import {IVisualizer} from '../types'; +import { + IVisualizer, + VisualizationConfigInput, + VisualizationConfigOptions, +} from '../types'; import {AiIntegrationBindings} from '../../../keys'; -import {LLMProvider} from '../../../types'; import {inject} from '@loopback/core'; import {AnyObject} from '@loopback/repository'; -import {VisualizationGraphState} from '../state'; import z from 'zod'; -import {RunnableSequence} from '@langchain/core/runnables'; import {visualizer} from '../decorators/visualizer.decorator'; +import {invokeLlmObject} from '../../../mastra/workflows/db-query/llm-helpers'; +import type {MastraLanguageModel} from '@mastra/core/agent'; @visualizer() export class BarVisualizer implements IVisualizer { @@ -52,28 +55,34 @@ You are an expert data visualization assistant. Your task is to create a bar cha }) as z.AnyZodObject; constructor( - @inject(AiIntegrationBindings.CheapLLM) - private readonly llm: LLMProvider, + @inject(AiIntegrationBindings.MastraCheapLLM) + private readonly llm: MastraLanguageModel, ) {} - async getConfig(state: VisualizationGraphState): Promise { - if (!state.sql || !state.queryDescription || !state.prompt) { + async getConfig( + input: VisualizationConfigInput, + options?: VisualizationConfigOptions, + ): Promise { + if (!input.sql || !input.queryDescription || !input.prompt) { throw new Error('Invalid State'); } - const llmWithStructuredOutput = this.llm.withStructuredOutput( + + const prompt = await this.renderPrompt.format({ + sql: input.sql, + description: input.queryDescription, + userPrompt: input.prompt, + }); + + const settings = await invokeLlmObject( + this.llm, + prompt, this.schema, + { + requestContext: options?.requestContext, + functionId: 'visualization.bar.config', + }, ); - const chain = RunnableSequence.from([ - this.renderPrompt, - llmWithStructuredOutput, - ]); - - const settings = await chain.invoke({ - sql: state.sql!, - description: state.queryDescription!, - userPrompt: state.prompt!, - }); return settings; } } diff --git a/src/components/visualization/visualizers/line.visualizer.ts b/src/components/visualization/visualizers/line.visualizer.ts index 5acc79f..3031384 100644 --- a/src/components/visualization/visualizers/line.visualizer.ts +++ b/src/components/visualization/visualizers/line.visualizer.ts @@ -1,13 +1,16 @@ import {PromptTemplate} from '@langchain/core/prompts'; -import {IVisualizer} from '../types'; +import { + IVisualizer, + VisualizationConfigInput, + VisualizationConfigOptions, +} from '../types'; import {AiIntegrationBindings} from '../../../keys'; -import {LLMProvider} from '../../../types'; import {inject} from '@loopback/core'; import {AnyObject} from '@loopback/repository'; -import {VisualizationGraphState} from '../state'; import z from 'zod'; -import {RunnableSequence} from '@langchain/core/runnables'; import {visualizer} from '../decorators/visualizer.decorator'; +import {invokeLlmObject} from '../../../mastra/workflows/db-query/llm-helpers'; +import type {MastraLanguageModel} from '@mastra/core/agent'; @visualizer() export class LineVisualizer implements IVisualizer { @@ -57,38 +60,50 @@ You are an expert data visualization assistant. Your task is to create a line ch }) as z.AnyZodObject; constructor( - @inject(AiIntegrationBindings.SmartNonThinkingLLM) - private readonly llm: LLMProvider, + @inject(AiIntegrationBindings.MastraSmartLLM) + private readonly llm: MastraLanguageModel, ) {} - async getConfig(state: VisualizationGraphState): Promise { - if (!state.sql || !state.queryDescription || !state.prompt) { + async getConfig( + input: VisualizationConfigInput, + options?: VisualizationConfigOptions, + ): Promise { + if (!input.sql || !input.queryDescription || !input.prompt) { throw new Error('Invalid State'); } - const llmWithStructuredOutput = this.llm.withStructuredOutput( - this.schema, - ); - const chain = RunnableSequence.from([ - this.renderPrompt, - llmWithStructuredOutput, - ]); + const prompt = await this.renderPrompt.format({ + sql: input.sql, + description: input.queryDescription, + userPrompt: input.prompt, + }); - const settings = await chain.invoke({ - sql: state.sql!, - description: state.queryDescription!, - userPrompt: state.prompt!, + const settings = await invokeLlmObject<{ + xAxisColumn: string; + yAxisColumn: string; + seriesColumns?: string | null | string[]; + }>(this.llm, prompt, this.schema, { + requestContext: options?.requestContext, + functionId: 'visualization.line.config', }); + if ( settings.seriesColumns === '' || settings.seriesColumns === undefined || settings.seriesColumns === null ) { settings.seriesColumns = null; + } else if (Array.isArray(settings.seriesColumns)) { + settings.seriesColumns = settings.seriesColumns + .map((value: string) => value.trim()) + .filter((value: string) => value.length > 0); } else { - settings.seriesColumns = - settings.seriesColumns?.split(',').map((s: string) => s.trim()) ?? []; + settings.seriesColumns = settings.seriesColumns + .split(',') + .map((s: string) => s.trim()) + .filter((value: string) => value.length > 0); } - return settings; + + return settings as unknown as AnyObject; } } diff --git a/src/components/visualization/visualizers/pie.visualizer.ts b/src/components/visualization/visualizers/pie.visualizer.ts index 0fd2f65..8c7c945 100644 --- a/src/components/visualization/visualizers/pie.visualizer.ts +++ b/src/components/visualization/visualizers/pie.visualizer.ts @@ -1,13 +1,16 @@ import {PromptTemplate} from '@langchain/core/prompts'; -import {IVisualizer} from '../types'; +import { + IVisualizer, + VisualizationConfigInput, + VisualizationConfigOptions, +} from '../types'; import {AiIntegrationBindings} from '../../../keys'; -import {LLMProvider} from '../../../types'; import {inject} from '@loopback/core'; import {AnyObject} from '@loopback/repository'; -import {VisualizationGraphState} from '../state'; import z from 'zod'; -import {RunnableSequence} from '@langchain/core/runnables'; import {visualizer} from '../decorators/visualizer.decorator'; +import {invokeLlmObject} from '../../../mastra/workflows/db-query/llm-helpers'; +import type {MastraLanguageModel} from '@mastra/core/agent'; @visualizer() export class PieVisualizer implements IVisualizer { @@ -46,28 +49,34 @@ You are an expert data visualization assistant. Your task is to create a pie cha }) as z.AnyZodObject; constructor( - @inject(AiIntegrationBindings.CheapLLM) - private readonly llm: LLMProvider, + @inject(AiIntegrationBindings.MastraCheapLLM) + private readonly llm: MastraLanguageModel, ) {} - async getConfig(state: VisualizationGraphState): Promise { - if (!state.sql || !state.queryDescription || !state.prompt) { + async getConfig( + input: VisualizationConfigInput, + options?: VisualizationConfigOptions, + ): Promise { + if (!input.sql || !input.queryDescription || !input.prompt) { throw new Error('Invalid State'); } - const llmWithStructuredOutput = this.llm.withStructuredOutput( + + const prompt = await this.renderPrompt.format({ + sql: input.sql, + description: input.queryDescription, + userPrompt: input.prompt, + }); + + const settings = await invokeLlmObject( + this.llm, + prompt, this.schema, + { + requestContext: options?.requestContext, + functionId: 'visualization.pie.config', + }, ); - const chain = RunnableSequence.from([ - this.renderPrompt, - llmWithStructuredOutput, - ]); - - const settings = await chain.invoke({ - sql: state.sql!, - description: state.queryDescription!, - userPrompt: state.prompt!, - }); return settings; } } diff --git a/src/keys.ts b/src/keys.ts index 8290687..480c4d6 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -1,5 +1,4 @@ import {VectorStore as VectorStoreType} from '@langchain/core/vectorstores'; -import {BaseCheckpointSaver} from '@langchain/langgraph'; import {BindingKey} from '@loopback/context'; import type {MastraLanguageModel} from '@mastra/core/agent'; import {ITransport} from './transports/types'; @@ -35,7 +34,7 @@ export namespace AiIntegrationBindings { export const EmbeddingModel = BindingKey.create( 'services.ai-reporting.embeddingModel', ); - export const Checkpointer = BindingKey.create( + export const Checkpointer = BindingKey.create( 'services.ai-reporting.checkpointer', ); export const Tools = BindingKey.create( @@ -54,7 +53,7 @@ export namespace AiIntegrationBindings { export const LimitStrategy = BindingKey.create( 'services.ai-reporting.limit-strategy', ); - export const ObfHandler = BindingKey.create( + export const ObfHandler = BindingKey.create( 'services.ai-reporting.obf-handler', ); export const SystemContext = BindingKey.create( diff --git a/src/mastra/bridge/workflow-request-context.ts b/src/mastra/bridge/workflow-request-context.ts index 832b484..93c7e87 100644 --- a/src/mastra/bridge/workflow-request-context.ts +++ b/src/mastra/bridge/workflow-request-context.ts @@ -40,6 +40,16 @@ export interface WorkflowRequestContext { abortSignal: AbortSignal; /** Authenticated user resolved from LoopBack auth middleware */ currentUser: IAuthUserWithPermissions | undefined; + /** Correlation id propagated across workflow, tools, and model calls */ + correlationId: string; + /** Workflow identifier for telemetry metadata */ + workflowId: string; + /** Optional chat session id associated with this workflow invocation */ + chatSessionId: string | undefined; + /** AI SDK telemetry toggle for request-scoped model calls */ + aiSdkTelemetryEnabled: boolean; + /** Additional AI SDK telemetry metadata propagated to model calls */ + aiSdkTelemetryMetadata: Record; } /** diff --git a/src/mastra/bridge/workflow-runner.ts b/src/mastra/bridge/workflow-runner.ts index d0bdb16..12d7ced 100644 --- a/src/mastra/bridge/workflow-runner.ts +++ b/src/mastra/bridge/workflow-runner.ts @@ -9,6 +9,8 @@ import { import {repository} from '@loopback/repository'; import {IAuthUserWithPermissions} from '@sourceloop/core'; import {AuthenticationBindings} from 'loopback4-authentication'; +import {randomUUID} from 'crypto'; +import {SpanStatusCode, trace} from '@opentelemetry/api'; import {RequestContext} from '@mastra/core/request-context'; import {BaseRetriever} from '@langchain/core/retrievers'; import {ChatStore} from '../../graphs/chat/chat.store'; @@ -47,6 +49,7 @@ import type { } from '../../components/visualization/types'; const debug = require('debug')('ai-integration:mastra:workflow-runner'); +const tracer = trace.getTracer('ai-reporting.mastra.workflow-runner'); /** * Type guard: checks if an unknown value is an LLMStreamEvent. @@ -149,6 +152,10 @@ export class WorkflowRunner { abortController: AbortController, sessionId?: string, ): AsyncGenerator { + const correlationId = randomUUID(); + const telemetryEnabled = this.aiConfig?.aiSdkTelemetry?.enabled ?? true; + const telemetryMetadata = this.aiConfig?.aiSdkTelemetry?.metadata ?? {}; + const eventQueue = new AsyncEventQueue(); const tokenAccumulator = new TokenUsageAccumulator(); const currentUser = await this.resolveOptionalCurrentUser(); @@ -168,6 +175,11 @@ export class WorkflowRunner { requestContext.set('systemContext', this.systemContext); requestContext.set('tokenUsageAccumulator', tokenAccumulator); requestContext.set('currentUser', currentUser); + requestContext.set('correlationId', correlationId); + requestContext.set('workflowId', 'chat-workflow'); + requestContext.set('chatSessionId', sessionId); + requestContext.set('aiSdkTelemetryEnabled', telemetryEnabled); + requestContext.set('aiSdkTelemetryMetadata', telemetryMetadata); requestContext.set('visualizerStore', await this.resolveVisualizerStore()); const chatDbQuerySchema = this.resolveDbQueryChatSchema(); @@ -177,22 +189,45 @@ export class WorkflowRunner { abortSignal: abortController.signal, currentUser, directCall: false, + correlationId, }); } - const run = await chatWorkflow.createRun(); - - // run.stream() executes the workflow lazily as we consume the returned iterator. - // The iterator yields WorkflowStreamEvent — steps emit via writer.write() which - // surfaces as {type: 'workflow-step-output', payload: {output: }}. - const workflowStream = run.stream({ - inputData: {prompt, files, sessionId}, - requestContext, + const span = tracer.startSpan('workflow.chat.execute', { + attributes: { + 'workflow.id': 'chat-workflow', + 'workflow.correlation_id': correlationId, + 'chat.session_id': sessionId ?? '', + 'chat.files_count': files.length, + }, }); - // Merge the workflow stream (writer.write events) and AsyncEventQueue (agent callbacks) - // concurrently. Yield all LLMStreamEvents to GenerationService in arrival order. - yield* this._mergeStreams(workflowStream, eventQueue, abortController); + try { + const run = await chatWorkflow.createRun(); + + // run.stream() executes the workflow lazily as we consume the returned iterator. + // The iterator yields WorkflowStreamEvent — steps emit via writer.write() which + // surfaces as {type: 'workflow-step-output', payload: {output: }}. + const workflowStream = run.stream({ + inputData: {prompt, files, sessionId}, + requestContext, + }); + + // Merge the workflow stream (writer.write events) and AsyncEventQueue (agent callbacks) + // concurrently. Yield all LLMStreamEvents to GenerationService in arrival order. + yield* this._mergeStreams(workflowStream, eventQueue, abortController); + + span.setStatus({code: SpanStatusCode.OK}); + } catch (error) { + span.recordException(error as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + span.end(); + } } /** @@ -228,33 +263,64 @@ export class WorkflowRunner { } const currentUser = await this.getCurrentUser(); + const correlationId = randomUUID(); + const telemetryEnabled = this.aiConfig?.aiSdkTelemetry?.enabled ?? true; + const telemetryMetadata = this.aiConfig?.aiSdkTelemetry?.metadata ?? {}; const requestContext = new RequestContext(); + requestContext.set('correlationId', correlationId); + requestContext.set('workflowId', 'db-query-workflow'); + requestContext.set('chatSessionId', undefined); + requestContext.set('aiSdkTelemetryEnabled', telemetryEnabled); + requestContext.set('aiSdkTelemetryMetadata', telemetryMetadata); + this.bindDbQueryContext(requestContext, { schema, abortSignal: abortController.signal, currentUser, directCall: options?.directCall ?? false, + correlationId, }); - const run = await dbQueryWorkflow.createRun(); - - const workflowStream = run.stream({ - inputData: { - prompt, - schema, - datasetId: options?.datasetId, - directCall: options?.directCall, + const span = tracer.startSpan('workflow.db-query.execute', { + attributes: { + 'workflow.id': 'db-query-workflow', + 'workflow.correlation_id': correlationId, + 'db-query.direct_call': options?.directCall ?? false, }, - requestContext, }); - // DBQuery doesn't use AsyncEventQueue (no Agent/tool callbacks) - // but we still use _mergeStreams for consistency with the abort logic - const emptyQueue = new AsyncEventQueue(); - emptyQueue.close(); // immediately close since no events will come from it + try { + const run = await dbQueryWorkflow.createRun(); + + const workflowStream = run.stream({ + inputData: { + prompt, + schema, + datasetId: options?.datasetId, + directCall: options?.directCall, + }, + requestContext, + }); + + // DBQuery doesn't use AsyncEventQueue (no Agent/tool callbacks) + // but we still use _mergeStreams for consistency with the abort logic + const emptyQueue = new AsyncEventQueue(); + emptyQueue.close(); // immediately close since no events will come from it + + yield* this._mergeStreams(workflowStream, emptyQueue, abortController); - yield* this._mergeStreams(workflowStream, emptyQueue, abortController); + span.setStatus({code: SpanStatusCode.OK}); + } catch (error) { + span.recordException(error as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + span.end(); + } } private async resolveOptionalCurrentUser(): Promise< @@ -322,6 +388,7 @@ export class WorkflowRunner { abortSignal: AbortSignal; currentUser: IAuthUserWithPermissions | undefined; directCall: boolean; + correlationId: string; }, ): void { if ( @@ -361,6 +428,7 @@ export class WorkflowRunner { requestContext.set('globalContext', this.dbGlobalContext ?? []); requestContext.set('abortSignal', params.abortSignal); requestContext.set('currentUser', params.currentUser); + requestContext.set('correlationId', params.correlationId); requestContext.set('fullSchema', params.schema); requestContext.set('directCall', params.directCall); requestContext.set('queryCache', { diff --git a/src/mastra/types.ts b/src/mastra/types.ts index ef86d4b..dcd65a8 100644 --- a/src/mastra/types.ts +++ b/src/mastra/types.ts @@ -29,6 +29,16 @@ export type ChatWorkflowRequestContext = { systemContext: string[] | undefined; /** Token usage accumulator for the request */ tokenUsageAccumulator: TokenUsageAccumulator; + /** Correlation id propagated across workflows/tools/model calls */ + correlationId: string; + /** Workflow identifier for telemetry metadata */ + workflowId: string; + /** Optional chat session id associated with request */ + chatSessionId: string | undefined; + /** AI SDK telemetry toggle for model calls */ + aiSdkTelemetryEnabled: boolean; + /** Additional request-scoped telemetry metadata */ + aiSdkTelemetryMetadata: Record; }; /** diff --git a/src/mastra/workflows/db-query/db-query-request-context.ts b/src/mastra/workflows/db-query/db-query-request-context.ts index 58ab0d2..b4bea6b 100644 --- a/src/mastra/workflows/db-query/db-query-request-context.ts +++ b/src/mastra/workflows/db-query/db-query-request-context.ts @@ -1,6 +1,7 @@ import type {RequestContext} from '@mastra/core/request-context'; import type {MastraLanguageModel} from '@mastra/core/agent'; import type {IAuthUserWithPermissions} from '@sourceloop/core'; +import type {AsyncEventQueue} from '../../bridge/async-event-queue'; import type { DatabaseSchema, DbQueryConfig, @@ -53,11 +54,23 @@ export interface DbQueryRequestContext { /** Abort signal from HTTP request */ abortSignal: AbortSignal; /** Authenticated user */ - currentUser: IAuthUserWithPermissions; + currentUser: IAuthUserWithPermissions | undefined; /** Full database schema (unfiltered) */ fullSchema: DatabaseSchema; /** Whether this is a direct internal call (not from chat tool) */ directCall: boolean; + /** Optional event queue when DBQuery runs inside chat tool execution */ + eventQueue?: AsyncEventQueue; + /** Correlation id propagated across workflow and model calls */ + correlationId?: string; + /** Workflow id attached by the workflow runner */ + workflowId?: string; + /** Optional chat session id for chat-originated tool execution */ + chatSessionId?: string; + /** Global AI SDK telemetry switch for request-scoped model calls */ + aiSdkTelemetryEnabled?: boolean; + /** Additional AI SDK telemetry metadata attached to model calls */ + aiSdkTelemetryMetadata?: Record; } /** Document returned by the query cache retriever */ diff --git a/src/mastra/workflows/db-query/llm-helpers.ts b/src/mastra/workflows/db-query/llm-helpers.ts index 98e8f50..9a4c43f 100644 --- a/src/mastra/workflows/db-query/llm-helpers.ts +++ b/src/mastra/workflows/db-query/llm-helpers.ts @@ -1,9 +1,114 @@ -import {Agent} from '@mastra/core/agent'; +import {generateObject, generateText} from 'ai'; import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {RequestContext} from '@mastra/core/request-context'; + +type TelemetryPrimitive = string | number | boolean; + +type InvokeLlmOptions = { + requestContext?: RequestContext; + functionId?: string; + metadata?: Record; + abortSignal?: AbortSignal; +}; + +type AiModel = Parameters[0]['model']; + +function isTelemetryPrimitive(value: unknown): value is TelemetryPrimitive { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +function resolveAbortSignal( + options: InvokeLlmOptions, +): AbortSignal | undefined { + if (options.abortSignal) { + return options.abortSignal; + } + const signalFromContext = options.requestContext?.get('abortSignal'); + return signalFromContext instanceof AbortSignal + ? signalFromContext + : undefined; +} + +function resolveUserId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const userId = (value as {id?: unknown}).id; + if (typeof userId === 'string' || typeof userId === 'number') { + return String(userId); + } + + return undefined; +} + +function buildTelemetryMetadata( + options: InvokeLlmOptions, +): Record { + const metadata: Record = {}; + const requestContext = options.requestContext; + + const correlationId = requestContext?.get('correlationId'); + if (typeof correlationId === 'string' && correlationId.length > 0) { + metadata.correlationId = correlationId; + } + + const workflowId = requestContext?.get('workflowId'); + if (typeof workflowId === 'string' && workflowId.length > 0) { + metadata.workflowId = workflowId; + } + + const chatSessionId = requestContext?.get('chatSessionId'); + if (typeof chatSessionId === 'string' && chatSessionId.length > 0) { + metadata.chatSessionId = chatSessionId; + } + + const userId = resolveUserId(requestContext?.get('currentUser')); + if (userId) { + metadata.userId = userId; + } + + const contextMetadata = requestContext?.get('aiSdkTelemetryMetadata'); + if (contextMetadata && typeof contextMetadata === 'object') { + for (const [key, value] of Object.entries( + contextMetadata as Record, + )) { + if (isTelemetryPrimitive(value)) { + metadata[key] = value; + } + } + } + + if (options.metadata) { + for (const [key, value] of Object.entries(options.metadata)) { + metadata[key] = value; + } + } + + return metadata; +} + +function buildTelemetryConfig(options: InvokeLlmOptions) { + const enabledFromContext = options.requestContext?.get( + 'aiSdkTelemetryEnabled', + ); + const isEnabled = + typeof enabledFromContext === 'boolean' ? enabledFromContext : true; + + return { + isEnabled, + functionId: options.functionId ?? 'ai-integration.llm.invoke', + metadata: buildTelemetryMetadata(options), + }; +} /** * Invoke an LLM with a prompt string and return the text response. - * Uses Mastra Agent.generate() as the project does not depend on the `ai` package directly. + * Uses AI SDK generateText() with optional request-scoped telemetry metadata. * * @param llm - Mastra language model * @param prompt - Formatted prompt string @@ -12,15 +117,45 @@ import type {MastraLanguageModel} from '@mastra/core/agent'; export async function invokeLlm( llm: MastraLanguageModel, prompt: string, + options: InvokeLlmOptions = {}, ): Promise { - const agent = new Agent({ - id: 'db-query-llm-agent', - name: 'DB Query LLM', - instructions: 'You are a helpful assistant.', - model: llm, - }); - const result = await agent.generate([{role: 'user', content: prompt}]); - return result.text ?? ''; + const requestOptions: Record = { + model: llm as unknown as AiModel, + prompt, + abortSignal: resolveAbortSignal(options), + }; + requestOptions['experimental_telemetry'] = buildTelemetryConfig(options); + + const result = await generateText( + requestOptions as Parameters[0], + ); + + return result.text; +} + +/** + * Invoke an LLM and enforce structured JSON output against the provided schema. + */ +export async function invokeLlmObject( + llm: MastraLanguageModel, + prompt: string, + schema: unknown, + options: InvokeLlmOptions = {}, +): Promise { + const requestOptions: Record = { + model: llm as unknown as AiModel, + prompt, + output: 'object', + schema: schema as never, + abortSignal: resolveAbortSignal(options), + }; + requestOptions['experimental_telemetry'] = buildTelemetryConfig(options); + + const result = await generateObject( + requestOptions as Parameters[0], + ); + + return result.object as TOutput; } /** diff --git a/src/mastra/workflows/db-query/steps/cache-check.step.ts b/src/mastra/workflows/db-query/steps/cache-check.step.ts index 08e00a4..88585d5 100644 --- a/src/mastra/workflows/db-query/steps/cache-check.step.ts +++ b/src/mastra/workflows/db-query/steps/cache-check.step.ts @@ -78,7 +78,10 @@ export const cacheCheckStep = createStep({ inputData.prompt, ).replace('{queries}', buildQueriesText(relevantDocs)); - const rawResponse = await invokeLlm(cheapLlm, prompt); + const rawResponse = await invokeLlm(cheapLlm, prompt, { + requestContext, + functionId: 'db-query.cache-check', + }); const decision = parseCacheDecision( stripThinkingTokens(rawResponse), relevantDocs.length, diff --git a/src/mastra/workflows/db-query/steps/change-classification.step.ts b/src/mastra/workflows/db-query/steps/change-classification.step.ts index 868a467..aaf0958 100644 --- a/src/mastra/workflows/db-query/steps/change-classification.step.ts +++ b/src/mastra/workflows/db-query/steps/change-classification.step.ts @@ -63,7 +63,10 @@ export const changeClassificationStep = createStep({ inputData.sampleSqlPrompt ?? '', ).replace('{newDescription}', inputData.prompt); - const rawOutput = await invokeLlm(cheapLlm, prompt); + const rawOutput = await invokeLlm(cheapLlm, prompt, { + requestContext, + functionId: 'db-query.change-classification', + }); const response = stripThinkingTokens(rawOutput).trim().toLowerCase(); const changeType = parseChangeType(response); diff --git a/src/mastra/workflows/db-query/steps/column-selection.step.ts b/src/mastra/workflows/db-query/steps/column-selection.step.ts index 5c1273f..49870c6 100644 --- a/src/mastra/workflows/db-query/steps/column-selection.step.ts +++ b/src/mastra/workflows/db-query/steps/column-selection.step.ts @@ -1,5 +1,6 @@ import {createStep} from '@mastra/core/workflows'; import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {RequestContext} from '@mastra/core/request-context'; import {z} from 'zod'; import {LLMStreamEventType} from '../../../../graphs/event.types'; import {asDbQueryContext} from '../db-query-request-context'; @@ -134,6 +135,7 @@ export const columnSelectionStep = createStep({ feedbacksText, checks, schema, + requestContext, writer, maxAttempts: 3, }); @@ -285,6 +287,7 @@ async function selectColumnsWithRetries(params: { feedbacksText: string; checks: string; schema: DatabaseSchema; + requestContext: RequestContext; writer: { write: (event: { type: LLMStreamEventType; @@ -307,7 +310,10 @@ async function selectColumnsWithRetries(params: { .replace('{feedbacks}', params.feedbacksText) .replace('{checks}', params.checks); - const rawResult = await invokeLlm(params.llm, prompt); + const rawResult = await invokeLlm(params.llm, prompt, { + requestContext: params.requestContext, + functionId: 'db-query.column-selection', + }); const output = stripThinkingTokens(rawResult); try { diff --git a/src/mastra/workflows/db-query/steps/dataset-persistence.step.ts b/src/mastra/workflows/db-query/steps/dataset-persistence.step.ts index 0451b53..fe966af 100644 --- a/src/mastra/workflows/db-query/steps/dataset-persistence.step.ts +++ b/src/mastra/workflows/db-query/steps/dataset-persistence.step.ts @@ -57,7 +57,7 @@ export const datasetPersistenceStep = createStep({ data: 'Dataset generated', }); - const tenantId = currentUser.tenantId; + const tenantId = currentUser?.tenantId; if (!tenantId) { throw new Error('User does not have a tenantId'); } @@ -74,7 +74,10 @@ export const datasetPersistenceStep = createStep({ .replace('{schema}', schemaHelper.asString(schema)) .replace('{checks}', checks); - const rawOutput = await invokeLlm(cheapLlm, prompt); + const rawOutput = await invokeLlm(cheapLlm, prompt, { + requestContext, + functionId: 'db-query.dataset-persistence', + }); description = stripThinkingTokens(rawOutput); } diff --git a/src/mastra/workflows/db-query/steps/description-generation.step.ts b/src/mastra/workflows/db-query/steps/description-generation.step.ts index 4c86230..0f9cab9 100644 --- a/src/mastra/workflows/db-query/steps/description-generation.step.ts +++ b/src/mastra/workflows/db-query/steps/description-generation.step.ts @@ -83,7 +83,10 @@ export const descriptionGenerationStep = createStep({ .replace('{schema}', schemaHelper.asString(schema)) .replace('{checks}', checks); - const rawOutput = await invokeLlm(cheapLlm, prompt); + const rawOutput = await invokeLlm(cheapLlm, prompt, { + requestContext, + functionId: 'db-query.description-generation', + }); const description = stripThinkingTokens(rawOutput); await writer.write({ diff --git a/src/mastra/workflows/db-query/steps/generate-checklist.step.ts b/src/mastra/workflows/db-query/steps/generate-checklist.step.ts index cf57df0..4ff7f0f 100644 --- a/src/mastra/workflows/db-query/steps/generate-checklist.step.ts +++ b/src/mastra/workflows/db-query/steps/generate-checklist.step.ts @@ -103,7 +103,10 @@ export const generateChecklistStep = createStep({ const results = await Promise.all( Array.from({length: parallelism}, () => - invokeLlm(cheapLlm, invokePrompt), + invokeLlm(cheapLlm, invokePrompt, { + requestContext, + functionId: 'db-query.generate-checklist', + }), ), ); diff --git a/src/mastra/workflows/db-query/steps/query-repair.step.ts b/src/mastra/workflows/db-query/steps/query-repair.step.ts index d9b750e..7c9a61e 100644 --- a/src/mastra/workflows/db-query/steps/query-repair.step.ts +++ b/src/mastra/workflows/db-query/steps/query-repair.step.ts @@ -120,7 +120,10 @@ export const queryRepairStep = createStep({ : '', ); - const rawOutput = await invokeLlm(cheapLlm, prompt); + const rawOutput = await invokeLlm(cheapLlm, prompt, { + requestContext, + functionId: 'db-query.query-repair', + }); const response = stripThinkingTokens(rawOutput); const sql = stripCodeBlock(response) || undefined; diff --git a/src/mastra/workflows/db-query/steps/semantic-validation.step.ts b/src/mastra/workflows/db-query/steps/semantic-validation.step.ts index a256b01..cb090dc 100644 --- a/src/mastra/workflows/db-query/steps/semantic-validation.step.ts +++ b/src/mastra/workflows/db-query/steps/semantic-validation.step.ts @@ -126,7 +126,10 @@ export const semanticValidationStep = createStep({ feedbacks: inputData.feedbacks, }); - const rawOutput = await invokeLlm(llm, prompt); + const rawOutput = await invokeLlm(llm, prompt, { + requestContext, + functionId: 'db-query.semantic-validation', + }); const response = stripThinkingTokens(rawOutput); const parsed = parseSemanticValidationResponse(response); diff --git a/src/mastra/workflows/db-query/steps/sql-generation.step.ts b/src/mastra/workflows/db-query/steps/sql-generation.step.ts index a00f303..599eab2 100644 --- a/src/mastra/workflows/db-query/steps/sql-generation.step.ts +++ b/src/mastra/workflows/db-query/steps/sql-generation.step.ts @@ -129,7 +129,10 @@ export const sqlGenerationStep = createStep({ .replace('{exampleQueries}', exampleQueries) .replace('{feedbacks}', feedbacksText); - const rawOutput = await invokeLlm(llm, prompt); + const rawOutput = await invokeLlm(llm, prompt, { + requestContext, + functionId: 'db-query.sql-generation', + }); const response = stripThinkingTokens(rawOutput); const sql = stripCodeBlock(response) || undefined; diff --git a/src/mastra/workflows/db-query/steps/syntactic-validation.step.ts b/src/mastra/workflows/db-query/steps/syntactic-validation.step.ts index ffd4cdb..92370a6 100644 --- a/src/mastra/workflows/db-query/steps/syntactic-validation.step.ts +++ b/src/mastra/workflows/db-query/steps/syntactic-validation.step.ts @@ -72,7 +72,10 @@ export const syntacticValidationStep = createStep({ .replace('{query}', inputData.sql) .replace('{tableNames}', tableNames.join(', ')); - const rawOutput = await invokeLlm(cheapLlm, prompt); + const rawOutput = await invokeLlm(cheapLlm, prompt, { + requestContext, + functionId: 'db-query.syntactic-validation', + }); const result = stripThinkingTokens(rawOutput); const categoryMatch = /(.*?)<\/category>/s.exec(result); diff --git a/src/mastra/workflows/db-query/steps/table-selection.step.ts b/src/mastra/workflows/db-query/steps/table-selection.step.ts index 8f72f84..f3c55bb 100644 --- a/src/mastra/workflows/db-query/steps/table-selection.step.ts +++ b/src/mastra/workflows/db-query/steps/table-selection.step.ts @@ -1,5 +1,6 @@ import {createStep} from '@mastra/core/workflows'; import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {RequestContext} from '@mastra/core/request-context'; import {z} from 'zod'; import {LLMStreamEventType} from '../../../../graphs/event.types'; import {asDbQueryContext} from '../db-query-request-context'; @@ -131,6 +132,7 @@ export const tableSelectionStep = createStep({ feedbacksText, checks, dbSchema, + requestContext, writer, maxAttempts: 2, }); @@ -223,6 +225,7 @@ async function selectTablesWithRetries(params: { feedbacksText: string; checks: string; dbSchema: DatabaseSchema; + requestContext: RequestContext; writer: { write: (event: { type: LLMStreamEventType; @@ -245,7 +248,10 @@ async function selectTablesWithRetries(params: { .replace('{feedbacks}', params.feedbacksText) .replace('{checks}', params.checks); - const rawResult = await invokeLlm(params.llm, prompt); + const rawResult = await invokeLlm(params.llm, prompt, { + requestContext: params.requestContext, + functionId: 'db-query.table-selection', + }); const output = stripThinkingTokens(rawResult); const parsed = parseTableSelectionOutput(output); diff --git a/src/mastra/workflows/db-query/steps/template-match.step.ts b/src/mastra/workflows/db-query/steps/template-match.step.ts index 086cfa1..6d350c9 100644 --- a/src/mastra/workflows/db-query/steps/template-match.step.ts +++ b/src/mastra/workflows/db-query/steps/template-match.step.ts @@ -92,7 +92,10 @@ ${placeholderText} inputData.prompt, ).replace('{templates}', templatesText); - const rawResponse = await invokeLlm(cheapLlm, prompt); + const rawResponse = await invokeLlm(cheapLlm, prompt, { + requestContext, + functionId: 'db-query.template-match', + }); const trimmed = stripThinkingTokens(rawResponse).trim(); if (trimmed === 'no_match') { diff --git a/src/mastra/workflows/db-query/steps/verify-checklist.step.ts b/src/mastra/workflows/db-query/steps/verify-checklist.step.ts index df63929..215c170 100644 --- a/src/mastra/workflows/db-query/steps/verify-checklist.step.ts +++ b/src/mastra/workflows/db-query/steps/verify-checklist.step.ts @@ -127,7 +127,10 @@ export const verifyChecklistStep = createStep({ outputInstructions, ); - const rawOutput = await invokeLlm(llm, prompt); + const rawOutput = await invokeLlm(llm, prompt, { + requestContext, + functionId: 'db-query.verify-checklist', + }); const verifiedIndexes = parseVerifiedIndexes( stripThinkingTokens(rawOutput), allChecks.length, diff --git a/src/mastra/workflows/db-query/tools/ask-about-dataset.tool.ts b/src/mastra/workflows/db-query/tools/ask-about-dataset.tool.ts index b50947f..8fef508 100644 --- a/src/mastra/workflows/db-query/tools/ask-about-dataset.tool.ts +++ b/src/mastra/workflows/db-query/tools/ask-about-dataset.tool.ts @@ -84,7 +84,10 @@ export const askAboutDatasetTool = createTool({ .replace('{context}', [...globalContext, ...schemaContext].join('\n')) .replace('{question}', inputData.question); - const llmResponse = await invokeLlm(cheapLlm, prompt); + const llmResponse = await invokeLlm(cheapLlm, prompt, { + requestContext, + functionId: 'db-query.ask-about-dataset', + }); const reply = stripThinkingTokens(llmResponse).trim(); return { diff --git a/src/mastra/workflows/db-query/tools/get-data-as-dataset.tool.ts b/src/mastra/workflows/db-query/tools/get-data-as-dataset.tool.ts index 8038b85..42841ea 100644 --- a/src/mastra/workflows/db-query/tools/get-data-as-dataset.tool.ts +++ b/src/mastra/workflows/db-query/tools/get-data-as-dataset.tool.ts @@ -8,7 +8,6 @@ import { type DbQueryWorkflowOutput, } from '../db-query-workflow-schemas'; import type {LLMStreamEvent} from '../../../../graphs/event.types'; -import type {AsyncEventQueue} from '../../../bridge/async-event-queue'; import type {JsonObject, JsonValue} from '../../../../types'; const DEFAULT_MAX_READ_ROWS_FOR_AI = 25; @@ -85,9 +84,7 @@ It internally fires an event that renders a grid for the dataset on the UI for t } const ctx = asDbQueryContext(requestContext); - const eventQueue = requestContext.get('eventQueue') as - | AsyncEventQueue - | undefined; + const eventQueue = ctx.get('eventQueue'); const schema = ctx.get('fullSchema'); const abortSignal = ctx.get('abortSignal'); diff --git a/src/mastra/workflows/db-query/tools/improve-dataset.tool.ts b/src/mastra/workflows/db-query/tools/improve-dataset.tool.ts index 6a831b6..b960665 100644 --- a/src/mastra/workflows/db-query/tools/improve-dataset.tool.ts +++ b/src/mastra/workflows/db-query/tools/improve-dataset.tool.ts @@ -8,7 +8,6 @@ import { type DbQueryWorkflowOutput, } from '../db-query-workflow-schemas'; import type {LLMStreamEvent} from '../../../../graphs/event.types'; -import type {AsyncEventQueue} from '../../../bridge/async-event-queue'; import type {JsonObject, JsonValue} from '../../../../types'; const DEFAULT_MAX_READ_ROWS_FOR_AI = 25; @@ -87,9 +86,7 @@ export const improveDatasetTool = createTool({ } const ctx = asDbQueryContext(requestContext); - const eventQueue = requestContext.get('eventQueue') as - | AsyncEventQueue - | undefined; + const eventQueue = ctx.get('eventQueue'); const schema = ctx.get('fullSchema'); const abortSignal = ctx.get('abortSignal'); diff --git a/src/mastra/workflows/visualization/steps/data-fetch.step.ts b/src/mastra/workflows/visualization/steps/data-fetch.step.ts index 4480f00..b8bf9ae 100644 --- a/src/mastra/workflows/visualization/steps/data-fetch.step.ts +++ b/src/mastra/workflows/visualization/steps/data-fetch.step.ts @@ -16,6 +16,10 @@ export const dataFetchStep = createStep({ }), outputSchema: visualizationWorkflowStateSchema, execute: async ({inputData, requestContext, writer}) => { + if (!requestContext) { + throw new Error('RequestContext is required for data-fetch step.'); + } + if (inputData.error) { return inputData; } @@ -24,7 +28,7 @@ export const dataFetchStep = createStep({ throw new Error('Invalid State'); } - const ctx = asVisualizationContext(requestContext!); + const ctx = asVisualizationContext(requestContext); const dataset = await ctx.get('datasetStore').findById(inputData.datasetId); await writer.write({ diff --git a/src/mastra/workflows/visualization/steps/query-generation.step.ts b/src/mastra/workflows/visualization/steps/query-generation.step.ts index 9e605eb..75b0ea7 100644 --- a/src/mastra/workflows/visualization/steps/query-generation.step.ts +++ b/src/mastra/workflows/visualization/steps/query-generation.step.ts @@ -55,11 +55,15 @@ export const queryGenerationStep = createStep({ }), outputSchema: visualizationWorkflowStateSchema, execute: async ({inputData, requestContext, writer}) => { + if (!requestContext) { + throw new Error('RequestContext is required for query-generation step.'); + } + if (inputData.error !== undefined || inputData.datasetId !== undefined) { return inputData; } - const ctx = asVisualizationContext(requestContext!); + const ctx = asVisualizationContext(requestContext); const schema = ctx.get('fullSchema'); if (!schema) { diff --git a/src/mastra/workflows/visualization/steps/render-config.step.ts b/src/mastra/workflows/visualization/steps/render-config.step.ts index 15723b6..b1bf0b4 100644 --- a/src/mastra/workflows/visualization/steps/render-config.step.ts +++ b/src/mastra/workflows/visualization/steps/render-config.step.ts @@ -2,7 +2,6 @@ import {createStep} from '@mastra/core/workflows'; import {z} from 'zod'; import {LLMStreamEventType} from '../../../../graphs/event.types'; import {ToolStatus} from '../../../../graphs/types'; -import type {VisualizationGraphState} from '../../../../components/visualization/state'; import {asVisualizationContext} from '../visualization-request-context'; import {visualizationWorkflowOutputSchema} from '../visualization-workflow-schemas'; @@ -19,6 +18,10 @@ export const renderConfigStep = createStep({ }), outputSchema: visualizationWorkflowOutputSchema, execute: async ({inputData, requestContext, writer}) => { + if (!requestContext) { + throw new Error('RequestContext is required for render-config step.'); + } + if (inputData.error) { return { datasetId: inputData.datasetId, @@ -28,7 +31,7 @@ export const renderConfigStep = createStep({ }; } - const ctx = asVisualizationContext(requestContext!); + const ctx = asVisualizationContext(requestContext); const visualizerStore = ctx.get('visualizerStore'); const visualizer = inputData.visualizerName ? visualizerStore.map[inputData.visualizerName] @@ -50,14 +53,19 @@ export const renderConfigStep = createStep({ }, }); - const settings = await visualizer.getConfig({ - prompt: inputData.prompt, - datasetId: inputData.datasetId, - sql: inputData.sql, - queryDescription: inputData.queryDescription, - visualizerName: visualizer.name, - type: inputData.type, - } as VisualizationGraphState); + const settings = await visualizer.getConfig( + { + prompt: inputData.prompt, + datasetId: inputData.datasetId, + sql: inputData.sql, + queryDescription: inputData.queryDescription, + visualizerName: visualizer.name, + type: inputData.type, + }, + { + requestContext, + }, + ); await writer.write({ type: LLMStreamEventType.ToolStatus, diff --git a/src/mastra/workflows/visualization/steps/visualization-selection.step.ts b/src/mastra/workflows/visualization/steps/visualization-selection.step.ts index 3b87574..35e0b2a 100644 --- a/src/mastra/workflows/visualization/steps/visualization-selection.step.ts +++ b/src/mastra/workflows/visualization/steps/visualization-selection.step.ts @@ -77,7 +77,13 @@ export const visualizationSelectionStep = createStep({ }), outputSchema: visualizationWorkflowStateSchema, execute: async ({inputData, requestContext, writer}) => { - const ctx = asVisualizationContext(requestContext!); + if (!requestContext) { + throw new Error( + 'RequestContext is required for visualization-selection step.', + ); + } + + const ctx = asVisualizationContext(requestContext); const visualizerStore = ctx.get('visualizerStore'); const visualizations = visualizerStore.list; @@ -119,7 +125,10 @@ export const visualizationSelectionStep = createStep({ .join('\n'), }); - const rawOutput = await invokeLlm(ctx.get('cheapLlm'), selectionPrompt); + const rawOutput = await invokeLlm(ctx.get('cheapLlm'), selectionPrompt, { + requestContext, + functionId: 'visualization-selection.step', + }); const output = stripThinkingTokens(rawOutput); if (output.trim().startsWith('none')) { diff --git a/src/mastra/workflows/visualization/tools/generate-visualization.tool.ts b/src/mastra/workflows/visualization/tools/generate-visualization.tool.ts index 212297a..e33b02f 100644 --- a/src/mastra/workflows/visualization/tools/generate-visualization.tool.ts +++ b/src/mastra/workflows/visualization/tools/generate-visualization.tool.ts @@ -7,8 +7,8 @@ import { visualizationWorkflowOutputSchema, type VisualizationWorkflowOutput, } from '../visualization-workflow-schemas'; +import {asVisualizationContext} from '../visualization-request-context'; import type {LLMStreamEvent} from '../../../../graphs/event.types'; -import type {AsyncEventQueue} from '../../../bridge/async-event-queue'; import type {JsonObject, JsonValue} from '../../../../types'; const looseObjectSchema = z.object({}).passthrough(); @@ -131,12 +131,9 @@ It does not return anything, instead it fires an event internally that renders t ); } - const eventQueue = requestContext.get('eventQueue') as - | AsyncEventQueue - | undefined; - const abortSignal = requestContext.get('abortSignal') as - | AbortSignal - | undefined; + const ctx = asVisualizationContext(requestContext); + const eventQueue = ctx.get('eventQueue'); + const abortSignal = ctx.get('abortSignal'); const run = await visualizationWorkflow.createRun(); const stream = run.stream({ diff --git a/src/providers/mastra-tools.provider.ts b/src/providers/mastra-tools.provider.ts index fc063da..b0b240d 100644 --- a/src/providers/mastra-tools.provider.ts +++ b/src/providers/mastra-tools.provider.ts @@ -1,19 +1,5 @@ -import {BindingScope, inject, injectable, Provider} from '@loopback/core'; -import {StructuredToolInterface} from '@langchain/core/tools'; -import {RunnableToolLike} from '@langchain/core/runnables'; -import {createTool} from '@mastra/core/tools'; -import {z} from 'zod'; -import {AiIntegrationBindings} from '../keys'; -import type {IGraphTool} from '../graphs/types'; -import type {LLMStreamEvent} from '../graphs/event.types'; -import type { - JsonObject, - JsonValue, - MastraToolDefinition, - MastraToolStore, - ToolStore, -} from '../types'; -import {asWorkflowContext} from '../mastra/bridge/workflow-request-context'; +import {BindingScope, injectable, Provider} from '@loopback/core'; +import type {MastraToolDefinition, MastraToolStore} from '../types'; import { askAboutDatasetTool, formatAskAboutDatasetResult, @@ -31,71 +17,6 @@ import { getGenerateVisualizationMetadata, } from '../mastra/workflows/visualization/tools'; -const debug = require('debug')('ai-integration:provider:mastra-tools'); - -type LegacyTool = StructuredToolInterface | RunnableToolLike; - -type LegacyInvokeConfig = { - configurable?: Record; - writer?: (event: LLMStreamEvent) => void; -}; - -type InvokableLegacyTool = { - invoke( - input: JsonObject, - config?: LegacyInvokeConfig, - ): Promise; -}; - -function isInvokableLegacyTool( - tool: LegacyTool, -): tool is LegacyTool & InvokableLegacyTool { - return 'invoke' in tool && typeof tool.invoke === 'function'; -} - -function resolveLegacyDescription(tool: LegacyTool, fallback: string): string { - if ('description' in tool && typeof tool.description === 'string') { - return tool.description; - } - return fallback; -} - -function resolveLegacyInputSchema(tool: LegacyTool): z.ZodType { - if ('schema' in tool && tool.schema instanceof z.ZodType) { - return tool.schema; - } - return z.object({}).passthrough(); -} - -function toJsonObject(value: JsonValue | JsonObject | undefined): JsonObject { - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - return value; - } - - if (typeof value === 'string' || typeof value === 'number') { - return {value}; - } - - if (typeof value === 'boolean') { - return {value}; - } - - if (value === null) { - return {value: null}; - } - - return {}; -} - -function toLegacyRecord(result: JsonObject): Record { - const output: Record = {}; - for (const [key, value] of Object.entries(result)) { - output[key] = - typeof value === 'string' ? value : JSON.stringify(value ?? null); - } - return output; -} - function createNativeDefinitions(): MastraToolDefinition[] { return [ { @@ -129,98 +50,13 @@ function createNativeDefinitions(): MastraToolDefinition[] { ]; } -async function createLegacyCompatibilityDefinition( - legacyTool: IGraphTool, -): Promise { - const builtTool = await legacyTool.build({configurable: {}}); - const description = resolveLegacyDescription(builtTool, legacyTool.key); - const inputSchema = resolveLegacyInputSchema(builtTool); - - const wrappedTool = createTool({ - id: legacyTool.key, - description, - inputSchema, - execute: async (inputData, context) => { - const eventQueue = context?.requestContext - ? asWorkflowContext(context.requestContext).get('eventQueue') - : undefined; - - const runtimeTool = await legacyTool.build({configurable: {}}); - if (!isInvokableLegacyTool(runtimeTool)) { - throw new Error(`Legacy tool ${legacyTool.key} is not invokable.`); - } - - const invokeConfig: LegacyInvokeConfig = { - configurable: {}, - writer: event => { - eventQueue?.push(event); - }, - }; - - const result = await runtimeTool.invoke( - toJsonObject(inputData), - invokeConfig, - ); - return toJsonObject(result); - }, - }); - - return { - id: legacyTool.key, - tool: wrappedTool, - source: 'legacy-compat', - formatResult: result => { - if (legacyTool.getValue) { - return legacyTool.getValue(toLegacyRecord(result)); - } - return JSON.stringify(result); - }, - getMetadata: result => { - if (legacyTool.getMetadata) { - const metadata = legacyTool.getMetadata(toLegacyRecord(result)); - return toJsonObject(metadata as JsonObject); - } - return {status: 'completed'}; - }, - }; -} - @injectable({scope: BindingScope.REQUEST}) export class MastraToolsProvider implements Provider { - constructor( - @inject(AiIntegrationBindings.Tools) - private readonly legacyToolStore: ToolStore, - ) {} - async value(): Promise { - const nativeDefinitions = createNativeDefinitions(); - const definitions: MastraToolDefinition[] = [...nativeDefinitions]; - const nativeIds = new Set( - nativeDefinitions.map(definition => definition.id), - ); - - for (const legacyTool of this.legacyToolStore.list) { - if (legacyTool.needsReview) { - continue; - } - if (nativeIds.has(legacyTool.key)) { - continue; - } - - try { - const compatibilityDefinition = - await createLegacyCompatibilityDefinition(legacyTool); - definitions.push(compatibilityDefinition); - } catch (error) { - debug( - `Failed to register legacy compatibility tool ${legacyTool.key}:`, - error, - ); - } - } + const definitions = createNativeDefinitions(); const map: Record = {}; - const tools: Record> = {}; + const tools: Record = {}; for (const definition of definitions) { map[definition.id] = definition; tools[definition.id] = definition.tool; diff --git a/src/sub-modules/obf/langfuse/langfuse.provider.ts b/src/sub-modules/obf/langfuse/langfuse.provider.ts index bc49641..6e68438 100644 --- a/src/sub-modules/obf/langfuse/langfuse.provider.ts +++ b/src/sub-modules/obf/langfuse/langfuse.provider.ts @@ -1,8 +1,40 @@ import {Provider, ValueOrPromise} from '@loopback/core'; -import {CallbackHandler} from '@langfuse/langchain'; +import {LangfuseSpanProcessor} from '@langfuse/otel'; +import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node'; -export class LangfuseObfProvider implements Provider { - value(): ValueOrPromise { - return new CallbackHandler(); +type LangfuseTelemetryHandle = { + forceFlush: () => Promise; +}; + +let tracerProvider: NodeTracerProvider | undefined; + +function ensureLangfuseTracerProvider(): NodeTracerProvider { + if (!tracerProvider) { + tracerProvider = new NodeTracerProvider({ + spanProcessors: [ + new LangfuseSpanProcessor({ + publicKey: process.env.LANGFUSE_PUBLIC_KEY, + secretKey: process.env.LANGFUSE_SECRET_KEY, + baseUrl: process.env.LANGFUSE_BASE_URL, + environment: process.env.LANGFUSE_TRACING_ENVIRONMENT, + release: process.env.LANGFUSE_RELEASE, + }), + ], + }); + tracerProvider.register(); + } + + return tracerProvider; +} + +export class LangfuseObfProvider implements Provider { + value(): ValueOrPromise { + const provider = ensureLangfuseTracerProvider(); + + return { + forceFlush: async () => { + await provider.forceFlush(); + }, + }; } } diff --git a/src/sub-modules/providers/anthropic/llms/anthropic.provider.ts b/src/sub-modules/providers/anthropic/llms/anthropic.provider.ts index 85786aa..5b50098 100644 --- a/src/sub-modules/providers/anthropic/llms/anthropic.provider.ts +++ b/src/sub-modules/providers/anthropic/llms/anthropic.provider.ts @@ -1,29 +1,19 @@ -import {AnthropicInput, ChatAnthropic} from '@langchain/anthropic'; +import {createAnthropic} from '@ai-sdk/anthropic'; import {Provider, ValueOrPromise} from '@loopback/core'; -import {LLMProvider} from '../../../../types'; -import {BaseChatModelParams} from '@langchain/core/language_models/chat_models'; +import type {MastraLanguageModel} from '@mastra/core/agent'; -export class Claude implements Provider { - value(): ValueOrPromise { +export class Claude implements Provider { + value(): ValueOrPromise { if (!process.env.CLAUDE_MODEL || !process.env.CLAUDE_API_KEY) { throw new Error( 'CLAUDE_MODEL and CLAUDE_API_KEY environment variables must be set', ); } - const config: AnthropicInput & BaseChatModelParams = { - model: process.env.CLAUDE_MODEL!, + + const provider = createAnthropic({ apiKey: process.env.CLAUDE_API_KEY, - }; - if (process.env.CLAUDE_THINKING === 'true') { - config.thinking = { - // eslint-disable-next-line @typescript-eslint/naming-convention - budget_tokens: parseInt(process.env.CLAUDE_THINKING_BUDGET ?? '1024'), - type: process.env.CLAUDE_THINKING === 'true' ? 'enabled' : 'disabled', - }; - } - if (process.env.CLAUDE_TEMPERATURE) { - config.temperature = parseInt(process.env.CLAUDE_TEMPERATURE); - } - return new ChatAnthropic(config); + }); + + return provider(process.env.CLAUDE_MODEL) as unknown as MastraLanguageModel; } } diff --git a/src/sub-modules/providers/aws/embedding/bedrock-embedding.provider.ts b/src/sub-modules/providers/aws/embedding/bedrock-embedding.provider.ts index c746617..b8f6957 100644 --- a/src/sub-modules/providers/aws/embedding/bedrock-embedding.provider.ts +++ b/src/sub-modules/providers/aws/embedding/bedrock-embedding.provider.ts @@ -1,7 +1,10 @@ -import {BedrockEmbeddings} from '@langchain/aws'; +import {embed, embedMany} from 'ai'; +import {createAmazonBedrock} from '@ai-sdk/amazon-bedrock'; import {Provider, ValueOrPromise} from '@loopback/core'; import {EmbeddingProvider} from '../../../../types'; +type AiEmbeddingModel = Parameters[0]['model']; + export class BedrockEmbedding implements Provider { value(): ValueOrPromise { if (!process.env.BEDROCK_EMBEDDING_MODEL) { @@ -9,13 +12,42 @@ export class BedrockEmbedding implements Provider { 'BEDROCK_EMBEDDING_MODEL environment variable is not set', ); } - return new BedrockEmbeddings({ - region: process.env.BEDROCK_AWS_REGION!, - credentials: { - accessKeyId: process.env.BEDROCK_AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.BEDROCK_AWS_SECRET_ACCESS_KEY!, - }, - model: process.env.BEDROCK_EMBEDDING_MODEL!, + + if (!process.env.BEDROCK_AWS_REGION) { + throw new Error( + 'BEDROCK_AWS_REGION environment variable is not set for Bedrock embedding provider.', + ); + } + + const provider = createAmazonBedrock({ + region: process.env.BEDROCK_AWS_REGION, + accessKeyId: process.env.BEDROCK_AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.BEDROCK_AWS_SECRET_ACCESS_KEY, + sessionToken: process.env.BEDROCK_AWS_SESSION_TOKEN, }); + + const model = provider.embeddingModel( + process.env.BEDROCK_EMBEDDING_MODEL, + ) as unknown as AiEmbeddingModel; + + return { + embedDocuments: async (texts: string[]) => { + if (texts.length === 0) { + return []; + } + const result = await embedMany({ + model, + values: texts, + }); + return result.embeddings.map(embedding => Array.from(embedding)); + }, + embedQuery: async (text: string) => { + const result = await embed({ + model, + value: text, + }); + return Array.from(result.embedding); + }, + } as EmbeddingProvider; } } diff --git a/src/sub-modules/providers/aws/llms/bedrock.provider.ts b/src/sub-modules/providers/aws/llms/bedrock.provider.ts index 2ab9f88..458b701 100644 --- a/src/sub-modules/providers/aws/llms/bedrock.provider.ts +++ b/src/sub-modules/providers/aws/llms/bedrock.provider.ts @@ -1,15 +1,15 @@ -import {ChatBedrockConverse, ChatBedrockConverseInput} from '@langchain/aws'; +import {createAmazonBedrock} from '@ai-sdk/amazon-bedrock'; import {Provider, ValueOrPromise} from '@loopback/core'; import {LLMProvider} from '../../../../types'; import {sanitizeFilenameForAwsConverse} from '../utils'; import {BedrockInstanceConfig} from '../types'; export class Bedrock implements Provider { - static createInstance(config: BedrockInstanceConfig): ChatBedrockConverse { - const client = new ChatBedrockConverse(config); - (client as unknown as LLMProvider).getFile = ( - file: Express.Multer.File, - ) => { + static createInstance(config: BedrockInstanceConfig): LLMProvider { + const provider = createAmazonBedrock(config.providerSettings); + const client = provider(config.model) as unknown as LLMProvider; + + client.getFile = (file: Express.Multer.File) => { return { type: 'document', document: { @@ -21,41 +21,51 @@ export class Bedrock implements Provider { }, }; }; + return client; } + value(): ValueOrPromise { return this._createdInstance(true); } - protected _createdInstance(thinking: boolean) { - if (!process.env.BEDROCK_MODEL) { + protected _createdInstance(thinking: boolean): LLMProvider { + const configuredModel = + !thinking && process.env.BEDROCK_NON_THINKING_MODEL + ? process.env.BEDROCK_NON_THINKING_MODEL + : process.env.BEDROCK_MODEL; + + if (!configuredModel) { throw new Error( 'Bedrock model is not specified. Please set the BEDROCK_MODEL environment variable.', ); } - const config: ChatBedrockConverseInput = { - model: process.env.BEDROCK_MODEL!, + + if (!process.env.BEDROCK_AWS_REGION) { + throw new Error( + 'BEDROCK_AWS_REGION environment variable is not set for Bedrock provider.', + ); + } + + const providerSettings: NonNullable< + BedrockInstanceConfig['providerSettings'] + > = { region: process.env.BEDROCK_AWS_REGION, - credentials: { - accessKeyId: process.env.BEDROCK_AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.BEDROCK_AWS_SECRET_ACCESS_KEY!, - }, }; - if (process.env.CLAUDE_THINKING && thinking) { - config.additionalModelRequestFields = { - // eslint-disable-next-line @typescript-eslint/naming-convention - reasoning_config: { - type: 'enabled', - // eslint-disable-next-line @typescript-eslint/naming-convention - budget_tokens: parseInt(process.env.CLAUDE_THINKING_BUDGET ?? '1024'), - }, - }; - } else { - config.temperature = parseInt(process.env.BEDROCK_TEMPERATURE ?? '0'); + + const accessKeyId = process.env.BEDROCK_AWS_ACCESS_KEY_ID; + const secretAccessKey = process.env.BEDROCK_AWS_SECRET_ACCESS_KEY; + if (accessKeyId && secretAccessKey) { + providerSettings.accessKeyId = accessKeyId; + providerSettings.secretAccessKey = secretAccessKey; + if (process.env.BEDROCK_AWS_SESSION_TOKEN) { + providerSettings.sessionToken = process.env.BEDROCK_AWS_SESSION_TOKEN; + } } + return Bedrock.createInstance({ - model: process.env.BEDROCK_MODEL!, - ...config, + model: configuredModel, + providerSettings, }); } } diff --git a/src/sub-modules/providers/aws/types.ts b/src/sub-modules/providers/aws/types.ts index 34bb4f0..5e00bab 100644 --- a/src/sub-modules/providers/aws/types.ts +++ b/src/sub-modules/providers/aws/types.ts @@ -1,6 +1,6 @@ -import {ChatBedrockConverseInput} from '@langchain/aws'; +import {AmazonBedrockProviderSettings} from '@ai-sdk/amazon-bedrock'; export type BedrockInstanceConfig = { model: string; - config?: Partial; + providerSettings?: AmazonBedrockProviderSettings; }; diff --git a/src/sub-modules/providers/cerebras/llm/cerebras.provider.ts b/src/sub-modules/providers/cerebras/llm/cerebras.provider.ts index 7f14a77..819630a 100644 --- a/src/sub-modules/providers/cerebras/llm/cerebras.provider.ts +++ b/src/sub-modules/providers/cerebras/llm/cerebras.provider.ts @@ -1,4 +1,4 @@ -import {ChatCerebras, ChatCerebrasInput} from '@langchain/cerebras'; +import {createCerebras} from '@ai-sdk/cerebras'; import {Provider} from '@loopback/core'; import {LLMProvider} from '../../../../types'; @@ -9,17 +9,12 @@ export class Cerebras implements Provider { 'CEREBRAS_MODEL and CEREBRAS_KEY environment variable is not set.', ); } - const config: ChatCerebrasInput = { - temperature: parseFloat(process.env.CEREBRAS_TEMPERATURE ?? '0'), - model: process.env.CEREBRAS_MODEL, - apiKey: process.env.CEREBRAS_KEY, // Default value. - }; - if (process.env.CEREBRAS_TOP_P) { - config.topP = parseFloat(process.env.CEREBRAS_TOP_P); - } - if (process.env.CEREBRAS_MAX_TOKENS) { - config.maxCompletionTokens = parseInt(process.env.CEREBRAS_MAX_TOKENS); - } - return new ChatCerebras(config); + + const provider = createCerebras({ + apiKey: process.env.CEREBRAS_KEY, + baseURL: process.env.CEREBRAS_BASE_URL, + }); + + return provider(process.env.CEREBRAS_MODEL) as unknown as LLMProvider; } } diff --git a/src/sub-modules/providers/google/embedding/gemini-embedding.provider.ts b/src/sub-modules/providers/google/embedding/gemini-embedding.provider.ts index dc0bae1..4570c4d 100644 --- a/src/sub-modules/providers/google/embedding/gemini-embedding.provider.ts +++ b/src/sub-modules/providers/google/embedding/gemini-embedding.provider.ts @@ -1,8 +1,10 @@ -import {TaskType} from '@google/generative-ai'; -import {GoogleGenerativeAIEmbeddings} from '@langchain/google-genai'; +import {embed, embedMany} from 'ai'; +import {createGoogleGenerativeAI} from '@ai-sdk/google'; import {Provider} from '@loopback/core'; import {EmbeddingProvider} from '../../../../types'; +type AiEmbeddingModel = Parameters[0]['model']; + export class GeminiEmbedding implements Provider { value() { if (!process.env.GOOGLE_EMBEDDING_MODEL || !process.env.GOOGLE_API_KEY) { @@ -11,10 +13,45 @@ export class GeminiEmbedding implements Provider { ); } - return new GoogleGenerativeAIEmbeddings({ - model: process.env.GOOGLE_EMBEDDING_MODEL!, - taskType: TaskType.RETRIEVAL_DOCUMENT, - title: process.env.GOOGLE_EMBEDDING_TITLE ?? 'AI Integration Embedding', + const provider = createGoogleGenerativeAI({ + apiKey: process.env.GOOGLE_API_KEY, }); + + const model = provider.embeddingModel( + process.env.GOOGLE_EMBEDDING_MODEL, + ) as unknown as AiEmbeddingModel; + + return { + embedDocuments: async (texts: string[]) => { + if (texts.length === 0) { + return []; + } + + const result = await embedMany({ + model, + values: texts, + providerOptions: { + google: { + taskType: 'RETRIEVAL_DOCUMENT', + }, + }, + }); + + return result.embeddings.map(embedding => Array.from(embedding)); + }, + embedQuery: async (text: string) => { + const result = await embed({ + model, + value: text, + providerOptions: { + google: { + taskType: 'RETRIEVAL_QUERY', + }, + }, + }); + + return Array.from(result.embedding); + }, + } as EmbeddingProvider; } } diff --git a/src/sub-modules/providers/google/llms/gemini.provider.ts b/src/sub-modules/providers/google/llms/gemini.provider.ts index ea1c58b..ebac561 100644 --- a/src/sub-modules/providers/google/llms/gemini.provider.ts +++ b/src/sub-modules/providers/google/llms/gemini.provider.ts @@ -1,17 +1,21 @@ import {Provider} from '@loopback/core'; -import {LLMProvider} from '../../../../types'; -import {ChatGoogleGenerativeAI} from '@langchain/google-genai'; +import {createGoogleGenerativeAI} from '@ai-sdk/google'; +import type {MastraLanguageModel} from '@mastra/core/agent'; -export class Gemini implements Provider { - value() { +export class Gemini implements Provider { + value(): MastraLanguageModel { if (!process.env.GOOGLE_CHAT_MODEL || !process.env.GOOGLE_API_KEY) { throw new Error( 'Google chat model is not specified. Please set the GOOGLE_CHAT_MODEL and GOOGLE_API_KEY environment variables.', ); } - return new ChatGoogleGenerativeAI({ - model: process.env.GOOGLE_CHAT_MODEL!, + const provider = createGoogleGenerativeAI({ + apiKey: process.env.GOOGLE_API_KEY, }); + + return provider( + process.env.GOOGLE_CHAT_MODEL, + ) as unknown as MastraLanguageModel; } } diff --git a/src/sub-modules/providers/groq/llms/groq.provider.ts b/src/sub-modules/providers/groq/llms/groq.provider.ts index 60e8c5a..c707c2e 100644 --- a/src/sub-modules/providers/groq/llms/groq.provider.ts +++ b/src/sub-modules/providers/groq/llms/groq.provider.ts @@ -1,5 +1,5 @@ import {Provider} from '@loopback/core'; -import {ChatGroq} from '@langchain/groq'; +import {createGroq} from '@ai-sdk/groq'; import {LLMProvider} from '../../../../types'; export class Groq implements Provider { @@ -9,10 +9,12 @@ export class Groq implements Provider { 'GROQ_MODEL and GROQ_API_KEY environment variable is not set.', ); } - return new ChatGroq({ - model: 'llama-3.3-70b-versatile', - temperature: 0, - maxTokens: undefined, + + const provider = createGroq({ + apiKey: process.env.GROQ_API_KEY, + baseURL: process.env.GROQ_BASE_URL, }); + + return provider(process.env.GROQ_MODEL) as unknown as LLMProvider; } } diff --git a/src/sub-modules/providers/ollama/embedding/ollama-embedding.provider.ts b/src/sub-modules/providers/ollama/embedding/ollama-embedding.provider.ts index 08de3b6..13bc352 100644 --- a/src/sub-modules/providers/ollama/embedding/ollama-embedding.provider.ts +++ b/src/sub-modules/providers/ollama/embedding/ollama-embedding.provider.ts @@ -1,15 +1,45 @@ -import {OllamaEmbeddings} from '@langchain/ollama'; +import {embed, embedMany} from 'ai'; +import {createOllama} from 'ollama-ai-provider'; import {Provider, ValueOrPromise} from '@loopback/core'; import {EmbeddingProvider} from '../../../../types'; +type AiEmbeddingModel = Parameters[0]['model']; + export class OllamaEmbedding implements Provider { value(): ValueOrPromise { if (!process.env.OLLAMA_EMBEDDING_MODEL) { throw new Error('OLLAMA_EMBEDDING_MODEL environment variable is not set'); } - return new OllamaEmbeddings({ - model: process.env.OLLAMA_EMBEDDING_MODEL!, - baseUrl: process.env.OLLAMA_URL ?? 'http://localhost:11434', + + const provider = createOllama({ + baseURL: + process.env.OLLAMA_BASE_URL ?? + process.env.OLLAMA_URL ?? + 'http://localhost:11434', }); + + const model = provider.embedding( + process.env.OLLAMA_EMBEDDING_MODEL, + ) as unknown as AiEmbeddingModel; + + return { + embedDocuments: async (texts: string[]) => { + if (texts.length === 0) { + return []; + } + const result = await embedMany({ + model, + values: texts, + }); + return result.embeddings.map(embedding => Array.from(embedding)); + }, + embedQuery: async (text: string) => { + const result = await embed({ + model, + value: text, + }); + return Array.from(result.embedding); + }, + } as EmbeddingProvider; } } diff --git a/src/sub-modules/providers/ollama/llms/ollama.provider.ts b/src/sub-modules/providers/ollama/llms/ollama.provider.ts index 0fd79d4..29b3e92 100644 --- a/src/sub-modules/providers/ollama/llms/ollama.provider.ts +++ b/src/sub-modules/providers/ollama/llms/ollama.provider.ts @@ -1,16 +1,21 @@ -import {ChatOllama} from '@langchain/ollama'; +import {createOllama} from 'ollama-ai-provider'; import {Provider, ValueOrPromise} from '@loopback/core'; +import {LLMProvider} from '../../../../types'; -export class Ollama implements Provider { - value(): ValueOrPromise { - if (!process.env.OLLAMA_MODEL || !process.env.OLLAMA_BASE_URL) { +export class Ollama implements Provider { + value(): ValueOrPromise { + const baseURL = process.env.OLLAMA_BASE_URL ?? process.env.OLLAMA_URL; + + if (!process.env.OLLAMA_MODEL || !baseURL) { throw new Error( - 'OLLAMA_MODEL and OLLAMA_BASE_URL environment variables must be set', + 'OLLAMA_MODEL and OLLAMA_BASE_URL (or OLLAMA_URL) environment variables must be set', ); } - return new ChatOllama({ - model: process.env.OLLAMA_MODEL, - baseUrl: process.env.OLLAMA_BASE_URL, + + const provider = createOllama({ + baseURL, }); + + return provider(process.env.OLLAMA_MODEL) as unknown as LLMProvider; } } diff --git a/src/sub-modules/providers/openai/llms/openai.provider.ts b/src/sub-modules/providers/openai/llms/openai.provider.ts index 5bd2217..dd2a978 100644 --- a/src/sub-modules/providers/openai/llms/openai.provider.ts +++ b/src/sub-modules/providers/openai/llms/openai.provider.ts @@ -1,24 +1,25 @@ import {Provider} from '@loopback/core'; -import {LLMProvider} from '../../../../types'; -import {ChatOpenAI} from '@langchain/openai'; +import {createOpenAI} from '@ai-sdk/openai'; +import type {MastraLanguageModel} from '@mastra/core/agent'; import {OpenAIInstanceConfig} from '../types'; -export class OpenAI implements Provider { - static createInstance(config: OpenAIInstanceConfig): ChatOpenAI { - return new ChatOpenAI({ - model: config.model, - ...config.config, +export class OpenAI implements Provider { + static createInstance(config: OpenAIInstanceConfig): MastraLanguageModel { + const provider = createOpenAI({ + apiKey: config.apiKey, + baseURL: config.baseURL, + organization: config.organization, + project: config.project, }); + + return provider(config.model) as unknown as MastraLanguageModel; } - value(): LLMProvider { + + value(): MastraLanguageModel { return OpenAI.createInstance({ - model: process.env.OPENAI_MODEL!, - config: { - temperature: Number.parseFloat(process.env.OPENAI_TEMPERATURE ?? '0'), - configuration: { - baseURL: process.env.OPENAI_API_BASE_URL, - }, - }, + model: process.env.OPENAI_MODEL ?? 'gpt-4o-mini', + apiKey: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_API_BASE_URL, }); } } diff --git a/src/sub-modules/providers/openai/types.ts b/src/sub-modules/providers/openai/types.ts index a7f0b57..317d9ae 100644 --- a/src/sub-modules/providers/openai/types.ts +++ b/src/sub-modules/providers/openai/types.ts @@ -1,6 +1,8 @@ -import {ChatOpenAIFields} from '@langchain/openai'; - export type OpenAIInstanceConfig = { model: string; - config: ChatOpenAIFields; + apiKey?: string; + baseURL?: string; + organization?: string; + project?: string; + settings?: Record; }; diff --git a/src/sub-modules/providers/openrouter/llms/openrouter.provider.ts b/src/sub-modules/providers/openrouter/llms/openrouter.provider.ts index ddb8812..abd3490 100644 --- a/src/sub-modules/providers/openrouter/llms/openrouter.provider.ts +++ b/src/sub-modules/providers/openrouter/llms/openrouter.provider.ts @@ -1,30 +1,34 @@ import {Provider} from '@loopback/core'; -import {ChatOpenRouter} from '@langchain/openrouter'; +import {createOpenRouter} from '@openrouter/ai-sdk-provider'; import {LLMProvider} from '../../../../types'; import {OpenRouterInstanceConfig} from '../types'; export class OpenRouter implements Provider { - static createInstance(config: OpenRouterInstanceConfig): ChatOpenRouter { - return new ChatOpenRouter({ - model: config.model, - ...config.config, - }); + static createInstance(config: OpenRouterInstanceConfig): LLMProvider { + const provider = createOpenRouter(config.providerSettings); + return provider(config.model, config.settings) as unknown as LLMProvider; } + value(): LLMProvider { if (!process.env.OPENROUTER_MODEL || !process.env.OPENROUTER_API_KEY) { throw new Error( 'OPENROUTER_MODEL and OPENROUTER_API_KEY environment variables must be set.', ); } + + const temperature = process.env.OPENROUTER_TEMPERATURE; + return OpenRouter.createInstance({ model: process.env.OPENROUTER_MODEL, - config: { + providerSettings: { apiKey: process.env.OPENROUTER_API_KEY, - temperature: Number.parseFloat( - process.env.OPENROUTER_TEMPERATURE ?? '0', - ), baseURL: process.env.OPENROUTER_BASE_URL, }, + settings: temperature + ? { + temperature: Number.parseFloat(temperature), + } + : undefined, }); } } diff --git a/src/sub-modules/providers/openrouter/types.ts b/src/sub-modules/providers/openrouter/types.ts index 6a83f1e..6555667 100644 --- a/src/sub-modules/providers/openrouter/types.ts +++ b/src/sub-modules/providers/openrouter/types.ts @@ -1,6 +1,10 @@ -import {ChatOpenRouterInput} from '@langchain/openrouter'; +import { + OpenRouterChatSettings, + OpenRouterProviderSettings, +} from '@openrouter/ai-sdk-provider'; export type OpenRouterInstanceConfig = { model: string; - config: Omit; + providerSettings?: OpenRouterProviderSettings; + settings?: OpenRouterChatSettings; }; diff --git a/src/types.ts b/src/types.ts index a64adb9..2dae36e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,9 @@ import {ChatAnthropic} from '@langchain/anthropic'; -import {BedrockEmbeddings, ChatBedrockConverse} from '@langchain/aws'; +import {ChatBedrockConverse} from '@langchain/aws'; import {ChatCerebras} from '@langchain/cerebras'; -import { - ChatGoogleGenerativeAI, - GoogleGenerativeAIEmbeddings, -} from '@langchain/google-genai'; -import {BaseCheckpointSaver} from '@langchain/langgraph'; -import {ChatOllama, OllamaEmbeddings} from '@langchain/ollama'; -import {ChatOpenAI, OpenAIEmbeddings} from '@langchain/openai'; +import {ChatGoogleGenerativeAI} from '@langchain/google-genai'; +import {ChatOllama} from '@langchain/ollama'; +import {ChatOpenAI} from '@langchain/openai'; import {createTool} from '@mastra/core/tools'; import {Provider} from '@loopback/core'; import {AnyObject} from '@loopback/repository'; @@ -34,6 +30,10 @@ export type AIIntegrationConfig = { bufferTokens?: number; period: number; // in seconds }; + aiSdkTelemetry?: { + enabled?: boolean; + metadata?: Record; + }; }; export type FileMessageBuilder = (file: Express.Multer.File) => AnyObject; @@ -52,13 +52,12 @@ export type LLMProvider = LLMProviderType & { getFile?: FileMessageBuilder; }; -export type EmbeddingProvider = - | OpenAIEmbeddings - | OllamaEmbeddings - | BedrockEmbeddings - | GoogleGenerativeAIEmbeddings; +export type EmbeddingProvider = { + embedDocuments(texts: string[]): Promise; + embedQuery(text: string): Promise; +}; -export type CheckpointerProvider = Provider; +export type CheckpointerProvider = Provider; export type ToolStore = { list: IGraphTool[]; @@ -78,7 +77,7 @@ export type MastraTool = ReturnType; export type MastraToolDefinition = { id: string; tool: MastraTool; - source: 'native' | 'legacy-compat'; + source: 'native'; formatResult: (result: JsonObject) => string; getMetadata: (result: JsonObject) => JsonObject; }; From 4f98658f3466866ed0038386021372b097f42a26 Mon Sep 17 00:00:00 2001 From: Piyush Singh Gaur Date: Fri, 22 May 2026 00:58:50 +0530 Subject: [PATCH 6/6] feat(mastra): migrate chat memory from chatstore to Mastra-memory --- package-lock.json | 649 +++++++++++++++-- package.json | 8 +- src/component.ts | 21 +- src/index.ts | 1 + src/keys.ts | 42 +- src/mastra/agents/chat-reasoning.agent.ts | 98 +-- src/mastra/bridge/workflow-request-context.ts | 14 +- src/mastra/bridge/workflow-runner.ts | 105 ++- src/mastra/types.ts | 12 +- .../workflows/chat/chat-workflow-schemas.ts | 28 +- src/mastra/workflows/chat/chat.workflow.ts | 4 +- .../chat/steps/agent-reasoning.step.ts | 34 +- .../workflows/chat/steps/end-session.step.ts | 28 +- .../chat/steps/file-processing.step.ts | 55 +- .../workflows/chat/steps/init-session.step.ts | 38 +- .../chat/steps/persist-conversation.step.ts | 74 +- .../chat/steps/prepare-context.step.ts | 64 +- src/observers/index.ts | 1 + src/observers/mastra-lifecycle.observer.ts | 31 + src/providers/index.ts | 1 + src/providers/mastra/index.ts | 2 + src/providers/mastra/mastra.provider.ts | 56 ++ src/providers/mastra/storage.provider.ts | 13 + src/scripts/backfill-mastra-memory.ts | 660 ++++++++++++++++++ 24 files changed, 1655 insertions(+), 384 deletions(-) create mode 100644 src/observers/index.ts create mode 100644 src/observers/mastra-lifecycle.observer.ts create mode 100644 src/providers/mastra/index.ts create mode 100644 src/providers/mastra/mastra.provider.ts create mode 100644 src/providers/mastra/storage.provider.ts create mode 100644 src/scripts/backfill-mastra-memory.ts diff --git a/package-lock.json b/package-lock.json index 483355c..e0f9d3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,8 @@ "@loopback/repository": "^8.0.11", "@loopback/sequelize": "^0.8.8", "@mastra/core": "^1.32.1", + "@mastra/libsql": "^1.11.1", + "@mastra/memory": "^1.19.0", "@openrouter/ai-sdk-provider": "^2.9.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/sdk-trace-node": "^2.7.1", @@ -534,9 +536,9 @@ } }, "node_modules/@ai-sdk/provider": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", - "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz", + "integrity": "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -564,12 +566,12 @@ }, "node_modules/@ai-sdk/provider-utils-v5": { "name": "@ai-sdk/provider-utils", - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.23.tgz", - "integrity": "sha512-60GYsRj5wIJQRcq5YwYJq4KhwLeStceXEJiZdecP1miiH+6FMmrnc7lZDOJoQ6m9lrudEb+uI4LEwddLz5+rPQ==", + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.25.tgz", + "integrity": "sha512-CvsRu+32Y8a167s+lrIBtsybvgTHp8j9y+6BeTvLeoW3Q+okw/b4CnNUFOLIXsRaKHQKAH+IHNJPYWywfpw0LA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, @@ -582,14 +584,14 @@ }, "node_modules/@ai-sdk/provider-utils-v6": { "name": "@ai-sdk/provider-utils", - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz", - "integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==", + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", - "eventsource-parser": "^3.0.6" + "eventsource-parser": "^3.0.8" }, "engines": { "node": ">=18" @@ -599,9 +601,9 @@ } }, "node_modules/@ai-sdk/provider-utils-v6/node_modules/@ai-sdk/provider": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", - "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -624,9 +626,9 @@ }, "node_modules/@ai-sdk/provider-v5": { "name": "@ai-sdk/provider", - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", - "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz", + "integrity": "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -637,9 +639,9 @@ }, "node_modules/@ai-sdk/provider-v6": { "name": "@ai-sdk/provider", - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", - "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -4463,6 +4465,180 @@ "@opentelemetry/api": "^1.9.0" } }, + "node_modules/@libsql/client": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.3.tgz", + "integrity": "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@libsql/core": "^0.17.3", + "@libsql/hrana-client": "^0.10.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.28", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.3.tgz", + "integrity": "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz", + "integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/isomorphic-fetch": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-fetch/-/isomorphic-fetch-0.3.1.tgz", + "integrity": "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@loopback/boot": { "version": "8.0.12", "resolved": "https://registry.npmjs.org/@loopback/boot/-/boot-8.0.12.tgz", @@ -4895,39 +5071,39 @@ } }, "node_modules/@mastra/core": { - "version": "1.32.1", - "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.32.1.tgz", - "integrity": "sha512-6ynJNZ9GMkLs11c9D4Ui9Z0eOP8GsAqPeMVhlnxExcdTls0ufFQSXFgwzBqtS97fot9IOA/fxDLvvW83fnsP0A==", + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/@mastra/core/-/core-1.36.0.tgz", + "integrity": "sha512-BEhDZPQeDcJ6jQRHtpfFLuoRiWAuv9dTCIjeWbXokzwDamI3D9jkyNzpBFJwFwy2S/a4jBTu4+d61nOaP7knTQ==", "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "~0.3.13", - "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.23", - "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.23", - "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.1", - "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.8", + "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.25", + "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.27", + "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.3", + "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.10", "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", "@isaacs/ttlcache": "^2.1.4", "@lukeed/uuid": "^2.0.1", - "@mastra/schema-compat": "1.2.9", + "@mastra/schema-compat": "1.2.10", "@modelcontextprotocol/sdk": "^1.29.0", "@sindresorhus/slugify": "^2.2.1", "@standard-schema/spec": "^1.1.0", "ajv": "^8.18.0", - "chat": "^4.24.0", + "chat": "^4.29.0", "croner": "^10.0.1", "dotenv": "^17.3.1", "execa": "^9.6.1", + "fastq": "^1.19.1", "gray-matter": "^4.0.3", "hono": "^4.12.8", "hono-openapi": "^1.3.0", "ignore": "^7.0.5", - "js-tiktoken": "^1.0.21", "json-schema": "^0.4.0", "lru-cache": "^11.2.7", "p-map": "^7.0.4", "p-retry": "^7.1.1", "picomatch": "^4.0.3", - "radash": "^12.1.1", + "posthog-node": "^5.30.6", "tokenx": "^1.3.0", "ws": "^8.20.0", "xxhash-wasm": "^1.1.0" @@ -5111,10 +5287,109 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@mastra/libsql": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@mastra/libsql/-/libsql-1.11.1.tgz", + "integrity": "sha512-38XP9dgF0zNXsRiybNkZhMpGHyyWSkFna6TjhMOsSPkLJsImhHw/NZTitDOoVEfJO80jwE2oCNrcxKzu7jqKNw==", + "license": "Apache-2.0", + "dependencies": { + "@libsql/client": "^0.15.15" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@mastra/core": ">=1.34.0-0 <2.0.0-0" + } + }, + "node_modules/@mastra/libsql/node_modules/@libsql/client": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz", + "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.15.14", + "@libsql/hrana-client": "^0.7.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.22", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@mastra/libsql/node_modules/@libsql/core": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.15.15.tgz", + "integrity": "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@mastra/libsql/node_modules/@libsql/hrana-client": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.7.0.tgz", + "integrity": "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-fetch": "^0.3.1", + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5", + "node-fetch": "^3.3.2" + } + }, + "node_modules/@mastra/libsql/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@mastra/memory": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@mastra/memory/-/memory-1.19.0.tgz", + "integrity": "sha512-vmW1zTX9/mnjTj/FeO3qaE1pP1y4dvaLzJwzIsm7zyZNMk9N/hsvk6dNA9WdvujkZfLDnrTBRafD2jNd12nt1Q==", + "license": "Apache-2.0", + "dependencies": { + "@mastra/schema-compat": "1.2.10", + "async-mutex": "^0.5.0", + "image-size": "^2.0.2", + "json-schema": "^0.4.0", + "lru-cache": "^11.2.7", + "probe-image-size": "^7.2.3", + "tokenx": "^1.3.0", + "xxhash-wasm": "^1.1.0" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@mastra/core": ">=1.4.1-0 <2.0.0-0", + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@mastra/memory/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@mastra/schema-compat": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@mastra/schema-compat/-/schema-compat-1.2.9.tgz", - "integrity": "sha512-1/RgazXqi1Wdyx8aR81CVS+sRyzlTGUL1YhhHkSULoEY8aXs58bvWkH/6iixlYsY0xGvn+0OPLCeSRkBCtDx4Q==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@mastra/schema-compat/-/schema-compat-1.2.10.tgz", + "integrity": "sha512-8Fg8PeO7GsRPOrEZAzc5udZgsF9ZDxih5JSoxjgnR79d0ImjKffhcoysPW6wIYXPEZ5i6/QDNR7rCazZZSD5Tg==", "license": "Apache-2.0", "dependencies": { "json-schema-to-zod": "^2.7.0", @@ -5412,6 +5687,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -5999,6 +6280,21 @@ "node": ">=12" } }, + "node_modules/@posthog/core": { + "version": "1.29.8", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.8.tgz", + "integrity": "sha512-wdX4/WzZ+sV92z4ppC9SjOWdztY/0bN74SbJFy1X8/1N8+aNTSHsGEKHtbHitkIkJc861oYWr4ZzOoV0iVDP4w==", + "license": "MIT", + "dependencies": { + "@posthog/types": "1.375.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.375.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.375.0.tgz", + "integrity": "sha512-ykjHtJv1eUnEUQIuCavMi/+lnBhZPRVnFDrbG6m4fS+vZ3ajn8dGooPpbWjF33Uo4g7W4ew51dBtJGf2evvurA==", + "license": "MIT" + }, "node_modules/@postman/form-data": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz", @@ -8258,6 +8554,15 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -8922,6 +9227,15 @@ "node": ">= 0.4" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -9786,9 +10100,9 @@ } }, "node_modules/chat": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/chat/-/chat-4.28.1.tgz", - "integrity": "sha512-oKBeLQ746rSmHWGoXmPgDOqMVdIe9cWFQBQ1G2pw0l2vV4sAsZgfEJmc1UYqSJR4kYy4PxKgRFy31pe4RJ644Q==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/chat/-/chat-4.29.0.tgz", + "integrity": "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw==", "license": "MIT", "dependencies": { "@workflow/serde": "4.1.0-beta.2", @@ -9798,6 +10112,21 @@ "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "ai": "^6.0.182", + "zod": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + }, + "zod": { + "optional": true + } } }, "node_modules/chokidar": { @@ -10950,6 +11279,15 @@ "node": ">=0.10" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -12753,7 +13091,6 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -12765,6 +13102,38 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fetch-blob/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -13147,6 +13516,18 @@ "node": ">= 12.20" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -14495,6 +14876,18 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -15499,6 +15892,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -15907,6 +16306,47 @@ "node": ">= 0.8.0" } }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, + "node_modules/libsql/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -16112,7 +16552,6 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, "license": "MIT" }, "node_modules/lodash.mergewith": { @@ -19139,6 +19578,44 @@ "dev": true, "license": "MIT" }, + "node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -23226,6 +23703,26 @@ "node": ">=0.10.0" } }, + "node_modules/posthog-node": { + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.35.0.tgz", + "integrity": "sha512-5Hos1mlwrZtzZbh1Pij1FyU9p4R3bajVtAKjPZ3vxhAScsGeyLsF5KqMaEAw3EYWmsX9SQ5CbYZtSlHf+nkw6g==", + "license": "MIT", + "dependencies": { + "@posthog/core": "1.29.8" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/postman-request": { "version": "2.88.1-postman.48", "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.48.tgz", @@ -23353,6 +23850,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/probe-image-size": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.3.0.tgz", + "integrity": "sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -23402,6 +23920,12 @@ "license": "ISC", "optional": true }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -23560,15 +24084,6 @@ ], "license": "MIT" }, - "node_modules/radash": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/radash/-/radash-12.1.1.tgz", - "integrity": "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==", - "license": "MIT", - "engines": { - "node": ">=14.18.0" - } - }, "node_modules/rambda": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/rambda/-/rambda-7.5.0.tgz", @@ -24238,7 +24753,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -24324,7 +24838,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -24453,6 +24967,15 @@ "node": ">=6" } }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -25842,6 +26365,30 @@ "license": "MIT", "peer": true }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "license": "MIT", + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", diff --git a/package.json b/package.json index 6fd8642..97c3783 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,9 @@ "migrate:pg:up": "db-migrate up --config migrations/pg/database.json --migrations-dir migrations/pg/migrations", "migrate:pg:down": "db-migrate down --config migrations/pg/database.json --migrations-dir migrations/pg/migrations", "prepare": "test \"$HUSKY\" = \"0\" || husky install", - "build:diagram": "ts-node scripts/visualize-graph" + "build:diagram": "ts-node scripts/visualize-graph", + "backfill:mastra-memory": "node dist/scripts/backfill-mastra-memory.js", + "backfill:mastra-memory:dry-run": "npm run backfill:mastra-memory -- --dry-run" }, "repository": { "type": "git", @@ -149,6 +151,8 @@ "@loopback/repository": "^8.0.11", "@loopback/sequelize": "^0.8.8", "@mastra/core": "^1.32.1", + "@mastra/libsql": "^1.11.1", + "@mastra/memory": "^1.19.0", "@openrouter/ai-sdk-provider": "^2.9.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/sdk-trace-node": "^2.7.1", @@ -292,4 +296,4 @@ ], "repositoryUrl": "https://github.com/sourcefuse/loopback4-llm-chat-extension.git" } -} +} \ No newline at end of file diff --git a/src/component.ts b/src/component.ts index 2e36a15..c80e0ff 100644 --- a/src/component.ts +++ b/src/component.ts @@ -2,10 +2,12 @@ import { Binding, BindingScope, Component, + Constructor, ControllerClass, CoreBindings, createBindingFromClass, inject, + LifeCycleObserver, ProviderMap, ServiceOrProviderClass, } from '@loopback/core'; @@ -35,7 +37,12 @@ import {ChatController, GenerationController} from './controllers'; import {ChatStore} from './graphs/chat'; import {WriterDB, AiIntegrationBindings, ReaderDB} from './keys'; import {Chat, Message} from './models'; -import {CacheModel, MastraToolsProvider} from './providers'; +import { + CacheModel, + DefaultMastraStorageProvider, + MastraProvider, + MastraToolsProvider, +} from './providers'; import {RedisCache, RedisCacheRepository} from './providers/cache/redis'; import {ChatRepository, MessageRepository} from './repositories'; import { @@ -49,6 +56,7 @@ import {SSETransport} from './transports'; import {AIIntegrationConfig} from './types'; import {PgVectorStore} from './sub-modules/db/postgresql'; import {WorkflowRunner} from './mastra/bridge/workflow-runner'; +import {MastraLifecycleObserver} from './observers'; const debug = require('debug')('ai-integration:log-events:component'); export class AiIntegrationsComponent implements Component { @@ -68,6 +76,14 @@ export class AiIntegrationsComponent implements Component { createBindingFromClass(RedisCache, { key: AiIntegrationBindings.Cache.key, }), + createBindingFromClass(DefaultMastraStorageProvider, { + key: AiIntegrationBindings.MastraStorage.key, + defaultScope: BindingScope.SINGLETON, + }), + createBindingFromClass(MastraProvider, { + key: AiIntegrationBindings.Mastra.key, + defaultScope: BindingScope.SINGLETON, + }), ]; this.providers = { @@ -85,6 +101,7 @@ export class AiIntegrationsComponent implements Component { ]; this.controllers = [GenerationController, ChatController]; + this.lifeCycleObservers = [MastraLifecycleObserver]; this.models = [Chat, Message, CacheModel]; this.repositories = [ ChatRepository, @@ -182,6 +199,8 @@ export class AiIntegrationsComponent implements Component { services: ServiceOrProviderClass[] | undefined; + lifeCycleObservers: Constructor[] | undefined; + /** * An optional list of Repository classes to bind for dependency injection * via `app.repository()` API. diff --git a/src/index.ts b/src/index.ts index 8d9e020..d46fae1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export * from './controllers'; export * from './decorators'; export * from './graphs'; export * from './keys'; +export * from './observers'; export * from './providers'; export * from './services'; export * from './transports'; diff --git a/src/keys.ts b/src/keys.ts index 480c4d6..3835497 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -1,6 +1,10 @@ import {VectorStore as VectorStoreType} from '@langchain/core/vectorstores'; import {BindingKey} from '@loopback/context'; import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {Mastra} from '@mastra/core/mastra'; +import type {MastraCompositeStore} from '@mastra/core/storage'; +import type {MastraEmbeddingModel, MastraVector} from '@mastra/core/vector'; +import type {WorkflowRunner} from './mastra/bridge/workflow-runner'; import {ITransport} from './transports/types'; import { AIIntegrationConfig, @@ -12,6 +16,12 @@ import { } from './types'; import {ILimitStrategy} from './services/limit-strategies/types'; +export interface IRunRegistry { + set(sessionId: string, runId: string): Promise; + get(sessionId: string): Promise; + delete(sessionId: string): Promise; +} + export namespace AiIntegrationBindings { export const Config = BindingKey.create( 'services.ai-reporting.config', @@ -34,9 +44,6 @@ export namespace AiIntegrationBindings { export const EmbeddingModel = BindingKey.create( 'services.ai-reporting.embeddingModel', ); - export const Checkpointer = BindingKey.create( - 'services.ai-reporting.checkpointer', - ); export const Tools = BindingKey.create( 'services.ai-reporting.tool-store', ); @@ -60,6 +67,35 @@ export namespace AiIntegrationBindings { `services.ai-reporting.system-context`, ); + // ── Mastra foundation bindings (Phase 1 migration) ────────────────────── + export const Mastra = BindingKey.create( + 'services.ai-reporting.mastra', + ); + + export const MastraStorage = BindingKey.create( + 'services.ai-reporting.mastraStorage', + ); + + export const MastraVectorStore = BindingKey.create( + 'services.ai-reporting.mastraVectorStore', + ); + + export const MastraEmbedder = BindingKey.create>( + 'services.ai-reporting.mastraEmbedder', + ); + + export const RunRegistry = BindingKey.create( + 'services.ai-reporting.runRegistry', + ); + + export const WorkflowRunner = BindingKey.create( + 'services.ai-reporting.workflowRunner', + ); + + export const ResourceId = BindingKey.create( + 'services.ai-reporting.resourceId', + ); + // ── Mastra LLM bindings (Phase 1 migration) ────────────────────────────── /** * Mastra-compatible chat LLM. diff --git a/src/mastra/agents/chat-reasoning.agent.ts b/src/mastra/agents/chat-reasoning.agent.ts index bea500f..55a3749 100644 --- a/src/mastra/agents/chat-reasoning.agent.ts +++ b/src/mastra/agents/chat-reasoning.agent.ts @@ -1,6 +1,7 @@ import {Agent} from '@mastra/core/agent'; import {RequestContext} from '@mastra/core/request-context'; import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {MastraMemory} from '@mastra/core/memory'; import type {MastraModelConfig} from '@mastra/core/llm'; import {LLMStreamEventType} from '../../graphs/event.types'; import type {AsyncEventQueue} from '../bridge/async-event-queue'; @@ -20,50 +21,59 @@ import {asWorkflowContext} from '../bridge/workflow-request-context'; * * RequestContext access uses `asWorkflowContext()` for fully typed, zero-any access. */ -export const chatReasoningAgent = new Agent({ - id: 'chat-reasoning-agent', - name: 'Chat Reasoning Agent', - instructions: async ({requestContext}: {requestContext: RequestContext}) => { - const ctx = asWorkflowContext(requestContext); - const systemCtx = ctx.get('systemContext'); - const additionalContext = systemCtx?.join('\n') ?? ''; - return [ - `You are a helpful AI assistant. You MUST always use one of the available tools to handle the user's request. Never respond with just text on the first message — always call the closest matching tool, even if you are unsure.`, - `Only use a single tool in a single message, but you can use multiple tools over subsequent messages if it could help with the user's requirements.`, - `If the user provides feedback, you can use that feedback to improve the result.`, - `Do not write any redundant messages before or after tool calls, be as concise as possible.`, - `Do not hallucinate details or make up information.`, - `Do not make assumptions about user's intent beyond what is explicitly provided in the prompt.`, - `Current date is ${new Date().toDateString()}`, - additionalContext, - ] - .filter(Boolean) - .join('\n'); - }, - model: ({ - requestContext, - }: { - requestContext: RequestContext; - }): MastraModelConfig => { - const ctx = asWorkflowContext(requestContext); - const llm: MastraLanguageModel = ctx.get('mastraChatLlm'); - if (!llm) { - throw new Error( - 'MastraChatLLM not found in RequestContext. ' + - 'Bind AiIntegrationBindings.MastraChatLLM in your LoopBack application.', - ); - } - return llm; - }, - tools: async ({requestContext}: {requestContext: RequestContext}) => { - const ctx = asWorkflowContext(requestContext); - const mastraTools: MastraToolStore = ctx.get('mastraTools'); - if (!mastraTools?.list?.length) { - return {}; - } - return mastraTools.tools; - }, -}); +export function createChatReasoningAgent(memory?: MastraMemory) { + return new Agent({ + id: 'chat-reasoning-agent', + name: 'Chat Reasoning Agent', + instructions: async ({ + requestContext, + }: { + requestContext: RequestContext; + }) => { + const ctx = asWorkflowContext(requestContext); + const systemCtx = ctx.get('systemContext'); + const additionalContext = systemCtx?.join('\n') ?? ''; + return [ + `You are a helpful AI assistant. You MUST always use one of the available tools to handle the user's request. Never respond with just text on the first message — always call the closest matching tool, even if you are unsure.`, + `Only use a single tool in a single message, but you can use multiple tools over subsequent messages if it could help with the user's requirements.`, + `If the user provides feedback, you can use that feedback to improve the result.`, + `Do not write any redundant messages before or after tool calls, be as concise as possible.`, + `Do not hallucinate details or make up information.`, + `Do not make assumptions about user's intent beyond what is explicitly provided in the prompt.`, + `Current date is ${new Date().toDateString()}`, + additionalContext, + ] + .filter(Boolean) + .join('\n'); + }, + model: ({ + requestContext, + }: { + requestContext: RequestContext; + }): MastraModelConfig => { + const ctx = asWorkflowContext(requestContext); + const llm: MastraLanguageModel = ctx.get('mastraChatLlm'); + if (!llm) { + throw new Error( + 'MastraChatLLM not found in RequestContext. ' + + 'Bind AiIntegrationBindings.MastraChatLLM in your LoopBack application.', + ); + } + return llm; + }, + tools: async ({requestContext}: {requestContext: RequestContext}) => { + const ctx = asWorkflowContext(requestContext); + const mastraTools: MastraToolStore = ctx.get('mastraTools'); + if (!mastraTools?.list?.length) { + return {}; + } + return mastraTools.tools; + }, + memory, + }); +} + +export const chatReasoningAgent = createChatReasoningAgent(); /** * Emit a Tool event to the AsyncEventQueue. diff --git a/src/mastra/bridge/workflow-request-context.ts b/src/mastra/bridge/workflow-request-context.ts index 93c7e87..97c0d79 100644 --- a/src/mastra/bridge/workflow-request-context.ts +++ b/src/mastra/bridge/workflow-request-context.ts @@ -1,7 +1,7 @@ import type {RequestContext} from '@mastra/core/request-context'; -import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {Agent, MastraLanguageModel} from '@mastra/core/agent'; +import type {MastraMemory} from '@mastra/core/memory'; import type {IAuthUserWithPermissions} from '@sourceloop/core'; -import type {ChatStore} from '../../graphs/chat/chat.store'; import type {AIIntegrationConfig, MastraToolStore} from '../../types'; import type {AsyncEventQueue} from './async-event-queue'; import type {TokenUsageAccumulator} from './token-usage-accumulator'; @@ -20,8 +20,10 @@ export interface WorkflowRequestContext { mastraChatLlm: MastraLanguageModel; /** LLM used for file summarisation (falls back to mastraChatLlm if not set) */ mastraFileLlm: MastraLanguageModel; - /** Chat session store — request-scoped */ - chatStore: ChatStore; + /** Shared Mastra memory instance used for thread persistence and recall */ + mastraMemory: MastraMemory; + /** Request-scoped chat agent instance resolved from Mastra singleton */ + chatReasoningAgent: Agent; /** Mastra-native tool registry for the chat Agent */ mastraTools: MastraToolStore; /** AI integration config (optional — may be undefined if not bound) */ @@ -46,6 +48,8 @@ export interface WorkflowRequestContext { workflowId: string; /** Optional chat session id associated with this workflow invocation */ chatSessionId: string | undefined; + /** Resource identifier used for memory isolation */ + resourceId: string; /** AI SDK telemetry toggle for request-scoped model calls */ aiSdkTelemetryEnabled: boolean; /** Additional AI SDK telemetry metadata propagated to model calls */ @@ -57,7 +61,7 @@ export interface WorkflowRequestContext { * * Usage: * const ctx = asWorkflowContext(requestContext); - * const chatStore = ctx.get('chatStore'); // typed as ChatStore + * const memory = ctx.get('mastraMemory'); // typed as MastraMemory */ export function asWorkflowContext( requestContext: RequestContext, diff --git a/src/mastra/bridge/workflow-runner.ts b/src/mastra/bridge/workflow-runner.ts index 12d7ced..e4d224d 100644 --- a/src/mastra/bridge/workflow-runner.ts +++ b/src/mastra/bridge/workflow-runner.ts @@ -6,17 +6,15 @@ import { injectable, service, } from '@loopback/core'; -import {repository} from '@loopback/repository'; import {IAuthUserWithPermissions} from '@sourceloop/core'; import {AuthenticationBindings} from 'loopback4-authentication'; import {randomUUID} from 'crypto'; import {SpanStatusCode, trace} from '@opentelemetry/api'; import {RequestContext} from '@mastra/core/request-context'; import {BaseRetriever} from '@langchain/core/retrievers'; -import {ChatStore} from '../../graphs/chat/chat.store'; +import {Mastra} from '@mastra/core/mastra'; import {LLMStreamEvent, LLMStreamEventType} from '../../graphs/event.types'; import {AiIntegrationBindings} from '../../keys'; -import {ChatRepository} from '../../repositories'; import {AIIntegrationConfig, MastraToolStore} from '../../types'; import {chatWorkflow} from '../workflows/chat/chat.workflow'; import {dbQueryWorkflow} from '../workflows/db-query/db-query.workflow'; @@ -69,7 +67,7 @@ function isLLMStreamEvent(value: unknown): value is LLMStreamEvent { * WorkflowRunner — the LoopBack 4 ↔ Mastra bridge. * * Responsibilities: - * 1. Resolve all REQUEST-scoped LoopBack services (ChatStore, LLMs, etc.) + * 1. Resolve request-scoped LoopBack services and identity context * 2. Build a typed RequestContext and inject it into the Mastra ChatWorkflow * 3. Stream the workflow via run.stream() and concurrently drain the AsyncEventQueue * 4. Yield LLMStreamEvents to the caller (GenerationService forwards to ITransport) @@ -87,8 +85,8 @@ export class WorkflowRunner { constructor( @inject.context() private readonly lbContext: Context, - @service(ChatStore) - private readonly chatStore: ChatStore, + @inject(AiIntegrationBindings.Mastra) + private readonly mastra: Mastra, @inject(AiIntegrationBindings.MastraChatLLM) private readonly mastraChatLlm: MastraLanguageModel, @inject(AiIntegrationBindings.MastraFileLLM, {optional: true}) @@ -101,8 +99,8 @@ export class WorkflowRunner { private readonly systemContext: string[] | undefined, @inject.getter(AuthenticationBindings.CURRENT_USER) private readonly getCurrentUser: Getter, - @repository(ChatRepository) - private readonly chatRepository: ChatRepository, + @inject(AiIntegrationBindings.ResourceId, {optional: true}) + private readonly resourceIdValue: string | undefined, // ── DBQuery bindings (optional — only present when DB Query component is loaded) @inject(AiIntegrationBindings.MastraCheapLLM, {optional: true}) private readonly mastraCheapLlm: MastraLanguageModel | undefined, @@ -159,6 +157,63 @@ export class WorkflowRunner { const eventQueue = new AsyncEventQueue(); const tokenAccumulator = new TokenUsageAccumulator(); const currentUser = await this.resolveOptionalCurrentUser(); + const chatAgent = this.mastra.getAgent('chatAgent'); + + if (!chatAgent) { + throw new Error( + 'Mastra chat agent is not configured. Ensure MastraProvider registers chatAgent.', + ); + } + + const memory = await chatAgent.getMemory(); + if (!memory) { + throw new Error('Mastra Memory is required but not configured.'); + } + + let resolvedSessionId = sessionId; + let resourceId = this.resolveResourceId(currentUser, sessionId); + let isNewSession = false; + + if (resolvedSessionId) { + const existingThread = await memory.getThreadById({ + threadId: resolvedSessionId, + }); + + if (!existingThread) { + throw new Error(`Chat session ${resolvedSessionId} was not found.`); + } + + resourceId = resourceId ?? existingThread.resourceId; + } else { + isNewSession = true; + + if (!resourceId) { + resolvedSessionId = randomUUID(); + resourceId = resolvedSessionId; + + await memory.createThread({ + threadId: resolvedSessionId, + resourceId, + title: prompt.slice(0, 80), + }); + } else { + const thread = await memory.createThread({ + resourceId, + title: prompt.slice(0, 80), + }); + resolvedSessionId = thread.id; + } + } + + if (!resolvedSessionId) { + throw new Error( + 'Failed to resolve chat session id for workflow execution.', + ); + } + + if (!resourceId) { + resourceId = resolvedSessionId; + } const requestContext = new RequestContext(); @@ -169,7 +224,7 @@ export class WorkflowRunner { 'mastraFileLlm', this.mastraFileLlm ?? this.mastraChatLlm, ); - requestContext.set('chatStore', this.chatStore); + requestContext.set('mastraMemory', memory); requestContext.set('mastraTools', this.mastraTools); requestContext.set('aiConfig', this.aiConfig ?? {}); requestContext.set('systemContext', this.systemContext); @@ -177,7 +232,9 @@ export class WorkflowRunner { requestContext.set('currentUser', currentUser); requestContext.set('correlationId', correlationId); requestContext.set('workflowId', 'chat-workflow'); - requestContext.set('chatSessionId', sessionId); + requestContext.set('chatSessionId', resolvedSessionId); + requestContext.set('resourceId', resourceId); + requestContext.set('chatReasoningAgent', chatAgent); requestContext.set('aiSdkTelemetryEnabled', telemetryEnabled); requestContext.set('aiSdkTelemetryMetadata', telemetryMetadata); requestContext.set('visualizerStore', await this.resolveVisualizerStore()); @@ -197,7 +254,7 @@ export class WorkflowRunner { attributes: { 'workflow.id': 'chat-workflow', 'workflow.correlation_id': correlationId, - 'chat.session_id': sessionId ?? '', + 'chat.session_id': resolvedSessionId, 'chat.files_count': files.length, }, }); @@ -209,7 +266,12 @@ export class WorkflowRunner { // The iterator yields WorkflowStreamEvent — steps emit via writer.write() which // surfaces as {type: 'workflow-step-output', payload: {output: }}. const workflowStream = run.stream({ - inputData: {prompt, files, sessionId}, + inputData: { + prompt, + files, + sessionId: resolvedSessionId, + isNewSession, + }, requestContext, }); @@ -333,6 +395,25 @@ export class WorkflowRunner { } } + private resolveResourceId( + currentUser: IAuthUserWithPermissions | undefined, + sessionId?: string, + ): string | undefined { + if (this.resourceIdValue?.trim()) { + return this.resourceIdValue; + } + + if (currentUser?.tenantId && currentUser?.userTenantId) { + return `${currentUser.tenantId}:${currentUser.userTenantId}`; + } + + if (currentUser?.userTenantId) { + return currentUser.userTenantId; + } + + return sessionId; + } + private async resolveVisualizerStore(): Promise { const bindings = this.lbContext.findByTag({ [VISUALIZATION_KEY]: true, diff --git a/src/mastra/types.ts b/src/mastra/types.ts index dcd65a8..19749aa 100644 --- a/src/mastra/types.ts +++ b/src/mastra/types.ts @@ -2,9 +2,9 @@ import {z} from 'zod'; import {LLMStreamEvent} from '../graphs/event.types'; import type {AsyncEventQueue} from './bridge/async-event-queue'; import type {TokenUsageAccumulator} from './bridge/token-usage-accumulator'; -import type {ChatStore} from '../graphs/chat/chat.store'; import type {AIIntegrationConfig, JsonObject, MastraToolStore} from '../types'; -import type {MastraLanguageModel} from '@mastra/core/agent'; +import type {Agent, MastraLanguageModel} from '@mastra/core/agent'; +import type {MastraMemory} from '@mastra/core/memory'; /** * Type-safe key map for the RequestContext used by the ChatWorkflow. @@ -19,8 +19,10 @@ export type ChatWorkflowRequestContext = { mastraChatLlm: MastraLanguageModel; /** Mastra-compatible LLM for file summarization */ mastraFileLlm: MastraLanguageModel; - /** Per-request chat data store */ - chatStore: ChatStore; + /** Shared Mastra memory instance */ + mastraMemory: MastraMemory; + /** Request-scoped chat agent loaded from Mastra singleton */ + chatReasoningAgent: Agent; /** Available Mastra-native tools for the agent */ mastraTools: MastraToolStore; /** AI integration configuration */ @@ -35,6 +37,8 @@ export type ChatWorkflowRequestContext = { workflowId: string; /** Optional chat session id associated with request */ chatSessionId: string | undefined; + /** Resource identifier used for memory scoping */ + resourceId: string; /** AI SDK telemetry toggle for model calls */ aiSdkTelemetryEnabled: boolean; /** Additional request-scoped telemetry metadata */ diff --git a/src/mastra/workflows/chat/chat-workflow-schemas.ts b/src/mastra/workflows/chat/chat-workflow-schemas.ts index 430d1b2..071a30a 100644 --- a/src/mastra/workflows/chat/chat-workflow-schemas.ts +++ b/src/mastra/workflows/chat/chat-workflow-schemas.ts @@ -38,6 +38,10 @@ export const ChatWorkflowInputSchema = z.object({ .string() .optional() .describe('Existing chat session ID for resuming a conversation'), + isNewSession: z + .boolean() + .optional() + .describe('Whether session was newly created by WorkflowRunner'), }); export type ChatWorkflowInput = z.infer; @@ -60,7 +64,6 @@ export type ChatWorkflowOutput = z.infer; export const InitSessionOutputSchema = z.object({ sessionId: z.string(), isNewSession: z.boolean(), - userMessageId: z.string().optional(), prompt: z.string(), files: z.array(z.object({}).passthrough()).default([]), }); @@ -71,17 +74,6 @@ export type InitSessionOutput = z.infer; */ export const PrepareContextOutputSchema = z.object({ sessionId: z.string(), - messages: z - .array( - z - .object({ - role: z.string(), - content: z.union([z.string(), z.array(z.object({}).passthrough())]), - }) - .passthrough(), - ) - .describe('Full conversation context (CoreMessage[])'), - userMessageId: z.string().optional(), prompt: z.string(), files: z.array(z.object({}).passthrough()).default([]), }); @@ -92,17 +84,6 @@ export type PrepareContextOutput = z.infer; */ export const FileProcessingOutputSchema = z.object({ sessionId: z.string(), - messages: z - .array( - z - .object({ - role: z.string(), - content: z.union([z.string(), z.array(z.object({}).passthrough())]), - }) - .passthrough(), - ) - .describe('Updated context after file processing'), - userMessageId: z.string().optional(), prompt: z.string(), }); export type FileProcessingOutput = z.infer; @@ -133,7 +114,6 @@ export const AgentReasoningOutputSchema = z.object({ }), ) .default({}), - userMessageId: z.string().optional(), }); export type AgentReasoningOutput = z.infer; diff --git a/src/mastra/workflows/chat/chat.workflow.ts b/src/mastra/workflows/chat/chat.workflow.ts index 7ac5941..6aa1d27 100644 --- a/src/mastra/workflows/chat/chat.workflow.ts +++ b/src/mastra/workflows/chat/chat.workflow.ts @@ -24,12 +24,14 @@ import {endSessionStep} from './steps/end-session.step'; * the event forwarding loop. * * RequestContext keys (injected by WorkflowRunner): - * - chatStore: ChatStore (REQUEST-scoped) + * - mastraMemory: Memory (shared singleton) + * - chatReasoningAgent: Agent (memory-enabled) * - eventQueue: AsyncEventQueue (per-request) * - tokenUsageAccumulator: TokenUsageAccumulator (per-request) * - mastraChatLlm: MastraLanguageModel (bound in LB4 DI) * - mastraFileLlm: MastraLanguageModel (optional, bound in LB4 DI) * - mastraTools: MastraToolStore (REQUEST-scoped via MastraToolsProvider) + * - resourceId: tenant/user scoped resource identity for memory isolation * - aiConfig: { maxTokens?, maxSteps?, modelName? } (optional, from LB4 config) * - systemContext: string[] (optional, from LB4 SystemContext binding) * - abortSignal: AbortSignal (from AbortController in GenerationService) diff --git a/src/mastra/workflows/chat/steps/agent-reasoning.step.ts b/src/mastra/workflows/chat/steps/agent-reasoning.step.ts index 7561e51..a961737 100644 --- a/src/mastra/workflows/chat/steps/agent-reasoning.step.ts +++ b/src/mastra/workflows/chat/steps/agent-reasoning.step.ts @@ -1,10 +1,7 @@ import {createStep} from '@mastra/core/workflows'; import {z} from 'zod'; import {LLMStreamEventType} from '../../../../graphs/event.types'; -import { - chatReasoningAgent, - emitToolStatusEvent, -} from '../../../agents/chat-reasoning.agent'; +import {emitToolStatusEvent} from '../../../agents/chat-reasoning.agent'; import {asWorkflowContext} from '../../../bridge/workflow-request-context'; import { FileProcessingOutputSchema, @@ -54,19 +51,19 @@ export const agentReasoningStep = createStep({ execute: async ({inputData, requestContext, writer}) => { const ctx = asWorkflowContext(requestContext); - const {sessionId, messages, userMessageId} = inputData; + const {sessionId, prompt} = inputData; const eventQueue = ctx.get('eventQueue'); const tokenAccumulator = ctx.get('tokenUsageAccumulator'); const mastraTools = ctx.get('mastraTools'); + const chatReasoningAgent = ctx.get('chatReasoningAgent'); + const resourceId = ctx.get('resourceId'); const abortSignal = ctx.get('abortSignal'); const aiConfig = ctx.get('aiConfig') as | {maxSteps?: number; modelName?: string} | undefined; - debug( - `AgentReasoning: streaming agent with ${messages.length} messages, session=${sessionId}`, - ); + debug(`AgentReasoning: streaming agent for session=${sessionId}`); const toolCallRecords: z.infer< typeof AgentReasoningOutputSchema @@ -74,14 +71,22 @@ export const agentReasoningStep = createStep({ let finalText = ''; - const agentOutput = await chatReasoningAgent.stream( - messages as Parameters[0], + const inputMessages: Parameters[0] = [ { - maxSteps: (aiConfig as {maxSteps?: number} | undefined)?.maxSteps ?? 20, - abortSignal, - requestContext: ctx, + role: 'user', + content: prompt, }, - ); + ]; + + const agentOutput = await chatReasoningAgent.stream(inputMessages, { + maxSteps: (aiConfig as {maxSteps?: number} | undefined)?.maxSteps ?? 20, + abortSignal, + requestContext: ctx, + memory: { + thread: sessionId, + resource: resourceId, + }, + }); // Consume the full stream using discriminated union narrowing. // AgentChunkType is: tool-call | tool-result | text-delta | step-finish | error | ... @@ -207,7 +212,6 @@ export const agentReasoningStep = createStep({ tokenMap: counts.map as z.infer< typeof AgentReasoningOutputSchema >['tokenMap'], - userMessageId, }; }, }); diff --git a/src/mastra/workflows/chat/steps/end-session.step.ts b/src/mastra/workflows/chat/steps/end-session.step.ts index 90cec1a..73f6b28 100644 --- a/src/mastra/workflows/chat/steps/end-session.step.ts +++ b/src/mastra/workflows/chat/steps/end-session.step.ts @@ -1,7 +1,6 @@ import {createStep} from '@mastra/core/workflows'; import {z} from 'zod'; import {LLMStreamEventType} from '../../../../graphs/event.types'; -import {asWorkflowContext} from '../../../bridge/workflow-request-context'; import { PersistConversationOutputSchema, ChatWorkflowOutputSchema, @@ -15,43 +14,24 @@ const debug = require('debug')('ai-integration:mastra:end-session.step'); * LangGraph equivalent: `EndSessionNode`. * * Responsibilities: - * - Update the session's cumulative token counts in the database * - Emit the TokenCount SSE event via writer.write() (workflow-native streaming) * * The AsyncEventQueue is NOT closed here — it is closed by AgentReasoningStep - * after agent.stream() completes. EndSession only handles DB updates and the + * after agent.stream() completes. EndSession only handles the * TokenCount event, which flows through the workflow stream (writer), not the queue. */ export const endSessionStep = createStep({ id: 'end-session', - description: - 'Update token counts in the DB; emit TokenCount event via writer', + description: 'Emit TokenCount event via writer', inputSchema: PersistConversationOutputSchema, outputSchema: ChatWorkflowOutputSchema, - execute: async ({inputData, requestContext, writer}) => { - const ctx = asWorkflowContext(requestContext); - const chatStore = ctx.get('chatStore'); - - const {sessionId, totalInputTokens, totalOutputTokens, tokenMap} = - inputData; + execute: async ({inputData, writer}) => { + const {sessionId, totalInputTokens, totalOutputTokens} = inputData; debug( `EndSession: session=${sessionId}, in=${totalInputTokens}, out=${totalOutputTokens}`, ); - // Update cumulative token counts in the database - try { - await chatStore.updateCounts( - sessionId, - totalInputTokens, - totalOutputTokens, - tokenMap, - ); - } catch (err) { - // Non-fatal — log and continue - debug('EndSession: failed to update token counts:', err); - } - // Emit TokenCount via writer (workflow-native streaming, not AsyncEventQueue) await writer.write({ type: LLMStreamEventType.TokenCount, diff --git a/src/mastra/workflows/chat/steps/file-processing.step.ts b/src/mastra/workflows/chat/steps/file-processing.step.ts index 29a5578..8faee54 100644 --- a/src/mastra/workflows/chat/steps/file-processing.step.ts +++ b/src/mastra/workflows/chat/steps/file-processing.step.ts @@ -7,7 +7,6 @@ import {Readable} from 'stream'; import {LLMStreamEventType} from '../../../../graphs/event.types'; import {asWorkflowContext} from '../../../bridge/workflow-request-context'; import {mergeAttachments} from '../../../../utils'; -import {Message} from '../../../../models'; import { PrepareContextOutputSchema, FileProcessingOutputSchema, @@ -47,7 +46,7 @@ type FileModelWithAdapter = MastraLanguageModel & { * - Replace the last user message in the context with an enhanced version * that merges the original prompt with all file summaries * - * If no files are present, messages and prompt pass through unchanged. + * If no files are present, prompt passes through unchanged. */ export const fileProcessingStep = createStep({ id: 'file-processing', @@ -58,14 +57,13 @@ export const fileProcessingStep = createStep({ execute: async ({inputData, requestContext, writer}) => { const ctx = asWorkflowContext(requestContext); - const {sessionId, messages, userMessageId, prompt, files} = inputData; + const {sessionId, prompt, files} = inputData; if (!files?.length) { debug('FileProcessing: no files to process, passing through'); - return {sessionId, messages, userMessageId, prompt}; + return {sessionId, prompt}; } - const chatStore = ctx.get('chatStore'); const fileLlm = ctx.get('mastraFileLlm') as MastraLanguageModel | undefined; if (!fileLlm) { @@ -74,11 +72,6 @@ export const fileProcessingStep = createStep({ ); } - // Retrieve the saved user Message entity for addAttachmentMessage - const userMessageRecord = userMessageId - ? await chatStore.findMessageById(sessionId, userMessageId) - : undefined; - let mergedPrompt = prompt; for (const file of files) { @@ -114,16 +107,6 @@ export const fileProcessingStep = createStep({ debug(`FileProcessing: file summary length=${summary.length}`); - // Persist the attachment message to the database - if (userMessageRecord) { - await chatStore.addAttachmentMessage( - sessionId, - userMessageRecord as Message, - multerFile, - summary, - ); - } - // Merge the file summary into the running prompt mergedPrompt = mergeAttachments( mergedPrompt, @@ -132,15 +115,8 @@ export const fileProcessingStep = createStep({ ); } - // Replace the last user message in the context with the enhanced version - const updatedMessages = replaceLastUserMessage(messages, mergedPrompt); - return { sessionId, - messages: updatedMessages as z.infer< - typeof FileProcessingOutputSchema - >['messages'], - userMessageId, prompt: mergedPrompt, }; }, @@ -213,28 +189,3 @@ function buildFileContentPart( mime_type: file.mimetype ?? 'application/pdf', }; } - -/** - * Replace the last user message with an enhanced version containing file summaries. - */ -function replaceLastUserMessage( - messages: z.infer['messages'], - enhancedPrompt: string, -): z.infer['messages'] { - // Find the last user message index - let lastUserIdx = -1; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'user') { - lastUserIdx = i; - break; - } - } - - if (lastUserIdx < 0) { - return [...messages, {role: 'user', content: enhancedPrompt}]; - } - - const updated = [...messages]; - updated[lastUserIdx] = {role: 'user', content: enhancedPrompt}; - return updated; -} diff --git a/src/mastra/workflows/chat/steps/init-session.step.ts b/src/mastra/workflows/chat/steps/init-session.step.ts index 509f576..3f20fde 100644 --- a/src/mastra/workflows/chat/steps/init-session.step.ts +++ b/src/mastra/workflows/chat/steps/init-session.step.ts @@ -1,7 +1,6 @@ import {createStep} from '@mastra/core/workflows'; import {z} from 'zod'; import {LLMStreamEventType} from '../../../../graphs/event.types'; -import {asWorkflowContext} from '../../../bridge/workflow-request-context'; import { ChatWorkflowInputSchema, InitSessionOutputSchema, @@ -15,53 +14,44 @@ const debug = require('debug')('ai-integration:mastra:init-session.step'); * LangGraph equivalent: `InitSessionNode` * * Responsibilities: - * - Call `chatStore.init()` to create or fetch the session - * - Persist the user's message to the database + * - Read the resolved thread id from workflow input * - Emit the `Init` SSE event for new sessions via writer.write() (workflow-native streaming) * - * Retry: 2 attempts (DB availability issues) - * Error: Throws if chatStore.init() fails after retries + * Retry: 2 attempts + * Error: Throws if WorkflowRunner did not resolve a session id */ export const initSessionStep = createStep({ id: 'init-session', description: - 'Initialise or resume a chat session; persist the user message; emit Init event', + 'Initialise or resume a chat session and emit Init event when needed', inputSchema: ChatWorkflowInputSchema, outputSchema: InitSessionOutputSchema, retries: 2, - execute: async ({inputData, requestContext, writer}) => { - const ctx = asWorkflowContext(requestContext); - const chatStore = ctx.get('chatStore'); + execute: async ({inputData, writer}) => { + const {prompt, files, sessionId, isNewSession = false} = inputData; - const {prompt, files, sessionId} = inputData; - const isNewSession = !sessionId; + if (!sessionId) { + throw new Error( + 'Chat session id was not resolved before init-session execution.', + ); + } debug( `InitSession: isNew=${isNewSession}, sessionId=${sessionId ?? 'none'}`, ); - // Create or resume the session - const chat = await chatStore.init(prompt, sessionId); - // Emit Init event via writer (workflow-native streaming, not AsyncEventQueue) if (isNewSession) { - debug(`Emitting Init event for new session ${chat.id}`); + debug(`Emitting Init event for new session ${sessionId}`); await writer.write({ type: LLMStreamEventType.Init, - data: {sessionId: chat.id}, + data: {sessionId}, }); } - // Persist the human message to the database - const savedUserMessage = await chatStore.addHumanMessageText( - chat.id, - prompt, - ); - return { - sessionId: chat.id, + sessionId, isNewSession, - userMessageId: savedUserMessage?.id, prompt, files: files as z.infer['files'], }; diff --git a/src/mastra/workflows/chat/steps/persist-conversation.step.ts b/src/mastra/workflows/chat/steps/persist-conversation.step.ts index d15a2e7..8a97b9a 100644 --- a/src/mastra/workflows/chat/steps/persist-conversation.step.ts +++ b/src/mastra/workflows/chat/steps/persist-conversation.step.ts @@ -1,88 +1,30 @@ import {createStep} from '@mastra/core/workflows'; -import {asWorkflowContext} from '../../../bridge/workflow-request-context'; import { AgentReasoningOutputSchema, PersistConversationOutputSchema, } from '../chat-workflow-schemas'; -import type {JsonObject} from '../../../../types'; const debug = require('debug')( 'ai-integration:mastra:persist-conversation.step', ); /** - * PersistConversationStep — save the AI response and tool results to the database. + * PersistConversationStep — preserve workflow shape for token accounting. * - * LangGraph equivalent: the persistence part of `CallLLMNode` + `RunToolNode`. - * - * Responsibilities: - * - Persist the AI's final text response as an AI-type message - * - For each tool call, persist a Tool-type message linked to the AI message - * - Retrieve per-tool metadata via MastraToolStore.getMetadata() - * - * Tool message metadata enrichment: - * `MastraToolDefinition.getMetadata(rawResult)` returns application-specific metadata - * stored alongside each persisted tool message. - * `MastraToolDefinition.formatResult(rawResult)` returns the human-readable content. + * Conversation persistence now happens inside Mastra Memory via + * `agent.stream({ memory: { thread, resource } })`. */ export const persistConversationStep = createStep({ id: 'persist-conversation', - description: 'Persist AI response and tool call results to the database', + description: 'Pass through token totals after memory-managed persistence', inputSchema: AgentReasoningOutputSchema, outputSchema: PersistConversationOutputSchema, - execute: async ({inputData, requestContext}) => { - const ctx = asWorkflowContext(requestContext); - const chatStore = ctx.get('chatStore'); - const mastraTools = ctx.get('mastraTools'); - - const { - sessionId, - finalText, - toolCalls, - totalInputTokens, - totalOutputTokens, - tokenMap, - } = inputData; - - debug( - `PersistConversation: session=${sessionId}, textLen=${finalText.length}, tools=${toolCalls.length}`, - ); - - // 1. Persist the AI's text response - const aiMessage = await chatStore.addAIMessageText(sessionId, finalText); - - if (!aiMessage) { - debug('PersistConversation: addAIMessageText returned undefined'); - } - - // 2. Persist each tool call as a linked Tool message - for (const toolCall of toolCalls) { - const toolDefinition = mastraTools?.map?.[toolCall.toolName]; - const result = toolCall.rawResult as JsonObject; - - const content = toolDefinition - ? toolDefinition.formatResult(result) - : JSON.stringify(result); - - const metadata = toolDefinition - ? toolDefinition.getMetadata(result) - : ({status: 'completed'} as JsonObject); - - if (aiMessage) { - await chatStore.addToolMessageText( - sessionId, - toolCall.toolCallId, - toolCall.toolName, - content, - metadata, - aiMessage, - toolCall.args, - ); - } - } + execute: async ({inputData}) => { + const {sessionId, totalInputTokens, totalOutputTokens, tokenMap} = + inputData; debug( - `PersistConversation: saved AI message (${toolCalls.length} tool messages)`, + `PersistConversation: memory-managed persistence for session=${sessionId}`, ); return {sessionId, totalInputTokens, totalOutputTokens, tokenMap}; diff --git a/src/mastra/workflows/chat/steps/prepare-context.step.ts b/src/mastra/workflows/chat/steps/prepare-context.step.ts index e664c5f..3384c45 100644 --- a/src/mastra/workflows/chat/steps/prepare-context.step.ts +++ b/src/mastra/workflows/chat/steps/prepare-context.step.ts @@ -1,7 +1,4 @@ import {createStep} from '@mastra/core/workflows'; -import {z} from 'zod'; -import {ContextWindowManager} from '../../../bridge/context-window-manager'; -import {asWorkflowContext} from '../../../bridge/workflow-request-context'; import { InitSessionOutputSchema, PrepareContextOutputSchema, @@ -10,70 +7,25 @@ import { const debug = require('debug')('ai-integration:mastra:prepare-context.step'); /** - * PrepareContextStep — build the conversation history for the agent. + * PrepareContextStep — normalize prompt payload before file processing. * - * LangGraph equivalent: combines `InitSessionNode`'s message loading and - * `ContextCompressionNode`'s trimming logic. - * - * Responsibilities: - * - Fetch all messages for the session from the database - * - Convert to CoreMessage format (Vercel AI SDK / Mastra-compatible) - * - Trim the history to fit within the context window - * - * Note: The current user message (just saved by InitSessionStep) IS included - * in the history. FileProcessingStep will replace the last user message with - * an enhanced version (prompt + file summaries) if files were uploaded. + * Thread recall is now handled by Mastra Memory in `agent.stream({memory})`. + * This step keeps the workflow contract stable while passing prompt/files ahead. */ export const prepareContextStep = createStep({ id: 'prepare-context', - description: - 'Load conversation history from the database and trim to context window', + description: 'Normalize prompt payload before file processing', inputSchema: InitSessionOutputSchema, outputSchema: PrepareContextOutputSchema, - execute: async ({inputData, requestContext}) => { - const ctx = asWorkflowContext(requestContext); - const chatStore = ctx.get('chatStore'); - const aiConfig = ctx.get('aiConfig') as {maxTokens?: number} | undefined; - - const {sessionId, prompt, files, userMessageId} = inputData; - - debug(`PrepareContext: loading history for session=${sessionId}`); - - const rawMessages = await chatStore.getMessages(sessionId); - debug(`PrepareContext: loaded ${rawMessages.length} messages`); - - const coreMessages: z.infer['messages'] = - []; - for (const msg of rawMessages) { - const coreMsg = await chatStore.toCoreMessage(msg); - if (coreMsg) { - coreMessages.push( - coreMsg as z.infer< - typeof PrepareContextOutputSchema - >['messages'][number], - ); - } - } - - const maxTokens = - (aiConfig as {maxTokens?: number} | undefined)?.maxTokens ?? - ContextWindowManager.DEFAULT_MAX_TOKENS; - const trimmedMessages = ContextWindowManager.trim(coreMessages, maxTokens); + execute: async ({inputData}) => { + const {sessionId, prompt, files} = inputData; - debug( - `PrepareContext: ${coreMessages.length} → ${trimmedMessages.length} messages after trim`, - ); + debug(`PrepareContext: pass-through for session=${sessionId}`); return { sessionId, - messages: trimmedMessages as z.infer< - typeof PrepareContextOutputSchema - >['messages'], - userMessageId, prompt, - files: (files ?? []) as z.infer< - typeof PrepareContextOutputSchema - >['files'], + files: files ?? [], }; }, }); diff --git a/src/observers/index.ts b/src/observers/index.ts new file mode 100644 index 0000000..c153aed --- /dev/null +++ b/src/observers/index.ts @@ -0,0 +1 @@ +export * from './mastra-lifecycle.observer'; diff --git a/src/observers/mastra-lifecycle.observer.ts b/src/observers/mastra-lifecycle.observer.ts new file mode 100644 index 0000000..10b4d66 --- /dev/null +++ b/src/observers/mastra-lifecycle.observer.ts @@ -0,0 +1,31 @@ +import { + BindingScope, + inject, + injectable, + lifeCycleObserver, + LifeCycleObserver, +} from '@loopback/core'; +import {Mastra} from '@mastra/core/mastra'; +import {AiIntegrationBindings} from '../keys'; + +@lifeCycleObserver('mastra') +@injectable({scope: BindingScope.SINGLETON}) +export class MastraLifecycleObserver implements LifeCycleObserver { + constructor( + @inject(AiIntegrationBindings.Mastra) + private readonly mastra: Mastra, + ) {} + + async start(): Promise { + // Reserved for optional startup preflight checks. + } + + async stop(): Promise { + try { + await this.mastra.shutdown(); + } catch (error) { + // Keep shutdown graceful even if Mastra emits a close-time error. + console.error('Mastra shutdown error:', error); + } + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 8eac6e6..3d66d90 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,4 +1,5 @@ export * from './cache'; +export * from './mastra'; export * from './mastra-tools.provider'; export * from './tools.provider'; export * from './vector-stores'; diff --git a/src/providers/mastra/index.ts b/src/providers/mastra/index.ts new file mode 100644 index 0000000..d6a5c4f --- /dev/null +++ b/src/providers/mastra/index.ts @@ -0,0 +1,2 @@ +export * from './mastra.provider'; +export * from './storage.provider'; diff --git a/src/providers/mastra/mastra.provider.ts b/src/providers/mastra/mastra.provider.ts new file mode 100644 index 0000000..d18302e --- /dev/null +++ b/src/providers/mastra/mastra.provider.ts @@ -0,0 +1,56 @@ +import {BindingScope, inject, injectable, Provider} from '@loopback/core'; +import {Mastra} from '@mastra/core/mastra'; +import type {MastraCompositeStore} from '@mastra/core/storage'; +import type {MastraEmbeddingModel, MastraVector} from '@mastra/core/vector'; +import {Memory} from '@mastra/memory'; +import {createChatReasoningAgent} from '../../mastra/agents/chat-reasoning.agent'; +import {AiIntegrationBindings} from '../../keys'; + +@injectable({scope: BindingScope.SINGLETON}) +export class MastraProvider implements Provider { + constructor( + @inject(AiIntegrationBindings.MastraStorage) + private readonly storage: MastraCompositeStore, + @inject(AiIntegrationBindings.MastraVectorStore, {optional: true}) + private readonly vectorStore?: MastraVector, + @inject(AiIntegrationBindings.MastraEmbedder, {optional: true}) + private readonly embedder?: MastraEmbeddingModel, + ) {} + + async value(): Promise { + const memory = new Memory({ + storage: this.storage, + vector: this.vectorStore, + embedder: this.embedder, + options: { + lastMessages: 20, + generateTitle: true, + semanticRecall: + this.vectorStore && this.embedder + ? { + topK: 5, + messageRange: 3, + scope: 'resource', + } + : false, + workingMemory: { + enabled: false, + }, + }, + }); + + const chatAgent = createChatReasoningAgent(memory); + + return new Mastra({ + agents: { + chatAgent, + }, + storage: this.storage, + vectors: this.vectorStore + ? { + default: this.vectorStore, + } + : undefined, + }); + } +} diff --git a/src/providers/mastra/storage.provider.ts b/src/providers/mastra/storage.provider.ts new file mode 100644 index 0000000..1cf2a83 --- /dev/null +++ b/src/providers/mastra/storage.provider.ts @@ -0,0 +1,13 @@ +import {BindingScope, injectable, Provider} from '@loopback/core'; +import type {MastraCompositeStore} from '@mastra/core/storage'; +import {LibSQLStore} from '@mastra/libsql'; + +@injectable({scope: BindingScope.SINGLETON}) +export class DefaultMastraStorageProvider implements Provider { + async value(): Promise { + return new LibSQLStore({ + id: 'mastra-default', + url: process.env.MASTRA_STORAGE_URL ?? 'file:./mastra.db', + }); + } +} diff --git a/src/scripts/backfill-mastra-memory.ts b/src/scripts/backfill-mastra-memory.ts new file mode 100644 index 0000000..ce92585 --- /dev/null +++ b/src/scripts/backfill-mastra-memory.ts @@ -0,0 +1,660 @@ +import * as path from 'path'; +import {randomUUID} from 'crypto'; +import {Application} from '@loopback/core'; +import {MastraCompositeStore} from '@mastra/core/storage'; +import type {MastraDBMessage, StorageThreadType} from '@mastra/core/memory'; +import {AiIntegrationBindings} from '../keys'; +import {MessageMetadataType} from '../graphs/chat/chat-metadata.type'; +import {ChatRepository} from '../repositories'; +import {mergeAttachments} from '../utils'; + +const debug = require('debug')('ai-integration:scripts:backfill-mastra-memory'); + +type ScriptOptions = { + appModule: string; + appExport?: string; + dryRun: boolean; + pageSize: number; + limit?: number; + chatId?: string; +}; + +type LegacyChat = { + id: string; + title?: string; + tenantId?: string; + userId?: string; + metadata?: Record; + createdOn?: unknown; + modifiedOn?: unknown; +}; + +type LegacyMessage = { + id?: string; + body?: string; + metadata?: Record; + parentMessageId?: string; + createdOn?: unknown; + modifiedOn?: unknown; +}; + +type MemoryStoreLike = { + getThreadById(args: { + threadId: string; + resourceId?: string; + }): Promise; + saveThread(args: {thread: StorageThreadType}): Promise; + listMessages(args: { + threadId: string | string[]; + resourceId?: string; + page?: number; + perPage?: number | false; + }): Promise<{messages: MastraDBMessage[]}>; + saveMessages(args: { + messages: MastraDBMessage[]; + }): Promise<{messages: MastraDBMessage[]}>; + getResourceById?(args: {resourceId: string}): Promise; + saveResource?(args: { + resource: { + id: string; + metadata?: Record; + createdAt: Date; + updatedAt: Date; + }; + }): Promise; +}; + +type Stats = { + chatsScanned: number; + chatsMigrated: number; + chatsSkippedExisting: number; + chatsFailed: number; + threadsCreated: number; + messagesMigrated: number; + attachmentsMerged: number; +}; + +function parseOptions(argv: string[]): ScriptOptions { + const flag = (name: string): string | undefined => { + const prefixed = argv.find(v => v.startsWith(`${name}=`)); + if (prefixed) { + return prefixed.slice(name.length + 1); + } + + const index = argv.indexOf(name); + if (index >= 0 && argv[index + 1]) { + return argv[index + 1]; + } + + return undefined; + }; + + const dryRun = argv.includes('--dry-run'); + const appModule = + flag('--app-module') ?? process.env.APP_MODULE ?? './dist/application'; + const appExport = flag('--app-export') ?? process.env.APP_EXPORT; + + const pageSizeRaw = + flag('--page-size') ?? process.env.BACKFILL_PAGE_SIZE ?? '100'; + const pageSize = Number(pageSizeRaw); + if (!Number.isFinite(pageSize) || pageSize <= 0) { + throw new Error(`Invalid page size: ${pageSizeRaw}`); + } + + const limitRaw = flag('--limit') ?? process.env.BACKFILL_LIMIT; + const limit = limitRaw ? Number(limitRaw) : undefined; + if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) { + throw new Error(`Invalid limit: ${limitRaw}`); + } + + const chatId = flag('--chat-id') ?? process.env.BACKFILL_CHAT_ID; + + return { + appModule, + appExport, + dryRun, + pageSize, + limit, + chatId, + }; +} + +function asRecord(value: unknown): Record { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record; + } + + return {}; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function toDate(value: unknown, fallback: Date = new Date()): Date { + if (value instanceof Date) { + return value; + } + + if (typeof value === 'string' || typeof value === 'number') { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + + return fallback; +} + +function resolveResourceId(chat: LegacyChat): string { + if (chat.tenantId && chat.userId) { + return `${chat.tenantId}:${chat.userId}`; + } + + if (chat.userId) { + return chat.userId; + } + + return chat.id; +} + +function getMetadataType(message: LegacyMessage): string | undefined { + const metadata = asRecord(message.metadata); + return asString(metadata.type)?.toLowerCase(); +} + +function mapMessageRole(message: LegacyMessage): MastraDBMessage['role'] { + const metadataType = getMetadataType(message); + + switch (metadataType) { + case MessageMetadataType.User: + case MessageMetadataType.Attachment: + return 'user'; + case MessageMetadataType.System: + return 'system'; + default: + return 'assistant'; + } +} + +function getMessageBody(message: LegacyMessage): string { + const body = typeof message.body === 'string' ? message.body : ''; + if (body.trim()) { + return body; + } + + const metadata = asRecord(message.metadata); + const summary = asString(metadata.summary); + if (summary) { + return summary; + } + + return ' '; +} + +function normalizeRootMessages(messages: LegacyMessage[]): { + roots: LegacyMessage[]; + childrenByParentId: Map; +} { + const childrenByParentId = new Map(); + const roots: LegacyMessage[] = []; + + for (const message of messages) { + const parentId = asString(message.parentMessageId); + if (!parentId) { + roots.push(message); + continue; + } + + const children = childrenByParentId.get(parentId) ?? []; + children.push(message); + childrenByParentId.set(parentId, children); + } + + const sortByTime = (a: LegacyMessage, b: LegacyMessage) => { + const left = toDate(a.createdOn).getTime(); + const right = toDate(b.createdOn).getTime(); + if (left === right) { + const leftId = a.id ?? ''; + const rightId = b.id ?? ''; + return leftId.localeCompare(rightId); + } + + return left - right; + }; + + roots.sort(sortByTime); + for (const children of childrenByParentId.values()) { + children.sort(sortByTime); + } + + return {roots, childrenByParentId}; +} + +function appendAttachmentSummaries( + messageText: string, + children: LegacyMessage[], +): {text: string; mergedCount: number} { + let text = messageText; + let mergedCount = 0; + + for (const child of children) { + const metadataType = getMetadataType(child); + if (metadataType !== MessageMetadataType.Attachment) { + continue; + } + + const metadata = asRecord(child.metadata); + const fileName = asString(metadata.fileName) ?? 'attachment'; + const summary = asString(metadata.summary) ?? getMessageBody(child); + text = mergeAttachments(text, fileName, summary); + mergedCount += 1; + } + + return {text, mergedCount}; +} + +function appendToolSummaries( + messageText: string, + children: LegacyMessage[], +): string { + const toolLines: string[] = []; + + for (const child of children) { + const metadataType = getMetadataType(child); + if (metadataType !== MessageMetadataType.Tool) { + continue; + } + + const metadata = asRecord(child.metadata); + const toolName = asString(metadata.toolName) ?? 'tool'; + const toolCallId = asString(metadata.id) ?? child.id ?? randomUUID(); + const body = getMessageBody(child).trim(); + toolLines.push( + body + ? `[tool:${toolName} id=${toolCallId}] ${body}` + : `[tool:${toolName} id=${toolCallId}] completed`, + ); + } + + if (!toolLines.length) { + return messageText; + } + + return `${messageText}\n\nTool activity:\n${toolLines.join('\n')}`; +} + +function toMastraMessage( + chat: LegacyChat, + resourceId: string, + message: LegacyMessage, + children: LegacyMessage[], +): {message: MastraDBMessage; mergedAttachments: number} { + const metadata = asRecord(message.metadata); + const messageId = message.id ?? randomUUID(); + + let text = getMessageBody(message); + let mergedAttachments = 0; + + if (getMetadataType(message) === MessageMetadataType.User) { + const merged = appendAttachmentSummaries(text, children); + text = merged.text; + mergedAttachments = merged.mergedCount; + } + + if (getMetadataType(message) === MessageMetadataType.AI) { + text = appendToolSummaries(text, children); + } + + const contentMetadata: Record = { + ...metadata, + legacy: { + chatId: chat.id, + messageId, + mergedAttachmentCount: mergedAttachments, + }, + }; + + const mastraMessage: MastraDBMessage = { + id: messageId, + role: mapMessageRole(message), + type: 'text', + createdAt: toDate(message.createdOn), + threadId: chat.id, + resourceId, + content: { + format: 2, + parts: [{type: 'text', text}], + metadata: contentMetadata, + } as MastraDBMessage['content'], + }; + + return {message: mastraMessage, mergedAttachments}; +} + +function isMemoryStoreLike(value: unknown): value is MemoryStoreLike { + const candidate = value as Partial | undefined; + return !!( + candidate && + typeof candidate.getThreadById === 'function' && + typeof candidate.saveThread === 'function' && + typeof candidate.listMessages === 'function' && + typeof candidate.saveMessages === 'function' + ); +} + +async function resolveMemoryStore(storage: unknown): Promise { + if (isMemoryStoreLike(storage)) { + return storage; + } + + if ( + storage instanceof MastraCompositeStore || + (typeof storage === 'object' && + storage !== null && + typeof (storage as {getStore?: unknown}).getStore === 'function') + ) { + const composite = storage as { + getStore: (domain: string) => Promise; + }; + const memory = await composite.getStore('memory'); + if (isMemoryStoreLike(memory)) { + return memory; + } + } + + throw new Error( + 'Could not resolve a memory storage domain from AiIntegrationBindings.MastraStorage.', + ); +} + +function resolveAppClass( + moduleExports: Record, + requestedExport?: string, +): new () => Application { + const candidates: unknown[] = []; + + if (requestedExport) { + candidates.push(moduleExports[requestedExport]); + } + + candidates.push( + moduleExports.default, + moduleExports.Application, + ...Object.values(moduleExports), + ); + + for (const candidate of candidates) { + if (typeof candidate !== 'function') { + continue; + } + + const ctor = candidate as new () => Application; + const prototype = (ctor as unknown as {prototype?: Record}) + .prototype; + + if (prototype && typeof prototype.start === 'function') { + return ctor; + } + } + + throw new Error( + `Failed to resolve LoopBack application class. Export requested: ${requestedExport ?? 'default'}`, + ); +} + +async function loadApplication(options: ScriptOptions): Promise { + const modulePath = path.isAbsolute(options.appModule) + ? options.appModule + : path.resolve(process.cwd(), options.appModule); + + debug(`Loading application module from ${modulePath}`); + + const moduleExports = require(modulePath) as Record; + const AppClass = resolveAppClass(moduleExports, options.appExport); + + const app = new AppClass(); + + const bootableApp = app as Application & {boot?: () => Promise}; + if (typeof bootableApp.boot === 'function') { + await bootableApp.boot(); + } + + await app.start(); + return app; +} + +async function ensureResource( + memoryStore: MemoryStoreLike, + resourceId: string, + chat: LegacyChat, +): Promise { + if (!memoryStore.getResourceById || !memoryStore.saveResource) { + return; + } + + const existing = await memoryStore.getResourceById({resourceId}); + if (existing) { + return; + } + + const createdAt = toDate(chat.createdOn); + const updatedAt = toDate(chat.modifiedOn, createdAt); + + await memoryStore.saveResource({ + resource: { + id: resourceId, + metadata: { + tenantId: chat.tenantId, + userId: chat.userId, + migratedFrom: 'legacy-chat-store', + }, + createdAt, + updatedAt, + }, + }); +} + +async function migrateSingleChat( + chatRepository: ChatRepository, + memoryStore: MemoryStoreLike, + chat: LegacyChat, + dryRun: boolean, + stats: Stats, +): Promise { + const resourceId = resolveResourceId(chat); + const existingThread = await memoryStore.getThreadById({ + threadId: chat.id, + resourceId, + }); + + if (existingThread) { + const existingMessages = await memoryStore.listMessages({ + threadId: chat.id, + resourceId, + page: 0, + perPage: 1, + }); + + if ((existingMessages.messages?.length ?? 0) > 0) { + stats.chatsSkippedExisting += 1; + return; + } + } + + const legacyMessagesRaw = await chatRepository.messages(chat.id).find({ + order: ['createdOn ASC', 'id ASC'], + }); + const legacyMessages = legacyMessagesRaw.map( + message => message as LegacyMessage, + ); + + const {roots, childrenByParentId} = normalizeRootMessages(legacyMessages); + + if (dryRun) { + stats.chatsMigrated += 1; + stats.messagesMigrated += roots.length; + return; + } + + await ensureResource(memoryStore, resourceId, chat); + + if (!existingThread) { + const threadCreatedAt = toDate(chat.createdOn); + const threadUpdatedAt = toDate(chat.modifiedOn, threadCreatedAt); + + await memoryStore.saveThread({ + thread: { + id: chat.id, + title: chat.title, + resourceId, + createdAt: threadCreatedAt, + updatedAt: threadUpdatedAt, + metadata: { + ...(chat.metadata ?? {}), + tenantId: chat.tenantId, + userId: chat.userId, + migratedFrom: 'legacy-chat-store', + legacyChatId: chat.id, + }, + }, + }); + + stats.threadsCreated += 1; + } + + const messagesToPersist: MastraDBMessage[] = []; + for (const root of roots) { + const rootId = asString(root.id); + const children = rootId ? (childrenByParentId.get(rootId) ?? []) : []; + const migrated = toMastraMessage(chat, resourceId, root, children); + messagesToPersist.push(migrated.message); + stats.attachmentsMerged += migrated.mergedAttachments; + } + + const batchSize = 200; + for (let i = 0; i < messagesToPersist.length; i += batchSize) { + await memoryStore.saveMessages({ + messages: messagesToPersist.slice(i, i + batchSize), + }); + } + + stats.chatsMigrated += 1; + stats.messagesMigrated += messagesToPersist.length; +} + +async function run(): Promise { + const options = parseOptions(process.argv.slice(2)); + + const stats: Stats = { + chatsScanned: 0, + chatsMigrated: 0, + chatsSkippedExisting: 0, + chatsFailed: 0, + threadsCreated: 0, + messagesMigrated: 0, + attachmentsMerged: 0, + }; + + let app: Application | undefined; + + try { + app = await loadApplication(options); + + const chatRepository = await app.get( + 'repositories.ChatRepository', + ); + const storage = await app.get(AiIntegrationBindings.MastraStorage); + const memoryStore = await resolveMemoryStore(storage); + + if (options.chatId) { + const chat = (await chatRepository.findById( + options.chatId, + )) as LegacyChat; + stats.chatsScanned = 1; + await migrateSingleChat( + chatRepository, + memoryStore, + chat, + options.dryRun, + stats, + ); + } else { + let skip = 0; + let scanned = 0; + + for (; options.limit === undefined || scanned < options.limit; ) { + const remaining = + options.limit !== undefined + ? options.limit - scanned + : options.pageSize; + if (remaining <= 0) { + break; + } + + const currentLimit = + options.limit !== undefined + ? Math.min(options.pageSize, remaining) + : options.pageSize; + + const chats = (await chatRepository.find({ + limit: currentLimit, + skip, + order: ['createdOn ASC'], + })) as LegacyChat[]; + + if (!chats.length) { + break; + } + + for (const chat of chats) { + stats.chatsScanned += 1; + scanned += 1; + + try { + await migrateSingleChat( + chatRepository, + memoryStore, + chat, + options.dryRun, + stats, + ); + } catch (error) { + stats.chatsFailed += 1; + const message = + error instanceof Error ? error.message : String(error); + console.error(`Failed to migrate chat ${chat.id}: ${message}`); + } + } + + skip += chats.length; + } + } + + const mode = options.dryRun ? 'DRY RUN' : 'EXECUTION'; + console.log(`[Mastra Backfill] ${mode} complete.`); + console.log( + JSON.stringify( + { + appModule: options.appModule, + appExport: options.appExport ?? 'default', + dryRun: options.dryRun, + ...stats, + }, + null, + 2, + ), + ); + } finally { + if (app) { + await app.stop(); + } + } +} + +run().catch(error => { + const message = + error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error('[Mastra Backfill] failed:', message); + process.exitCode = 1; +});