diff --git a/langgraph/package.json b/langgraph/package.json index 2f83c24..f8954c4 100644 --- a/langgraph/package.json +++ b/langgraph/package.json @@ -13,7 +13,9 @@ "build": "tsc -b && vite build", "lint": "eslint .", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@assistant-ui/react": "^0.8.0", @@ -85,7 +87,8 @@ "tailwindcss": "^4.0.6", "typescript": "~5.7.2", "typescript-eslint": "^8.22.0", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.1.1" }, "overrides": { "react-is": "^19.0.0-rc-69d4b800-20241021" diff --git a/langgraph/pnpm-lock.yaml b/langgraph/pnpm-lock.yaml index 94be835..5bde838 100644 --- a/langgraph/pnpm-lock.yaml +++ b/langgraph/pnpm-lock.yaml @@ -213,6 +213,9 @@ importers: vite: specifier: ^6.1.0 version: 6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0) + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0) packages: @@ -685,6 +688,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -1320,6 +1326,9 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -1350,6 +1359,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1483,6 +1495,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1539,6 +1580,10 @@ packages: resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + assistant-stream@0.0.21: resolution: {integrity: sha512-tQuGIuTGtmNnHSc6hxANVjO23LPmwFBjZnOt0xOhoBQBqW3vBv6DK2QOObgT9wnfLY8i2DRDwDbw8fSvYVzdMA==} @@ -1586,6 +1631,10 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1608,6 +1657,10 @@ packages: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1633,6 +1686,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1789,6 +1846,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -1807,6 +1873,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1899,6 +1969,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1979,6 +2052,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2002,6 +2078,10 @@ packages: resolution: {integrity: sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==} engines: {node: '>=18'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2047,6 +2127,15 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} @@ -2389,6 +2478,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -2537,6 +2629,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} @@ -2551,6 +2646,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -2874,6 +2972,13 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pdf-parse@2.4.5: resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} engines: {node: '>=20.16.0 <21 || >=22.3.0'} @@ -2927,6 +3032,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + postcss-modules-extract-imports@3.1.0: resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} engines: {node: ^10 || ^12 || >= 14} @@ -3231,6 +3340,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3268,10 +3380,16 @@ packages: stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stacktrace-parser@0.1.11: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-hash@1.1.3: resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} @@ -3305,6 +3423,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} @@ -3353,6 +3474,28 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3539,6 +3682,11 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@6.2.0: resolution: {integrity: sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3579,6 +3727,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -3597,6 +3773,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + winston-console-format@1.0.8: resolution: {integrity: sha512-dq7t/E0D0QRi4XIOwu6HM1+5e//WPqylH88GVjKEhQVrzGFg34MCz+G7pMJcXFBen9C0kBsu5GYgbYsE2LDwKw==} @@ -4197,6 +4378,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -4817,6 +5000,11 @@ snapshots: dependencies: '@babel/types': 7.26.9 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -4845,6 +5033,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.6 @@ -5020,6 +5210,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -5063,6 +5295,8 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + assistant-stream@0.0.21: dependencies: nanoid: 3.3.8 @@ -5114,6 +5348,8 @@ snapshots: dependencies: run-applescript: 7.0.0 + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5132,6 +5368,14 @@ snapshots: adler-32: 1.3.1 crc-32: 1.2.2 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5151,6 +5395,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -5278,6 +5524,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize@1.2.0: {} decimal.js-light@2.5.1: {} @@ -5288,6 +5538,8 @@ snapshots: dedent@1.5.3: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} default-browser-id@5.0.0: {} @@ -5361,6 +5613,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -5489,6 +5743,10 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.6 + esutils@2.0.3: {} event-target-shim@5.0.1: {} @@ -5514,6 +5772,8 @@ snapshots: exit-hook@4.0.0: {} + expect-type@1.3.0: {} + extend@3.0.2: {} extract-zip@2.0.1: @@ -5568,6 +5828,10 @@ snapshots: dependencies: pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fecha@4.2.3: {} figures@6.1.0: @@ -5922,6 +6186,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -6048,6 +6314,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lowlight@1.20.0: dependencies: fault: 1.0.4 @@ -6063,6 +6331,10 @@ snapshots: dependencies: react: 19.0.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -6605,6 +6877,10 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + pathe@2.0.3: {} + + pathval@2.0.1: {} + pdf-parse@2.4.5: dependencies: '@napi-rs/canvas': 0.1.80 @@ -6655,6 +6931,8 @@ snapshots: picomatch@2.3.1: {} + picomatch@4.0.4: {} + postcss-modules-extract-imports@3.1.0(postcss@8.5.3): dependencies: postcss: 8.5.3 @@ -7023,6 +7301,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-swizzle@0.2.2: @@ -7050,10 +7330,14 @@ snapshots: stack-trace@0.0.10: {} + stackback@0.0.2: {} + stacktrace-parser@0.1.11: dependencies: type-fest: 0.7.1 + std-env@3.10.0: {} + string-hash@1.1.3: {} string-width@4.2.3: @@ -7089,6 +7373,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + strnum@1.1.2: {} strnum@2.2.3: {} @@ -7135,6 +7423,21 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7323,6 +7626,27 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0): dependencies: esbuild: 0.25.0 @@ -7336,6 +7660,48 @@ snapshots: tsx: 4.19.3 yaml: 2.7.0 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0) + vite-node: 3.2.4(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.13.5 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + web-namespaces@2.0.1: {} web-streams-polyfill@4.0.0-beta.3: {} @@ -7351,6 +7717,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + winston-console-format@1.0.8: dependencies: colors: 1.4.0 diff --git a/langgraph/src/agent/enterprise/nodes/__tests__/preValidate.test.ts b/langgraph/src/agent/enterprise/nodes/__tests__/preValidate.test.ts new file mode 100644 index 0000000..082154b --- /dev/null +++ b/langgraph/src/agent/enterprise/nodes/__tests__/preValidate.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi } from "vitest"; + +// Mock config to avoid missing env var errors at import time +vi.mock("@/agent/utils/config", () => ({ + config: { + kb: { url: "http://mock", searchPath: "/search", apiKey: "mock" }, + gongdan: { apiBase: "http://mock", apiKey: "mock" }, + jina: { apiKey: "mock" }, + serper: { apiKey: "mock" }, + daytona: { apiKey: "mock", apiUrl: "http://mock" }, + azureOpenAI: { apiKey: "mock", endpoint: "http://mock", apiVersion: "2024", deployment: "mock" }, + }, +})); + +import { preValidateToolCall } from "../tool-executor.js"; +import type { EnterpriseState } from "../../types.js"; +import { HumanMessage } from "@langchain/core/messages"; + +/** + * Build a minimal EnterpriseState stub for testing preValidateToolCall. + * Only `messages` and `execution_log` are inspected by the validator. + */ +function makeState( + overrides: Partial> = {}, +): EnterpriseState { + return { + messages: overrides.messages ?? [new HumanMessage("hello world test")], + ui: [], + timestamp: Date.now(), + toolStatus: [], + execution_log: overrides.execution_log ?? [], + } as unknown as EnterpriseState; +} + +// --------------------------------------------------------------------------- +// ticket_detail +// --------------------------------------------------------------------------- +describe("preValidateToolCall - ticket_detail", () => { + it("passes with a valid ticket_id", () => { + const result = preValidateToolCall( + "ticket_detail", + { ticket_id: "TK-2026-123456" }, + makeState(), + ); + expect(result).toBeNull(); + }); + + it.each(["xxx", "null", "{ticket_id}", "undefined", "test", "id", "unknown"])( + "blocks placeholder id: %s", + (badId) => { + const result = preValidateToolCall( + "ticket_detail", + { ticket_id: badId }, + makeState(), + ); + expect(result).not.toBeNull(); + expect(result).toContain("ticket_detail"); + }, + ); +}); + +// --------------------------------------------------------------------------- +// kb_search +// --------------------------------------------------------------------------- +describe("preValidateToolCall - kb_search", () => { + it("blocks query shorter than 5 characters", () => { + const result = preValidateToolCall( + "kb_search", + { query: "abc" }, + makeState(), + ); + expect(result).toContain("搜索词过短"); + }); + + it("blocks duplicate same-query in the same turn", () => { + const state = makeState({ + execution_log: [ + { + tool: "kb_search", + status: "success", + inputSummary: "query: VPN connection issue", + summary: "found 3 results", + timestamp: Date.now(), + durationMs: 100, + resultCount: 3, + }, + ], + }); + const result = preValidateToolCall( + "kb_search", + { query: "VPN connection issue" }, + state, + ); + expect(result).toContain("已查询过相同内容"); + }); + + it("passes with a valid long-enough query and no duplicates", () => { + const result = preValidateToolCall( + "kb_search", + { query: "how to reset password" }, + makeState(), + ); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// ticket_list - page validation +// --------------------------------------------------------------------------- +describe("preValidateToolCall - ticket_list page range", () => { + it("blocks page=0 (below range)", () => { + const result = preValidateToolCall( + "ticket_list", + { page: 0 }, + makeState(), + ); + expect(result).toContain("page 参数无效"); + }); + + it("blocks page=101 (above range)", () => { + const result = preValidateToolCall( + "ticket_list", + { page: 101 }, + makeState(), + ); + expect(result).toContain("page 参数无效"); + }); + + it.each([1, 50, 100])("passes with valid page=%i", (page) => { + const result = preValidateToolCall( + "ticket_list", + { page }, + makeState(), + ); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// execution_log length >= 3 +// --------------------------------------------------------------------------- +describe("preValidateToolCall - tool call limit", () => { + it("blocks when execution_log.length >= 3", () => { + const state = makeState({ + execution_log: [ + { tool: "kb_search", status: "success", summary: "", timestamp: 1, durationMs: 0, inputSummary: "" }, + { tool: "ticket_list", status: "success", summary: "", timestamp: 2, durationMs: 0, inputSummary: "" }, + { tool: "ticket_detail", status: "success", summary: "", timestamp: 3, durationMs: 0, inputSummary: "" }, + ], + }); + const result = preValidateToolCall( + "kb_search", + { query: "something long enough" }, + state, + ); + expect(result).toContain("达到单轮上限"); + }); +}); + +// --------------------------------------------------------------------------- +// ticket_list when message contains TK-xxxx +// --------------------------------------------------------------------------- +describe("preValidateToolCall - ticket_list with TK-xxxx in message", () => { + it("blocks ticket_list when user message contains a ticket number", () => { + const state = makeState({ + messages: [new HumanMessage("请帮我查一下 TK-2026-296691 的状态")], + }); + const result = preValidateToolCall( + "ticket_list", + { page: 1 }, + state, + ); + expect(result).toContain("ticket_detail"); + }); + + it("passes ticket_list when message has no ticket number", () => { + const state = makeState({ + messages: [new HumanMessage("列出最近的工单")], + }); + const result = preValidateToolCall( + "ticket_list", + { page: 1 }, + state, + ); + expect(result).toBeNull(); + }); +}); diff --git a/langgraph/src/agent/enterprise/nodes/tool-executor.ts b/langgraph/src/agent/enterprise/nodes/tool-executor.ts index 300312c..230953b 100644 --- a/langgraph/src/agent/enterprise/nodes/tool-executor.ts +++ b/langgraph/src/agent/enterprise/nodes/tool-executor.ts @@ -185,7 +185,7 @@ async function withTimeout( * Pre-validate tool call arguments before execution. * Returns a human-readable block reason, or null if validation passes. */ -function preValidateToolCall( +export function preValidateToolCall( name: string, args: Record, state: EnterpriseState, @@ -204,7 +204,7 @@ function preValidateToolCall( return "ticket_detail 调用被拦截:未提供有效工单编号,请改用 ticket_list 查询工单列表"; } // Placeholder ID detection - const PLACEHOLDER = /^(xxx|unknown|test|id|null|undefined|\d{1,2})$/i; + const PLACEHOLDER = /^(xxx|unknown|test|id|null|undefined|\d{1,2}|\{.*\})$/i; if (PLACEHOLDER.test(id)) { return `ticket_detail 调用被拦截:工单 ID "${id}" 看起来是占位符,请先用 ticket_list 查询真实工单号`; } diff --git a/langgraph/src/agent/enterprise/tools/soc-client.ts b/langgraph/src/agent/enterprise/tools/soc-client.ts index 2d89525..f4228eb 100644 --- a/langgraph/src/agent/enterprise/tools/soc-client.ts +++ b/langgraph/src/agent/enterprise/tools/soc-client.ts @@ -6,6 +6,7 @@ */ import { config } from "@/agent/utils/config"; +import { getToolConfig } from "@/agent/utils/toolConfig"; // --- Knowledge Base Search --- export async function kbSearch(query: string): Promise<{ @@ -24,7 +25,7 @@ export async function kbSearch(query: string): Promise<{ "api-key": config.kb.apiKey, }, body: JSON.stringify({ query, top: 5, search_mode: "hybrid" }), - signal: AbortSignal.timeout(45000), + signal: AbortSignal.timeout(getToolConfig("kb_search").timeoutMs), }); if (!resp.ok) throw new Error(`KB search failed: ${resp.status}`); return resp.json(); @@ -47,7 +48,7 @@ export async function ticketList( const url = `${config.gongdan.apiBase}/api/tickets?page=${page}&pageSize=${pageSize}`; const resp = await fetch(url, { headers: { "X-Api-Key": config.gongdan.apiKey }, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(getToolConfig("ticket_list").timeoutMs), }); if (!resp.ok) throw new Error(`Ticket list failed: ${resp.status}`); return resp.json(); @@ -59,6 +60,7 @@ export async function ticketDetail( ): Promise> { const base = config.gongdan.apiBase; const headers = { "X-Api-Key": config.gongdan.apiKey }; + const tdTimeout = getToolConfig("ticket_detail").timeoutMs; // If ticketId looks like a ticketNumber (e.g. "TK-2026-296691"), resolve UUID first. // Strategy: try query-param filter first; if no match, fall back to full list + local find. @@ -69,7 +71,7 @@ export async function ticketDetail( const searchUrl = `${base}/api/tickets?${paramName}=${encodeURIComponent(ticketId)}&pageSize=50`; const searchResp = await fetch(searchUrl, { headers, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(tdTimeout), }); if (searchResp.ok) { const searchData = await searchResp.json(); @@ -91,7 +93,7 @@ export async function ticketDetail( const listUrl = `${base}/api/tickets?pageSize=100`; const listResp = await fetch(listUrl, { headers, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(tdTimeout), }); if (listResp.ok) { const listData = await listResp.json(); @@ -111,7 +113,7 @@ export async function ticketDetail( let url = `${base}/api/tickets/detail/${resolvedId}`; let resp = await fetch(url, { headers, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(tdTimeout), }); // If /detail/ returns 404, fall back to /api/tickets/{id} @@ -119,7 +121,7 @@ export async function ticketDetail( url = `${base}/api/tickets/${resolvedId}`; resp = await fetch(url, { headers, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(tdTimeout), }); } @@ -147,7 +149,7 @@ export async function webSearch(query: string): Promise<{ method: "POST", headers, body: JSON.stringify({ q: query, num: 5 }), - signal: AbortSignal.timeout(12000), + signal: AbortSignal.timeout(getToolConfig("web_search").timeoutMs), }); if (!searchResp.ok) throw new Error(`Jina search failed: ${searchResp.status}`); const searchData = await searchResp.json(); @@ -159,7 +161,7 @@ export async function webSearch(query: string): Promise<{ if (!r.url) return ""; const readResp = await fetch(`https://r.jina.ai/${r.url}`, { headers, - signal: AbortSignal.timeout(8000), + signal: AbortSignal.timeout(getToolConfig("web_search").timeoutMs), }); if (!readResp.ok) return ""; const data = await readResp.json(); @@ -200,7 +202,7 @@ export async function googleSearch(query: string): Promise<{ "Content-Type": "application/json", }, body: JSON.stringify({ q: query, num: 10, gl: "cn", hl: "zh-cn" }), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(getToolConfig("google_search").timeoutMs), }); if (!resp.ok) throw new Error(`Serper search failed: ${resp.status}`); const data = await resp.json(); @@ -229,7 +231,7 @@ export async function webRead(url: string): Promise<{ content: string; title: st Authorization: `Bearer ${config.jina.apiKey}`, Accept: "application/json", }, - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(getToolConfig("web_search").timeoutMs), }); if (!resp.ok) throw new Error(`Jina read failed: ${resp.status}`); const data = await resp.json(); @@ -252,7 +254,7 @@ export async function jinaRerank(query: string, documents: string[], topN = 5): model: "jina-reranker-v2-base-multilingual", top_n: topN, }), - signal: AbortSignal.timeout(10000), + signal: AbortSignal.timeout(getToolConfig("web_search").timeoutMs), }); if (!resp.ok) throw new Error(`Jina rerank failed: ${resp.status}`); return resp.json(); @@ -264,13 +266,10 @@ export async function jinaRerank(query: string, documents: string[], topN = 5): const SANDBOX_MAX_CODE_LENGTH = 10_000; /** Maximum output length (characters) before truncation */ const SANDBOX_MAX_OUTPUT_LENGTH = 3_000; -/** Default code execution timeout (ms) */ -const SANDBOX_DEFAULT_EXEC_TIMEOUT_MS = 15_000; - export async function sandboxRun( code: string, language = "python", - timeoutMs = SANDBOX_DEFAULT_EXEC_TIMEOUT_MS, + timeoutMs = getToolConfig("code_execute").timeoutMs, ): Promise<{ exit_code: number; stdout: string; @@ -295,7 +294,7 @@ export async function sandboxRun( method: "POST", headers, body: JSON.stringify({ autoStopInterval: 5, autoDeleteInterval: 0 }), - signal: AbortSignal.timeout(30000), + signal: AbortSignal.timeout(getToolConfig("code_execute").timeoutMs * 2), }); } catch (e) { const isTimeout = e instanceof DOMException && e.name === "AbortError"; diff --git a/langgraph/src/agent/utils/__tests__/retry.test.ts b/langgraph/src/agent/utils/__tests__/retry.test.ts new file mode 100644 index 0000000..fd4fd71 --- /dev/null +++ b/langgraph/src/agent/utils/__tests__/retry.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from "vitest"; +import { executeWithRetry } from "../retry.js"; + +describe("executeWithRetry", () => { + it("returns result on first success with retryCount=0 (no retries triggered)", async () => { + const onAttempt = vi.fn(); + const result = await executeWithRetry( + () => Promise.resolve("ok"), + 2, + { backoffMs: 10, onAttempt }, + ); + expect(result).toBe("ok"); + expect(onAttempt).not.toHaveBeenCalled(); + }); + + it("retries once then succeeds (retryCount=1)", async () => { + let call = 0; + const onAttempt = vi.fn(); + const result = await executeWithRetry( + () => { + call++; + if (call === 1) return Promise.reject(new Error("fail1")); + return Promise.resolve("ok2"); + }, + 2, + { backoffMs: 10, onAttempt }, + ); + expect(result).toBe("ok2"); + expect(onAttempt).toHaveBeenCalledTimes(1); + expect(onAttempt).toHaveBeenCalledWith(1); + }); + + it("throws the last error when all attempts fail", async () => { + let call = 0; + await expect( + executeWithRetry( + () => { + call++; + return Promise.reject(new Error(`fail${call}`)); + }, + 2, // 2 retries = 3 total attempts + { backoffMs: 10 }, + ), + ).rejects.toThrow("fail3"); + }); + + it("rejects immediately when abortSignal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + const fn = vi.fn(() => Promise.reject(new Error("should-retry"))); + + await expect( + executeWithRetry(fn, 3, { + backoffMs: 10, + abortSignal: controller.signal, + }), + ).rejects.toThrow(); + + // fn is called once (the initial attempt), then during the backoff wait + // the abort fires and rejects with RunCancelled before any retry + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("rejects with RunCancelled when abortSignal fires during backoff", async () => { + const controller = new AbortController(); + let call = 0; + + const fn = () => { + call++; + if (call === 1) { + // Abort during the backoff delay after first failure + setTimeout(() => controller.abort(), 5); + return Promise.reject(new Error("fail1")); + } + return Promise.resolve("should-not-reach"); + }; + + await expect( + executeWithRetry(fn, 3, { + backoffMs: 5000, // long backoff so abort fires first + abortSignal: controller.signal, + }), + ).rejects.toThrow("RunCancelled"); + }); +}); diff --git a/langgraph/src/agent/utils/retry.ts b/langgraph/src/agent/utils/retry.ts index 2fe1585..0191b70 100644 --- a/langgraph/src/agent/utils/retry.ts +++ b/langgraph/src/agent/utils/retry.ts @@ -26,6 +26,11 @@ export async function executeWithRetry( `[retry] attempt ${attempt}/${maxAttempts} failed, retrying in ${delay}ms`, ); await new Promise((resolve, reject) => { + // Check if signal already aborted before adding listener + if (options?.abortSignal?.aborted) { + reject(new Error("RunCancelled")); + return; + } const t = setTimeout(resolve, delay); options?.abortSignal?.addEventListener("abort", () => { clearTimeout(t); diff --git a/langgraph/vitest.config.ts b/langgraph/vitest.config.ts new file mode 100644 index 0000000..879052f --- /dev/null +++ b/langgraph/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, +});