diff --git a/package-lock.json b/package-lock.json index 8025286a..5c556f1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "packages/*", "packages/web-ui/example" ], + "dependencies": { + "get-east-asian-width": "^1.4.0" + }, "devDependencies": { "@biomejs/biome": "2.3.5", "@types/node": "^22.10.5", @@ -758,6 +761,29 @@ "node": ">=12" } }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1451,6 +1477,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2593,21 +2632,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2798,13 +2822,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3062,16 +3079,12 @@ } }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/diff": { @@ -3122,9 +3135,9 @@ } }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/end-of-stream": { @@ -3947,6 +3960,36 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4251,15 +4294,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/lit": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", @@ -4795,16 +4829,6 @@ "node": ">=10" } }, - "node_modules/prebuild-install/node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -4845,26 +4869,20 @@ } }, "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5203,32 +5221,26 @@ "license": "MIT" }, "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "safe-buffer": "~5.2.0" } }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5258,12 +5270,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5430,21 +5436,6 @@ "node": ">=6" } }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6342,12 +6333,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6374,6 +6359,29 @@ "node": ">=8" } }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6463,13 +6471,6 @@ "node": ">=8" } }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6522,8 +6523,8 @@ "version": "0.16.0", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.15.0", - "@mariozechner/pi-tui": "^0.15.0" + "@mariozechner/pi-ai": "^0.16.0", + "@mariozechner/pi-tui": "^0.16.0" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6534,93 +6535,20 @@ "node": ">=20.0.0" } }, - "packages/agent/node_modules/@mariozechner/pi-ai": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.15.0.tgz", - "integrity": "sha512-vCwDfL4DtIZ73+gnWngFDpi6e7yTzHnX21sWTkpWHOT86BRLVu9gWhUo9lEvQbwA8R15qO5rjRR5J6MRu34Tjw==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.71.2", - "@google/genai": "1.31.0", - "@sinclair/typebox": "^0.34.41", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "chalk": "^5.6.2", - "openai": "6.10.0", - "partial-json": "^0.1.7", - "zod-to-json-schema": "^3.24.6" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/agent/node_modules/@mariozechner/pi-tui": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.15.0.tgz", - "integrity": "sha512-4kSCO1fbMmpsyro4F+v4hqkkkPBlM1t5LF8UE51AgcA9i/OwVX3Nq5tPgMJ5vvPgexhd8N9hgzFwlQfTzYOetg==", - "license": "MIT", - "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, "packages/agent/node_modules/@types/node": { - "version": "24.10.1", + "version": "24.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", + "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, - "packages/agent/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "packages/agent/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/agent/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/agent/node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -6649,7 +6577,9 @@ } }, "packages/ai/node_modules/@types/node": { - "version": "24.10.1", + "version": "24.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", + "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==", "dev": true, "license": "MIT", "dependencies": { @@ -6658,6 +6588,8 @@ }, "packages/ai/node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -6666,9 +6598,9 @@ "version": "0.16.0", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.15.0", - "@mariozechner/pi-ai": "^0.15.0", - "@mariozechner/pi-tui": "^0.15.0", + "@mariozechner/pi-agent-core": "^0.16.0", + "@mariozechner/pi-ai": "^0.16.0", + "@mariozechner/pi-tui": "^0.16.0", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6686,55 +6618,6 @@ "node": ">=20.0.0" } }, - "packages/coding-agent/node_modules/@mariozechner/pi-agent-core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.15.0.tgz", - "integrity": "sha512-p1SosZkdYR2fGG/2/2RzUyl4kVkrjrJQ2VGgc0lQklBf/zlKBtkiMDKHGdEVDq3y3J8JnDUleqTZZEAlzMcdXQ==", - "license": "MIT", - "dependencies": { - "@mariozechner/pi-ai": "^0.15.0", - "@mariozechner/pi-tui": "^0.15.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/coding-agent/node_modules/@mariozechner/pi-ai": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.15.0.tgz", - "integrity": "sha512-vCwDfL4DtIZ73+gnWngFDpi6e7yTzHnX21sWTkpWHOT86BRLVu9gWhUo9lEvQbwA8R15qO5rjRR5J6MRu34Tjw==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.71.2", - "@google/genai": "1.31.0", - "@sinclair/typebox": "^0.34.41", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "chalk": "^5.6.2", - "openai": "6.10.0", - "partial-json": "^0.1.7", - "zod-to-json-schema": "^3.24.6" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/coding-agent/node_modules/@mariozechner/pi-tui": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.15.0.tgz", - "integrity": "sha512-4kSCO1fbMmpsyro4F+v4hqkkkPBlM1t5LF8UE51AgcA9i/OwVX3Nq5tPgMJ5vvPgexhd8N9hgzFwlQfTzYOetg==", - "license": "MIT", - "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, "packages/coding-agent/node_modules/@types/node": { "version": "24.10.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", @@ -6745,47 +6628,6 @@ "undici-types": "~7.16.0" } }, - "packages/coding-agent/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "packages/coding-agent/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/coding-agent/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/coding-agent/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6799,8 +6641,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.15.0", - "@mariozechner/pi-ai": "^0.15.0", + "@mariozechner/pi-agent-core": "^0.16.0", + "@mariozechner/pi-ai": "^0.16.0", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6819,106 +6661,20 @@ "node": ">=20.0.0" } }, - "packages/mom/node_modules/@mariozechner/pi-agent-core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.15.0.tgz", - "integrity": "sha512-p1SosZkdYR2fGG/2/2RzUyl4kVkrjrJQ2VGgc0lQklBf/zlKBtkiMDKHGdEVDq3y3J8JnDUleqTZZEAlzMcdXQ==", - "license": "MIT", - "dependencies": { - "@mariozechner/pi-ai": "^0.15.0", - "@mariozechner/pi-tui": "^0.15.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/mom/node_modules/@mariozechner/pi-ai": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.15.0.tgz", - "integrity": "sha512-vCwDfL4DtIZ73+gnWngFDpi6e7yTzHnX21sWTkpWHOT86BRLVu9gWhUo9lEvQbwA8R15qO5rjRR5J6MRu34Tjw==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.71.2", - "@google/genai": "1.31.0", - "@sinclair/typebox": "^0.34.41", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "chalk": "^5.6.2", - "openai": "6.10.0", - "partial-json": "^0.1.7", - "zod-to-json-schema": "^3.24.6" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/mom/node_modules/@mariozechner/pi-tui": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.15.0.tgz", - "integrity": "sha512-4kSCO1fbMmpsyro4F+v4hqkkkPBlM1t5LF8UE51AgcA9i/OwVX3Nq5tPgMJ5vvPgexhd8N9hgzFwlQfTzYOetg==", - "license": "MIT", - "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, "packages/mom/node_modules/@types/node": { - "version": "24.10.1", + "version": "24.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", + "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, - "packages/mom/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "packages/mom/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/mom/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/mom/node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -6927,7 +6683,7 @@ "version": "0.16.0", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.15.0", + "@mariozechner/pi-agent-core": "^0.16.0", "chalk": "^5.5.0" }, "bin": { @@ -6938,96 +6694,6 @@ "node": ">=20.0.0" } }, - "packages/pods/node_modules/@mariozechner/pi-agent-core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.15.0.tgz", - "integrity": "sha512-p1SosZkdYR2fGG/2/2RzUyl4kVkrjrJQ2VGgc0lQklBf/zlKBtkiMDKHGdEVDq3y3J8JnDUleqTZZEAlzMcdXQ==", - "license": "MIT", - "dependencies": { - "@mariozechner/pi-ai": "^0.15.0", - "@mariozechner/pi-tui": "^0.15.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/pods/node_modules/@mariozechner/pi-ai": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.15.0.tgz", - "integrity": "sha512-vCwDfL4DtIZ73+gnWngFDpi6e7yTzHnX21sWTkpWHOT86BRLVu9gWhUo9lEvQbwA8R15qO5rjRR5J6MRu34Tjw==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.71.2", - "@google/genai": "1.31.0", - "@sinclair/typebox": "^0.34.41", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "chalk": "^5.6.2", - "openai": "6.10.0", - "partial-json": "^0.1.7", - "zod-to-json-schema": "^3.24.6" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/pods/node_modules/@mariozechner/pi-tui": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.15.0.tgz", - "integrity": "sha512-4kSCO1fbMmpsyro4F+v4hqkkkPBlM1t5LF8UE51AgcA9i/OwVX3Nq5tPgMJ5vvPgexhd8N9hgzFwlQfTzYOetg==", - "license": "MIT", - "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/pods/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "packages/pods/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/pods/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/proxy": { "name": "@mariozechner/pi-proxy", "version": "0.16.0", @@ -7065,6 +6731,8 @@ }, "packages/tui/node_modules/mime-db": { "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -7072,6 +6740,8 @@ }, "packages/tui/node_modules/mime-types": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -7084,30 +6754,14 @@ "url": "https://opencollective.com/express" } }, - "packages/tui/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", "version": "0.16.0", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.15.0", - "@mariozechner/pi-tui": "^0.15.0", + "@mariozechner/pi-ai": "^0.16.0", + "@mariozechner/pi-tui": "^0.16.0", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -7141,87 +6795,6 @@ "typescript": "^5.7.3", "vite": "^7.1.6" } - }, - "packages/web-ui/example/node_modules/@mariozechner/pi-ai": { - "resolved": "packages/ai", - "link": true - }, - "packages/web-ui/node_modules/@mariozechner/pi-ai": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.15.0.tgz", - "integrity": "sha512-vCwDfL4DtIZ73+gnWngFDpi6e7yTzHnX21sWTkpWHOT86BRLVu9gWhUo9lEvQbwA8R15qO5rjRR5J6MRu34Tjw==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.71.2", - "@google/genai": "1.31.0", - "@sinclair/typebox": "^0.34.41", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "chalk": "^5.6.2", - "openai": "6.10.0", - "partial-json": "^0.1.7", - "zod-to-json-schema": "^3.24.6" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/web-ui/node_modules/@mariozechner/pi-tui": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.15.0.tgz", - "integrity": "sha512-4kSCO1fbMmpsyro4F+v4hqkkkPBlM1t5LF8UE51AgcA9i/OwVX3Nq5tPgMJ5vvPgexhd8N9hgzFwlQfTzYOetg==", - "license": "MIT", - "dependencies": { - "@types/mime-types": "^2.1.4", - "chalk": "^5.5.0", - "marked": "^15.0.12", - "mime-types": "^3.0.1", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/web-ui/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "packages/web-ui/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/web-ui/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index e12dcb2f..dc08eedb 100644 --- a/package.json +++ b/package.json @@ -34,5 +34,8 @@ "engines": { "node": ">=20.0.0" }, - "version": "0.0.3" + "version": "0.0.3", + "dependencies": { + "get-east-asian-width": "^1.4.0" + } } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 24e8f4f1..86febabb 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -176,11 +176,6 @@ export class Agent { throw new Error("No model configured"); } - // Set up running prompt tracking - this.runningPrompt = new Promise((resolve) => { - this.resolveRunningPrompt = resolve; - }); - // Build user message with attachments const content: Array = [{ type: "text", text: input }]; if (attachments?.length) { @@ -204,6 +199,62 @@ export class Agent { timestamp: Date.now(), }; + await this._runAgentLoop(userMessage); + } + + /** + * Continue from the current context without adding a new user message. + * Used for retry after overflow recovery when context already has user message or tool results. + */ + async continue() { + const messages = this._state.messages; + if (messages.length === 0) { + throw new Error("No messages to continue from"); + } + + const lastMessage = messages[messages.length - 1]; + if (lastMessage.role !== "user" && lastMessage.role !== "toolResult") { + throw new Error(`Cannot continue from message role: ${lastMessage.role}`); + } + + await this._runAgentLoopContinue(); + } + + /** + * Internal: Run the agent loop with a new user message. + */ + private async _runAgentLoop(userMessage: AppMessage) { + const { llmMessages, cfg } = await this._prepareRun(); + + const events = this.transport.run(llmMessages, userMessage as Message, cfg, this.abortController!.signal); + + await this._processEvents(events); + } + + /** + * Internal: Continue the agent loop from current context. + */ + private async _runAgentLoopContinue() { + const { llmMessages, cfg } = await this._prepareRun(); + + const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal); + + await this._processEvents(events); + } + + /** + * Prepare for running the agent loop. + */ + private async _prepareRun() { + const model = this._state.model; + if (!model) { + throw new Error("No model configured"); + } + + this.runningPrompt = new Promise((resolve) => { + this.resolveRunningPrompt = resolve; + }); + this.abortController = new AbortController(); this._state.isStreaming = true; this._state.streamMessage = null; @@ -222,9 +273,7 @@ export class Agent { model, reasoning, getQueuedMessages: async () => { - // Return queued messages based on queue mode if (this.queueMode === "one-at-a-time") { - // Return only first message if (this.messageQueue.length > 0) { const first = this.messageQueue[0]; this.messageQueue = this.messageQueue.slice(1); @@ -232,7 +281,6 @@ export class Agent { } return []; } else { - // Return all queued messages at once const queued = this.messageQueue.slice(); this.messageQueue = []; return queued as QueuedMessage[]; @@ -240,32 +288,30 @@ export class Agent { }, }; - // Track all messages generated in this prompt + const llmMessages = await this.messageTransformer(this._state.messages); + + return { llmMessages, cfg, model }; + } + + /** + * Process events from the transport. + */ + private async _processEvents(events: AsyncIterable) { + const model = this._state.model!; const generatedMessages: AppMessage[] = []; + let partial: AppMessage | null = null; try { - let partial: Message | null = null; - - // Transform app messages to LLM-compatible messages (initial set) - const llmMessages = await this.messageTransformer(this._state.messages); - - for await (const ev of this.transport.run( - llmMessages, - userMessage as Message, - cfg, - this.abortController.signal, - )) { - // Update internal state BEFORE emitting events - // so handlers see consistent state + for await (const ev of events) { switch (ev.type) { case "message_start": { - partial = ev.message; - this._state.streamMessage = ev.message; + partial = ev.message as AppMessage; + this._state.streamMessage = ev.message as Message; break; } case "message_update": { - partial = ev.message; - this._state.streamMessage = ev.message; + partial = ev.message as AppMessage; + this._state.streamMessage = ev.message as Message; break; } case "message_end": { @@ -299,7 +345,6 @@ export class Agent { } } - // Emit after state is updated this.emit(ev as AgentEvent); } diff --git a/packages/agent/src/transports/AppTransport.ts b/packages/agent/src/transports/AppTransport.ts index 5beb9dc6..69b9af46 100644 --- a/packages/agent/src/transports/AppTransport.ts +++ b/packages/agent/src/transports/AppTransport.ts @@ -11,7 +11,7 @@ import type { ToolCall, UserMessage, } from "@mariozechner/pi-ai"; -import { agentLoop } from "@mariozechner/pi-ai"; +import { agentLoop, agentLoopContinue } from "@mariozechner/pi-ai"; import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js"; import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js"; import type { ProxyAssistantMessageEvent } from "./proxy-types.js"; @@ -335,14 +335,8 @@ export class AppTransport implements AgentTransport { this.options = options; } - async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { - const authToken = await this.options.getAuthToken(); - if (!authToken) { - throw new Error("Auth token is required for AppTransport"); - } - - // Use proxy - no local API key needed - const streamFn = (model: Model, context: Context, options?: SimpleStreamOptions) => { + private async getStreamFn(authToken: string) { + return (model: Model, context: Context, options?: SimpleStreamOptions) => { return streamSimpleProxy( model, context, @@ -353,24 +347,51 @@ export class AppTransport implements AgentTransport { this.options.proxyUrl, ); }; + } - // Messages are already LLM-compatible (filtered by Agent) - const context: AgentContext = { + private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext { + return { systemPrompt: cfg.systemPrompt, messages, tools: cfg.tools, }; + } - const pc: AgentLoopConfig = { + private buildLoopConfig(cfg: AgentRunConfig): AgentLoopConfig { + return { model: cfg.model, reasoning: cfg.reasoning, getQueuedMessages: cfg.getQueuedMessages, }; + } + + async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { + const authToken = await this.options.getAuthToken(); + if (!authToken) { + throw new Error("Auth token is required for AppTransport"); + } + + const streamFn = await this.getStreamFn(authToken); + const context = this.buildContext(messages, cfg); + const pc = this.buildLoopConfig(cfg); - // Yield events from the upstream agentLoop iterator - // Pass streamFn as the 5th parameter to use proxy for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn as any)) { yield ev; } } + + async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { + const authToken = await this.options.getAuthToken(); + if (!authToken) { + throw new Error("Auth token is required for AppTransport"); + } + + const streamFn = await this.getStreamFn(authToken); + const context = this.buildContext(messages, cfg); + const pc = this.buildLoopConfig(cfg); + + for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any)) { + yield ev; + } + } } diff --git a/packages/agent/src/transports/ProviderTransport.ts b/packages/agent/src/transports/ProviderTransport.ts index c46b16c0..eacb53cd 100644 --- a/packages/agent/src/transports/ProviderTransport.ts +++ b/packages/agent/src/transports/ProviderTransport.ts @@ -2,6 +2,7 @@ import { type AgentContext, type AgentLoopConfig, agentLoop, + agentLoopContinue, type Message, type UserMessage, } from "@mariozechner/pi-ai"; @@ -33,18 +34,15 @@ export class ProviderTransport implements AgentTransport { this.options = options; } - async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { - // Get API key + private async getModelAndKey(cfg: AgentRunConfig) { let apiKey: string | undefined; if (this.options.getApiKey) { apiKey = await this.options.getApiKey(cfg.model.provider); } - if (!apiKey) { throw new Error(`No API key found for provider: ${cfg.model.provider}`); } - // Clone model and modify baseUrl if CORS proxy is enabled let model = cfg.model; if (this.options.corsProxyUrl && cfg.model.baseUrl) { model = { @@ -53,23 +51,43 @@ export class ProviderTransport implements AgentTransport { }; } - // Messages are already LLM-compatible (filtered by Agent) - const context: AgentContext = { + return { model, apiKey }; + } + + private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext { + return { systemPrompt: cfg.systemPrompt, messages, tools: cfg.tools, }; + } - const pc: AgentLoopConfig = { + private buildLoopConfig(model: typeof cfg.model, apiKey: string, cfg: AgentRunConfig): AgentLoopConfig { + return { model, reasoning: cfg.reasoning, apiKey, getQueuedMessages: cfg.getQueuedMessages, }; + } + + async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { + const { model, apiKey } = await this.getModelAndKey(cfg); + const context = this.buildContext(messages, cfg); + const pc = this.buildLoopConfig(model, apiKey, cfg); - // Yield events from agentLoop for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) { yield ev; } } + + async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { + const { model, apiKey } = await this.getModelAndKey(cfg); + const context = this.buildContext(messages, cfg); + const pc = this.buildLoopConfig(model, apiKey, cfg); + + for await (const ev of agentLoopContinue(context, pc, signal)) { + yield ev; + } + } } diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts index 9444c365..736ba0c3 100644 --- a/packages/agent/src/transports/types.ts +++ b/packages/agent/src/transports/types.ts @@ -19,10 +19,14 @@ export interface AgentRunConfig { * Events yielded must match the @mariozechner/pi-ai AgentEvent types. */ export interface AgentTransport { + /** Run with a new user message */ run( messages: Message[], userMessage: Message, config: AgentRunConfig, signal?: AbortSignal, ): AsyncIterable; + + /** Continue from current context (no new user message) */ + continue(messages: Message[], config: AgentRunConfig, signal?: AbortSignal): AsyncIterable; } diff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts index a521d042..f18030b3 100644 --- a/packages/agent/test/e2e.test.ts +++ b/packages/agent/test/e2e.test.ts @@ -1,8 +1,26 @@ -import type { Model } from "@mariozechner/pi-ai"; +import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { calculateTool, getModel } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; import { Agent, ProviderTransport } from "../src/index.js"; +function createTransport() { + return new ProviderTransport({ + getApiKey: async (provider) => { + const envVarMap: Record = { + google: "GEMINI_API_KEY", + openai: "OPENAI_API_KEY", + anthropic: "ANTHROPIC_API_KEY", + xai: "XAI_API_KEY", + groq: "GROQ_API_KEY", + cerebras: "CEREBRAS_API_KEY", + zai: "ZAI_API_KEY", + }; + const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`; + return process.env[envVar]; + }, + }); +} + async function basicPrompt(model: Model) { const agent = new Agent({ initialState: { @@ -11,22 +29,7 @@ async function basicPrompt(model: Model) { thinkingLevel: "off", tools: [], }, - transport: new ProviderTransport({ - getApiKey: async (provider) => { - // Map provider names to env var names - const envVarMap: Record = { - google: "GEMINI_API_KEY", - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - xai: "XAI_API_KEY", - groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - zai: "ZAI_API_KEY", - }; - const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`; - return process.env[envVar]; - }, - }), + transport: createTransport(), }); await agent.prompt("What is 2+2? Answer with just the number."); @@ -54,22 +57,7 @@ async function toolExecution(model: Model) { thinkingLevel: "off", tools: [calculateTool], }, - transport: new ProviderTransport({ - getApiKey: async (provider) => { - // Map provider names to env var names - const envVarMap: Record = { - google: "GEMINI_API_KEY", - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - xai: "XAI_API_KEY", - groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - zai: "ZAI_API_KEY", - }; - const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`; - return process.env[envVar]; - }, - }), + transport: createTransport(), }); await agent.prompt("Calculate 123 * 456 using the calculator tool."); @@ -111,22 +99,7 @@ async function abortExecution(model: Model) { thinkingLevel: "off", tools: [calculateTool], }, - transport: new ProviderTransport({ - getApiKey: async (provider) => { - // Map provider names to env var names - const envVarMap: Record = { - google: "GEMINI_API_KEY", - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - xai: "XAI_API_KEY", - groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - zai: "ZAI_API_KEY", - }; - const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`; - return process.env[envVar]; - }, - }), + transport: createTransport(), }); const promptPromise = agent.prompt("Calculate 100 * 200, then 300 * 400, then sum the results."); @@ -156,22 +129,7 @@ async function stateUpdates(model: Model) { thinkingLevel: "off", tools: [], }, - transport: new ProviderTransport({ - getApiKey: async (provider) => { - // Map provider names to env var names - const envVarMap: Record = { - google: "GEMINI_API_KEY", - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - xai: "XAI_API_KEY", - groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - zai: "ZAI_API_KEY", - }; - const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`; - return process.env[envVar]; - }, - }), + transport: createTransport(), }); const events: Array = []; @@ -204,22 +162,7 @@ async function multiTurnConversation(model: Model) { thinkingLevel: "off", tools: [], }, - transport: new ProviderTransport({ - getApiKey: async (provider) => { - // Map provider names to env var names - const envVarMap: Record = { - google: "GEMINI_API_KEY", - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - xai: "XAI_API_KEY", - groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - zai: "ZAI_API_KEY", - }; - const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`; - return process.env[envVar]; - }, - }), + transport: createTransport(), }); await agent.prompt("My name is Alice."); @@ -284,8 +227,8 @@ describe("Agent E2E Tests", () => { }); }); - describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider (claude-3-5-haiku-20241022)", () => { - const model = getModel("anthropic", "claude-3-5-haiku-20241022"); + describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider (claude-haiku-4-5)", () => { + const model = getModel("anthropic", "claude-haiku-4-5"); it("should handle basic text prompt", async () => { await basicPrompt(model); @@ -404,3 +347,164 @@ describe("Agent E2E Tests", () => { }); }); }); + +describe("Agent.continue()", () => { + describe("validation", () => { + it("should throw when no messages in context", async () => { + const agent = new Agent({ + initialState: { + systemPrompt: "Test", + model: getModel("anthropic", "claude-haiku-4-5"), + }, + transport: createTransport(), + }); + + await expect(agent.continue()).rejects.toThrow("No messages to continue from"); + }); + + it("should throw when last message is assistant", async () => { + const agent = new Agent({ + initialState: { + systemPrompt: "Test", + model: getModel("anthropic", "claude-haiku-4-5"), + }, + transport: createTransport(), + }); + + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Hello" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-haiku-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + agent.replaceMessages([assistantMessage]); + + await expect(agent.continue()).rejects.toThrow("Cannot continue from message role: assistant"); + }); + }); + + describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from user message", () => { + const model = getModel("anthropic", "claude-haiku-4-5"); + + it("should continue and get response when last message is user", async () => { + const agent = new Agent({ + initialState: { + systemPrompt: "You are a helpful assistant. Follow instructions exactly.", + model, + thinkingLevel: "off", + tools: [], + }, + transport: createTransport(), + }); + + // Manually add a user message without calling prompt() + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: "Say exactly: HELLO WORLD" }], + timestamp: Date.now(), + }; + agent.replaceMessages([userMessage]); + + // Continue from the user message + await agent.continue(); + + expect(agent.state.isStreaming).toBe(false); + expect(agent.state.messages.length).toBe(2); + expect(agent.state.messages[0].role).toBe("user"); + expect(agent.state.messages[1].role).toBe("assistant"); + + const assistantMsg = agent.state.messages[1] as AssistantMessage; + const textContent = assistantMsg.content.find((c) => c.type === "text"); + expect(textContent).toBeDefined(); + if (textContent?.type === "text") { + expect(textContent.text.toUpperCase()).toContain("HELLO WORLD"); + } + }); + }); + + describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from tool result", () => { + const model = getModel("anthropic", "claude-haiku-4-5"); + + it("should continue and process tool results", async () => { + const agent = new Agent({ + initialState: { + systemPrompt: + "You are a helpful assistant. After getting a calculation result, state the answer clearly.", + model, + thinkingLevel: "off", + tools: [calculateTool], + }, + transport: createTransport(), + }); + + // Set up a conversation state as if tool was just executed + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: "What is 5 + 3?" }], + timestamp: Date.now(), + }; + + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [ + { type: "text", text: "Let me calculate that." }, + { type: "toolCall", id: "calc-1", name: "calculate", arguments: { expression: "5 + 3" } }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-haiku-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; + + const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: "calc-1", + toolName: "calculate", + content: [{ type: "text", text: "5 + 3 = 8" }], + isError: false, + timestamp: Date.now(), + }; + + agent.replaceMessages([userMessage, assistantMessage, toolResult]); + + // Continue from the tool result + await agent.continue(); + + expect(agent.state.isStreaming).toBe(false); + // Should have added an assistant response + expect(agent.state.messages.length).toBeGreaterThanOrEqual(4); + + const lastMessage = agent.state.messages[agent.state.messages.length - 1]; + expect(lastMessage.role).toBe("assistant"); + + if (lastMessage.role === "assistant") { + const textContent = lastMessage.content + .filter((c) => c.type === "text") + .map((c) => (c as { type: "text"; text: string }).text) + .join(" "); + // Should mention 8 in the response + expect(textContent).toMatch(/8/); + } + }); + }); +}); diff --git a/packages/agent/vitest.config.ts b/packages/agent/vitest.config.ts index 41086536..bcc497fa 100644 --- a/packages/agent/vitest.config.ts +++ b/packages/agent/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { globals: true, environment: "node", - testTimeout: 10000, // 10 seconds + testTimeout: 30000, // 30 seconds for API calls }, }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f44cd76b..5adca86e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **`agentLoopContinue` function**: Continue an agent loop from existing context without adding a new user message. Validates that the last message is `user` or `toolResult`. Useful for retry after context overflow or resuming from manually-added tool results. + ### Breaking Changes - Removed provider-level tool argument validation. Validation now happens in `agentLoop` via `executeToolCalls`, allowing models to retry on validation errors. For manual tool execution, use `validateToolCall(tools, toolCall)` or `validateToolArguments(tool, toolCall)`. diff --git a/packages/ai/README.md b/packages/ai/README.md index c340f5d8..99395fdc 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -898,6 +898,34 @@ const messages = await stream.result(); context.messages.push(...messages); ``` +### Continuing from Existing Context + +Use `agentLoopContinue` to resume an agent loop without adding a new user message. This is useful for: +- Retrying after context overflow (after compaction reduces context size) +- Resuming from tool results that were added manually to the context + +```typescript +import { agentLoopContinue, AgentContext } from '@mariozechner/pi-ai'; + +// Context already has messages - last must be 'user' or 'toolResult' +const context: AgentContext = { + systemPrompt: 'You are helpful.', + messages: [userMessage, assistantMessage, toolResult], + tools: [myTool] +}; + +// Continue processing from the tool result +const stream = agentLoopContinue(context, { model }); + +for await (const event of stream) { + // Same events as agentLoop, but no user message events emitted +} + +const newMessages = await stream.result(); +``` + +**Validation**: Throws if context has no messages or if the last message is an assistant message. + ### Defining Tools with TypeBox Tools use TypeBox schemas for runtime validation and type inference: diff --git a/packages/ai/src/agent/agent-loop.ts b/packages/ai/src/agent/agent-loop.ts index 32d727d0..fcb536c7 100644 --- a/packages/ai/src/agent/agent-loop.ts +++ b/packages/ai/src/agent/agent-loop.ts @@ -4,7 +4,10 @@ import { EventStream } from "../utils/event-stream.js"; import { validateToolArguments } from "../utils/validation.js"; import type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, AgentToolResult, QueuedMessage } from "./types.js"; -// Main prompt function - returns a stream of events +/** + * Start an agent loop with a new user message. + * The prompt is added to the context and events are emitted for it. + */ export function agentLoop( prompt: UserMessage, context: AgentContext, @@ -12,92 +15,137 @@ export function agentLoop( signal?: AbortSignal, streamFn?: typeof streamSimple, ): EventStream { - const stream = new EventStream( - (event: AgentEvent) => event.type === "agent_end", - (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []), - ); + const stream = createAgentStream(); - // Run the prompt async (async () => { - // Track new messages generated during this prompt - const newMessages: AgentContext["messages"] = []; - // Create user message for the prompt - const messages = [...context.messages, prompt]; - newMessages.push(prompt); + const newMessages: AgentContext["messages"] = [prompt]; + const currentContext: AgentContext = { + ...context, + messages: [...context.messages, prompt], + }; stream.push({ type: "agent_start" }); stream.push({ type: "turn_start" }); stream.push({ type: "message_start", message: prompt }); stream.push({ type: "message_end", message: prompt }); - // Update context with new messages - const currentContext: AgentContext = { - ...context, - messages, - }; - - // Keep looping while we have tool calls or queued messages - let hasMoreToolCalls = true; - let firstTurn = true; - let queuedMessages: QueuedMessage[] = (await config.getQueuedMessages?.()) || []; - - while (hasMoreToolCalls || queuedMessages.length > 0) { - if (!firstTurn) { - stream.push({ type: "turn_start" }); - } else { - firstTurn = false; - } - - // Process queued messages first (inject before next assistant response) - if (queuedMessages.length > 0) { - for (const { original, llm } of queuedMessages) { - stream.push({ type: "message_start", message: original }); - stream.push({ type: "message_end", message: original }); - if (llm) { - currentContext.messages.push(llm); - newMessages.push(llm); - } - } - queuedMessages = []; - } - - // console.log("agent-loop: ", [...currentContext.messages]); - - // Stream assistant response - const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); - newMessages.push(message); - - if (message.stopReason === "error" || message.stopReason === "aborted") { - // Stop the loop on error or abort - stream.push({ type: "turn_end", message, toolResults: [] }); - stream.push({ type: "agent_end", messages: newMessages }); - stream.end(newMessages); - return; - } - - // Check for tool calls - const toolCalls = message.content.filter((c) => c.type === "toolCall"); - hasMoreToolCalls = toolCalls.length > 0; - - const toolResults: ToolResultMessage[] = []; - if (hasMoreToolCalls) { - // Execute tool calls - toolResults.push(...(await executeToolCalls(currentContext.tools, message, signal, stream))); - currentContext.messages.push(...toolResults); - newMessages.push(...toolResults); - } - stream.push({ type: "turn_end", message, toolResults: toolResults }); - - // Get queued messages after turn completes - queuedMessages = (await config.getQueuedMessages?.()) || []; - } - stream.push({ type: "agent_end", messages: newMessages }); - stream.end(newMessages); + await runLoop(currentContext, newMessages, config, signal, stream, streamFn); })(); return stream; } +/** + * Continue an agent loop from the current context without adding a new message. + * Used for retry after overflow - context already has user message or tool results. + * Throws if the last message is not a user message or tool result. + */ +export function agentLoopContinue( + context: AgentContext, + config: AgentLoopConfig, + signal?: AbortSignal, + streamFn?: typeof streamSimple, +): EventStream { + // Validate that we can continue from this context + const lastMessage = context.messages[context.messages.length - 1]; + if (!lastMessage) { + throw new Error("Cannot continue: no messages in context"); + } + if (lastMessage.role !== "user" && lastMessage.role !== "toolResult") { + throw new Error(`Cannot continue from message role: ${lastMessage.role}. Expected 'user' or 'toolResult'.`); + } + + const stream = createAgentStream(); + + (async () => { + const newMessages: AgentContext["messages"] = []; + const currentContext: AgentContext = { ...context }; + + stream.push({ type: "agent_start" }); + stream.push({ type: "turn_start" }); + // No user message events - we're continuing from existing context + + await runLoop(currentContext, newMessages, config, signal, stream, streamFn); + })(); + + return stream; +} + +function createAgentStream(): EventStream { + return new EventStream( + (event: AgentEvent) => event.type === "agent_end", + (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []), + ); +} + +/** + * Shared loop logic for both agentLoop and agentLoopContinue. + */ +async function runLoop( + currentContext: AgentContext, + newMessages: AgentContext["messages"], + config: AgentLoopConfig, + signal: AbortSignal | undefined, + stream: EventStream, + streamFn?: typeof streamSimple, +): Promise { + let hasMoreToolCalls = true; + let firstTurn = true; + let queuedMessages: QueuedMessage[] = (await config.getQueuedMessages?.()) || []; + + while (hasMoreToolCalls || queuedMessages.length > 0) { + if (!firstTurn) { + stream.push({ type: "turn_start" }); + } else { + firstTurn = false; + } + + // Process queued messages first (inject before next assistant response) + if (queuedMessages.length > 0) { + for (const { original, llm } of queuedMessages) { + stream.push({ type: "message_start", message: original }); + stream.push({ type: "message_end", message: original }); + if (llm) { + currentContext.messages.push(llm); + newMessages.push(llm); + } + } + queuedMessages = []; + } + + // Stream assistant response + const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); + newMessages.push(message); + + if (message.stopReason === "error" || message.stopReason === "aborted") { + // Stop the loop on error or abort + stream.push({ type: "turn_end", message, toolResults: [] }); + stream.push({ type: "agent_end", messages: newMessages }); + stream.end(newMessages); + return; + } + + // Check for tool calls + const toolCalls = message.content.filter((c) => c.type === "toolCall"); + hasMoreToolCalls = toolCalls.length > 0; + + const toolResults: ToolResultMessage[] = []; + if (hasMoreToolCalls) { + // Execute tool calls + toolResults.push(...(await executeToolCalls(currentContext.tools, message, signal, stream))); + currentContext.messages.push(...toolResults); + newMessages.push(...toolResults); + } + stream.push({ type: "turn_end", message, toolResults: toolResults }); + + // Get queued messages after turn completes + queuedMessages = (await config.getQueuedMessages?.()) || []; + } + + stream.push({ type: "agent_end", messages: newMessages }); + stream.end(newMessages); +} + // Helper functions async function streamAssistantResponse( context: AgentContext, diff --git a/packages/ai/src/agent/index.ts b/packages/ai/src/agent/index.ts index 9ecb854d..57d64212 100644 --- a/packages/ai/src/agent/index.ts +++ b/packages/ai/src/agent/index.ts @@ -1,3 +1,3 @@ -export { agentLoop } from "./agent-loop.js"; +export { agentLoop, agentLoopContinue } from "./agent-loop.js"; export * from "./tools/index.js"; export type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, QueuedMessage } from "./types.js"; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index c7a813ed..241d73dc 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3275,13 +3275,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, - output: 0.7999999999999999, + input: 0.15, + output: 0.75, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 163840, - maxTokens: 163840, + contextWindow: 8192, + maxTokens: 7168, } satisfies Model<"openai-completions">, "openai/gpt-4o-audio-preview": { id: "openai/gpt-4o-audio-preview", @@ -4516,8 +4516,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.049999999999999996, - output: 0.22, + input: 0.04, + output: 0.15, cacheRead: 0, cacheWrite: 0, }, diff --git a/packages/ai/test/agent.test.ts b/packages/ai/test/agent.test.ts index c939ff00..9b669214 100644 --- a/packages/ai/test/agent.test.ts +++ b/packages/ai/test/agent.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from "vitest"; -import { agentLoop } from "../src/agent/agent-loop.js"; +import { agentLoop, agentLoopContinue } from "../src/agent/agent-loop.js"; import { calculateTool } from "../src/agent/tools/calculate.js"; import type { AgentContext, AgentEvent, AgentLoopConfig } from "../src/agent/types.js"; import { getModel } from "../src/models.js"; -import type { Api, Message, Model, OptionsForApi, UserMessage } from "../src/types.js"; +import type { + Api, + AssistantMessage, + Message, + Model, + OptionsForApi, + ToolResultMessage, + UserMessage, +} from "../src/types.js"; async function calculateTest(model: Model, options: OptionsForApi = {}) { // Create the agent context with the calculator tool @@ -282,7 +290,7 @@ describe("Agent Calculator Tests", () => { }); describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider Agent", () => { - const model = getModel("anthropic", "claude-3-5-haiku-20241022"); + const model = getModel("anthropic", "claude-haiku-4-5"); it("should calculate multiple expressions and sum the results", async () => { const result = await calculateTest(model); @@ -351,3 +359,175 @@ describe("Agent Calculator Tests", () => { }, 30000); }); }); + +describe("agentLoopContinue", () => { + describe("validation", () => { + const model = getModel("anthropic", "claude-haiku-4-5"); + const baseContext: AgentContext = { + systemPrompt: "You are a helpful assistant.", + messages: [], + tools: [], + }; + const config: AgentLoopConfig = { model }; + + it("should throw when context has no messages", () => { + expect(() => agentLoopContinue(baseContext, config)).toThrow("Cannot continue: no messages in context"); + }); + + it("should throw when last message is an assistant message", () => { + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Hello" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-haiku-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + const context: AgentContext = { + ...baseContext, + messages: [assistantMessage], + }; + expect(() => agentLoopContinue(context, config)).toThrow( + "Cannot continue from message role: assistant. Expected 'user' or 'toolResult'.", + ); + }); + + // Note: "should not throw" tests for valid inputs are covered by the E2E tests below + // which actually consume the stream and verify the output + }); + + describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from user message", () => { + const model = getModel("anthropic", "claude-haiku-4-5"); + + it("should continue and get assistant response when last message is user", async () => { + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: "Say exactly: HELLO WORLD" }], + timestamp: Date.now(), + }; + + const context: AgentContext = { + systemPrompt: "You are a helpful assistant. Follow instructions exactly.", + messages: [userMessage], + tools: [], + }; + + const config: AgentLoopConfig = { model }; + + const events: AgentEvent[] = []; + const stream = agentLoopContinue(context, config); + + for await (const event of stream) { + events.push(event); + } + + const messages = await stream.result(); + + // Should have gotten an assistant response + expect(messages.length).toBe(1); + expect(messages[0].role).toBe("assistant"); + + // Verify event sequence - no user message events since we're continuing + const eventTypes = events.map((e) => e.type); + expect(eventTypes).toContain("agent_start"); + expect(eventTypes).toContain("turn_start"); + expect(eventTypes).toContain("message_start"); + expect(eventTypes).toContain("message_end"); + expect(eventTypes).toContain("turn_end"); + expect(eventTypes).toContain("agent_end"); + + // Should NOT have user message events (that's the difference from agentLoop) + const messageEndEvents = events.filter((e) => e.type === "message_end"); + expect(messageEndEvents.length).toBe(1); // Only assistant message + expect((messageEndEvents[0] as any).message.role).toBe("assistant"); + }, 30000); + }); + + describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from tool result", () => { + const model = getModel("anthropic", "claude-haiku-4-5"); + + it("should continue processing after tool results", async () => { + // Simulate a conversation where: + // 1. User asked to calculate something + // 2. Assistant made a tool call + // 3. Tool result is ready + // 4. We continue from here + + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: "What is 5 + 3? Use the calculator." }], + timestamp: Date.now(), + }; + + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [ + { type: "text", text: "Let me calculate that for you." }, + { type: "toolCall", id: "calc-1", name: "calculate", arguments: { expression: "5 + 3" } }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-haiku-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }; + + const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: "calc-1", + toolName: "calculate", + content: [{ type: "text", text: "5 + 3 = 8" }], + isError: false, + timestamp: Date.now(), + }; + + const context: AgentContext = { + systemPrompt: "You are a helpful assistant. After getting a calculation result, state the answer clearly.", + messages: [userMessage, assistantMessage, toolResult], + tools: [calculateTool], + }; + + const config: AgentLoopConfig = { model }; + + const events: AgentEvent[] = []; + const stream = agentLoopContinue(context, config); + + for await (const event of stream) { + events.push(event); + } + + const messages = await stream.result(); + + // Should have gotten an assistant response + expect(messages.length).toBeGreaterThanOrEqual(1); + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.role).toBe("assistant"); + + // The assistant should mention the result (8) + if (lastMessage.role === "assistant") { + const textContent = lastMessage.content + .filter((c) => c.type === "text") + .map((c) => (c as any).text) + .join(" "); + expect(textContent).toMatch(/8/); + } + }, 30000); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 92e71c0e..33031311 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +### Changed + +- **Simplified compaction flow**: Removed proactive compaction (aborting mid-turn when threshold approached). Compaction now triggers in two cases only: (1) overflow error from LLM, which compacts and auto-retries, or (2) threshold crossed after a successful turn, which compacts without retry. + +- **Compaction retry uses `Agent.continue()`**: Auto-retry after overflow now uses the new `continue()` API instead of re-sending the user message, preserving exact context state. + +- **Merged turn prefix summary**: When a turn is split during compaction, the turn prefix summary is now merged into the main history summary instead of being stored separately. + +### Added + +- **`isCompacting` property on AgentSession**: Check if auto-compaction is currently running. + +- **Session compaction indicator**: When resuming a compacted session, displays "Session compacted N times" status message. + +### Fixed + +- **Block input during compaction**: User input is now blocked while auto-compaction is running to prevent race conditions. + +- **Skip error messages in usage calculation**: Context size estimation now skips both aborted and error messages, as neither have valid usage data. + ## [0.16.0] - 2025-12-09 ### Breaking Changes diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 426806ba..8b98d990 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -267,7 +267,11 @@ Long sessions can exhaust context windows. Compaction summarizes older messages **Manual:** `/compact` or `/compact Focus on the API changes` -**Automatic:** Enable with `/autocompact`. Triggers when context exceeds threshold. +**Automatic:** Enable with `/autocompact`. When enabled, triggers in two cases: +- **Overflow recovery**: LLM returns context overflow error. Compacts and auto-retries. +- **Threshold maintenance**: Context exceeds `contextWindow - reserveTokens` after a successful turn. Compacts without retry. + +When disabled, neither case triggers automatic compaction (use `/compact` manually if needed). **How it works:** 1. Cut point calculated to keep ~20k tokens of recent messages diff --git a/packages/coding-agent/docs/RPC.md b/packages/coding-agent/docs/rpc.md similarity index 99% rename from packages/coding-agent/docs/RPC.md rename to packages/coding-agent/docs/rpc.md index 6ba5651a..67c17942 100644 --- a/packages/coding-agent/docs/RPC.md +++ b/packages/coding-agent/docs/rpc.md @@ -109,6 +109,7 @@ Response: "model": {...}, "thinkingLevel": "medium", "isStreaming": false, + "isCompacting": false, "queueMode": "all", "sessionFile": "/path/to/session.jsonl", "sessionId": "abc123", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 95f2789a..694c931a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -14,11 +14,11 @@ */ import type { Agent, AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Model, ToolResultMessage } from "@mariozechner/pi-ai"; +import type { AssistantMessage, Model } from "@mariozechner/pi-ai"; import { isContextOverflow } from "@mariozechner/pi-ai"; import { getModelsPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; -import { calculateContextTokens, compact, estimateTokens, shouldCompact } from "./compaction.js"; +import { calculateContextTokens, compact, shouldCompact } from "./compaction.js"; import { exportSessionToHtml } from "./export-html.js"; import type { BashExecutionMessage } from "./messages.js"; import { getApiKeyForModel, getAvailableModels } from "./model-config.js"; @@ -112,8 +112,6 @@ export class AgentSession { // Compaction state private _compactionAbortController: AbortController | null = null; private _autoCompactionAbortController: AbortController | null = null; - private _abortingForCompaction = false; - private _lastUserMessageText: string | null = null; // Bash execution state private _bashAbortController: AbortController | null = null; @@ -148,48 +146,19 @@ export class AgentSession { // Handle session persistence if (event.type === "message_end") { - // Skip saving aborted message if we're aborting for compaction - const isAbortedForCompaction = - this._abortingForCompaction && - event.message.role === "assistant" && - (event.message as AssistantMessage).stopReason === "aborted"; - - if (!isAbortedForCompaction) { - this.sessionManager.saveMessage(event.message); - } + this.sessionManager.saveMessage(event.message); // Initialize session after first user+assistant exchange if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) { this.sessionManager.startSession(this.agent.state); } - // Track user message text for potential retry after overflow - if (event.message.role === "user") { - const content = (event.message as { content: unknown }).content; - if (typeof content === "string") { - this._lastUserMessageText = content; - } else if (Array.isArray(content)) { - this._lastUserMessageText = content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); - } - } - // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { - this._lastAssistantMessage = event.message as AssistantMessage; + this._lastAssistantMessage = event.message; } } - // Handle turn_end for proactive compaction check - if (event.type === "turn_end") { - await this._checkProactiveCompaction( - event.message as AssistantMessage, - event.toolResults as ToolResultMessage[], - ); - } - // Check auto-compaction after agent completes if (event.type === "agent_end" && this._lastAssistantMessage) { const msg = this._lastAssistantMessage; @@ -274,6 +243,11 @@ export class AgentSession { return this.agent.state.isStreaming; } + /** Whether auto-compaction is currently running */ + get isCompacting(): boolean { + return this._autoCompactionAbortController !== null || this._compactionAbortController !== null; + } + /** All messages including custom types like BashExecutionMessage */ get messages(): AppMessage[] { return this.agent.state.messages; @@ -622,91 +596,41 @@ export class AgentSession { this._autoCompactionAbortController?.abort(); } - /** - * Check for proactive compaction after turn_end (before next LLM call). - * Estimates context size and aborts if threshold would be crossed. - */ - private async _checkProactiveCompaction( - assistantMessage: AssistantMessage, - toolResults: ToolResultMessage[], - ): Promise { - const settings = this.settingsManager.getCompactionSettings(); - if (!settings.enabled) return; - - // Skip if message was aborted or errored - if (assistantMessage.stopReason === "aborted" || assistantMessage.stopReason === "error") return; - - // Only check if there are tool calls (meaning another turn will happen) - const hasToolCalls = assistantMessage.content.some((c) => c.type === "toolCall"); - if (!hasToolCalls) return; - - // Estimate context size: last usage + tool results - const contextTokens = calculateContextTokens(assistantMessage.usage); - const toolResultTokens = toolResults.reduce((sum, msg) => sum + estimateTokens(msg), 0); - const estimatedTotal = contextTokens + toolResultTokens; - - const contextWindow = this.model?.contextWindow ?? 0; - - if (!shouldCompact(estimatedTotal, contextWindow, settings)) return; - - // Threshold crossed - abort for compaction - this._abortingForCompaction = true; - this.agent.abort(); - } - /** * Handle compaction after agent_end. - * Checks for overflow (reactive) or threshold (proactive after abort). + * Two cases: + * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry + * 2. Threshold: Turn succeeded but context over threshold, compact, NO auto-retry (user continues manually) */ private async _handleAgentEndCompaction(assistantMessage: AssistantMessage): Promise { const settings = this.settingsManager.getCompactionSettings(); + if (!settings.enabled) return; + + // Skip if message was aborted (user cancelled) + if (assistantMessage.stopReason === "aborted") return; + const contextWindow = this.model?.contextWindow ?? 0; - // Check 1: Overflow detection (reactive recovery) - const isOverflow = isContextOverflow(assistantMessage, contextWindow); - - // Check 2: Aborted for compaction (proactive) - const wasAbortedForCompaction = this._abortingForCompaction; - this._abortingForCompaction = false; - - // Check 3: Threshold crossed but turn succeeded (maintenance compaction) - const contextTokens = - assistantMessage.stopReason === "error" ? 0 : calculateContextTokens(assistantMessage.usage); - const thresholdCrossed = settings.enabled && shouldCompact(contextTokens, contextWindow, settings); - - // Determine which action to take - let reason: "overflow" | "threshold" | null = null; - let willRetry = false; - - if (isOverflow) { - reason = "overflow"; - willRetry = true; - // Remove the overflow error message from agent state + // Case 1: Overflow - LLM returned context overflow error + if (isContextOverflow(assistantMessage, contextWindow)) { + // Remove the error message from agent state (it IS saved to session for history, + // but we don't want it in context for the retry) const messages = this.agent.state.messages; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.replaceMessages(messages.slice(0, -1)); } - } else if (wasAbortedForCompaction) { - reason = "threshold"; - willRetry = true; - // Remove the aborted message from agent state - const messages = this.agent.state.messages; - if ( - messages.length > 0 && - messages[messages.length - 1].role === "assistant" && - (messages[messages.length - 1] as AssistantMessage).stopReason === "aborted" - ) { - this.agent.replaceMessages(messages.slice(0, -1)); - } - } else if (thresholdCrossed) { - reason = "threshold"; - willRetry = false; // Turn succeeded, no retry needed + await this._runAutoCompaction("overflow", true); + return; } - if (!reason) return; + // Case 2: Threshold - turn succeeded but context is getting large + // Skip if this was an error (non-overflow errors don't have usage data) + if (assistantMessage.stopReason === "error") return; - // Run compaction - await this._runAutoCompaction(reason, willRetry); + const contextTokens = calculateContextTokens(assistantMessage.usage); + if (shouldCompact(contextTokens, contextWindow, settings)) { + await this._runAutoCompaction("threshold", false); + } } /** @@ -754,11 +678,22 @@ export class AgentSession { }; this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry }); - // Auto-retry if needed - if (willRetry && this._lastUserMessageText) { - // Small delay to let UI update - await new Promise((resolve) => setTimeout(resolve, 100)); - await this.prompt(this._lastUserMessageText); + // Auto-retry if needed - use continue() since user message is already in context + if (willRetry) { + // Remove trailing error message from agent state (it's kept in session file for history) + // This is needed because continue() requires last message to be user or toolResult + const messages = this.agent.state.messages; + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") { + this.agent.replaceMessages(messages.slice(0, -1)); + } + + // Use setTimeout to break out of the event handler chain + setTimeout(() => { + this.agent.continue().catch(() => { + // Retry failed - silently ignore, user can manually retry + }); + }, 100); } } catch (error) { // Compaction failed - emit end event without retry diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index de7b6332..58920895 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -41,11 +41,12 @@ export function calculateContextTokens(usage: Usage): number { /** * Get usage from an assistant message if available. + * Skips aborted and error messages as they don't have valid usage data. */ function getAssistantUsage(msg: AppMessage): Usage | null { if (msg.role === "assistant" && "usage" in msg) { const assistantMsg = msg as AssistantMessage; - if (assistantMsg.stopReason !== "aborted" && assistantMsg.usage) { + if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) { return assistantMsg.usage; } } @@ -81,36 +82,59 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett /** * Estimate token count for a message using chars/4 heuristic. * This is conservative (overestimates tokens). - * Accepts any message type (AppMessage, ToolResultMessage, etc.) */ -export function estimateTokens(message: { - role: string; - content?: unknown; - command?: string; - output?: string; -}): number { +export function estimateTokens(message: AppMessage): number { let chars = 0; - // Handle custom message types that don't have standard content + // Handle bashExecution messages if (message.role === "bashExecution") { - chars = (message.command?.length || 0) + (message.output?.length || 0); + const bash = message as unknown as { command: string; output: string }; + chars = bash.command.length + bash.output.length; return Math.ceil(chars / 4); } - // Standard messages with content - const content = message.content; - if (typeof content === "string") { - chars = content.length; - } else if (Array.isArray(content)) { - for (const block of content) { + // Handle user messages + if (message.role === "user") { + const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; + if (typeof content === "string") { + chars = content.length; + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } + } + } + return Math.ceil(chars / 4); + } + + // Handle assistant messages + if (message.role === "assistant") { + const assistant = message as AssistantMessage; + for (const block of assistant.content) { if (block.type === "text") { chars += block.text.length; } else if (block.type === "thinking") { chars += block.thinking.length; + } else if (block.type === "toolCall") { + chars += block.name.length + JSON.stringify(block.arguments).length; } } + return Math.ceil(chars / 4); } - return Math.ceil(chars / 4); + + // Handle tool results + if (message.role === "toolResult") { + const toolResult = message as { content: Array<{ type: string; text?: string }> }; + for (const block of toolResult.content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } + } + return Math.ceil(chars / 4); + } + + return 0; } /** @@ -166,6 +190,9 @@ export interface CutPointResult { /** * Find the cut point in session entries that keeps approximately `keepRecentTokens`. * + * Algorithm: Walk backwards from newest, accumulating estimated message sizes. + * Stop when we've accumulated >= keepRecentTokens. Cut at that point. + * * Can cut at user OR assistant messages (never tool results). When cutting at an * assistant message with tool calls, its tool results come after and will be kept. * @@ -188,46 +215,23 @@ export function findCutPoint( return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; } - // Collect assistant usages walking backwards from endIndex - const assistantUsages: Array<{ index: number; tokens: number }> = []; - for (let i = endIndex - 1; i >= startIndex; i--) { - const entry = entries[i]; - if (entry.type === "message") { - const usage = getAssistantUsage(entry.message); - if (usage) { - assistantUsages.push({ - index: i, - tokens: calculateContextTokens(usage), - }); - } - } - } - - if (assistantUsages.length === 0) { - // No usage info, keep from last cut point - const lastCutPoint = cutPoints[cutPoints.length - 1]; - const entry = entries[lastCutPoint]; - const isUser = entry.type === "message" && entry.message.role === "user"; - return { - firstKeptEntryIndex: lastCutPoint, - turnStartIndex: isUser ? -1 : findTurnStartIndex(entries, lastCutPoint, startIndex), - isSplitTurn: !isUser, - }; - } - - // Walk through and find where cumulative token difference exceeds keepRecentTokens - const newestTokens = assistantUsages[0].tokens; + // Walk backwards from newest, accumulating estimated message sizes + let accumulatedTokens = 0; let cutIndex = startIndex; // Default: keep everything in range - for (let i = 1; i < assistantUsages.length; i++) { - const tokenDiff = newestTokens - assistantUsages[i].tokens; - if (tokenDiff >= keepRecentTokens) { - // Find the valid cut point at or after the assistant we want to keep - const lastKeptAssistantIndex = assistantUsages[i - 1].index; + for (let i = endIndex - 1; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type !== "message") continue; - // Find closest valid cut point at or before lastKeptAssistantIndex - for (let c = cutPoints.length - 1; c >= 0; c--) { - if (cutPoints[c] <= lastKeptAssistantIndex) { + // Estimate this message's size + const messageTokens = estimateTokens(entry.message); + accumulatedTokens += messageTokens; + + // Check if we've exceeded the budget + if (accumulatedTokens >= keepRecentTokens) { + // Find the closest valid cut point at or after this entry + for (let c = 0; c < cutPoints.length; c++) { + if (cutPoints[c] >= i) { cutIndex = cutPoints[c]; break; } @@ -404,9 +408,8 @@ export async function compact( } } - // Generate summaries (can be parallel if both needed) + // Generate summaries (can be parallel if both needed) and merge into one let summary: string; - let turnPrefixSummary: string | undefined; if (cutResult.isSplitTurn && turnPrefixMessages.length > 0) { // Generate both summaries in parallel @@ -416,8 +419,8 @@ export async function compact( : Promise.resolve("No prior history."), generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal), ]); - summary = historyResult; - turnPrefixSummary = turnPrefixResult; + // Merge into single summary + summary = historyResult + "\n\n---\n\n**Turn Context (split turn):**\n\n" + turnPrefixResult; } else { // Just generate history summary summary = await generateSummary( @@ -434,7 +437,6 @@ export async function compact( type: "compaction", timestamp: new Date().toISOString(), summary, - turnPrefixSummary, firstKeptEntryIndex: cutResult.firstKeptEntryIndex, tokensBefore, }; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 05f492d6..fd4f2278 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -50,8 +50,6 @@ export interface CompactionEntry { type: "compaction"; timestamp: string; summary: string; - /** Summary of turn prefix when a turn was split (user message to first kept message) */ - turnPrefixSummary?: string; firstKeptEntryIndex: number; // Index into session entries where we start keeping tokensBefore: number; } @@ -180,18 +178,9 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { } } - // Build final messages: summaries + kept messages + // Build final messages: summary + kept messages const messages: AppMessage[] = []; - - // Add history summary messages.push(createSummaryMessage(compactionEvent.summary)); - - // Add turn prefix summary if present (when a turn was split) - if (compactionEvent.turnPrefixSummary) { - messages.push(createSummaryMessage(compactionEvent.turnPrefixSummary)); - } - - // Add kept messages messages.push(...keptMessages); return { messages, thinkingLevel, model }; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f28e2b06..a1865119 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -407,6 +407,11 @@ export class InteractiveMode { } } + // Block input during compaction (will retry automatically) + if (this.session.isCompacting) { + return; + } + // Queue message if agent is streaming if (this.session.isStreaming) { await this.session.queueMessage(text); @@ -604,10 +609,6 @@ export class InteractiveMode { compactionComponent.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(compactionComponent); this.footer.updateState(this.session.state); - - if (event.willRetry) { - this.showStatus("Compacted context, retrying..."); - } } this.ui.requestRender(); break; @@ -743,6 +744,14 @@ export class InteractiveMode { renderInitialMessages(state: AgentState): void { this.renderMessages(state.messages, { updateFooter: true, populateHistory: true }); + + // Show compaction info if session was compacted + const entries = this.sessionManager.loadEntries(); + const compactionCount = entries.filter((e) => e.type === "compaction").length; + if (compactionCount > 0) { + const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; + this.showStatus(`Session compacted ${times}`); + } } async getUserInput(): Promise { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 5521a706..f9718627 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -90,6 +90,7 @@ export async function runRpcMode(session: AgentSession): Promise { model: session.model, thinkingLevel: session.thinkingLevel, isStreaming: session.isStreaming, + isCompacting: session.isCompacting, queueMode: session.queueMode, sessionFile: session.sessionFile, sessionId: session.sessionId, diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 278b12b5..019c800e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -63,6 +63,7 @@ export interface RpcSessionState { model: Model | null; thinkingLevel: ThinkingLevel; isStreaming: boolean; + isCompacting: boolean; queueMode: "all" | "one-at-a-time"; sessionFile: string; sessionId: string; diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts new file mode 100644 index 00000000..c8d3b47e --- /dev/null +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -0,0 +1,233 @@ +/** + * E2E tests for AgentSession compaction behavior. + * + * These tests use real LLM calls (no mocking) to verify: + * - Manual compaction works correctly + * - Session persistence during compaction + * - Compaction entry is saved to session file + */ + +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent, ProviderTransport } from "@mariozechner/pi-agent-core"; +import { getModel } from "@mariozechner/pi-ai"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.js"; +import { SessionManager } from "../src/core/session-manager.js"; +import { SettingsManager } from "../src/core/settings-manager.js"; +import { codingTools } from "../src/core/tools/index.js"; + +const API_KEY = process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_OAUTH_TOKEN; + +describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { + let session: AgentSession; + let tempDir: string; + let sessionManager: SessionManager; + let events: AgentSessionEvent[]; + + beforeEach(() => { + // Create temp directory for session files + tempDir = join(tmpdir(), `pi-compaction-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + + // Track events + events = []; + }); + + afterEach(async () => { + if (session) { + session.dispose(); + } + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + }); + + function createSession() { + const model = getModel("anthropic", "claude-sonnet-4-5")!; + + const transport = new ProviderTransport({ + getApiKey: () => API_KEY, + }); + + const agent = new Agent({ + transport, + initialState: { + model, + systemPrompt: "You are a helpful assistant. Be concise.", + tools: codingTools, + }, + }); + + sessionManager = new SessionManager(false); + const settingsManager = new SettingsManager(tempDir); + + session = new AgentSession({ + agent, + sessionManager, + settingsManager, + }); + + // Subscribe to track events + session.subscribe((event) => { + events.push(event); + }); + + return session; + } + + it("should trigger manual compaction via compact()", async () => { + createSession(); + + // Send a few prompts to build up history + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); + + await session.prompt("What is 3+3? Reply with just the number."); + await session.agent.waitForIdle(); + + // Manually compact + const result = await session.compact(); + + expect(result.summary).toBeDefined(); + expect(result.summary.length).toBeGreaterThan(0); + expect(result.tokensBefore).toBeGreaterThan(0); + + // Verify messages were compacted (should have summary + recent) + const messages = session.messages; + expect(messages.length).toBeGreaterThan(0); + + // First message should be the summary (a user message with summary content) + const firstMsg = messages[0]; + expect(firstMsg.role).toBe("user"); + }, 120000); + + it("should maintain valid session state after compaction", async () => { + createSession(); + + // Build up history + await session.prompt("What is the capital of France? One word answer."); + await session.agent.waitForIdle(); + + await session.prompt("What is the capital of Germany? One word answer."); + await session.agent.waitForIdle(); + + // Compact + await session.compact(); + + // Session should still be usable + await session.prompt("What is the capital of Italy? One word answer."); + await session.agent.waitForIdle(); + + // Should have messages after compaction + expect(session.messages.length).toBeGreaterThan(0); + + // The agent should have responded + const assistantMessages = session.messages.filter((m) => m.role === "assistant"); + expect(assistantMessages.length).toBeGreaterThan(0); + }, 180000); + + it("should persist compaction to session file", async () => { + createSession(); + + await session.prompt("Say hello"); + await session.agent.waitForIdle(); + + await session.prompt("Say goodbye"); + await session.agent.waitForIdle(); + + // Compact + await session.compact(); + + // Load entries from session manager + const entries = sessionManager.loadEntries(); + + // Should have a compaction entry + const compactionEntries = entries.filter((e) => e.type === "compaction"); + expect(compactionEntries.length).toBe(1); + + const compaction = compactionEntries[0]; + expect(compaction.type).toBe("compaction"); + if (compaction.type === "compaction") { + expect(compaction.summary.length).toBeGreaterThan(0); + // firstKeptEntryIndex can be 0 if all messages fit within keepRecentTokens + // (which is the case for small conversations) + expect(compaction.firstKeptEntryIndex).toBeGreaterThanOrEqual(0); + expect(compaction.tokensBefore).toBeGreaterThan(0); + } + }, 120000); + + it("should work with --no-session mode (in-memory only)", async () => { + const model = getModel("anthropic", "claude-sonnet-4-5")!; + + const transport = new ProviderTransport({ + getApiKey: () => API_KEY, + }); + + const agent = new Agent({ + transport, + initialState: { + model, + systemPrompt: "You are a helpful assistant. Be concise.", + tools: codingTools, + }, + }); + + // Create session manager and disable file persistence + const noSessionManager = new SessionManager(false); + noSessionManager.disable(); + + const settingsManager = new SettingsManager(tempDir); + + const noSessionSession = new AgentSession({ + agent, + sessionManager: noSessionManager, + settingsManager, + }); + + try { + // Send prompts + await noSessionSession.prompt("What is 2+2? Reply with just the number."); + await noSessionSession.agent.waitForIdle(); + + await noSessionSession.prompt("What is 3+3? Reply with just the number."); + await noSessionSession.agent.waitForIdle(); + + // Compact should work even without file persistence + const result = await noSessionSession.compact(); + + expect(result.summary).toBeDefined(); + expect(result.summary.length).toBeGreaterThan(0); + + // In-memory entries should have the compaction + const entries = noSessionManager.loadEntries(); + const compactionEntries = entries.filter((e) => e.type === "compaction"); + expect(compactionEntries.length).toBe(1); + } finally { + noSessionSession.dispose(); + } + }, 120000); + + it("should emit correct events during auto-compaction", async () => { + createSession(); + + // Build some history + await session.prompt("Say hello"); + await session.agent.waitForIdle(); + + // Manually trigger compaction and check events + await session.compact(); + + // Check that no auto_compaction events were emitted for manual compaction + const autoCompactionEvents = events.filter( + (e) => e.type === "auto_compaction_start" || e.type === "auto_compaction_end", + ); + // Manual compaction doesn't emit auto_compaction events + expect(autoCompactionEvents.length).toBe(0); + + // Regular events should have been emitted + const messageEndEvents = events.filter((e) => e.type === "message_end"); + expect(messageEndEvents.length).toBeGreaterThan(0); + }, 120000); +}); diff --git a/packages/web-ui/src/agent/transports/AppTransport.ts b/packages/web-ui/src/agent/transports/AppTransport.ts index 0d5135a8..90525a7b 100644 --- a/packages/web-ui/src/agent/transports/AppTransport.ts +++ b/packages/web-ui/src/agent/transports/AppTransport.ts @@ -11,7 +11,7 @@ import type { ToolCall, UserMessage, } from "@mariozechner/pi-ai"; -import { agentLoop } from "@mariozechner/pi-ai"; +import { agentLoop, agentLoopContinue } from "@mariozechner/pi-ai"; import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js"; import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js"; import { clearAuthToken, getAuthToken } from "../../utils/auth-token.js"; @@ -315,51 +315,57 @@ function streamSimpleProxy( return stream; } -// Proxy transport executes the turn using a remote proxy server /** * Transport that uses an app server with user authentication tokens. * The server manages user accounts and proxies requests to LLM providers. */ export class AppTransport implements AgentTransport { - // Hardcoded proxy URL for now - will be made configurable later private readonly proxyUrl = "https://genai.mariozechner.at"; - async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { + private async getStreamFn() { const authToken = await getAuthToken(); if (!authToken) { throw new Error(i18n("Auth token is required for proxy transport")); } - // Use proxy - no local API key needed - const streamFn = (model: Model, context: Context, options?: SimpleStreamOptions) => { - return streamSimpleProxy( - model, - context, - { - ...options, - authToken, - }, - this.proxyUrl, - ); + return (model: Model, context: Context, options?: SimpleStreamOptions) => { + return streamSimpleProxy(model, context, { ...options, authToken }, this.proxyUrl); }; + } - // Messages are already LLM-compatible (filtered by Agent) - const context: AgentContext = { + private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext { + return { systemPrompt: cfg.systemPrompt, messages, tools: cfg.tools, }; + } - const pc: AgentLoopConfig = { + private buildLoopConfig(cfg: AgentRunConfig): AgentLoopConfig { + return { model: cfg.model, reasoning: cfg.reasoning, getQueuedMessages: cfg.getQueuedMessages, }; + } + + async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { + const streamFn = await this.getStreamFn(); + const context = this.buildContext(messages, cfg); + const pc = this.buildLoopConfig(cfg); - // Yield events from the upstream agentLoop iterator - // Pass streamFn as the 5th parameter to use proxy for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn as any)) { yield ev; } } + + async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { + const streamFn = await this.getStreamFn(); + const context = this.buildContext(messages, cfg); + const pc = this.buildLoopConfig(cfg); + + for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any)) { + yield ev; + } + } } diff --git a/packages/web-ui/src/agent/transports/ProviderTransport.ts b/packages/web-ui/src/agent/transports/ProviderTransport.ts index e92f2fa2..68b498b9 100644 --- a/packages/web-ui/src/agent/transports/ProviderTransport.ts +++ b/packages/web-ui/src/agent/transports/ProviderTransport.ts @@ -2,6 +2,7 @@ import { type AgentContext, type AgentLoopConfig, agentLoop, + agentLoopContinue, type Message, type UserMessage, } from "@mariozechner/pi-ai"; @@ -14,37 +15,53 @@ import type { AgentRunConfig, AgentTransport } from "./types.js"; * Uses CORS proxy only for providers that require it (Anthropic OAuth, Z-AI). */ export class ProviderTransport implements AgentTransport { - async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { - // Get API key from storage + private async getModelAndKey(cfg: AgentRunConfig) { const apiKey = await getAppStorage().providerKeys.get(cfg.model.provider); if (!apiKey) { throw new Error("no-api-key"); } - // Get proxy URL from settings (if available) const proxyEnabled = await getAppStorage().settings.get("proxy.enabled"); const proxyUrl = await getAppStorage().settings.get("proxy.url"); - - // Apply proxy only if this provider/key combination requires it const model = applyProxyIfNeeded(cfg.model, apiKey, proxyEnabled ? proxyUrl || undefined : undefined); - // Messages are already LLM-compatible (filtered by Agent) - const context: AgentContext = { + return { model, apiKey }; + } + + private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext { + return { systemPrompt: cfg.systemPrompt, messages, tools: cfg.tools, }; + } - const pc: AgentLoopConfig = { + private buildLoopConfig(model: typeof cfg.model, apiKey: string, cfg: AgentRunConfig): AgentLoopConfig { + return { model, reasoning: cfg.reasoning, apiKey, getQueuedMessages: cfg.getQueuedMessages, }; + } + + async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { + const { model, apiKey } = await this.getModelAndKey(cfg); + const context = this.buildContext(messages, cfg); + const pc = this.buildLoopConfig(model, apiKey, cfg); - // Yield events from agentLoop for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) { yield ev; } } + + async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { + const { model, apiKey } = await this.getModelAndKey(cfg); + const context = this.buildContext(messages, cfg); + const pc = this.buildLoopConfig(model, apiKey, cfg); + + for await (const ev of agentLoopContinue(context, pc, signal)) { + yield ev; + } + } } diff --git a/packages/web-ui/src/agent/transports/types.ts b/packages/web-ui/src/agent/transports/types.ts index afc099a8..74d28628 100644 --- a/packages/web-ui/src/agent/transports/types.ts +++ b/packages/web-ui/src/agent/transports/types.ts @@ -13,10 +13,14 @@ export interface AgentRunConfig { // We re-export the Message type above; consumers should use the upstream AgentEvent type. export interface AgentTransport { + /** Run with a new user message */ run( messages: Message[], userMessage: Message, config: AgentRunConfig, signal?: AbortSignal, - ): AsyncIterable; // passthrough of AgentEvent from upstream + ): AsyncIterable; + + /** Continue from current context (no new user message) */ + continue(messages: Message[], config: AgentRunConfig, signal?: AbortSignal): AsyncIterable; }