diff --git a/package-lock.json b/package-lock.json index 7e6eccba..013ae1e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,9 @@ "@commitlint/cli": "^21.1.0", "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", + "@langchain/anthropic": "^1.5.1", + "@langchain/core": "^1.2.2", + "@langchain/ollama": "^1.3.0", "@types/better-sqlite3": "^7.6.12", "@types/node": "^25.9.3", "@vitest/coverage-v8": "^4.1.10", @@ -34,6 +37,7 @@ "eslint-config-prettier": "^10.1.8", "fast-check": "^4.8.0", "husky": "^9.1.7", + "langchain": "^1.5.3", "lint-staged": "^17.0.8", "playwright": "^1.61.1", "prettier": "^3.9.4", @@ -48,6 +52,28 @@ "node": ">=20" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.103.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.103.0.tgz", + "integrity": "sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -106,6 +132,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -180,6 +216,13 @@ "keyv": "^5.6.0" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@clack/core": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz", @@ -1467,9 +1510,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1486,9 +1526,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1505,9 +1542,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1524,9 +1558,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1543,9 +1574,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1562,9 +1590,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1581,9 +1606,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1600,9 +1622,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1619,9 +1638,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1644,9 +1660,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1669,9 +1682,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1694,9 +1704,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1719,9 +1726,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1744,9 +1748,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1769,9 +1770,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1794,9 +1792,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1956,6 +1951,169 @@ "dev": true, "license": "MIT" }, + "node_modules/@langchain/anthropic": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.1.tgz", + "integrity": "sha512-j92zCCd5BFH3rHMRzc2wBmSKDoVpinof1oh8aFiAz9TWbSOc4tGU4n6bqwy/wP0GH1uO96zZHLGCHBMPgrxTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.103.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.2.1" + } + }, + "node_modules/@langchain/core": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.2.tgz", + "integrity": "sha512-KfjEOT6sCg0vvItagfEtGpmrGoLMGfma4Affb5BGEqPmS2YR3AxW54pABSkhQlzCehTB+0BnLquAe1lGF4J9zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "@standard-schema/spec": "^1.1.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.5.0 <1.0.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@langchain/core/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@langchain/core/node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/core/node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@langchain/langgraph": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.7.tgz", + "integrity": "sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@langchain/langgraph-checkpoint": "^1.1.3", + "@langchain/langgraph-sdk": "~1.9.25", + "@langchain/protocol": "^0.0.18", + "@standard-schema/spec": "1.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.1.48", + "zod": "^3.25.32 || ^4.2.0" + } + }, + "node_modules/@langchain/langgraph-checkpoint": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.3.tgz", + "integrity": "sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.1.48" + } + }, + "node_modules/@langchain/langgraph-sdk": { + "version": "1.9.25", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.25.tgz", + "integrity": "sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@langchain/protocol": "^0.0.18", + "@types/json-schema": "^7.0.15", + "p-queue": "^9.0.1", + "p-retry": "^7.1.1" + }, + "peerDependencies": { + "@langchain/core": "^1.1.48", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", + "svelte": "^4.0.0 || ^5.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@langchain/ollama": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@langchain/ollama/-/ollama-1.3.0.tgz", + "integrity": "sha512-uzLkZVTXN0SI5zAoJbJzL4y/QN7gbmJ1txBgFfqJ5M0Qf+Yu2Zu8q98L3jIrfM+akWMaNmRU4R4APhcfQmeIsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ollama": "^0.6.3" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0" + } + }, + "node_modules/@langchain/protocol": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.18.tgz", + "integrity": "sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@linear/sdk": { "version": "88.0.0", "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-88.0.0.tgz", @@ -2210,9 +2368,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2230,9 +2385,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2250,9 +2402,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2270,9 +2419,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2290,9 +2436,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2310,9 +2453,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2428,6 +2568,13 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4510,6 +4657,13 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense" + }, "node_modules/fast-string-truncated-width": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz", @@ -5251,6 +5405,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", @@ -5353,6 +5520,16 @@ "node": ">=10" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -5406,6 +5583,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5456,6 +5647,96 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/langchain": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.5.3.tgz", + "integrity": "sha512-YRYPy1xPq4CgKe0NdPoscYHnKBW5cViEyWiZsUKQfN3xL1X5DpGlIpaBQg1A/t6o9ePKE7J5b7nqrfLz8t7/Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@langchain/langgraph": "^1.4.7", + "@langchain/langgraph-checkpoint": "^1.1.3", + "langsmith": ">=0.5.0 <1.0.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.2.1" + } + }, + "node_modules/langsmith": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.8.2.tgz", + "integrity": "sha512-WX85yuL9vHAFIXJ4Nde/2VsjZT5o6FzX617uKDGtcgMojPe+IM6lgiZeVagv/egC79bQS9HPKVD/UPe/6zGFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-queue": "6.6.2" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*", + "ws": ">=7" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/langsmith/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/langsmith/node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/langsmith/node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5624,9 +5905,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5648,9 +5926,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5672,9 +5947,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5696,9 +5968,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6243,6 +6512,16 @@ "node": ">=18" } }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -6378,6 +6657,16 @@ ], "license": "MIT" }, + "node_modules/ollama": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", + "integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -6442,6 +6731,16 @@ "node": ">= 0.8.0" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-queue": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.1.tgz", @@ -6459,6 +6758,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-timeout": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", @@ -7504,6 +7819,17 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7784,6 +8110,13 @@ "node": ">=14.17" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8151,6 +8484,13 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 14d55177..815dbb80 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,9 @@ "@commitlint/cli": "^21.1.0", "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", + "@langchain/anthropic": "^1.5.1", + "@langchain/core": "^1.2.2", + "@langchain/ollama": "^1.3.0", "@types/better-sqlite3": "^7.6.12", "@types/node": "^25.9.3", "@vitest/coverage-v8": "^4.1.10", @@ -67,6 +70,7 @@ "eslint-config-prettier": "^10.1.8", "fast-check": "^4.8.0", "husky": "^9.1.7", + "langchain": "^1.5.3", "lint-staged": "^17.0.8", "playwright": "^1.61.1", "prettier": "^3.9.4", diff --git a/scripts/spikes/lia396_provider_override_walking_skeleton.md b/scripts/spikes/lia396_provider_override_walking_skeleton.md new file mode 100644 index 00000000..1cc88a8e --- /dev/null +++ b/scripts/spikes/lia396_provider_override_walking_skeleton.md @@ -0,0 +1,169 @@ +# Spike: per-request provider overrides through LangChain's `wrapModelCall` middleware (LIA-396 / A3) + +**Date:** 2026-07-14 · **Verdict: per-AC breakdown below (no blanket PASS)** +— selection and provider-attribution logging proven live and deterministic; +the subagent/Ollama leg is fully live with zero mocking; the main-agent/ +Claude leg's live network execution is explicitly deferred to A4 +(LIA-397, credential-proxy) since `ANTHROPIC_API_KEY` is absent here. + +## Question + +Can LangChain JS's real `wrapModelCall` middleware contract — the third +member of the middleware family after `createAgent` (A1) and `wrapToolCall` +(A2) — route different chat model providers to different requests **within +one logical session**? This is A3 of the Linear-tracked MA milestone +(LIA-394..400), continuing from A1 (LIA-394, PR #1031) and A2 (LIA-395, PR +#1032, both still open/unmerged). + +LIA-396 lists five acceptance criteria: + +1. `wrapModelCall` selects a Claude model for the main-agent request. +2. `wrapModelCall` selects a local Ollama model for a subagent request. +3. Both requests execute within one logical session. +4. Logs or test assertions identify which provider handled each request. +5. The proof can be reused by the A7 benchmark harness. + +## Method + +### Reuse target — unmodified + +`wrapModelCall`'s real contract, confirmed by reading +`node_modules/langchain/dist/agents/middleware/types.d.ts`: +`createMiddleware({ name, wrapModelCall: async (request: ModelRequest, +handler: WrapModelCallHandler) => Promise })`, passed +to `createAgent({ middleware: [...] })`. The official JSDoc example shows +`request.model` being swapped and re-dispatched via +`handler({...request, model: fallbackModel})` — this spike's +`createProviderRoutingMiddleware` uses exactly that documented pattern, not +a novel mechanism. + +### `createProviderRoutingMiddleware` — the reusable primitive + +A real `wrapModelCall` middleware that reads `request.runtime.context.role`, +resolves a model instance via an injectable `resolveModel` Strategy, swaps +`request.model`, and records every selection to an inspectable `log: +ProviderRoutingRecord[]` (`requestIndex`, `role`, `providerClass`, `modelId`, +`executed`). `resolveModel`/`executeOverride` are Strategy-pattern seams — +interchangeable per-role algorithms, matching A2's `invokeGate` +injectable-seam precedent. `executeOverride` is an optional second Strategy +seam that, when it returns a message, short-circuits the real network call +for a given role — used ONLY to route around the disclosed +`ANTHROPIC_API_KEY` absence, never silently, and always recorded truthfully +in the log (`executed: false`). + +### "One logical session" — one top-level `agent.invoke()`, one nested `agent.invoke()` + +Modeled the deus-native two-tier architecture directly: a `delegate_to_ +subagent` tool whose `execute()` constructs and invokes a *second*, +nested `createAgent`, sharing the *same* `wrapModelCall` middleware instance +as the outer agent, disambiguated by `request.runtime.context.role` +(`'main'` vs `'sub'`). This is call-stack/session continuity — both legs +occur within one real `mainAgent.invoke()` call tree, one shared middleware +instance, one shared routing log. Two independent top-level `.invoke()` +calls with no nesting would not satisfy this — there would be no shared +call stack and nothing forcing the two calls to be causally related within +one run. + +### Provider selection — both real, live, in the same call stack + +`defaultMainSubResolver()`: role `'main'` → a real `ChatAnthropic` instance +(placeholder API key when `ANTHROPIC_API_KEY` is absent — construction +throws without *some* key present, confirmed empirically); role `'sub'` → +a real `ChatOllama` instance pointed at local Ollama (`OLLAMA_URL`, reusing +the repo's existing env-var convention). Both providers expose the model id +on the same `.model` field, confirmed by construction — uniform +provider-attribution logging with no per-provider branching. + +### Driving the loop without a Claude credential + +`makeScriptedMainStub()`: a *stateful*, call-count-aware `executeOverride` +for role `'main'` only — its first invocation returns a `tool_calls`-bearing +`AIMessage` naming `delegate_to_subagent`, every subsequent invocation +returns a plain final `AIMessage`. A stateless single-canned-response stub +would never actually invoke the tool, so the `'sub'` leg would never fire — +this was caught and fixed during planning, before implementation. The +`'sub'`/Ollama leg has no override — it always hits the real local Ollama +server for real. + +## What was built + +- `scripts/spikes/lia396_provider_override_walking_skeleton.ts` — + `createProviderRoutingMiddleware` (the reusable primitive), + `defaultMainSubResolver`, `makeScriptedMainStub`, + `makeDelegateToSubagentTool`, and a `main()` direct-execution demo. +- `scripts/spikes/lia396_provider_override_walking_skeleton.test.ts` — 7 + tests (6 passing, 1 conditionally skipped): + - Selection: `'main'` role resolves a real `ChatAnthropic` instance with + the right model id; `'sub'` role resolves a real `ChatOllama` instance; + an unrecognized role passes the original `request.model` through + unchanged. + - Routing log: entries recorded in order with correct + `requestIndex`/`role`/`providerClass`/`modelId`; `executed: false` on + the `executeOverride` short-circuit path vs `executed: true` on the + default real-handler path. + - **The integration test** — one real `mainAgent.invoke()` call, real + nested `subAgent.invoke()`, real Ollama network call: asserts exactly + one `'sub'` log entry (`ChatOllama`, `executed: true`), at least two + `'main'` entries (`ChatAnthropic`, `executed: false`) straddling the + `'sub'` entry in `requestIndex` order (the live `main → sub → main` + trace), and that the subagent's actual returned content is real + (non-empty, not the canned stub string) — proving the Ollama round-trip + genuinely happened, not just that the mechanism fired. + - Optional live full-Claude smoke test, gated on `ANTHROPIC_API_KEY` + presence (same disposition as A1) — skipped in this environment. +- `vitest.config.ts` — same `scripts/spikes/**/*.test.ts` glob addition + A1/A2 made (independently, since neither is merged yet). +- Direct-execution demo output (`npx tsx + scripts/spikes/lia396_provider_override_walking_skeleton.ts`), matching + the automated assertions exactly: + ``` + ── Transcript ── + [human] Delegate to your subagent to find out its favorite fruit, then report back. + [ai:model] + [tool:delegate_to_subagent] {"subagent_answer":"Apple"} + [ai:model] Delegated to the subagent and got an answer back. + + ── Routing log ── + #0 role=main provider=ChatAnthropic model=claude-opus-4-8 executed=false + #1 role=sub provider=ChatOllama model=gemma4:e2b executed=true + #2 role=main provider=ChatAnthropic model=claude-opus-4-8 executed=false + ``` + +## Verdict — per acceptance criterion + +- **AC1** "wrapModelCall selects a Claude model for the main-agent request" + — **PASS**. A real `ChatAnthropic` instance is genuinely constructed and + assigned to `request.model` for the `'main'` role, live, in the same + session run as the `'sub'` leg. +- **AC2** "wrapModelCall selects a local Ollama model for a subagent + request" — **PASS**. A real `ChatOllama` instance is genuinely + constructed, assigned, AND executed against local Ollama + (`gemma4:e2b`) — zero mocking on this leg. +- **AC3** "Both requests execute within one logical session" — **PARTIAL**. + Session/call-stack nesting: yes — one top-level `agent.invoke()`, one + nested `agent.invoke()` inside `delegate_to_subagent`, sharing one + middleware instance and one routing log, live-verified `main → sub → + main` trace. Live network *execution* of both legs: no — the `'main'`/ + Claude leg is stubbed via the disclosed `executeOverride` seam because + `ANTHROPIC_API_KEY` is absent; only the `'sub'`/Ollama leg genuinely + executes over the network. Full literal AC3 satisfaction (both legs + actually executing) is A4's job (LIA-397, credential-proxy), not A3's — + stated explicitly here rather than folded into a blanket pass. +- **AC4** "Logs or test assertions identify which provider handled each + request" — **PASS**. `ProviderRoutingRecord[]` entries carry + `role`/`providerClass`/`modelId`/`executed` per request. +- **AC5** "The proof can be reused by the A7 benchmark harness" — + **PASS**. `createProviderRoutingMiddleware` is a standalone, parameterized + export with no hardcoded `'main'`/`'sub'` coupling — that logic lives only + in the separately-exported, explicitly-example-labeled + `defaultMainSubResolver()`. A7 supplies its own resolver per benchmark + matrix cell. + +## Scope of this spike + +Read-only proof-of-concept; nothing wired into production. No modifications +to any reused mechanism. Not merged to `main` — pushed as a PR for review +only. When A4 (LIA-397) lands, the same `createProviderRoutingMiddleware` +runs with no `executeOverride` on the `'main'` leg and a real Anthropic call +happens live — nothing about this design needs to change for that +transition; `executeOverride` simply becomes unused for that role. diff --git a/scripts/spikes/lia396_provider_override_walking_skeleton.test.ts b/scripts/spikes/lia396_provider_override_walking_skeleton.test.ts new file mode 100644 index 00000000..69253446 --- /dev/null +++ b/scripts/spikes/lia396_provider_override_walking_skeleton.test.ts @@ -0,0 +1,288 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { AIMessage } from '@langchain/core/messages'; +import { ChatOllama } from '@langchain/ollama'; +import { createAgent, type ModelRequest } from 'langchain'; +import { describe, expect, it, vi } from 'vitest'; + +import { + CLAUDE_MODEL_ID, + OLLAMA_BASE_URL, + OLLAMA_SUB_MODEL_ID, + createProviderRoutingMiddleware, + defaultMainSubResolver, + makeDelegateToSubagentTool, + makeScriptedMainStub, +} from './lia396_provider_override_walking_skeleton.js'; + +/** + * The integration test below requires a real, reachable local Ollama server + * with OLLAMA_SUB_MODEL_ID actually pulled — infrastructure this repo's + * default test suite cannot assume (CI, other contributors' machines, or + * even a machine with Ollama running but a different model set). Self-gates + * via a live probe (daemon reachable AND the specific model present in + * /api/tags, short timeout) rather than a manual opt-in env var, mirroring + * the ANTHROPIC_API_KEY-presence gate used for the Claude smoke test below + * — the suite adapts to what's actually available instead of failing with a + * missing-model error on a machine that has Ollama but not this model. + */ +async function isOllamaSubModelAvailable(): Promise { + try { + const response = await fetch(`${OLLAMA_BASE_URL}/api/tags`, { + signal: AbortSignal.timeout(1000), + }); + if (!response.ok) return false; + const body = (await response.json()) as { + models?: Array<{ name?: string; model?: string }>; + }; + return (body.models ?? []).some( + (m) => m.name === OLLAMA_SUB_MODEL_ID || m.model === OLLAMA_SUB_MODEL_ID, + ); + } catch { + return false; + } +} + +const OLLAMA_AVAILABLE = await isOllamaSubModelAvailable(); + +/** + * Minimal fake ModelRequest for direct wrapModelCall invocation — only the + * fields the middleware actually reads (request.model, request.runtime + * .context.role) need to be present, mirroring A2's own precedent + * (lia395_warden_veto_walking_skeleton.test.ts:251-255) of casting a + * minimal object literal rather than fully satisfying ModelRequest's shape. + */ +function fakeRequest( + role: string | undefined, + model: ModelRequest['model'], +): ModelRequest { + return { + model, + messages: [], + runtime: { context: role === undefined ? undefined : { role } }, + } as unknown as ModelRequest; +} + +describe('createProviderRoutingMiddleware — selection', () => { + it("selects a real ChatAnthropic instance for role 'main'", async () => { + const { middleware } = createProviderRoutingMiddleware({ + resolveModel: defaultMainSubResolver(), + }); + const canned = new AIMessage({ content: 'ok' }); + const handler = vi.fn().mockResolvedValue(canned); + + await middleware.wrapModelCall!( + fakeRequest('main', new ChatOllama({ model: OLLAMA_SUB_MODEL_ID })), + handler, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const passedRequest = handler.mock.calls[0]?.[0] as ModelRequest; + expect(passedRequest.model).toBeInstanceOf(ChatAnthropic); + expect((passedRequest.model as ChatAnthropic).model).toBe(CLAUDE_MODEL_ID); + }); + + it("selects a real ChatOllama instance for role 'sub'", async () => { + const { middleware } = createProviderRoutingMiddleware({ + resolveModel: defaultMainSubResolver(), + }); + const canned = new AIMessage({ content: 'ok' }); + const handler = vi.fn().mockResolvedValue(canned); + + await middleware.wrapModelCall!( + fakeRequest( + 'sub', + new ChatAnthropic({ model: CLAUDE_MODEL_ID, apiKey: 'placeholder' }), + ), + handler, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const passedRequest = handler.mock.calls[0]?.[0] as ModelRequest; + expect(passedRequest.model).toBeInstanceOf(ChatOllama); + expect((passedRequest.model as ChatOllama).model).toBe(OLLAMA_SUB_MODEL_ID); + }); + + it('passes the original request.model through for an unrecognized role', async () => { + const { middleware } = createProviderRoutingMiddleware({ + resolveModel: defaultMainSubResolver(), + }); + const original = new ChatOllama({ model: OLLAMA_SUB_MODEL_ID }); + const handler = vi.fn().mockResolvedValue(new AIMessage({ content: 'ok' })); + + await middleware.wrapModelCall!( + fakeRequest('mystery-role', original), + handler, + ); + + const passedRequest = handler.mock.calls[0]?.[0] as ModelRequest; + expect(passedRequest.model).toBe(original); + }); +}); + +describe('createProviderRoutingMiddleware — routing log', () => { + it('records requestIndex/role/providerClass/modelId/executed in order', async () => { + const { middleware, log } = createProviderRoutingMiddleware({ + resolveModel: defaultMainSubResolver(), + }); + const handler = vi.fn().mockResolvedValue(new AIMessage({ content: 'ok' })); + + await middleware.wrapModelCall!( + fakeRequest('main', new ChatOllama({ model: OLLAMA_SUB_MODEL_ID })), + handler, + ); + await middleware.wrapModelCall!( + fakeRequest('sub', new ChatOllama({ model: OLLAMA_SUB_MODEL_ID })), + handler, + ); + + expect(log).toHaveLength(2); + expect(log[0]).toMatchObject({ + requestIndex: 0, + role: 'main', + providerClass: 'ChatAnthropic', + modelId: CLAUDE_MODEL_ID, + executed: true, + }); + expect(log[1]).toMatchObject({ + requestIndex: 1, + role: 'sub', + providerClass: 'ChatOllama', + modelId: OLLAMA_SUB_MODEL_ID, + executed: true, + }); + }); + + it('records executed:false when executeOverride short-circuits the call', async () => { + const { middleware, log } = createProviderRoutingMiddleware({ + resolveModel: defaultMainSubResolver(), + executeOverride: (role) => + role === 'main' ? new AIMessage({ content: 'stubbed' }) : undefined, + }); + const handler = vi.fn().mockResolvedValue(new AIMessage({ content: 'ok' })); + + const result = await middleware.wrapModelCall!( + fakeRequest('main', new ChatOllama({ model: OLLAMA_SUB_MODEL_ID })), + handler, + ); + + expect(handler).not.toHaveBeenCalled(); + expect((result as AIMessage).content).toBe('stubbed'); + expect(log[0]).toMatchObject({ role: 'main', executed: false }); + }); +}); + +describe('one logical session — nested delegate_to_subagent (real Ollama call)', () => { + const maybeIt = OLLAMA_AVAILABLE ? it : it.skip; + + maybeIt( + 'routes main -> sub -> main within one agent.invoke() call, sub leg genuinely live', + async () => { + const { middleware: sharedMiddleware, log } = + createProviderRoutingMiddleware({ + resolveModel: defaultMainSubResolver(), + executeOverride: makeScriptedMainStub(), + }); + const placeholderModel = new ChatOllama({ model: OLLAMA_SUB_MODEL_ID }); + const delegateTool = makeDelegateToSubagentTool( + sharedMiddleware, + placeholderModel, + ); + + const mainAgent = createAgent({ + model: placeholderModel, + tools: [delegateTool], + middleware: [sharedMiddleware], + }); + + const result = await mainAgent.invoke( + { + messages: [ + { + role: 'user', + content: + 'Delegate to your subagent to find out its favorite fruit, then report back.', + }, + ], + }, + { context: { role: 'main' } }, + ); + + const subEntries = log.filter((r) => r.role === 'sub'); + const mainEntries = log.filter((r) => r.role === 'main'); + + expect(subEntries).toHaveLength(1); + expect(subEntries[0]).toMatchObject({ + providerClass: 'ChatOllama', + executed: true, + }); + + expect(mainEntries.length).toBeGreaterThanOrEqual(2); + expect( + mainEntries.every( + (r) => r.providerClass === 'ChatAnthropic' && r.executed === false, + ), + ).toBe(true); + + const subIndex = subEntries[0]!.requestIndex; + expect(mainEntries.some((r) => r.requestIndex < subIndex)).toBe(true); + expect(mainEntries.some((r) => r.requestIndex > subIndex)).toBe(true); + + const toolMessage = result.messages.find( + (m) => (m as { name?: string }).name === 'delegate_to_subagent', + ); + expect(toolMessage).toBeDefined(); + const payload = JSON.parse(String(toolMessage!.content)) as { + subagent_answer: string; + }; + // Real local Ollama response — non-empty, not the canned main-leg stub string. + expect(payload.subagent_answer.length).toBeGreaterThan(0); + expect(payload.subagent_answer).not.toBe( + 'Delegated to the subagent and got an answer back.', + ); + }, + 30_000, + ); +}); + +describe('optional live full-Claude smoke test', () => { + const hasCredential = Boolean(process.env.ANTHROPIC_API_KEY); + const maybeIt = hasCredential ? it : it.skip; + + maybeIt( + 'runs the same one-session flow with a real Claude call, no executeOverride', + async () => { + const { middleware: sharedMiddleware, log } = + createProviderRoutingMiddleware({ + resolveModel: defaultMainSubResolver(), + }); + const placeholderModel = new ChatOllama({ model: OLLAMA_SUB_MODEL_ID }); + const delegateTool = makeDelegateToSubagentTool( + sharedMiddleware, + placeholderModel, + ); + + const mainAgent = createAgent({ + model: placeholderModel, + tools: [delegateTool], + middleware: [sharedMiddleware], + }); + + await mainAgent.invoke( + { + messages: [ + { + role: 'user', + content: + 'Delegate to your subagent to find out its favorite fruit, then report back.', + }, + ], + }, + { context: { role: 'main' } }, + ); + + const mainEntries = log.filter((r) => r.role === 'main'); + expect(mainEntries.some((r) => r.executed === true)).toBe(true); + }, + 60_000, + ); +}); diff --git a/scripts/spikes/lia396_provider_override_walking_skeleton.ts b/scripts/spikes/lia396_provider_override_walking_skeleton.ts new file mode 100644 index 00000000..d952fc2a --- /dev/null +++ b/scripts/spikes/lia396_provider_override_walking_skeleton.ts @@ -0,0 +1,292 @@ +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { ChatAnthropic } from '@langchain/anthropic'; +import { AIMessage } from '@langchain/core/messages'; +import { ChatOllama } from '@langchain/ollama'; +import { + createAgent, + createMiddleware, + tool, + type AgentMiddleware, + type ModelRequest, +} from 'langchain'; + +export const CLAUDE_MODEL_ID = 'claude-opus-4-8'; // same as A1's MODEL_ID +export const OLLAMA_SUB_MODEL_ID = 'gemma4:e2b'; +// Reuses the repo's existing OLLAMA_URL convention (.env.example:36, used +// elsewhere for embeddings/judge) rather than introducing a new env var — +// mapped to @langchain/ollama's baseUrl constructor field internally. +export const OLLAMA_BASE_URL = + process.env.OLLAMA_URL ?? 'http://localhost:11434'; +const ANTHROPIC_PLACEHOLDER_KEY = 'sk-ant-lia396-placeholder-never-sent'; + +export type ProviderRole = string; // deliberately open (not 'main'|'sub' +// union) so A7 can add more roles without touching this file. + +export interface ProviderRoutingRecord { + /** Monotonic, 0-based, across this middleware instance's whole lifetime — + * lets a caller reconstruct request order across a nested session. */ + requestIndex: number; + role: ProviderRole; + /** request.model's constructor name AFTER selection, e.g. 'ChatAnthropic'. */ + providerClass: string; + /** Best-effort model id: (model as { model?: string }).model. */ + modelId: string | undefined; + /** true = the real LangChain handler ran (network call happened); + * false = executeOverride short-circuited it (e.g. no live credential). */ + executed: boolean; +} + +/** + * Resolves which model instance should handle a request for a given role. + * Return the request's own request.model unchanged to mean "no override for + * this role" (passthrough). Strategy-pattern seam (interchangeable per-role + * algorithm, matching A2's `invokeGate` injectable-seam precedent) — this IS + * the thing under test/reuse; A7 supplies its own resolver per benchmark + * matrix cell. + */ +export type ProviderResolver = ( + role: ProviderRole, + request: ModelRequest, +) => ModelRequest['model']; + +/** + * Optional per-role execution override (Strategy-pattern seam, same family + * as ProviderResolver). Return undefined to mean "call the real handler" + * (default for every role). Return an AIMessage to short-circuit the real + * network call — used ONLY to route around a known, disclosed environmental + * gap (e.g. missing credential), never silently. + * + * IMPORTANT: a caller driving a tool-calling loop from a stub MUST close + * over call-count state and script a tool_calls-bearing AIMessage on the + * turn that should trigger a tool, and a plain AIMessage on the turn that + * should end the loop — same shape as FakeToolCallingModel's scripted + * toolCalls array, just applied here (a stateless single-canned-response + * stub would never actually invoke a tool, so a nested subagent leg would + * never fire). + */ +export type ExecuteOverride = ( + role: ProviderRole, + request: ModelRequest, + model: ModelRequest['model'], +) => Promise | AIMessage | undefined; + +export interface CreateProviderRoutingMiddlewareOptions { + resolveModel: ProviderResolver; + executeOverride?: ExecuteOverride; + /** role read when request.runtime.context is missing/has no `.role`. */ + defaultRole?: ProviderRole; +} + +export interface ProviderRoutingMiddleware { + middleware: AgentMiddleware; + log: ProviderRoutingRecord[]; +} + +/** + * Reusable primitive: a real wrapModelCall middleware that selects a model + * per-request based on request.runtime.context.role, and records every + * selection (and whether it actually executed) to an inspectable log. + * A7 (LIA-400) imports this directly for its cross-provider benchmark + * matrix — no reimplementation needed. + */ +export function createProviderRoutingMiddleware( + options: CreateProviderRoutingMiddlewareOptions, +): ProviderRoutingMiddleware { + const log: ProviderRoutingRecord[] = []; + let requestIndex = 0; + + const middleware = createMiddleware({ + name: 'ProviderRouting', + wrapModelCall: async (request, handler) => { + const role = + (request.runtime.context as { role?: ProviderRole } | undefined) + ?.role ?? + options.defaultRole ?? + 'unknown'; + const model = options.resolveModel(role, request); + const index = requestIndex++; + + const override = await options.executeOverride?.(role, request, model); + if (override) { + log.push({ + requestIndex: index, + role, + providerClass: model.constructor.name, + modelId: (model as { model?: string }).model, + executed: false, + }); + return override; + } + + const result = await handler({ ...request, model }); + log.push({ + requestIndex: index, + role, + providerClass: model.constructor.name, + modelId: (model as { model?: string }).model, + executed: true, + }); + return result; + }, + }); + + return { middleware, log }; +} + +/** + * Default resolver for THIS spike's two roles — 'main' -> ChatAnthropic, + * 'sub' -> ChatOllama. Constructs a NEW instance per call. A7 will likely + * bring its own resolver covering more providers, so this is documented as + * an example, not baked into createProviderRoutingMiddleware itself. + */ +export function defaultMainSubResolver(): ProviderResolver { + return (role, request) => { + if (role === 'main') { + const apiKey = process.env.ANTHROPIC_API_KEY ?? ANTHROPIC_PLACEHOLDER_KEY; + return new ChatAnthropic({ model: CLAUDE_MODEL_ID, apiKey }); + } + if (role === 'sub') { + return new ChatOllama({ + model: OLLAMA_SUB_MODEL_ID, + baseUrl: OLLAMA_BASE_URL, + }); + } + return request.model; // passthrough for unrecognized roles + }; +} + +/** + * Builds a stateful executeOverride that short-circuits ONLY the 'main' + * role's execution, scripting a tool_calls-bearing AIMessage on its first + * invocation (so the ReAct loop actually invokes delegate_to_subagent) and + * a plain final AIMessage on every subsequent invocation. The 'sub' role + * always returns undefined (no override — real handler, real Ollama call). + */ +export function makeScriptedMainStub(): ExecuteOverride { + let mainCallCount = 0; + return (role) => { + if (role !== 'main') return undefined; + mainCallCount += 1; + if (mainCallCount === 1) { + return new AIMessage({ + content: '', + tool_calls: [ + { + name: 'delegate_to_subagent', + args: { task: 'What is your favorite fruit? Answer in one word.' }, + id: 'call_1', + type: 'tool_call', + }, + ], + }); + } + return new AIMessage({ + content: 'Delegated to the subagent and got an answer back.', + }); + }; +} + +/** + * Builds the delegate_to_subagent tool: its execute() nests a SECOND + * createAgent + invoke(), sharing the caller-supplied middleware instance, + * tagged { role: 'sub' }. This nesting IS "one logical session" (call-stack + * continuity) — see the plan's design-question-1 resolution. + */ +export function makeDelegateToSubagentTool( + sharedMiddleware: AgentMiddleware, + placeholderModel: ModelRequest['model'], +) { + return tool( + async (args: { task: string }) => { + const subAgent = createAgent({ + model: placeholderModel, // overridden by sharedMiddleware every call + middleware: [sharedMiddleware], + }); + const subResult = await subAgent.invoke( + { messages: [{ role: 'user', content: args.task }] }, + { context: { role: 'sub' } }, + ); + const last = subResult.messages.at(-1); + return JSON.stringify({ subagent_answer: last?.content ?? '' }); + }, + { + name: 'delegate_to_subagent', + description: 'Delegates a sub-task to a local subagent model.', + schema: { + type: 'object', + properties: { task: { type: 'string' } }, + required: ['task'], + additionalProperties: false, + }, + }, + ); +} + +// ── Direct-execution smoke demo ───────────────────────────────────────── + +async function runOneSessionDemo(): Promise { + const { middleware: sharedMiddleware, log } = createProviderRoutingMiddleware( + { + resolveModel: defaultMainSubResolver(), + executeOverride: makeScriptedMainStub(), + }, + ); + const placeholderModel = new ChatOllama({ + model: OLLAMA_SUB_MODEL_ID, + baseUrl: OLLAMA_BASE_URL, + }); + const delegateTool = makeDelegateToSubagentTool( + sharedMiddleware, + placeholderModel, + ); + + const mainAgent = createAgent({ + model: placeholderModel, // overridden by sharedMiddleware every call + tools: [delegateTool], + middleware: [sharedMiddleware], + }); + + const result = await mainAgent.invoke( + { + messages: [ + { + role: 'user', + content: + 'Delegate to your subagent to find out its favorite fruit, then report back.', + }, + ], + }, + { context: { role: 'main' } }, + ); + + console.log('\n── Transcript ──'); + for (const message of result.messages) { + const name = (message as { name?: string }).name; + console.log( + `[${message.getType()}${name ? `:${name}` : ''}] ${String(message.content)}`, + ); + } + + console.log('\n── Routing log ──'); + for (const record of log) { + console.log( + `#${record.requestIndex} role=${record.role} provider=${record.providerClass} model=${record.modelId} executed=${record.executed}`, + ); + } +} + +async function main(): Promise { + await runOneSessionDemo(); +} + +const invokedDirectly = + process.argv[1] !== undefined && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (invokedDirectly) { + main().catch((err) => { + console.error('A3 spike failed:', err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 46ba2703..2ada0c2b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], + include: [ + 'src/**/*.test.ts', + 'setup/**/*.test.ts', + 'scripts/spikes/**/*.test.ts', + ], coverage: { provider: 'v8', reporter: ['text', 'text-summary', 'lcov'],