diff --git a/bun.lock b/bun.lock index 029b72b..72a0da1 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,8 @@ "smol-toml": "^1.4.2", }, "devDependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/ui-utils": "^1.2.11", "@eslint/js": "^9.35.0", "@modelcontextprotocol/sdk": "1.20.0", "@stylistic/eslint-plugin": "^5.4.0", @@ -27,6 +29,7 @@ "@types/sinon": "^17.0.4", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", + "ai": "^5.0.102", "async-mutex": "^0.5.0", "chrome-devtools-frontend": "1.0.1524741", "commander": "^14.0.1", @@ -66,11 +69,14 @@ "@ai-sdk/google": "^2.0.43", "@ai-sdk/openai": "^2.0.72", "@ai-sdk/openai-compatible": "^1.0.27", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/ui-utils": "^1.2.11", "@anthropic-ai/claude-agent-sdk": "^0.1.11", "@browseros/common": "workspace:*", "@browseros/server": "workspace:*", "@browseros/tools": "workspace:*", "@google/gemini-cli-core": "^0.16.0", + "@hono/node-server": "^1.19.6", "@openrouter/ai-sdk-provider": "~1.2.5", "ai": "^5.0.101", "zod": "^4.1.12", @@ -78,6 +84,7 @@ "devDependencies": { "@types/bun": "latest", "typescript": "^5.9.3", + "vitest": "^4.0.14", }, "optionalDependencies": { "chrome-devtools-mcp": "latest", @@ -222,7 +229,9 @@ "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + + "@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.23", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-DktXOjzS2hOuuj2Zpo7fEooONfMa5bm09pt1/Vt4vn30YugELoezn/ZQ/TG5uSQ7+Zl/ElxAvi4vGDorj1Tirg=="], @@ -422,6 +431,8 @@ "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], + "@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -732,10 +743,14 @@ "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/chrome": ["@types/chrome@0.1.24", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-9iO9HL2bMeGS4C8m6gNFWUyuPE5HEUFk+rGh+7oriUjg+ata4Fc9PoVlu8xvGm7yoo3AmS3J6fAjoFj61NL2rw=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], @@ -858,6 +873,20 @@ "@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@vitest/expect": ["@vitest/expect@4.0.14", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.14", "@vitest/utils": "4.0.14", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.14", "", { "dependencies": { "@vitest/spy": "4.0.14", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.14", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ=="], + + "@vitest/runner": ["@vitest/runner@4.0.14", "", { "dependencies": { "@vitest/utils": "4.0.14", "pathe": "^2.0.3" } }, "sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.14", "", { "dependencies": { "@vitest/pretty-format": "4.0.14", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag=="], + + "@vitest/spy": ["@vitest/spy@4.0.14", "", {}, "sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg=="], + + "@vitest/utils": ["@vitest/utils@4.0.14", "", { "dependencies": { "@vitest/pretty-format": "4.0.14", "tinyrainbow": "^3.0.3" } }, "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], @@ -916,7 +945,7 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ai": ["ai@5.0.101", "", { "dependencies": { "@ai-sdk/gateway": "2.0.15", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/P4fgs2PGYTBaZi192YkPikOudsl9vccA65F7J7LvoNTOoP5kh1yAsJPsKAy6FXU32bAngai7ft1UDyC3u7z5g=="], + "ai": ["ai@5.0.102", "", { "dependencies": { "@ai-sdk/gateway": "2.0.15", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-snRK3nS5DESOjjpq7S74g8YszWVMzjagfHqlJWZsbtl9PyOS+2XUd8dt2wWg/jdaq/jh0aU66W1mx5qFjUQyEg=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -952,6 +981,8 @@ "arrify": ["arrify@2.0.1", "", {}, "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -1046,6 +1077,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], + "chai": ["chai@6.2.1", "", {}, "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], @@ -1260,6 +1293,8 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -1282,6 +1317,8 @@ "expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], + "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], + "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.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" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], @@ -1794,6 +1831,8 @@ "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1976,6 +2015,8 @@ "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], + "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -2010,6 +2051,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="], @@ -2048,8 +2091,12 @@ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "stream-events": ["stream-events@1.0.5", "", { "dependencies": { "stubs": "^3.0.0" } }, "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg=="], @@ -2110,10 +2157,14 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -2204,6 +2255,10 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="], + + "vitest": ["vitest@4.0.14", "", { "dependencies": { "@vitest/expect": "4.0.14", "@vitest/mocker": "4.0.14", "@vitest/pretty-format": "4.0.14", "@vitest/runner": "4.0.14", "@vitest/snapshot": "4.0.14", "@vitest/spy": "4.0.14", "@vitest/utils": "4.0.14", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.14", "@vitest/browser-preview": "4.0.14", "@vitest/browser-webdriverio": "4.0.14", "@vitest/ui": "4.0.14", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw=="], + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], "watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="], @@ -2234,6 +2289,8 @@ "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wildcard": ["wildcard@2.0.1", "", {}, "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -2276,6 +2333,24 @@ "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + + "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + + "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + + "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + + "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + + "@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "@ai-sdk/ui-utils/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2346,6 +2421,8 @@ "accepts/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-keywords/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], diff --git a/package.json b/package.json index a396a6d..f089bf3 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "smol-toml": "^1.4.2" }, "devDependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/ui-utils": "^1.2.11", "@eslint/js": "^9.35.0", "@modelcontextprotocol/sdk": "1.20.0", "@stylistic/eslint-plugin": "^5.4.0", @@ -69,6 +71,7 @@ "@types/sinon": "^17.0.4", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", + "ai": "^5.0.102", "async-mutex": "^0.5.0", "chrome-devtools-frontend": "1.0.1524741", "commander": "^14.0.1", diff --git a/packages/agent/package.json b/packages/agent/package.json index 586553d..846d4f2 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -34,18 +34,22 @@ "@ai-sdk/google": "^2.0.43", "@ai-sdk/openai": "^2.0.72", "@ai-sdk/openai-compatible": "^1.0.27", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/ui-utils": "^1.2.11", "@anthropic-ai/claude-agent-sdk": "^0.1.11", "@browseros/common": "workspace:*", "@browseros/server": "workspace:*", "@browseros/tools": "workspace:*", "@google/gemini-cli-core": "^0.16.0", + "@hono/node-server": "^1.19.6", "@openrouter/ai-sdk-provider": "~1.2.5", "ai": "^5.0.101", "zod": "^4.1.12" }, "devDependencies": { "@types/bun": "latest", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.14" }, "optionalDependencies": { "chrome-devtools-mcp": "latest" diff --git a/packages/agent/src/agent/GeminiAgent.ts b/packages/agent/src/agent/GeminiAgent.ts index fa019fa..a7ef2c4 100644 --- a/packages/agent/src/agent/GeminiAgent.ts +++ b/packages/agent/src/agent/GeminiAgent.ts @@ -94,8 +94,6 @@ export class GeminiAgent { }); await geminiConfig.initialize(); - - console.log('resolvedConfig', resolvedConfig); const contentGenerator = new VercelAIContentGenerator(resolvedConfig); (geminiConfig as unknown as { contentGenerator: VercelAIContentGenerator }).contentGenerator = contentGenerator; diff --git a/packages/agent/src/agent/gemini-vercel-sdk-adapter/index.ts b/packages/agent/src/agent/gemini-vercel-sdk-adapter/index.ts index b2af847..35520d7 100644 --- a/packages/agent/src/agent/gemini-vercel-sdk-adapter/index.ts +++ b/packages/agent/src/agent/gemini-vercel-sdk-adapter/index.ts @@ -122,6 +122,7 @@ export class VercelAIContentGenerator implements ContentGenerator { tools, temperature: request.config?.temperature, topP: request.config?.topP, + abortSignal: request.config?.abortSignal, }); return this.responseStrategy.streamToGemini( diff --git a/packages/agent/src/agent/gemini-vercel-sdk-adapter/strategies/response.ts b/packages/agent/src/agent/gemini-vercel-sdk-adapter/strategies/response.ts index 6f064da..3a3a452 100644 --- a/packages/agent/src/agent/gemini-vercel-sdk-adapter/strategies/response.ts +++ b/packages/agent/src/agent/gemini-vercel-sdk-adapter/strategies/response.ts @@ -186,6 +186,25 @@ export class ResponseConversionStrategy { usage = this.estimateUsage(textAccumulator); } + // Emit finish stream part to Hono SSE for useChat compatibility + if (honoStream) { + try { + // Emit finish_message part with finishReason and usage + // Format: e:{"finishReason":"stop","usage":{"promptTokens":10,"completionTokens":5}} + // Map to LanguageModelV1FinishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown' + const mappedFinishReason = this.mapToDataStreamFinishReason(finishReason); + await honoStream.write(formatDataStreamPart('finish_message', { + finishReason: mappedFinishReason, + usage: usage ? { + promptTokens: usage.promptTokens ?? 0, + completionTokens: usage.completionTokens ?? 0, + } : undefined, + })); + } catch { + // Failed to write finish part + } + } + // Yield final response with tool calls and metadata if (toolCallsMap.size > 0 || finishReason || usage) { const parts: Part[] = []; @@ -281,6 +300,19 @@ export class ResponseConversionStrategy { } } + /** + * Map Vercel finish reasons to data stream protocol finish reasons + * LanguageModelV1FinishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown' + * Mostly passthrough except 'max-tokens' → 'length' + */ + private mapToDataStreamFinishReason( + reason: VercelFinishReason | undefined, + ): 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown' { + if (!reason) return 'stop'; + if (reason === 'max-tokens') return 'length'; + return reason; + } + /** * Create empty response for error cases */ diff --git a/packages/agent/src/errors.ts b/packages/agent/src/errors.ts new file mode 100644 index 0000000..cb2af91 --- /dev/null +++ b/packages/agent/src/errors.ts @@ -0,0 +1,64 @@ +export class HttpAgentError extends Error { + constructor( + message: string, + public statusCode: number = 500, + public code?: string, + ) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } + + toJSON() { + return { + error: { + name: this.name, + message: this.message, + code: this.code, + statusCode: this.statusCode, + }, + }; + } +} + +export class ValidationError extends HttpAgentError { + constructor(message: string, public details?: unknown) { + super(message, 400, 'VALIDATION_ERROR'); + } + + override toJSON() { + return { + error: { + name: this.name, + message: this.message, + code: this.code, + statusCode: this.statusCode, + details: this.details, + }, + }; + } +} + +export class SessionNotFoundError extends HttpAgentError { + constructor(public conversationId: string) { + super(`Session "${conversationId}" not found.`, 404, 'SESSION_NOT_FOUND'); + } +} + +export class AgentExecutionError extends HttpAgentError { + constructor(message: string, public originalError?: Error) { + super(message, 500, 'AGENT_EXECUTION_ERROR'); + } + + override toJSON() { + return { + error: { + name: this.name, + message: this.message, + code: this.code, + statusCode: this.statusCode, + originalError: this.originalError?.message, + }, + }; + } +} diff --git a/packages/agent/src/http/HttpServer.ts b/packages/agent/src/http/HttpServer.ts new file mode 100644 index 0000000..894815d --- /dev/null +++ b/packages/agent/src/http/HttpServer.ts @@ -0,0 +1,175 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { stream } from 'hono/streaming'; +import { serve } from '@hono/node-server'; +import { formatDataStreamPart } from '@ai-sdk/ui-utils'; +import { logger } from '@browseros/common'; +import type { Context, Next } from 'hono'; +import type { ContentfulStatusCode } from 'hono/utils/http-status'; +import type { z } from 'zod'; + +import { SessionManager } from '../session/SessionManager.js'; +import { HttpAgentError, ValidationError, AgentExecutionError } from '../errors.js'; +import { ChatRequestSchema, HttpServerConfigSchema } from './types.js'; +import type { HttpServerConfig, ValidatedHttpServerConfig, ChatRequest } from './types.js'; + +type AppVariables = { + validatedBody: unknown; +}; + +const DEFAULT_MCP_SERVER_URL = 'http://127.0.0.1:9150/mcp'; +const DEFAULT_TEMP_DIR = '/tmp'; + +function validateRequest(schema: z.ZodType) { + return async (c: Context<{ Variables: AppVariables }>, next: Next) => { + try { + const body = await c.req.json(); + const validated = schema.parse(body); + c.set('validatedBody', validated); + await next(); + } catch (err) { + if (err && typeof err === 'object' && 'issues' in err) { + const zodError = err as { issues: unknown }; + logger.warn('Request validation failed', { issues: zodError.issues }); + throw new ValidationError('Request validation failed', zodError.issues); + } + throw err; + } + }; +} + +export function createHttpServer(config: HttpServerConfig) { + const validatedConfig: ValidatedHttpServerConfig = HttpServerConfigSchema.parse(config); + const mcpServerUrl = validatedConfig.mcpServerUrl || process.env.MCP_SERVER_URL || DEFAULT_MCP_SERVER_URL; + + const app = new Hono<{ Variables: AppVariables }>(); + const sessionManager = new SessionManager(); + + app.use( + '/*', + cors({ + origin: (origin) => origin || '*', + allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + credentials: true, + }), + ); + + app.onError((err, c) => { + const error = err as Error; + + if (error instanceof HttpAgentError) { + logger.warn('HTTP Agent Error', { + name: error.name, + message: error.message, + code: error.code, + statusCode: error.statusCode, + }); + return c.json(error.toJSON(), error.statusCode as ContentfulStatusCode); + } + + logger.error('Unhandled Error', { + message: error.message, + stack: error.stack, + }); + + return c.json( + { + error: { + name: 'InternalServerError', + message: error.message || 'An unexpected error occurred', + code: 'INTERNAL_SERVER_ERROR', + statusCode: 500, + }, + }, + 500, + ); + }); + + app.get('/health', (c) => c.json({ status: 'ok' })); + + app.post('/chat', validateRequest(ChatRequestSchema), async (c) => { + const request = c.get('validatedBody') as ChatRequest; + + logger.info('Chat request received', { + conversationId: request.conversationId, + provider: request.provider, + model: request.model, + }); + + c.header('Content-Type', 'text/plain; charset=utf-8'); + c.header('X-Vercel-AI-Data-Stream', 'v1'); + c.header('Cache-Control', 'no-cache'); + c.header('Connection', 'keep-alive'); + + // Get abort signal from the raw request - fires when client disconnects + const abortSignal = c.req.raw.signal; + + return stream(c, async (honoStream) => { + try { + const agent = await sessionManager.getOrCreate({ + conversationId: request.conversationId, + provider: request.provider, + model: request.model, + apiKey: request.apiKey, + baseUrl: request.baseUrl, + // Azure-specific + resourceName: request.resourceName, + // AWS Bedrock-specific + region: request.region, + accessKeyId: request.accessKeyId, + secretAccessKey: request.secretAccessKey, + sessionToken: request.sessionToken, + // Agent-specific + tempDir: validatedConfig.tempDir || DEFAULT_TEMP_DIR, + mcpServerUrl, + }); + + await agent.execute(request.message, honoStream, abortSignal); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Agent execution failed'; + logger.error('Agent execution error', { + conversationId: request.conversationId, + error: errorMessage, + }); + await honoStream.write(formatDataStreamPart('error', errorMessage)); + throw new AgentExecutionError('Agent execution failed', error instanceof Error ? error : undefined); + } + }); + }); + + app.delete('/chat/:conversationId', (c) => { + const conversationId = c.req.param('conversationId'); + const deleted = sessionManager.delete(conversationId); + + if (deleted) { + return c.json({ + success: true, + message: `Session ${conversationId} deleted`, + sessionCount: sessionManager.count(), + }); + } + + return c.json({ + success: false, + message: `Session ${conversationId} not found`, + }, 404); + }); + + const server = serve({ + fetch: app.fetch, + port: validatedConfig.port, + hostname: validatedConfig.host, + }); + + logger.info('HTTP Agent Server started', { + port: validatedConfig.port, + host: validatedConfig.host, + }); + + return { + app, + server, + config: validatedConfig, + }; +} diff --git a/packages/agent/src/http/index.ts b/packages/agent/src/http/index.ts new file mode 100644 index 0000000..58ed9a8 --- /dev/null +++ b/packages/agent/src/http/index.ts @@ -0,0 +1,3 @@ +export { createHttpServer } from './HttpServer.js'; +export { HttpServerConfigSchema, ChatRequestSchema } from './types.js'; +export type { HttpServerConfig, ValidatedHttpServerConfig, ChatRequest } from './types.js'; diff --git a/packages/agent/src/http/types.ts b/packages/agent/src/http/types.ts new file mode 100644 index 0000000..6da0379 --- /dev/null +++ b/packages/agent/src/http/types.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { VercelAIConfigSchema } from '../agent/gemini-vercel-sdk-adapter/types.js'; + +/** + * Chat request schema extends VercelAIConfig with request-specific fields + */ +export const ChatRequestSchema = VercelAIConfigSchema.extend({ + conversationId: z.string().uuid(), + message: z.string().min(1, 'Message cannot be empty'), +}); + +export type ChatRequest = z.infer; + +export interface HttpServerConfig { + port: number; + host?: string; + corsOrigins?: string[]; + tempDir?: string; + mcpServerUrl?: string; +} + +export const HttpServerConfigSchema = z.object({ + port: z.number().int().positive(), + host: z.string().optional().default('0.0.0.0'), + corsOrigins: z.array(z.string()).optional().default(['*']), + tempDir: z.string().optional().default('/tmp'), + mcpServerUrl: z.string().optional(), +}); + +export type ValidatedHttpServerConfig = z.infer; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 27a47a1..1b483c3 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,16 +1,14 @@ -/** - * @license - * Copyright 2025 BrowserOS - */ +export { createHttpServer } from './http/index.js'; +export { HttpServerConfigSchema, ChatRequestSchema } from './http/index.js'; +export type { HttpServerConfig, ValidatedHttpServerConfig, ChatRequest } from './http/index.js'; -// Public API exports for integration with main server -export {createServer as createAgentServer} from './websocket/server.js'; -export {ServerConfigSchema as AgentServerConfigSchema} from './websocket/server.js'; -export type {ServerConfig as AgentServerConfig} from './websocket/server.js'; -export type {ControllerBridge} from '@browseros/controller-server'; +// Alias for backwards compatibility with packages/server +export { createHttpServer as createAgentServer } from './http/index.js'; +export type { HttpServerConfig as AgentServerConfig } from './http/index.js'; -// Agent factory exports -export {AgentFactory} from './agent/AgentFactory.js'; -export type {AgentConstructor} from './agent/AgentFactory.js'; -export {registerAgents} from './agent/registry.js'; -export {BaseAgent} from './agent/BaseAgent.js'; +export { GeminiAgent, AIProvider } from './agent/index.js'; +export type { AgentConfig } from './agent/index.js'; + +export { SessionManager } from './session/index.js'; + +export { HttpAgentError, ValidationError, SessionNotFoundError, AgentExecutionError } from './errors.js'; diff --git a/packages/agent/src/session/SessionManager.test.ts b/packages/agent/src/session/SessionManager.test.ts deleted file mode 100644 index ec602df..0000000 --- a/packages/agent/src/session/SessionManager.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * @license - * Copyright 2025 BrowserOS - */ - -import {describe, it, expect, beforeEach, afterEach} from 'bun:test'; - -import type {FormattedEvent} from '../utils/EventFormatter.js'; - -import {BaseAgent} from '../agent/BaseAgent.js'; -import type {AgentConfig} from '../agent/types.js'; -import {AgentFactory} from '../agent/AgentFactory.js'; -import {SessionManager} from './SessionManager.js'; - -// Test agent implementation -class TestAgent extends BaseAgent { - constructor(config: AgentConfig) { - super('test-agent', config); - } - - async *execute(message: string): AsyncGenerator { - yield {type: 'test', content: message, metadata: {}} as any; - } - - async destroy(): Promise { - this.markDestroyed(); - } -} - -describe('SessionManager-unit-test', () => { - let sessionManager: SessionManager; - let mockControllerBridge: any; - - beforeEach(() => { - // Register test agent - AgentFactory.register('test-agent', TestAgent as any, 'Test agent'); - - // Create fresh instance for each test - sessionManager = new SessionManager({ - maxSessions: 5, - idleTimeoutMs: 60000, - }); - }); - - afterEach(() => { - // Clean up agent registry - AgentFactory.clear(); - }); - - // Unit Test 1 - Creation and initialization - it('tests that SessionManager creates with correct initial state', () => { - expect(sessionManager).toBeDefined(); - - // Verify private fields are initialized - expect(sessionManager['sessions']).toBeInstanceOf(Map); - expect(sessionManager['agents']).toBeInstanceOf(Map); - expect(sessionManager['sessions'].size).toBe(0); - expect(sessionManager['agents'].size).toBe(0); - - // Verify config is stored - expect(sessionManager['config'].maxSessions).toBe(5); - expect(sessionManager['config'].idleTimeoutMs).toBe(60000); - }); - - // Unit Test 2 - Session creation and state management - it('tests that session creates and updates state correctly', () => { - const agentConfig = { - resourcesDir: '/test/resources', - executionDir: '/test/execution', - apiKey: 'test-key', - }; - - // Check initial state - expect(sessionManager['sessions'].size).toBe(0); - expect(sessionManager.isAtCapacity()).toBe(false); - - // Create session - const session = sessionManager.createSession( - {id: crypto.randomUUID(), agentType: 'test-agent'}, - agentConfig, - ); - - // Verify state changes - expect(session).toBeDefined(); - expect(session.id).toBeDefined(); - expect(session.messageCount).toBe(0); - expect(sessionManager['sessions'].size).toBe(1); - expect(sessionManager['agents'].size).toBe(1); - - // Verify capacity calculation - const capacity = sessionManager.getCapacity(); - expect(capacity.active).toBe(1); - expect(capacity.available).toBe(4); - }); - - // Unit Test 3 - Session state transitions - it('tests that session state transitions handle correctly', () => { - const sessionId = crypto.randomUUID(); - const agentConfig = { - resourcesDir: '/test/resources', - executionDir: '/test/execution', - apiKey: 'test-key', - }; - - // Create session - sessionManager.createSession( - {id: sessionId, agentType: 'test-agent'}, - agentConfig, - ); - const session = sessionManager['sessions'].get(sessionId); - - // Initial state should be IDLE - expect(session?.state).toBe('idle'); - - // Mark as processing - const marked = sessionManager.markProcessing(sessionId); - expect(marked).toBe(true); - expect(session?.state).toBe('processing'); - expect(session?.messageCount).toBe(1); - - // Try to mark as processing again (should fail) - const markedAgain = sessionManager.markProcessing(sessionId); - expect(markedAgain).toBe(false); - expect(session?.messageCount).toBe(1); // Should not increment - - // Mark as idle - sessionManager.markIdle(sessionId); - expect(session?.state).toBe('idle'); - expect(session?.lastActivity).toBeGreaterThan(0); - }); - - // Unit Test 4 - Idle session detection - it('tests that idle sessions identify correctly', async () => { - const sessionId = crypto.randomUUID(); - const agentConfig = { - resourcesDir: '/test/resources', - executionDir: '/test/execution', - apiKey: 'test-key', - }; - - // Create session with short idle timeout - const shortTimeoutManager = new SessionManager( - { - maxSessions: 5, - idleTimeoutMs: 100, // 100ms - }, - mockControllerBridge, - ); - - shortTimeoutManager.createSession( - {id: sessionId, agentType: 'test-agent'}, - agentConfig, - ); - - // Mark as idle - shortTimeoutManager.markIdle(sessionId); - - // Initially should not be idle - let idleSessions = shortTimeoutManager.findIdleSessions(); - expect(idleSessions).toHaveLength(0); - - // Wait for timeout - await new Promise(resolve => setTimeout(resolve, 150)); - - // Now should be detected as idle - idleSessions = shortTimeoutManager.findIdleSessions(); - expect(idleSessions).toHaveLength(1); - expect(idleSessions[0]).toBe(sessionId); - - // Cleanup - await shortTimeoutManager.deleteSession(sessionId); - }); - - // Unit Test 5 - Capacity management - it('tests that capacity limits enforce correctly', () => { - const smallManager = new SessionManager( - { - maxSessions: 2, - idleTimeoutMs: 60000, - }, - mockControllerBridge, - ); - - const agentConfig = { - resourcesDir: '/test/resources', - executionDir: '/test/execution', - apiKey: 'test-key', - }; - - // Create first session - smallManager.createSession({agentType: 'test-agent'}, agentConfig); - expect(smallManager.isAtCapacity()).toBe(false); - - // Create second session - smallManager.createSession({agentType: 'test-agent'}, agentConfig); - expect(smallManager.isAtCapacity()).toBe(true); - - // Try to create third session (should throw) - expect(() => { - smallManager.createSession({agentType: 'test-agent'}, agentConfig); - }).toThrow('Server at capacity'); - }); -}); diff --git a/packages/agent/src/session/SessionManager.ts b/packages/agent/src/session/SessionManager.ts index 3f50583..e2bcb1f 100644 --- a/packages/agent/src/session/SessionManager.ts +++ b/packages/agent/src/session/SessionManager.ts @@ -1,516 +1,48 @@ -/** - * @license - * Copyright 2025 BrowserOS - */ +import { logger } from '@browseros/common'; +import { GeminiAgent } from '../agent/GeminiAgent.js'; +import type { AgentConfig } from '../agent/types.js'; -import {logger} from '@browseros/common'; -import type {ControllerBridge} from '@browseros/controller-server'; -import {z} from 'zod'; - -import {AgentFactory} from '../agent/AgentFactory.js'; -import type {BaseAgent} from '../agent/BaseAgent.js'; -import type {AgentConfig} from '../agent/types.js'; - - -/** - * Session state enum - */ -enum SessionState { - IDLE = 'idle', // Connected, waiting for messages - PROCESSING = 'processing', // Actively processing a message - CLOSING = 'closing', // Cleanup initiated - CLOSED = 'closed', // Fully closed -} - -/** - * Session data model schema - * Note: Does NOT store WebSocket reference to prevent memory leaks - */ -const SessionSchema = z.object({ - id: z.string().uuid(), - userId: z.string().optional(), // Klavis user ID for MCP integration - state: z.nativeEnum(SessionState), - createdAt: z.number().positive(), - lastActivity: z.number().positive(), - messageCount: z.number().nonnegative(), -}); - -type Session = z.infer; - -/** - * Session metrics for monitoring - */ -const SessionMetricsSchema = z.object({ - totalSessions: z.number().nonnegative(), - activeSessions: z.number().nonnegative(), - idleSessions: z.number().nonnegative(), - processingSessions: z.number().nonnegative(), - averageMessageCount: z.number().nonnegative(), -}); - -type SessionMetrics = z.infer; - -/** - * Session creation options - */ -const CreateSessionOptionsSchema = z.object({ - id: z.string().uuid().optional(), // Optional: specify sessionId (useful for testing) - userId: z.string().optional(), // Optional: Klavis user ID for MCP integration - agentType: z.string().min(1).optional(), // Optional: agent type (defaults to 'codex-sdk') -}); - -type CreateSessionOptions = z.infer; - -/** - * Session configuration - */ -const SessionConfigSchema = z.object({ - maxSessions: z.number().positive(), - idleTimeoutMs: z.number().positive(), -}); - -type SessionConfig = z.infer; - -/** - * SessionManager - Manages multiple concurrent WebSocket sessions - * - * Architecture: - * - Does NOT store WebSocket references (prevents memory leaks) - * - Stores session metadata only - * - Server maintains Map separately - * - Provides capacity checking and idle session detection - * - Receives shared ControllerBridge for browser extension connection - */ export class SessionManager { - private sessions: Map; - private agents: Map; - private config: SessionConfig; - private controllerBridge: ControllerBridge; - private cleanupTimerId?: Timer; - - constructor(config: SessionConfig, controllerBridge: ControllerBridge) { - this.sessions = new Map(); - this.agents = new Map(); - this.config = config; - this.controllerBridge = controllerBridge; - - logger.info('SessionManager initialized', { - maxSessions: config.maxSessions, - idleTimeoutMs: config.idleTimeoutMs, - sharedControllerBridge: true, - }); - } - - /** - * Create a new session with an agent - * - * @param options - Session creation options (includes optional agentType) - * @param agentConfig - Agent configuration - * @returns Session instance - */ - createSession( - options?: CreateSessionOptions, - agentConfig?: AgentConfig, - ): Session { - // Check capacity first - if (this.isAtCapacity()) { - throw new Error( - `Server at capacity (max ${this.config.maxSessions} sessions)`, - ); - } - - const sessionId = options?.id || crypto.randomUUID(); - const now = Date.now(); - - const session: Session = { - id: sessionId, - userId: options?.userId, - state: SessionState.IDLE, - createdAt: now, - lastActivity: now, - messageCount: 0, - }; - - // Validate with Zod - SessionSchema.parse(session); - - this.sessions.set(sessionId, session); - - // Create agent if config provided - if (agentConfig) { - try { - // Use factory to create agent (defaults to 'codex-sdk' if not specified) - const agentType = options?.agentType || 'codex-sdk'; - const agent = AgentFactory.create( - agentType, - agentConfig, - this.controllerBridge, - ); - this.agents.set(sessionId, agent); - - logger.info('Session created with agent', { - sessionId, - agentType, - totalSessions: this.sessions.size, - }); - } catch (error) { - // Cleanup session if agent creation fails - this.sessions.delete(sessionId); - - logger.error('Failed to create agent for session', { - sessionId, - error: error instanceof Error ? error.message : String(error), - }); + private sessions = new Map(); - throw error; - } - } else { - logger.info('Session created without agent', { - sessionId, - totalSessions: this.sessions.size, - }); - } - - return session; - } + async getOrCreate(config: AgentConfig): Promise { + const existing = this.sessions.get(config.conversationId); - /** - * Get a session by ID - */ - getSession(sessionId: string): Session | undefined { - return this.sessions.get(sessionId); - } - - /** - * Check if a session exists - */ - hasSession(sessionId: string): boolean { - return this.sessions.has(sessionId); - } - - /** - * Get agent for a session - * - * @param sessionId - Session ID - * @returns BaseAgent instance or undefined if not found - */ - getAgent(sessionId: string): BaseAgent | undefined { - return this.agents.get(sessionId); - } - - /** - * Get user ID for a session - * - * @param sessionId - Session ID - * @returns User ID or undefined if not set - */ - getUserId(sessionId: string): string | undefined { - return this.sessions.get(sessionId)?.userId; - } - - /** - * Update session activity timestamp - */ - updateActivity(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session) { - logger.warn('Attempted to update activity for non-existent session', { - sessionId, + if (existing) { + logger.info('Reusing existing session', { + conversationId: config.conversationId, + historyLength: existing.getHistory().length, }); - return; - } - - session.lastActivity = Date.now(); - - logger.debug('Session activity updated', { - sessionId, - messageCount: session.messageCount, - }); - } - - /** - * Mark session as processing a message - * Note: Does NOT update lastActivity - idle timer only starts after completion - */ - markProcessing(sessionId: string): boolean { - const session = this.sessions.get(sessionId); - if (!session) { - return false; - } - - // Reject if already processing (prevent concurrent message handling) - if (session.state === SessionState.PROCESSING) { - logger.warn('Session already processing message', {sessionId}); - return false; + return existing; } - session.state = SessionState.PROCESSING; - session.messageCount++; - // ❌ Removed: session.lastActivity = Date.now() - // Idle timer starts from markIdle(), not here + const agent = await GeminiAgent.create(config); + this.sessions.set(config.conversationId, agent); - logger.debug('Session marked as processing', { - sessionId, - messageCount: session.messageCount, + logger.info('Session added to manager', { + conversationId: config.conversationId, + totalSessions: this.sessions.size, }); - return true; - } - - /** - * Mark session as idle (done processing) - * Updates lastActivity - starts the idle timeout countdown - */ - markIdle(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (!session) { - return; - } - - session.state = SessionState.IDLE; - session.lastActivity = Date.now(); // ✅ Idle timer starts here - - logger.debug('Session marked as idle', {sessionId}); + return agent; } - /** - * Cancel current execution for a session - * Triggers abort on the agent if it's executing - * CRITICAL: Does NOT mark session as idle - let processMessage() handle that - * - * @param sessionId - Session ID - * @returns true if cancel was triggered, false if not executing or agent not found - */ - cancelExecution(sessionId: string): boolean { - const agent = this.agents.get(sessionId); - if (!agent) { - logger.warn('⚠️ Cancel requested but no agent found', {sessionId}); - return false; - } - - // Defensive: check abort support - if (typeof agent.abort !== 'function') { - logger.warn('⚠️ Agent does not support cancel', { - sessionId, - agentType: agent.getMetadata().type, + delete(conversationId: string): boolean { + const deleted = this.sessions.delete(conversationId); + if (deleted) { + logger.info('Session deleted', { + conversationId, + remainingSessions: this.sessions.size, }); - return false; - } - - if (!agent.isExecuting()) { - logger.debug('⚠️ Cancel requested but agent not executing', {sessionId}); - return false; - } - - logger.info('🛑 Cancelling execution', {sessionId}); - agent.abort(); - - // CRITICAL: Do NOT mark idle here! - // Let the original processMessage() call mark idle when it completes - // Otherwise we get race condition: new messages can start while execute() is still in finally block - - return true; - } - - /** - * Delete a session and its agent - * - * Now async to support agent cleanup - */ - async deleteSession(sessionId: string): Promise { - const session = this.sessions.get(sessionId); - if (!session) { - return false; - } - - // Mark as closed - session.state = SessionState.CLOSED; - - // Destroy agent (NEW) - const agent = this.agents.get(sessionId); - if (agent) { - try { - await agent.destroy(); - this.agents.delete(sessionId); - logger.debug('Agent destroyed', {sessionId}); - } catch (error) { - logger.error('Failed to destroy agent', { - sessionId, - error: error instanceof Error ? error.message : String(error), - }); - // Continue with session deletion even if agent cleanup fails - } } - - // Delete session - this.sessions.delete(sessionId); - - logger.info('Session deleted', { - sessionId, - remainingSessions: this.sessions.size, - messageCount: session.messageCount, - lifetime: Date.now() - session.createdAt, - }); - - return true; + return deleted; } - /** - * Check if server is at capacity - */ - isAtCapacity(): boolean { - return this.sessions.size >= this.config.maxSessions; - } - - /** - * Get current capacity status - */ - getCapacity(): {active: number; max: number; available: number} { - const active = this.sessions.size; - const max = this.config.maxSessions; - return { - active, - max, - available: max - active, - }; - } - - /** - * Find idle sessions that have timed out - * Returns array of sessionIds to close - */ - findIdleSessions(): string[] { - const now = Date.now(); - const idleSessionIds: string[] = []; - - for (const [sessionId, session] of this.sessions) { - const idleTime = now - session.lastActivity; - - // Only cleanup sessions that are IDLE (not actively processing) - if ( - session.state === SessionState.IDLE && - idleTime > this.config.idleTimeoutMs - ) { - idleSessionIds.push(sessionId); - - logger.info('Idle session detected', { - sessionId, - idleTimeMs: idleTime, - threshold: this.config.idleTimeoutMs, - }); - } - } - - return idleSessionIds; + count(): number { + return this.sessions.size; } - /** - * Start periodic cleanup of idle sessions - * Returns cleanup function to stop the timer - */ - startCleanup(intervalMs = 60000): () => void { - if (this.cleanupTimerId) { - logger.warn('Cleanup timer already running'); - return () => {}; - } - - logger.info('Starting periodic session cleanup', {intervalMs}); - - this.cleanupTimerId = setInterval(() => { - const idleSessionIds = this.findIdleSessions(); - - if (idleSessionIds.length > 0) { - logger.info('Cleanup found idle sessions', { - count: idleSessionIds.length, - sessionIds: idleSessionIds, - }); - } - - // Note: Actual WebSocket closing happens in server.ts - // This just identifies which sessions to close - }, intervalMs); - - // Return cleanup function - return () => { - if (this.cleanupTimerId) { - clearInterval(this.cleanupTimerId); - this.cleanupTimerId = undefined; - logger.info('Session cleanup stopped'); - } - }; - } - - /** - * Get session metrics - */ - getMetrics(): SessionMetrics { - let idleCount = 0; - let processingCount = 0; - let totalMessages = 0; - - for (const session of this.sessions.values()) { - totalMessages += session.messageCount; - - if (session.state === SessionState.IDLE) { - idleCount++; - } else if (session.state === SessionState.PROCESSING) { - processingCount++; - } - } - - return { - totalSessions: this.sessions.size, - activeSessions: this.sessions.size, - idleSessions: idleCount, - processingSessions: processingCount, - averageMessageCount: - this.sessions.size > 0 ? totalMessages / this.sessions.size : 0, - }; - } - - /** - * Get all session IDs - */ - getAllSessionIds(): string[] { - return Array.from(this.sessions.keys()); - } - - /** - * Shutdown - cleanup all sessions and agents - * - * Now async to support agent cleanup - */ - async shutdown(): Promise { - logger.info('SessionManager shutting down', { - activeSessions: this.sessions.size, - activeAgents: this.agents.size, - }); - - // Stop cleanup timer - if (this.cleanupTimerId) { - clearInterval(this.cleanupTimerId); - this.cleanupTimerId = undefined; - } - - // Destroy all agents (NEW) - const destroyPromises: Array> = []; - for (const [sessionId, agent] of this.agents) { - destroyPromises.push( - agent.destroy().catch(error => { - logger.error('Failed to destroy agent during shutdown', { - sessionId, - error: error instanceof Error ? error.message : String(error), - }); - }), - ); - } - - await Promise.all(destroyPromises); - this.agents.clear(); - - // Clear all sessions - this.sessions.clear(); - - logger.info('SessionManager shutdown complete'); + has(conversationId: string): boolean { + return this.sessions.has(conversationId); } } diff --git a/packages/agent/src/session/index.ts b/packages/agent/src/session/index.ts new file mode 100644 index 0000000..7b31a78 --- /dev/null +++ b/packages/agent/src/session/index.ts @@ -0,0 +1 @@ +export { SessionManager } from './SessionManager.js'; diff --git a/packages/agent/src/websocket/protocol.ts b/packages/agent/src/websocket/protocol.ts deleted file mode 100644 index 6896192..0000000 --- a/packages/agent/src/websocket/protocol.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @license - * Copyright 2025 BrowserOS - */ - -import {z} from 'zod'; - -/** - * MESSAGE PROTOCOL - * - * Client → Server: ClientMessage - * Server → Client: ServerEvent - */ - -// ============================================================================ -// CLIENT → SERVER MESSAGES -// ============================================================================ - -/** - * Regular message from client - */ -export const ClientRegularMessageSchema = z.object({ - type: z.literal('message'), - content: z.string().min(1, 'Message content cannot be empty'), -}); - -/** - * Cancel message from client - */ -export const ClientCancelMessageSchema = z.object({ - type: z.literal('cancel'), - sessionId: z.string().optional(), -}); - -/** - * Discriminated union of all client message types - */ -export const ClientMessageSchema = z.discriminatedUnion('type', [ - ClientRegularMessageSchema, - ClientCancelMessageSchema, -]); - -export type ClientMessage = z.infer; - -// ============================================================================ -// SERVER → CLIENT EVENTS -// ============================================================================ - -/** - * Connection confirmation event - */ -export const ConnectionEventSchema = z.object({ - type: z.literal('connection'), - data: z.object({ - status: z.literal('connected'), - sessionId: z.string(), - timestamp: z.number(), - }), -}); - -export type ConnectionEvent = z.infer; - -/** - * Agent event (init, response, tool_use, tool_result, completion, error) - * Uses FormattedEvent structure from Phase 1 - */ -export const AgentEventSchema = z.object({ - type: z.enum([ - 'init', - 'thinking', - 'tool_use', - 'tool_result', - 'response', - 'completion', - 'error', - ]), - content: z.string(), -}); - -export type AgentEvent = z.infer; - -/** - * Cancelled event (acknowledgment of cancel request) - */ -export const CancelledEventSchema = z.object({ - type: z.literal('cancelled'), - sessionId: z.string(), - message: z.string().optional(), -}); - -export type CancelledEvent = z.infer; - -/** - * Error event - */ -export const ErrorEventSchema = z.object({ - type: z.literal('error'), - error: z.string(), - code: z.string().optional(), -}); - -export type ErrorEvent = z.infer; - -/** - * Union of all server event types - */ -export const ServerEventSchema = z.union([ - ConnectionEventSchema, - AgentEventSchema, - CancelledEventSchema, - ErrorEventSchema, -]); - -export type ServerEvent = z.infer; - -// ============================================================================ -// VALIDATION HELPERS -// ============================================================================ - -/** - * Validate a client message - * @throws {z.ZodError} if validation fails - */ -export function validateClientMessage(data: unknown): ClientMessage { - return ClientMessageSchema.parse(data); -} - -/** - * Try to parse a client message, returning null on error - */ -export function tryParseClientMessage(data: unknown): ClientMessage | null { - const result = ClientMessageSchema.safeParse(data); - return result.success ? result.data : null; -} - -/** - * Validate a server event - * @throws {z.ZodError} if validation fails - */ -export function validateServerEvent(data: unknown): ServerEvent { - return ServerEventSchema.parse(data); -} diff --git a/packages/agent/src/websocket/server.ts b/packages/agent/src/websocket/server.ts deleted file mode 100644 index 6569481..0000000 --- a/packages/agent/src/websocket/server.ts +++ /dev/null @@ -1,547 +0,0 @@ -/** - * @license - * Copyright 2025 BrowserOS - */ - -import {logger} from '@browseros/common'; -import type {ControllerBridge} from '@browseros/controller-server'; -import type {ServerWebSocket} from 'bun'; -import {z} from 'zod'; - -import {SessionManager} from '../session/SessionManager.js'; - - -import { - tryParseClientMessage, - type ServerEvent, - type ConnectionEvent, - type ErrorEvent, -} from './protocol.js'; - -/** - * WebSocket data stored per connection - */ -const WebSocketDataSchema = z.object({ - sessionId: z.string().uuid(), - createdAt: z.number().positive(), -}); - -type WebSocketData = z.infer; - -/** - * Server configuration schema - */ -export const ServerConfigSchema = z.object({ - port: z.number().int().min(1).max(65535), - resourcesDir: z.string().min(1, 'Resources directory is required'), - executionDir: z.string().optional(), - mcpServerPort: z.number().positive().optional(), - apiKey: z.string().optional(), - baseUrl: z.string().url().optional(), - modelName: z.string().optional(), - maxSessions: z.number().int().positive(), - idleTimeoutMs: z.number().positive(), - eventGapTimeoutMs: z.number().positive(), -}); - -export type ServerConfig = z.infer; - -/** - * Server statistics (internal, no validation needed) - */ -interface ServerStats { - startTime: number; - connections: number; - messagesProcessed: number; -} - -/** - * Global server state - */ -const stats: ServerStats = { - startTime: Date.now(), - connections: 0, - messagesProcessed: 0, -}; - -/** - * Create and start the WebSocket server - * - * @param config - Server configuration - * @param controllerBridge - Shared ControllerBridge for browser extension connection - */ -export function createServer( - config: ServerConfig, - controllerBridge: ControllerBridge, -) { - logger.info('🚀 Starting WebSocket server...', { - port: config.port, - maxSessions: config.maxSessions, - idleTimeoutMs: config.idleTimeoutMs, - eventGapTimeoutMs: config.eventGapTimeoutMs, - sharedControllerBridge: true, - }); - - // Create SessionManager with shared ControllerBridge - const sessionManager = new SessionManager( - { - maxSessions: config.maxSessions, - idleTimeoutMs: config.idleTimeoutMs, - }, - controllerBridge, - ); - - // Track WebSocket connections (needed to close idle sessions) - const wsConnections = new Map>(); - - // Cleanup idle sessions callback (now async) -> commenting out for now as we should let BrowserOS agent handle this - // const cleanupIdle = async () => { - // const idleSessionIds = sessionManager.findIdleSessions(); - - // for (const sessionId of idleSessionIds) { - // const ws = wsConnections.get(sessionId); - // if (ws) { - // logger.info('🧹 Closing idle session', {sessionId}); - // ws.close(1001, 'Idle timeout'); - // wsConnections.delete(sessionId); - // } - // await sessionManager.deleteSession(sessionId); - // } - // }; - - // // Run cleanup check with the timer - // setInterval(cleanupIdle, 60000); - - const server = Bun.serve({ - port: config.port, - - /** - * HTTP request handler (for health check and upgrade) - */ - async fetch(req, server) { - const url = new URL(req.url); - - logger.info(`${req.method} ${url.pathname}`); - - // Health check endpoint - if (url.pathname === '/health') { - return handleHealthCheck(sessionManager); - } - - // WebSocket upgrade - if (req.headers.get('upgrade') === 'websocket') { - // Check capacity BEFORE upgrading - if (sessionManager.isAtCapacity()) { - const capacity = sessionManager.getCapacity(); - logger.warn('⛔ Connection rejected - server at capacity', { - active: capacity.active, - max: capacity.max, - }); - - return new Response( - JSON.stringify({ - error: 'Server at capacity', - capacity: capacity, - }), - { - status: 503, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': '60', - }, - }, - ); - } - - // Create session ID before upgrade - const sessionId = crypto.randomUUID(); - - const success = server.upgrade(req, { - data: { - sessionId, - createdAt: Date.now(), - }, - }); - - if (success) { - return undefined; - } - - return new Response('WebSocket upgrade failed', {status: 500}); - } - - // 404 for other routes - return new Response('Not Found', {status: 404}); - }, - - /** - * WebSocket handlers - */ - websocket: { - /** - * Handle new WebSocket connection - */ - open(ws) { - const {sessionId, createdAt} = ws.data; - - try { - // Build agent config from server config - // Normalize executionDir: if not provided, use resourcesDir - const agentConfig = { - resourcesDir: config.resourcesDir, - executionDir: config.executionDir || config.resourcesDir, - mcpServerPort: config.mcpServerPort, - apiKey: config.apiKey, - baseUrl: config.baseUrl, - modelName: config.modelName, - }; - - // Create session with agent - sessionManager.createSession({id: sessionId}, agentConfig); - - // Track WebSocket connection - wsConnections.set(sessionId, ws); - - stats.connections++; - - logger.info('✅ Client connected', { - sessionId, - activeSessions: sessionManager.getMetrics().activeSessions, - }); - - // Send connection confirmation - const connectionEvent: ConnectionEvent = { - type: 'connection', - data: { - status: 'connected', - sessionId, - timestamp: createdAt, - }, - }; - - ws.send(JSON.stringify(connectionEvent)); - } catch (error) { - // Should not happen (capacity checked in fetch) - logger.error('❌ Failed to create session', { - sessionId, - error: error instanceof Error ? error.message : String(error), - }); - ws.close(1008, 'Failed to create session'); - } - }, - - /** - * Handle incoming messages from client - */ - async message(ws, message) { - const {sessionId} = ws.data; - - try { - // Check if session exists - if (!sessionManager.hasSession(sessionId)) { - sendError(ws, 'Session not found'); - ws.close(1008, 'Session not found'); - return; - } - - // Parse message - const messageStr = - typeof message === 'string' - ? message - : new TextDecoder().decode(message); - - logger.debug('📥 Message received', {sessionId, message: messageStr}); - - // Parse and validate - const parsedData = JSON.parse(messageStr); - const clientMessage = tryParseClientMessage(parsedData); - - if (!clientMessage) { - sendError(ws, 'Invalid message format'); - return; - } - - // Handle cancel message - if (clientMessage.type === 'cancel') { - logger.info('🛑 Cancel request received', {sessionId}); - - const success = sessionManager.cancelExecution(sessionId); - - // Send cancelled acknowledgment - const cancelledEvent = { - type: 'cancelled', - sessionId, - message: success - ? 'Execution cancelled' - : 'No active execution to cancel', - }; - ws.send(JSON.stringify(cancelledEvent)); - - logger.info(success ? '✅ Cancel successful' : '⚠️ Nothing to cancel', {sessionId}); - return; - } - - // Handle regular message - // Try to mark session as processing (reject if already processing) - if (!sessionManager.markProcessing(sessionId)) { - sendError( - ws, - 'Session is already processing a message. Please wait.', - ); - return; - } - - // Update stats - stats.messagesProcessed++; - - // Process the message with Claude SDK - try { - await processMessage( - ws, - clientMessage.content, - config, - sessionManager, - ); - - // Mark session as idle after successful processing - sessionManager.markIdle(sessionId); - } catch (error) { - const errorMsg = - error instanceof Error ? error.message : String(error); - - // Check for event gap timeout - if (errorMsg.includes('Event gap timeout')) { - logger.error('⏱️ Agent timeout - deleting session', { - sessionId, - timeout: config.eventGapTimeoutMs, - }); - - // Send error to client - sendError( - ws, - `⏱️ Agent timeout: No activity for ${config.eventGapTimeoutMs / 1000}s`, - ); - - // Immediately delete session and close connection (now async) - await sessionManager.deleteSession(sessionId); - wsConnections.delete(sessionId); - ws.close(1008, 'Agent timeout - no activity'); - return; - } - - // Other errors - mark idle normally - sessionManager.markIdle(sessionId); - throw error; - } - } catch (error) { - logger.error('❌ Error processing message', { - sessionId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - - // Mark session as idle on error - sessionManager.markIdle(sessionId); - - sendError( - ws, - 'Error processing message: ' + - (error instanceof Error ? error.message : String(error)), - ); - } - }, - - /** - * Handle WebSocket close - */ - async close(ws, code, reason) { - const {sessionId} = ws.data; - - // Delete session from manager (now async) - await sessionManager.deleteSession(sessionId); - - // Remove WebSocket tracking - wsConnections.delete(sessionId); - - logger.info('👋 Client disconnected', { - sessionId, - code, - reason: reason || 'No reason provided', - remainingSessions: sessionManager.getMetrics().activeSessions, - }); - }, - }, - }); - - logger.info(`✅ Server started on port ${config.port}`); - logger.info(` WebSocket: ws://localhost:${config.port}`); - logger.info(` Health: http://localhost:${config.port}/health`); - - return server; -} - -/** - * Process a message through ClaudeSDKAgent and stream events back - */ -async function processMessage( - ws: ServerWebSocket, - message: string, - config: ServerConfig, - sessionManager: SessionManager, -) { - const {sessionId} = ws.data; - - logger.info('🤖 Processing with agent...', {sessionId, message}); - - try { - // Get agent for this session - const agent = sessionManager.getAgent(sessionId); - if (!agent) { - throw new Error('Agent not found for session'); - } - - let eventCount = 0; - let lastEventType = ''; - let lastEventTime = Date.now(); - - // Get async iterator from agent - const iterator = agent.execute(message)[Symbol.asyncIterator](); - - // Stream events with gap timeout monitoring (SAME AS BEFORE) - while (true) { - // Calculate time since last event - const timeSinceLastEvent = Date.now() - lastEventTime; - const remainingTime = Math.max( - 1000, - config.eventGapTimeoutMs - timeSinceLastEvent, - ); - - // Create timeout promise - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('EventGapTimeout')); - }, remainingTime); - }); - - // Race next event with gap timeout - let result; - try { - result = await Promise.race([iterator.next(), timeoutPromise]); - } catch (timeoutError) { - // Cleanup iterator (fire-and-forget - session will be deleted anyway) - if (iterator.return) { - iterator.return(undefined).catch(() => {}); - } - throw new Error( - `Event gap timeout: No activity for ${config.eventGapTimeoutMs / 1000}s`, - ); - } - - // Check if iteration is done - if (result.done) break; - - // Update last event time - lastEventTime = Date.now(); - const formattedEvent = result.value; // Already FormattedEvent! - - eventCount++; - lastEventType = formattedEvent.type; - - // Send to client - catch errors if client disconnected - try { - ws.send(JSON.stringify(formattedEvent.toJSON())); - - logger.debug('📤 Event sent', { - sessionId, - type: formattedEvent.type, - eventCount, - }); - } catch (sendError) { - // Client disconnected during streaming - logger.info( - '⚠️ Client disconnected during event streaming, stopping iterator', - { - sessionId, - eventCount, - }, - ); - - // Cleanup iterator - if (iterator.return) { - await iterator.return(undefined).catch(() => {}); - } - - // Exit cleanly - don't throw, just return - // (throwing would trigger outer error handler which tries to sendError again) - return; - } - } - - logger.info('✅ Message processed successfully', { - sessionId, - totalEvents: eventCount, - lastEventType, - }); - } catch (error) { - logger.error('❌ Agent error', { - sessionId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - - sendError( - ws, - 'Agent error: ' + - (error instanceof Error ? error.message : String(error)), - ); - - // Re-throw to be caught by outer handler - throw error; - } -} - -/** - * Send an error event to the client - */ -function sendError(ws: ServerWebSocket, error: string) { - const errorEvent: ErrorEvent = { - type: 'error', - error, - }; - - ws.send(JSON.stringify(errorEvent)); -} - -/** - * Handle health check endpoint - */ -function handleHealthCheck(sessionManager: SessionManager): Response { - const uptime = Date.now() - stats.startTime; - const capacity = sessionManager.getCapacity(); - const metrics = sessionManager.getMetrics(); - - const health = { - status: 'healthy', - uptime: uptime, - sessions: { - active: capacity.active, - max: capacity.max, - available: capacity.available, - idle: metrics.idleSessions, - processing: metrics.processingSessions, - }, - stats: { - totalConnections: stats.connections, - messagesProcessed: stats.messagesProcessed, - averageMessagesPerSession: metrics.averageMessageCount, - }, - timestamp: Date.now(), - }; - - return new Response(JSON.stringify(health), { - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - }, - }); -} diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json index 56f24f6..8ab8365 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -8,6 +8,6 @@ "declarationMap": true }, "include": ["src/**/*"], - "exclude": ["dist/**/*", "node_modules"], + "exclude": ["dist/**/*", "node_modules", "src/**/*.backup", "src/**/*.backup/**/*", "src/*.backup/**/*", "src/agent.backup/**/*", "src/http-server.backup/**/*", "src/session.backup/**/*", "src/websocket.backup/**/*"], "references": [{"path": "../controller-server"}, {"path": "../tools"}] } diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index bdfe0d0..65db6dc 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -8,19 +8,13 @@ import type http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; -import { - createAgentServer, - registerAgents, - type AgentServerConfig, -} from '@browseros/agent'; +import { createHttpServer as createAgentHttpServer } from '@browseros/agent'; import { ensureBrowserConnected, McpContext, Mutex, logger, readVersion, - fetchBrowserOSConfig, - getLLMConfigFromProvider, } from '@browseros/common'; import { ControllerContext, @@ -68,7 +62,7 @@ void (async () => { toolMutex, }); - const agentServer = await startAgentServer(ports, controllerBridge); + const agentServer = startAgentServer(ports); logSummary(ports); @@ -181,104 +175,30 @@ function startMcpServer(config: { return mcpServer; } -// get LLM configuration for agent server -async function getLLMConfig(): Promise<{ - apiKey?: string; - baseUrl: string; - modelName: string; -}> { - const envApiKey = process.env.BROWSEROS_API_KEY; - const envBaseUrl = process.env.BROWSEROS_LLM_BASE_URL; - const envModelName = process.env.BROWSEROS_LLM_MODEL_NAME; - - let configApiKey: string | undefined; - let configBaseUrl: string | undefined; - let configModelName: string | undefined; - - // Try to fetch from config URL - const configUrl = process.env.BROWSEROS_CONFIG_URL; - if (configUrl) { - try { - logger.info('Fetching LLM config from BrowserOS Config URL', { - configUrl, - }); - const config = await fetchBrowserOSConfig(configUrl); - const llmConfig = getLLMConfigFromProvider(config, 'default'); - - configApiKey = llmConfig.apiKey; - configBaseUrl = llmConfig.baseUrl; - configModelName = llmConfig.modelName; - - logger.info('Loaded config from BrowserOS Config (default provider)'); - } catch (error) { - logger.warn('Failed to fetch config from URL', { - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // Apply env var overrides (env takes precedence) - const apiKey = envApiKey ?? configApiKey; - const baseUrl = envBaseUrl ?? configBaseUrl; - const modelName = envModelName ?? configModelName; - - // Validate required fields - if (!baseUrl || !modelName) { - throw new Error( - 'LLM configuration required: baseUrl and modelName must be set via BROWSEROS_LLM_BASE_URL and BROWSEROS_LLM_MODEL_NAME environment variables, or via BROWSEROS_CONFIG_URL', - ); - } - - logger.info('Using LLM config', { - baseUrl, - modelName, - apiKeySource: envApiKey ? 'env' : configApiKey ? 'config' : 'none', - }); - - return { - apiKey, - baseUrl, - modelName, - }; -} - -async function startAgentServer( +function startAgentServer( ports: ReturnType, - controllerBridge: ControllerBridge, -): Promise { - // Register all available agents (Codex SDK, Claude SDK, etc.) - registerAgents(); +): { server: any; config: any } { + const mcpServerUrl = `http://127.0.0.1:${ports.httpMcpPort}/mcp`; - const llmConfig = await getLLMConfig(); - - const agentConfig: AgentServerConfig = { + const { server, config } = createAgentHttpServer({ port: ports.agentPort, - resourcesDir: ports.resourcesDir, - executionDir: ports.executionDir, - mcpServerPort: ports.httpMcpPort, - apiKey: llmConfig.apiKey, - baseUrl: llmConfig.baseUrl, - modelName: llmConfig.modelName, - maxSessions: parseInt(process.env.MAX_SESSIONS || '5'), - idleTimeoutMs: parseInt(process.env.SESSION_IDLE_TIMEOUT_MS || '90000'), - eventGapTimeoutMs: parseInt(process.env.EVENT_GAP_TIMEOUT_MS || '120000'), - }; - - const agentServer = createAgentServer(agentConfig, controllerBridge); + host: '0.0.0.0', + corsOrigins: ['*'], + tempDir: ports.executionDir || ports.resourcesDir, + mcpServerUrl, + }); - logger.info(`[Agent Server] Listening on ws://127.0.0.1:${ports.agentPort}`); - logger.info( - `[Agent Server] Config: resourcesDir=${agentConfig.resourcesDir}, model=${agentConfig.modelName || 'default'}, sessions=${agentConfig.maxSessions}`, - ); + logger.info(`[Agent Server] Listening on http://127.0.0.1:${ports.agentPort}`); + logger.info(`[Agent Server] MCP Server URL: ${mcpServerUrl}`); - return agentServer; + return { server, config }; } function logSummary(ports: ReturnType) { logger.info(''); logger.info('Services running:'); logger.info(` Controller Server: ws://127.0.0.1:${ports.extensionPort}`); - logger.info(` Agent Server: ws://127.0.0.1:${ports.agentPort}`); + logger.info(` Agent Server: http://127.0.0.1:${ports.agentPort}`); if (ports.mcpServerEnabled) { logger.info(` MCP Server: http://127.0.0.1:${ports.httpMcpPort}/mcp`); } @@ -287,7 +207,7 @@ function logSummary(ports: ReturnType) { function createShutdownHandler( mcpServer: http.Server, - agentServer: any, + agentServer: { server: any; config: any }, controllerBridge: ControllerBridge, ) { return async () => { @@ -296,7 +216,7 @@ function createShutdownHandler( await shutdownMcpServer(mcpServer, logger); logger.info('Stopping agent server...'); - agentServer.stop(); + agentServer.server.close(); logger.info('Closing ControllerBridge...'); await controllerBridge.close();